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.
- 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.
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.
- applyVolume/setVol now pass the element's CURRENT level as the normalize
fallback instead of undefined, so a corrupt persisted volume holds the
playing level rather than jumping to PLEX_SCALE.default=1 (full) — the "full
blast" failure mode playerVolume.ts's header explicitly warns against. (The
old bare clamp muted instead; both are wrong for corrupt data.) readMerged
shallow-merges stored prefs without per-field validation, so a corrupt
volume is reachable.
- setVol normalises the level ONCE and both applies + persists that same value,
so the <video> element and the stored pref can never disagree.
- Test documents the hold-last-known contract (corrupt raw + valid fallback ->
fallback; only an unusable fallback lands on the scale default).
Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234).
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.
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.
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.
VERSION bump + chores release note for the R10 .btn-primary/.btn-danger/.btn-warn
recipe unification (refactor; a few primary buttons gained the standard hover).
- 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 app-wide 'glass over image' tier: panels stay near-opaque (surface 90%, menu 100%)
so chrome text is readable, but glass-card drops to 50% so the per-scheme backdrop image
gently shows through the feed cards (blur 22, inset 10). User-tuned via the live GlassTuner
and validated ("the image just barely shows through, text still readable"); supersedes the
uniform-88% pass that had killed the image-through-cards look. Only the data-backdrop=on
tier — :root (image off) and .glass-media (Plex art) unchanged.
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.
Root cause: html[data-backdrop="on"] and .glass-media shared ONE translucent tier, so a value tuned for
Plex art (meant to show its poster through) was forced onto the whole app — over the feed, the nav and
popovers read straight through it. Split them: the app-wide background-image tier is now near-opaque
(surface 88% / card 90% / menu 93% / blur 22px), while the Plex-art tier stays translucent (72/80/85/14).
Tuned live on the user's prod session (50% -> 72% was still too see-through; 88% reads clean). 0.60.2.
With a background image on, glass surfaces used surface-alpha 50% + 10px blur, so the feed read
straight THROUGH the nav/menus/popovers (user-reported: distractingly transparent). Raised to
72%/80%/85% + 14px blur — the backdrop image still shows as soft colour, but content behind no longer
punches through. Verified in-browser: nav bg alpha 0.5 -> 0.72, no inline override on a clean pref.
Cuts the v4 (Tailwind migration) + focus/a11y foundation work for release. User-facing note covers the
app-wide keyboard-focus ring, steadier menu highlights, the three previously-invisible focus inputs,
and keyboard-reachable saved-view row actions; the v4 migration and dead-style cleanup are listed as
chores.
First DOM-capable vitest lane for the repo: default env stays node (pure lib helpers), a test opts
into jsdom per-file with a // @vitest-environment jsdom docblock; plugin-react wired for JSX/hook tests.
Adds jsdom + @testing-library/react devDeps and 3 back-ladder tests (LIFO peel, stacks over
useBackToClose, inactive layer is transparent) driven by direct popstate dispatch — deterministic,
no dependency on jsdom's async back(). Closes the deferred R2 component-test gap (useBackToExit).
The large-logical-iframe + CSS-scale-down trick to force a 1080p start did not
work behind the user's work VPN (YouTube caps quality by network there regardless
of viewport size), and the two-phase native switch could downgrade a latched
1080p to 480p on some streams. Not worth the complexity for an unreliable win —
revert to the original "opens at whatever resolution YouTube picks" behaviour;
the user sets 1080p manually via fullscreen + the gear as before.
Kept: the windowed maximise (fullscreen) button added to the player meta row.
Two-phase HD: phase 1 keeps the fixed 1920x1080 iframe scaled down to latch
1080p; once the quality poll sees hd1080, switch to native responsive sizing so
YouTube renders its own controls (the gear especially) at full size instead of
scaled-down. A latched pick survives the shrink on a normal VOD.
CONDITIONAL / under observation: on one was_live stream in dev, quality dropped
hd1080 -> 480p ~2s after going native (YouTube re-caps to the small viewport);
held 1080p elsewhere. Gated behind HD_DROP_TO_NATIVE so phase 2 can be turned
off in one line to fall back to the guaranteed-1080p permanent-scale phase 1.
Correctness:
- Escape is now strictly topmost-only for EVERY mode. Hover popovers register in
the shared layerStack too (and floating-ui's own Escape is off for all modes), so
an open tooltip/hover-card no longer either swallows the Escape from a Modal/player
underneath (its document-level stopPropagation vs their window listeners) or
double-closes both. A player's own overlay hover-card now takes Escape before the
player, keeping it alive.
- An invisible (referenceHidden, scrolled-out) surface is no longer a dismissable
layer, so it can't silently eat an Escape aimed at what's actually on screen, nor
make a player defer.
- PopoverSurface splits into a wrapper that returns null before any hook and an inner
that holds them, so a mounted-but-closed popover (≈10 per table row) subscribes no
fullscreen listeners — was ~1800 document listeners on a 100-row table.
- The fullscreen portal no longer remounts on a key: one persistent container is
re-parented body↔fullscreen-element with appendChild (atomic move), so the open
panel's focus/caret/scroll/animation survive a transition and an element SWAP is
handled by the element-identity dependency.
- AddToPlaylist drops `disabled={busy}` on its items (it blurred the focused item to
<body> and permanently killed roving focus); re-entrancy is already guarded in
toggle(), and aria-busy conveys the pending state.
- New `hostsInput` option turns off BOTH typeahead and focus-item-on-hover for the two
menus with an inline text field, so a pointer passing over a list item no longer
steals the field's caret and mis-routes the next Enter. Replaces their `typeahead:false`.
- ChannelTags' single-pass fit loop gains a whole-set fast path, so it stops collapsing
chips into "+N" when they would all have fitted.
- `initialFocus={-1}` is applied only when a `selectedIndex` is set (the sync-focus race
it works around); a plain nav menu now lets the focus manager focus normally, so an
item-less menu (first-run tag editor) still has a keyboard entry point.
a11y:
- `ariaLabel` now wins on menu-role panels — the surface strips useRole's competing
`aria-labelledby` when an explicit label is given (verified: the filter/View menus
are named by their column/label, not the trigger).
- PopoverItem routes caller props THROUGH getItemProps and applies its own
tabIndex/disabled/data-active/className last, so a caller handler or tabIndex can no
longer be silently dropped or clobber the roving model.
Regressions / consistency:
- Tooltip shows/hides on contact again (explicit 0/0 hover delay) instead of inheriting
the platform's 120/140 ms intent delay.
- ChannelTags' editor list regains its useScrollFade overflow mask.
- ChannelTagsControl takes a `bounded` prop; the channel detail page passes false so
tags show inline in the wide header instead of collapsing to "+N".
- ChannelPage's tag-catalog query is gated on subscribed && !demo (matches the only
consumer's render condition); the manager's tag toggle now also invalidates
qk.channel(id) so the detail page's cached tags stay in step.
- DetailCustomizeMenu + PrefToggle revert off the platform to the pre-R8 inline
`absolute` menu: in the player's overlay it renders inside a transform:scale(1.25)
subtree, and portalling out to <body> painted it at 1×. Same reason the player's own
menus stay hand-rolled; this also restores PrefToggle to plain buttons (the rows are
independent toggles, not a radio group). The stale PlayerModal fullscreen comment is
corrected too.
Gate green (tsc/eslint/76 vitest). Self-UAT in-browser: hover-over-item no longer
steals the tag input's caret; text filter keeps caret while narrowing; menu aria-label
is the label not the trigger; selectedIndex lands focus on the checked item; roving +
Escape-close+refocus intact; add-to-playlist items never disabled. NOT verified here
(needs a device / playing media): the fullscreen portal reparent and the player-overlay
paths — owed device UAT, as they already were.
AddToPlaylist, LanguageSwitcher, the Plex DetailCustomizeMenu and ViewSwitcher each
had their own anchoring, dismissal and (for ViewSwitcher) keyboard engine. All four now
use the platform, so their items are PopoverItems with real menuitemradio/aria-checked,
roving focus, typeahead and flip/shift anchoring.
Two platform additions this needed:
- `selectedIndex`: opening a single-choice menu must land on the choice already in
effect, which is what ViewSwitcher's hand-rolled engine did by seeding its roving
index to the current option.
- `ariaLabel` on PopoverSurface: an icon-only trigger leaves the panel with no name.
Fixed while wiring `selectedIndex` up: it stranded focus on the menu container. That
path focuses its item synchronously in a layout effect, and FloatingFocusManager's own
initial focus then ran afterwards and took focus back; the no-selectedIndex path only
escaped this because it defers focus to a microtask while waiting for the item list to
populate. On a roving list the list navigation owns initial focus, so the manager is now
told to take none (initialFocus -1).
Behaviour changes worth naming:
- The header LanguageSwitcher no longer opens on hover. The platform focuses the first
item on open, so a hover-open would yank focus off whatever the user was doing. Both
variants are now click-only, like every other menu in the app.
- PrefToggle became a PopoverItem, so the rows callers inject through the menu's `extra`
prop join the same roving list as the built-in ones.
- AddToPlaylist fetches its list on open, so on the first paint there are no items to
focus and focus stays on the trigger (as it did before the migration); arrows move
into the list once it lands.
Verified in-browser: ViewSwitcher opens on the checked mode, arrows rove, Escape closes
and refocuses the trigger; the ChannelTags pilot is unregressed; AddToPlaylist anchors
bottom-start without the click reaching the feed card underneath; the rail language menu
keeps its right-edge, bottom-aligned anchoring.
The platform's FloatingPortal targeted <body>, which is painted UNDERNEATH whatever
holds the fullscreen lock. That is why the player's in-bar menus were either
non-portaled (and clipped) or, for add-to-playlist / download, disabled outright in
fullscreen. PopoverSurface now portals into `document.fullscreenElement` while one
exists, via a new reactive `useFullscreenElement()` on the existing WebKit-aware
fullscreen lib.
Two floating-ui details this had to work around: a null `root` means "the container
does not exist yet" (no portal node is created at all), so the no-fullscreen case must
pass undefined; and the portal node is built once and ignores a later root change, so
the portal is keyed on the fullscreen state to remount on a transition.
Migrated the three hand-rolled hover engines onto the platform:
- Tooltip: kept as a passive caption via a new `passthrough` prop on PopoverSurface,
so it cannot swallow a click aimed at a control it overlaps during the close delay.
Gains aria-describedby wiring it never had.
- AboutCell: the 160 ms hide-timer hover bridge is now safePolygon; the close-on-page-
scroll is now hide-and-follow, so the card reappears when the row scrolls back.
- TagManager: same bridge removal; the scroll-fade moves to an inner scroller so the
mask rides the scrolling box rather than the glass panel.
Verified in-browser: tooltip flip + centering + pointer-transparency, About card flip-
above with the measured height, tag popover right-start anchoring, and the portal
round-trip body -> fullscreen element -> body on a live fullscreenchange.