ChannelsProvider and ChannelDiscovery each hand-rolled the same sort plumbing
(useState<SortState> + parseSortState + a persist key). Both now use the shared
useSortState hook. hydrateSort gains an empty-options rule: with no vocabulary
passed it parses without healing (these owners heal for display via
ColumnSortControl against the live columns), so the un-healed provider store is
preserved. plexSortKeys made module-private (knip).
Closes R13 S1: feed, Plex, and the two channel tables all sort through one
SortControl + one SortState model. Gate green: tsc/eslint(0 err)/prettier/knip
clean, vitest 94.
Introduce the one sort control every content surface will share:
- columnSort.ts gains the SortOption vocabulary + pure pickSort/healSort/
hydrateSort helpers (direction lives in SortState, never in the key).
- SortControl.tsx: the generic dropdown + asc/desc toggle, with an optional
"no sort" choice (tables) and a trailing adornment slot (the feed's reshuffle).
- ColumnSortControl is now a thin wrapper over SortControl (API unchanged, so
Channels + ChannelDiscovery are untouched); optionsFromColumns moved to
lib/sortOptions.ts to keep the component file fast-refresh clean.
- useSortState.ts: the per-account persist/hydrate/heal plumbing the channel
manager and discovery each hand-rolled, ready to replace both.
Pure logic is fully unit-tested (columnSort.test.ts 10 -> 23). Gate green:
tsc/eslint/prettier clean, vitest 93 pass. Feed/Plex/channels migration follows.
22 vitest cases for two pure helpers that had none:
- columnSort: numeric-vs-locale comparison, asc/desc, no-mutation, the null/junk
coercion in parseSortState, and the unsorted→asc→desc→unsorted click cycle.
- linkify: http/www/bare-domain URLs, the version-number non-match, the
"<platform>: @handle" mention (handle linked, prefix kept as text), alias→canonical
base URL, bare @handle/#hashtag left plain, and multi-link ordering. Nodes are
inspected structurally, so the existing node-env vitest needs no DOM.
useCardPager (a hook) is deferred to when component/hook test infra lands with the
backend pytest sprint.