The fresh-tab restore bug wasn't Channels-specific: any provider that mounts above
<App> and reads per-account state at useState-init hit it, since <App> pins the tab's
account only after /api/me. Audited all of them — FeedFilters/FeedView/Prefs/App already
self-correct via their own me?.id server-prefs adopt effects, so the still-broken ones
were the pure-localStorage providers with no server adopt: NavigationProvider (landing
page), PlexProvider (scope/show/sort/filters/q), PlaylistsProvider (selected playlist).
Each now adopts the shared useAccountRehydrate hook (markDirty in the setters; page uses
replaceState so the restore doesn't add a phantom Back step). Also baked re-hydrate into
useAccountPersistedState/Object so their consumers (persisted tabs, player prefs) are
robust even if one ever mounts before the pin. Self-E2E'd on a genuinely fresh tab: it
lands on Plex with Movies/In-progress/Year/search all restored on the FIRST load.
The provider tree mounts above <App>, which pins this tab's account only after /api/me
resolves — so on a genuinely fresh tab (empty sessionStorage) the manager's sort, filters,
view and layout initialised from an unknown account and stayed at defaults until a second
reload. Make the active account reactive (a subscribable store + useActiveAccountId via
useSyncExternalStore; setActiveAccount/clearActiveAccount notify) and re-read the stored
value when the account transitions null→id, via a shared useAccountRehydrate hook. A
markDirty guard (called by each owning setter) prevents clobbering a value the user changed
in the brief pre-pin window; the effect is keyed on the account id only, so the user's own
writes can't loop it. useSortState, useFilterState and ChannelsProvider (view/layout) all
adopt it. Affects sort identically — this was a long-standing caveat, not new to R13.
Review fixes (user /code-review high):
- DataTable: hoist a stable EMPTY_FILTERS fallback so a controlled caller that omits
`filters` doesn't churn a fresh {} each render.
- DataTable: the persistKey blob now stores only the slices this table OWNS — a
controlled sort/filter is written as null/{} instead of a stale never-read duplicate.
- useFilterState: optional `migrate` seed (used only when storage is empty);
ChannelsProvider carries the retired LS.channelFilter sync chip forward via migrateSyncChip.
- Channels: guard the sync-chip read with typeof instead of an `as string` cast.
Also unify the tag filter, same as the sync one: the top "YOUR TAGS" chip row is now a
controlled view over the Tags COLUMN filter (`filters.tags`), replacing its own tagFilter
state — so the chips and the Tags funnel share one state bidirectionally, the card layout
applies it via filterRows, and it persists with the rest of the map.
The top sync chips and the Sync column's own select filter were two independent
states with different vocab (all/needs_full/fully_synced vs fully/needsFull) and
subtly different semantics. DataTable gains a controlled filters/onFiltersChange
pair (symmetric to the existing controlled sort); ChannelsProvider now owns the
filter map via a new useFilterState hook, so the top chips, the Sync column
funnel, the card layout, and the header "full history" deep-link all drive ONE
filters.sync. The card layout reuses the exported filterRows predicate instead of
its own status pre-filter. Vocab unified on the column's fully = recent_synced &&
backfill_done, which now matches the Sync cell's own "fully synced" badge.
- Shared usePlexClearFilters hook replaces the duplicated clearAll (was copied in
PlexBrowse and PlexSidebar) — one "reset every Plex narrowing" both call.
- The Plex duration chip's minutes unit is localized (plex.filter.durationUnit:
"min"/"perc") instead of a hardcoded "m".
- The Plex collection chip falls back to the "Collection" label, not the raw
rating_key id, when collectionTitle is missing.
- Dropped the dead rowClassName that dimmed hidden rows in the subscribed table
(hidden channels are excluded there now — they live in the Hidden tab).
- Renamed the misleading `set` null-check predicate to `has`.
Gate green: tsc/eslint-0/prettier/knip clean, vitest 106.
Plex now shows the same at-a-glance "what's narrowing this" row the feed has,
instead of the narrowing being visible only inside the collapsed sidebar. New
usePlexActiveFilters is the Plex analogue of useActiveFilters — it turns the Plex
state (expanded filters + watch-state + sort) into the source-agnostic
ActiveFilter[] the SHARED ActiveFilterChips renders. One removable chip per
dimension (genres/ratings/people emit one each), plus Clear-all resetting filters
+ watch-state + sort. Labels reuse the existing plex.* i18n keys; no new strings.
Lives in components (not lib) since it reads the Plex provider — lib stays
provider-free like useActiveFilters. Gate green: tsc/eslint-0/prettier/knip.
Self-E2E'd: a sort=year chip renders, removing it resets sort to title and hides
the row.
The Hidden and Blocked tabs were a bare row of name chips ("two withered
chips"). Both now render a responsive grid of real channel cards via a shared
ExcludedChannelList: avatar + name + open-on-YouTube (reusing ChannelLink) and
one labelled reversing action (Unhide / Unblock). One presentation, parameterised
by icon/label/action/copy — the two "excluded channel" tabs now match.
Gate green: tsc/eslint-0/prettier/knip clean. Self-E2E'd: Hidden tab renders both
channels as cards with avatars, YT links, and Unhide buttons.
Hidden subscriptions get their own tab (mirroring Blocked): a flat list with an
Unhide action, instead of dimmed rows scattered through the Subscriptions table.
- ChannelsView += "hidden"; the subscribed table now EXCLUDES hidden channels,
so hiding a channel (the eye button) moves it to the Hidden tab and unhiding
(Eye) returns it — both via the existing patch mutation.
- The "Hidden" top-level status chip is dropped (ChannelStatusFilter loses
"hidden"); the status row is now purely sync-status (all/needs_full/fully_synced).
- Bilingual i18n (en/hu): channels.tabs.hidden + a channels.hidden block; the now
unused channels.filters.hidden removed.
Hidden = out of the personal (Mine) feed only, still in the shared Library — a
softer exclusion than Blocked (hidden everywhere). Gate green: tsc/eslint-0/
prettier/vitest 106. Self-E2E'd: hide → appears in Hidden tab → unhide → back,
all /api 200.
- SortControl no longer renders a blank select when an always-sorted view
(no defaultLabel) is handed a key its options don't offer: it falls back to
the first option instead of value="" (which also tripped React's controlled
warning). Robustness now lives in the primitive, not each caller.
- PLEX_SORT_FALLBACK is Object.frozen: it's handed out by reference from
healSort/healPlexSort and stored as provider state, so freezing turns any
accidental in-place SortState mutation into a loud error instead of silently
corrupting the shared fallback.
- Test: SortControl falls back to the first option for an unavailable key.
Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 106.
- PlexSidebar activeCount now compares the HEALED sort, so an out-of-scope key
(movie "duration" after switching to shows) no longer inflates the
active-filter badge while the control + grid show the default.
- PlexProvider strips the legacy `sortDir` from the filters blob, so it stops
leaking into the facets query key and being re-persisted on every change.
- PlexProvider sort migration guards the fall-through: valid JSON that isn't a
SortState now falls back instead of using the raw JSON string as the key.
- New shared healPlexSort(sort, scope) is the single heal both PlexBrowse and
PlexSidebar use (removes the noop-label plexSortOptions call + the duplication).
- Tests: useSortState.dom.test.ts (hydrate/persist/heal round-trip via renderHook)
and SortControl.dom.test.tsx (options, pick, direction toggle, directionless,
trailing). vitest 94 -> 105.
Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 105.
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.
Plex sort was a bespoke chip-pill row plus a separate asc/desc chip pair, with
the key in PlexState.sort and the direction hidden in PlexFilters.sortDir. Now:
- sort is one SortState {key,dir}; sortDir is dropped from PlexFilters (and from
the facet-key destructure). The wire call still splits it into sort + sort_dir
at the server boundary, so the backend is untouched.
- The chip sort-row is replaced by the shared SortControl (select + arrow) — the
same idiom the feed and channel manager use. New lib/plexSort.ts owns the
scope-dependent vocabulary (movies add duration) + the title/asc fallback.
- PlexProvider migrates the old storage in place: a legacy bare-key plexSort
string plus the old filters.sortDir become the {key,dir} state, so an existing
sort preference survives the upgrade.
- PlexBrowse and PlexSidebar heal the stored sort against the current scope's
options, so a movie-only key (duration) left over after switching to shows
can't ask the server for an unavailable sort.
Gate green: tsc clean, eslint 0 errors, prettier clean, vitest 93.
The feed's bespoke select + asc/desc button is now the generic SortControl,
fed a SortOption[] built from the feed vocabulary (relevance offered only with
a query; shuffle/relevance directionless). The feedSort wire codec
(parseSort/buildSort) stays as the server-boundary translation, and the
reshuffle button rides SortControl's `trailing` slot so shuffle keeps its
re-seed affordance. Behaviour-equivalent; ArrowUp/ArrowDown imports dropped.
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.
Volume-consolidation (conservative — no behaviour or prefs-format change):
- playerVolume.ts is now scale-generic (a VolumeScale parameterises range /
default / integer rounding). YT_SCALE (0-100 ints) and PLEX_SCALE (0..1
float) bind the same tested core; the YouTube wrappers keep the original API
so the existing tests are unchanged.
- The Plex usePlayerVolume hook drops its bespoke clamp01 arithmetic for the
shared normalizeVolumeIn / stepVolumeIn at PLEX_SCALE. Side effect (a latent
bug fix): a corrupt persisted volume now falls back to the default instead of
a bare clamp silently muting it. The separate .muted flag stays Plex-only.
- clamp01 stays for the seek-bar fraction (its non-volume use).
- Tests: the generic core is covered at both scales (float no-round, corrupt ->
default, step/clamp) — the Plex-side arithmetic finally has unit coverage.
PlexPlayerSettings extraction (verbatim JSX move, behaviour-equivalent):
- The ~240-line gear/tuning panel (sync / playback / subtitle / clock tabs)
moves out of PlexPlayer into PlexPlayerSettings.tsx (a pure prefs+patchPrefs
view owning no state). PlexPlayer shrinks 1402 -> 1173 lines; Slider/Toggle
imports it no longer uses are dropped.
Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234). Dev
server smoke: app builds + loads clean, no console errors. The interactive
settings panel needs a playing Plex video (unreachable on localdev) -> owed to
the user's UAT.
Two behaviour-equivalent cleanups from the review:
- Drop the redundant per-render `audioRef.current = audioOrd` mirror in
PlexPlayer: the only two setAudioOrd callers (usePlexTracks.changeAudio
+ the detail effect) already set audioRef synchronously, so the mirror
never carried a value they hadn't written.
- Narrow the time-tracking effect deps from [id, patchPrefs] to [id];
patchPrefs was a vestigial, unused dependency (eslint confirms [id] is
the complete set).
The third finding (usePlayerVolume duplicating playerVolume.ts) is the
tracked R12 volume-consolidation sprint, not a review fix.
S4a-4a of the PlexPlayer decomposition. Move audio/subtitle track
selection + subtitle rendering into usePlexTracks: the selected-ordinal
state, change/cycle audio+subtitle, the debounced sub-offset, the audio
A/V-sync offset, and the two subtitle effects (force-showing the mounted
track + positioning cues at the user's subPos). The shared media refs
(video/hls/audio/aoff/abs/multiAudio) stay component-owned and are passed
in, since loadSession + the detail effect write audioRef too; absRef is
hoisted above the hook call for that. No behaviour change; verified by
self-E2E (subtitle on/off via 'S' mounts/removes the <track>, mode
'showing', VTT 804 cues all positioned to line=subPos, subLang persists;
playback uninterrupted). Audio switch is single-track here so cycleAudio
correctly no-ops.
S4a-3b of the PlexPlayer decomposition. Move the intro/credits auto-skip
machinery (activeMarker detection, the skipProgress countdown, doSkip,
cancelAutoSkip + the running-interval and reset-on-id effects) into
useAutoSkip. The hook returns skipProgressRef / cancelAutoSkipRef — the
forward-declaration bridge the earlier keyboard/back handlers read to
cancel a running countdown; those two stable refs are now listed in the
backIntent/keyboard dep arrays (they were provably-stable local refs
before, params now). No behaviour change; verified by self-E2E (credits
marker detected -> countdown fill renders + advances while playing ->
auto-skip fires -> item marked Watched + player closes).
S4a-3a of the PlexPlayer decomposition. Move two byte-equivalent
behaviours into lib/plexPlayerHooks.ts: usePlexProgress (the 10s
interval flush + pause/pagehide beacon + reopen cache-patch checkpoint;
absRef stays in the component since seek/skip/ended mutate it too) and
usePlayerVolume (HTML5 <video> 0..1 volume + independent mute, persisted
+ re-applied per session). usePlayerVolume is the seam the later R12
volume-consolidation sprint will rework to share playerVolume.ts's
tested pure core with the YouTube player. No behaviour change; verified
by self-E2E (volume-bar sets + persists video.volume, 'm' toggles mute
keeping the level, pause fires plexProgress 200).
S4a-2 of the PlexPlayer decomposition. Move three self-contained,
byte-equivalent behaviours into lib/plexPlayerHooks.ts: useWallClock
(15s top-bar clock), useFullscreen (wrapper fs toggle + reactive flag),
and useControlsVisibility (auto-hiding control bar + wake). The ref
mirrors the hooks read (barAutoHide/menuOpen/video-paused) stay in the
component and are passed in, so the timer sees fresh state. No behaviour
change; verified by self-E2E (real HLS playback: clock renders, controls
auto-hide after 3s while playing, wake restores + stays while paused).
S4a-1 of the PlexPlayer decomposition. Move the module-level pure
helpers (prefs types/defaults, track-language resolution, subtitle
shadow CSS, wall-clock formatting, HLS_SUB_LAG, fmt) to
lib/plexPlayerPrefs.ts and the presentational building blocks
(Ctrl/Slider/Toggle/PanelHead) to plexPlayerControls.tsx. No hooks or
behaviour touched; PlexPlayer.tsx 1889->1661.
R12 S4b. PlexShowView and PlexSeasonView were copy-paste siblings — the same
glass hero frame, play/resume poster button, customize-menu slot, and the
mark-watched action button, duplicated verbatim. Two shared components now own
that structure:
- DetailHero: the hero shell (frame + poster button + customize slot + info
column via children); the season page passes a smaller poster size.
- WatchedToggle: the mark-whole-show/season watched action (icon + label flip);
the differing labels are props.
Single source of truth for the hero — a future hero change touches one place
instead of two. Gate green (tsc / eslint 0 errors / prettier / knip / vitest 79)
+ self-E2E: the show detail and season detail pages both render their hero,
metadata, actions and watched-toggle identically to before (screenshot-verified;
the stale [hmr] PlexBrowse errors in the console buffer are from an intermediate
edit — the fresh Vite server logs clean).
R12 S4c. Channels.tsx shed its ~150-line column block and 3 cell components
into the co-located channelColumns.tsx:
- SyncBadge / SyncCell / ActionsCell move verbatim (prop-driven, self-contained).
- the column array becomes a buildChannelColumns(deps) factory next to
subsColumn — the manager owns the state/mutations and passes them in; the
inline patch.mutate wrappers become onHide/onDeep callbacks so the factory
doesn't couple to the mutation type.
Channels.tsx 905 -> 628 lines; no behaviour change. Gate green (tsc / eslint 0
errors / prettier / knip / vitest 79) + self-E2E: the manager renders every
column and cell (priority stepper, actions, sync badges, tags) with no console
errors.
- admin._decide: add an idempotency guard (409 unless status was 'pending') so a
repeat/stale approve OR deny can't re-run its side effects — now that deny emails,
this stops duplicate denial mails and a rejection mail to an already-approved user.
- SavedViewsWidget: extract runViewMutation(fn, failMsg) (returns success) — dedups
the five try/catch+refresh blocks AND lets create/rename close their input only on
success, so a failed create keeps the user's typed name.
- worker._notify_download_done: take user_id/title captured BEFORE the caller's commit
(no post-commit refresh SELECT); add a so the inbox labels an edit job
'Edit ready' instead of 'Download ready' (new inbox.editDone i18n, HU+EN).
Gate green (ruff/tsc/eslint-0/prettier/knip/vitest 79/pytest 193); smoke-tested the
saved-views + notifications surfaces render clean on a fresh dev server.
Give equal-weight actions equal-weight feedback (R11).
S1 Plex error toasts: the watched-toggles (card / season mark-all / episode /
info-panel / show mark-all) used to fail silently on 403/404/429; each now surfaces
the server reason via a shared notifyActionError() + resyncs the optimistic flip.
The playback auto-mark-watched stays intentionally silent (a mid-play toast is
noise; the next sync corrects it). New generic lib/actionErrors.ts (Plex + saved
views reuse it; notifyYouTubeActionError delegates its non-403 case to it).
S2 download-done inbox notification: worker.py emits a best-effort type=download_done
notice to the job owner on real completion (regular + edit jobs, not instant
cache-hits), rendered/localized in the inbox with an Open-Downloads link.
S3 optimistic rollbacks: SavedViewsWidget create/rename/toggle-default/remove/reorder
and the inbox revert-state no longer swallow failures — they toast + refetch to
resync (the reorder's optimistic order reverts to server truth).
S4 access-request deny: deny_invite now emails the requester (send_access_denied),
mirroring approve — no more silent limbo.
- Generalize the recipe into a .btn-{tone} family: add .btn-danger / .btn-warn
siblings sharing one hover-dim, and migrate the two hand-rolled tone-action
buttons (ConfirmProvider danger confirm, Banner warning action) onto them, so
primary/danger/warn action paints can't drift.
- Reword the recipe comment: it owns paint + one standard hover-dim (not
'PAINT-ONLY' — that mislabel obscured the hover behaviour).
- Drop the now-redundant hover:opacity-90 from Banner's action-button base (each
tone's recipe supplies the hover).
The 3 previously hover-less primary buttons keep the standard hover-dim gained in
the prior commit (intentional consistency; now accurately documented). Gate green
(tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed btn-danger/btn-warn
compute to their tone/tone-fg tokens.
Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text +
hover:opacity dim) in index.css @layer components, alongside the selection-paint
recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24
files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping
each site's geometry (padding/radius/font/gap/disabled/shadow).
Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble,
selection toggles (tableHeader — R8 popover territory), and the round play FAB
(hover:scale, not the opacity-dim primary idiom). Three previously hover-less
primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover
dim for consistency.
Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated
button computes to accent/accent-fg and both recipe rules load.
- Extract the optimistic + debounced-flush feed-priority flow into a shared
useChannelPriority hook (mirrors useChannelTagActions); the manager and the
channel-detail page now share it instead of two drifting copies.
- NavSidebar: drop the always-true `slim` const and its dead expanded-rail
branches; refresh the now-stale pinned/collapsible comments.
- Remove the orphaned `navCollapsed` localStorage key (no readers/writers left
after the always-slim rail).
- Channel page: drop the wrapper native `title` that duplicated PriorityStepper's
own Tooltip.
The keyboard-focus nav-label finding needed no change: the popover platform
already wires useFocus (visibleOnly), so a real Tab press shows the flyout label
(verified in-browser).
Follow-ups on the 7-item batch:
- The slim nav slot was still w-20 while its card is w-14, leaving a ~24px dead
gap the filter tab/bar couldn't dock against. Slot is now w-14 (exactly the
card width), so the filter's flat-left tab sits flush on the rail's right edge
(measured gap 0), both collapsed and expanded.
- The Plex art backdrop was invisible: a position:fixed layer at z-index -1 is
painted BEHIND the body's opaque background here, so nothing showed. Paint the
art on the app root (#root) instead — it's exactly the viewport and a real
ancestor of the transparent nav/header/content, so the art fills the whole page
edge-to-edge and the glass chrome floats over it (verified in-browser).
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
PriorityStepper and add it to the channel detail header (subscribed, non-demo);
backend channel_detail now returns the per-subscription `priority`.
2 Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
transparent no-op here, so it's glass-card that provides the depth); clamp Plex
card titles to a fixed 2-line height so 1- vs 2-line titles align.
3 Nav rail is now always a slim icon-only column, flush to the left edge; each icon
reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
navCollapsed pref plumbing.
4 Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
full-height slot next to the rail), so approaching the nav no longer pops the
filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
grace period + split hover/focus handler groups.
5 Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
transparent app) instead of only the inset <main> box.
6 Plex season binge continues across seasons: the season view's play queue now
spans the whole show (allKeys), so a season's last episode rolls into the next
season's first (seasonKeys stays for "add season to playlist").
7 When a Plex video/episode ends with nothing queued next, the player returns to
the previous page (onEnded → onClose) instead of stranding a black screen.
The first S4 pass migrated a subset, which left same-idiom selection pills
hand-rolled and made adjacent controls inconsistent (a /code-review finding).
This completes the sweep so every selected-among-options control shares the
recipe:
- plain pills → .pill (+ aria-pressed): Channels status filters + view tabs,
Sidebar filter-tag chips + date presets + custom-dates, Stats tabs + ranges.
- glass-card-resting pills → .pill with the resting `glass-card glass-hover` kept
in the non-pressed branch (solid accent when pressed): PlexSidebar library
scope + the facet Chip (retired the round-1 "deferred" note — the recipe
already expresses this via the resting branch, no new variant needed).
- custom-dates aria-pressed is coerced with !! so a date string can't leak into
the attribute and miss the [aria-pressed="true"] paint.
- drop the dead `border` on ChannelTags' chip-solid (accent border on accent bg).
Out of scope (a separate concern, NOT selection-among-options): primary/action
buttons that share the accent fill — Banner action, ConfirmProvider primary,
PlexBrowse primary, PlayerModal watched-toggle — these want a future .btn-primary
recipe. Left by-design: tableHeader filter options (PopoverItem + keepSelectedPaint,
owned by the popover platform).
The ~14 hand-rolled "this is the selected/current/active one" idioms each
repeated the same `sel ? "bg-accent text-accent-fg border-accent" : "…"` inline
ternary, which had drifted. Three attribute-driven recipes in @layer components
now own that paint, keyed on the ARIA the control already carries: .pill
(aria-pressed segments/toggles), .is-current (aria-current nav/list items), and
.chip / .chip-solid (tag & filter tokens). The JSX just toggles the ARIA; the CSS
paints, so one control's selected look can't diverge from the next's. No
focus-visible in the recipes — the @layer base rule already rings every button/a.
Migrated 15 sites (Feed show/content, Channels tag filter, Sidebar or-and +
scope, PlexSidebar match, Tabs, SettingsPanel tab-rail, PlexPlaylistView layout
[soft], NavSidebar nav + settings, ActiveFilterChips, PlexSidebar RemovableChip,
ChannelTags, ChannelPicker). Deliberately left: PlexSidebar facet Chip and
PlexPlaylistView already covered; SavedViews row (paint split across two
elements), the scheme swatch (border-only on a coloured swatch), and
ChannelPicker's option rows (text-only selection) — noted for a follow-up.
A hovered/roving CHECKED row got only a thin ring (R9 split), reading as a weak
afterthought next to an unchecked row's vivid accent fill. Now selected rows get the
SAME bg-accent/35 fill + a STRONGER ring (ring-2 accent/80 vs the unselected ring-1
accent/60); the child checkmark/checkbox stays legible on the tint. The one idiom that
DID rely on the R9 ring-only — the table-filter options, whose selected item paints its
own solid bg-accent ROW — opts back in via a new `keepSelectedPaint` PopoverItem prop,
so its solid paint isn't downgraded to a 35% tint. Scopes the R9 exception to the one
menu that needs it instead of penalising every checkbox menu.
Welcome kept the last local `const inputCls` (rounded-xl, py-2.5) after S3b consolidated
the other three, leaving its auth fields the sole inputs off the unified rounded-lg recipe.
Import the shared inputCls and drop the local const — now every input is truly one recipe.
(Auth fields go rounded-xl→rounded-lg, py-2.5→py-2 to match.)
The header row's three pills now share one shell: SyncStatus was glass/rounded-xl while
ModuleName and SearchBar were glass-menu/rounded-2xl/h-11. Aligned it (glass-menu, rounded-2xl,
h-11) so the pill row reads as one system, per user call.
The accent + warning Banner action buttons had no hover state (flagged during S1 UAT).
Add the house hover:opacity-90 idiom. (The header-pill .glass-chrome consolidation is
deferred — 2 of the 3 pills already share the glass-menu/rounded-2xl look; unifying the
odd one, SyncStatus, needs a visual call on whether it becomes a full-height pill.)
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent
RING focus), replacing the settings-family border-focus style app-wide:
- deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import
the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match.
- ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/
table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap
bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is.
- refreshed the form.tsx doc-comment (the old two-style split is gone).
The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation.
- NotificationsPanel per-row .glass -> glass-card (the rows are cards, not panel surface).
- New .overlay-backdrop recipe (4px blur = backdrop-blur-xs) with a data-perf override that
DROPS the blur. The viewport-sized modal scrim blur (Modal/PlayerModal/Welcome) was a raw
backdrop-blur-xs utility that survived perf mode while every .glass had its blur dropped —
the most expensive blur in the app. Overlays keep their bg-black/NN.
- gotcha: write ONLY the standard backdrop-filter; a manual -webkit- line makes lightningcss
drop the standard prop (webkit-only output -> no blur in Firefox).
The ~124 px-frozen text-[Npx] labels didn't track the Settings font-scale slider
while their rem-sized neighbours did. Add text-2xs (0.6875rem) / text-3xs
(0.625rem) font-size-ONLY tokens (no paired line-height -> matches the arbitrary
they replace, zero vertical-rhythm shift) and codemod every text-[Npx] to a rem
class (rare 8/9/12/13px -> rem arbitraries).
Also: --hdr-h 68px -> 4.25rem so header content clears the rem-sized pills at
scale 1.3 instead of sliding under; NavSidebar/SidePanel slot widths + their
icon/badge px sizes go rem so the rails keep pace with their (now-scaling)
labels; a w-24/h-[54px] thumb -> h-13.5 to preserve 16:9 at scale. Media
thumbnails, page-width caps and fixed container widths deliberately stay px.
Verified via getComputedStyle at scale 1.06/1.3: text-2xs 11.66->14.3px, header
clearance 72->88px, nav slot 246->302px — all scale proportionally.
- PlexBrowse PLAYABLE_TINT: transcode was collapsed onto remux's bg-warn; give it
bg-danger so direct/remux/transcode read as a distinct good->heavy green/amber/red
gradient again.
- Live indicators (VideoCard row + thumbnail badge, playlistColumns badge): revert to
fixed live-red + white instead of the --danger token. 'live' is a broadcast marker,
not an error state, and the badge sits over a thumbnail (no app-bg contrast need);
restores the conventional red/white LIVE look and decouples it from error retunes.
- ChannelPage exploring badge: drop the redundant dark:text-warn (no-op after the
token rename — --warn is already theme-scoped).
error/fatal toast shade merge left as-is (both are 'bad'; the 400/500 step was subtle).
- Add --info/--info-fg (blue; dark #60a5fa / light #2563eb) so info notifications
stop borrowing --accent, which collided with a status colour in schemes whose
accent shares its hue (YouTube red = error, Slate orange = warn, Matrix/Forest
green = success). Toaster info tone accent -> info.
- Restore hover feedback the codemod flattened: 'bg-X ... hover:bg-X' /
'text-X ... hover:text-X' collapsed to a no-op once both shades mapped to one
shadeless token. Reuse the house hover:opacity-{80,90} idiom (ConfirmProvider
danger button, DownloadCenter error link).
Add theme-scoped --danger/--warn/--success (+ -fg) tokens wired into the v4
@theme inline, replacing ~110 raw red/amber/emerald/green palette refs across
34 files. Fixes light-theme contrast failures (fixed Tailwind shades fell to
~1.7-3:1 on white). Status colors are scheme-independent — recognised by hue,
shifting only by theme to hold contrast (bright on dark bg, darkened on light).
Solid fills use the paired -fg text; @source inline guarantees the full -fg set
exists. IMDb rating gold kept as a deliberate decorative non-status exception.
The surface's onMouseLeave nulled hoverIndex, so moving the pointer off a menu snapped the highlight
back to activeIndex — the first item in a menu with a free-text input (ChannelTags), where the field
swallows arrow keys so there is no list-nav holding a keyboard position. The keyboard and typeahead
already reclaim the highlight by nulling hoverIndex in onNavigate/onMatch, so the mouseleave reset was
redundant; dropping it keeps the last hover. 0.60.3.
- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass
box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset
top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the
inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment.
- #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action
buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so
they were redundant. Verified: the pin button rings from the global rule alone.
- #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills.
- #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix
(has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured:
no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a
focus indicator); the overlapping ring is an accepted cosmetic edge-case.
- #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation.
Gate green (tsc/eslint/prettier/knip/vitest 79).
- Global :focus-visible fallback (@layer base): one accent outline for keyboard users on every
button/link/[role=button]/[role=tab]/summary that doesn't define its own. Non-input controls had no
visible keyboard focus app-wide; this closes the void with a single rule. More specific focus styles
(.selectable, an input's focus:ring, a menu item's data-active highlight) override it.
- PopoverItem now carries outline-hidden so menu items keep their data-active highlight without also
drawing the global outline (no double ring).
- Nav rail rows adopt .selectable (via rowBase); rail chrome buttons (logo/pin/about/sign-out) get a
focus-visible ring; the settings row already uses rowBase. Deduped the account trigger's selectable.
Deferred to R10: the chip/pill selected-PAINT unification (a .chip/.pill/.is-current repaint of the
~14 selection idioms) — the R10 token redefinition repaints those surfaces anyway, at less risk than a
hand migration now. R9's job (the focus/a11y foundation) is complete.
Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style confirms the global ring
renders (2px accent, offset 2px) on a plain button, .selectable overrides it with an inset ring, and
an outline-hidden menu button draws no global outline.
S1 — recipe foundation:
- Move .glass/.glass-card/.glass-menu into @layer components so a JSX shadow-* utility overrides the
glass box-shadow again; the ~9 dead shadow-* on glass surfaces (modals, menus, save-bar, chat) now
paint their intended drop-shadow. Perf-mode and :hover overrides stay unlayered so they still win.
- Add a .selectable recipe: the hover tint menu/list rows already use, PLUS the app-wide
focus-visible ring that was missing on non-input controls (the biggest a11y gap).
- Split the popover roving highlight from the selected state (AnchoredPopover): a selected/checked
PopoverItem now gets a ring ONLY, so its resting paint (bg-accent fill / text-accent / checkbox)
survives being highlighted instead of downgrading to a 35% tint or being recoloured. Fixes the 4
review findings deferred from R10-S0 generically; drops the leftover per-consumer data-active ring
in tableHeader.
S2 — worst offenders:
- Account-menu trigger + switch/add/sign-out rows adopt .selectable (visible keyboard focus).
- Saved-views rows: group-focus-within reveals the hover-only action buttons for keyboard users;
aria-current on the active row; focus-visible ring on every button.
- The 3 invisible-focus inputs get an accent focus ring: SearchBar + ShareDialog via focus-within on
the glass pill/row, AboutCell via focus-visible on the trigger.
Deferred to a later R9 sprint: the full unification of all ~14 selection idioms, a shared <NavItem>,
and .is-current/.chip recipes (no consumer yet in this pass).
Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style checks confirm the glass
shadow now wins, the focus outlines render (2px accent), and the split renders (selected ring-only,
unselected fill).
Second UAT finding on the tag popover: the keyboard-active row stayed put while
a fainter CSS :hover chased the pointer — two competing highlights. Root cause:
floating-ui's focusItemOnHover is (correctly) OFF for input-hosting menus because
setting its activeIndex on hover drags DOM focus onto the item and blurs the
create-tag input mid-typing; the only pointer feedback left was the item's own
`hover:bg-*`.
Fix, applied in the shared PopoverItem so it's general:
- Drive the visible highlight off a NEW `hoverIndex` state (highlightIndex =
hoverIndex ?? activeIndex), decoupled from floating-ui's activeIndex — hovering
sets only hoverIndex, never DOM focus, so one highlight follows the pointer AND
the input keeps its caret. Keyboard nav (onNavigate/onMatch) clears hoverIndex
so it takes the highlight back; leaving the menu clears it too.
- Split PopoverItem's `active` into focusActive (roving tabindex, keyboard) vs
visualActive (data-active highlight).
- Remove the now-redundant `hover:bg-*` from the 5 consumers so data-active is the
sole highlight (it was also a same-specificity tie with data-active).
Verified in-browser: hovering moves the single highlight; typing "testtag" in the
create field then hovering items keeps focus+caret on the input (caretPos intact);
mouse-leave returns the highlight to the keyboard row. Gate: tsc/eslint/prettier/
vitest 79 green.
The roving-focus row in every popover menu was barely distinguishable from the
menu surface — an accent fill at only 24%, and the recipe was copy-pasted across
5 consumers. Centralize it in PopoverItem (ACTIVE_HIGHLIGHT, applied to every
item) and strengthen it: accent/35 fill + a 1px inset accent ring for a defined
edge that reads across all 6 schemes in both themes. Consumers no longer carry
the recipe.
Uses the now-working `bg-accent/NN` / `ring-accent/NN` utilities (v4 does the
opacity modifier via color-mix) instead of the old hand-written color-mix
arbitrary value the v3 no-op forced. Verified in-browser: fill + inset ring
render on the active item, dark and light.
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.
- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
"rounded-sm" inside a releaseNotes.ts prose sentence.
Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.