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.
Installation
npx shadcn@latest add https://craftbits.dev/r/signal-sorter.jsonUsage
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
- Caller owns the list.
signalsis 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. - Category match is by id. Each
signal.categoryis matched againstcategories[].id. Unmatched ids park the signal in the unsorted tray; an empty string or any unknown value is treated the same way. - Per-bin tone wins. Pass
toneon a category to override the component-leveltonefor that bin's header, chip fills, and count strip. The fallback chain iscategory.tonethentonethen"accent". - Totals strip is optional. The strip beneath the bin row shows
sorted N / totaland, when present,unsorted M. Drop it withshowTotals={false}when the parent already surfaces a tally. - Stagger on entry. Bins enter with a small
y: 8 -> 0slide 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. - Reduced motion. Bin entries, chip entries, count flips, and exit transitions all collapse to instant under
prefers-reduced-motion: reduce.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
categories | SignalSorterCategory[] | required | Ordered bin definitions. At least two recommended for a meaningful sort. |
signals | SignalSorterSignal[] | required | Flat signals list. Unmatched category ids park signals in the unsorted tray. |
title | ReactNode | — | Content rendered above the bin row. |
footer | ReactNode | — | Content rendered below the totals strip. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Default tone for bins without an explicit tone. |
unsortedLabel | ReactNode | "Unsorted" | Header label for the unsorted tray. |
hideUnsorted | boolean | false | Hide the unsorted tray even when signals reference unknown ids. |
emptyBinLabel | ReactNode | "—" | Placeholder rendered inside empty bins. |
showTotals | boolean | true | Show the sorted N / total strip beneath the bins. |
transition | Transition | SPRINGS.smooth | Override transitions. Reduced-motion users snap regardless. |
className | string | — | Merged 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 arole="listitem"with an explicitaria-labelnaming the bin label and the current chip count. - The unsorted tray carries its own
aria-labelso 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, anddata-has-unsortedon 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), aPredictionGateon the code morph, aMagicMoveBlockcomparing 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 thetitle/footerslots.