SignalSorter

A pure playback viz for "sort signals into category bins". The caller passes a flat signals list — each entry is { id, value, category } — plus an ordered list of categories that define the bins. The component renders one column per category, drops each signal as a tinted chip in its bin, and shows a totals strip with per-bin counts and the overall total.

Pure visualisation primitive — it does not own the state machine, the phase reducer, the prediction gates, or the audio cues the source lesson layered on. Generic enough to cover any "classify these items" lesson: sliding-window pattern recognition, bug-vs-feature triage, signal/kill-signal sorting games, or any drag-and-drop classifier rendered after the fact.

Classify these problems
signal sorter
Sliding Window
0
contiguous + monotonic constraint
Not Sliding Window
0
subsequence, negatives, or DP
sorted 0 / 0
No signals to sort yet.

Installation

npx shadcn@latest add https://craftbits.dev/r/signal-sorter.json

Usage

import { SignalSorter } from "@craft-bits/core";
 
<SignalSorter
  categories={[
    { id: "sw", label: "Sliding Window", tone: "success" },
    { id: "not-sw", label: "Not Sliding Window", tone: "error" },
  ]}
  signals={[
    { id: "max-sum-k", value: "Max Sum Size K", category: "sw" },
    { id: "lis", value: "Longest Increasing Subseq", category: "not-sw" },
  ]}
/>

Driving it from a classification reducer — push each newly-classified signal onto the list and the matching chip animates into its bin:

const [signals, setSignals] = useState<SignalSorterSignal[]>([]);
 
function classify(id: string, value: string, category: string) {
  setSignals((s) => [...s, { id, value, category }]);
}
 
<SignalSorter categories={CATEGORIES} signals={signals} />

With an unsorted tray — signals whose category does not match any bin id park in a neutral tray beneath the bins:

<SignalSorter
  categories={CATEGORIES}
  signals={[
    { id: "a", value: "Known", category: "sw" },
    { id: "b", value: "Pending", category: "" },
  ]}
/>

Hiding the unsorted tray — when you only want to render confirmed classifications, set hideUnsorted and the tray collapses even if signals reference unknown ids:

<SignalSorter categories={CATEGORIES} signals={signals} hideUnsorted />

Per-bin tone — drop a tone on the category to colour the header, the chips, and the count strip independently of the component-level tone:

<SignalSorter
  tone="default"
  categories={[
    { id: "good", label: "Keep", tone: "success" },
    { id: "warn", label: "Review", tone: "warning" },
    { id: "bad", label: "Drop", tone: "error" },
  ]}
  signals={signals}
/>

Understanding the component

  1. Caller owns the list. signals is a flat array of { id, value, category }. Order is the order chips render inside their bin. Push to the list when you classify, splice out when you un-classify — the component reads the snapshot, never mutates it.
  2. Category match is by id. Each signal.category is matched against categories[].id. Unmatched ids park the signal in the unsorted tray; an empty string or any unknown value is treated the same way.
  3. Per-bin tone wins. Pass tone on a category to override the component-level tone for that bin's header, chip fills, and count strip. The fallback chain is category.tone then tone then "accent".
  4. Totals strip is optional. The strip beneath the bin row shows sorted N / total and, when present, unsorted M. Drop it with showTotals={false} when the parent already surfaces a tally.
  5. Stagger on entry. Bins enter with a small y: 8 -> 0 slide and a per-column stagger. Chips inside each bin enter with their own per-item stagger and a layout-aware reorder so re-classified signals slide between bins instead of cutting.
  6. Reduced motion. Bin entries, chip entries, count flips, and exit transitions all collapse to instant under prefers-reduced-motion: reduce.

Props

PropTypeDefaultDescription
categoriesSignalSorterCategory[]requiredOrdered bin definitions. At least two recommended for a meaningful sort.
signalsSignalSorterSignal[]requiredFlat signals list. Unmatched category ids park signals in the unsorted tray.
titleReactNodeContent rendered above the bin row.
footerReactNodeContent rendered below the totals strip.
tone"default" | "accent" | "success" | "warning" | "error""accent"Default tone for bins without an explicit tone.
unsortedLabelReactNode"Unsorted"Header label for the unsorted tray.
hideUnsortedbooleanfalseHide the unsorted tray even when signals reference unknown ids.
emptyBinLabelReactNode"—"Placeholder rendered inside empty bins.
showTotalsbooleantrueShow the sorted N / total strip beneath the bins.
transitionTransitionSPRINGS.smoothOverride transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The bin row is a role="list" labelled by an off-screen "signal sorter" header, and each bin is a role="listitem" with an explicit aria-label naming the bin label and the current chip count.
  • The unsorted tray carries its own aria-label so screen-reader users can tell sorted bins apart from the pending tray.
  • The totals strip uses aria-live="polite" so every count flip is announced without spam.
  • An off-screen role="status" paragraph summarises the entire sort, including per-bin counts and the unsorted total, on every render so consumers of the live region get the same narration sighted users see.
  • Tone differences are signalled by colour AND by the explicit bin label, so the distinction is never colour-only.
  • The component exposes data-tone, data-bin, and data-has-unsorted on the root and on each bin so consumer apps can hook custom styles or assistive tooling.
  • Motion respects prefers-reduced-motion: reduce — bin entries, chip entries, the count flip, and the unsorted-tray fade all collapse to instant.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/observation/SignalSorter.tsx). The source was a 1725-line four-phase lesson component bundling a full reducer-driven state machine (gut-check / framework-intro / framework-tagging / code-bridge / code-query / verify / done), a PredictionGate on the code morph, a MagicMoveBlock comparing sliding-window and DP solutions, audio cues (correct, wrong, insight, streak, unlock), per-distractor feedback, signal and kill-signal toggles, and a phase-aware progress bar. The library extract keeps only the pure visualisation primitive — the row of category bins, the chip enter/exit animation, the per-bin count, and the optional unsorted tray — and lets the caller compose any phases, prediction gates, scoring, narration, or sound on top via the title / footer slots.