The column filter panel, the nav's account panel and the header's sync read-out are
mixed content with their own tab order rather than lists of choices, so they migrate as
`dialog` popovers — trapping and returning focus, dismissing through the shared layer
stack instead of their own document listeners.
The column filter needed a structural change: its open state lived in the header row
while the panel rendered per column, which cannot work when the platform anchors a panel
to its own trigger. Funnel and panel are now one module-level component (module-level
still matters — declaring it inline once remounted the popover on every keystroke, which
broke the caret and IME composition). That retires three hand-rolled focus chores at
once: capturing the trigger element at open time because a per-column ref nulled out
before focus could return, the effect that stopped Escape stranding focus on <body>, and
the useDismiss registration that had to include the trigger so its own mousedown did not
close-then-reopen the panel.
The filter also stops being one undifferentiated panel: a text filter is a dialog whose
input takes initial focus, while the select and multi filters are real menus with roving
focus — select as radios, multi as checkboxes.
`menuitemcheckbox` is new to the platform, and the two existing multi-select toggle lists
(channel tags, add-to-playlist) move onto it as well: several tags or playlists can be on
at once, so they were never one exclusive choice. Role-only change, no interaction
difference.
Verified in-browser: all three anchor exactly where their hand-rolled placement did,
Escape closes and returns focus to the trigger, the text filter keeps focus and caret
position while typing narrows the table, and the multi filter opens as a checkbox menu
with roving focus.
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.
Replace the three mutually-unaware Escape systems — Modal's private modalStack,
the popovers' document listeners with a blanket stopPropagation, and the two
players' modalCount() checks — with one shared lib/layerStack.ts. Every open
dismissable layer registers in open order; a layer acts on Escape only when it
is topmost, and reads anyLayerAbove() to decide whether to defer. Coordination
is now a stack lookup, not event-propagation order, so it no longer depends on
which listener happens to fire first.
- Modal + useDismiss register via the stack; useDismiss drops the blanket
stopPropagation (its documented side effect: it swallowed a player's own
Escape while any popover was open).
- Both players register as a layer and defer via anyLayerAbove() instead of
modalCount(); PlayerModal checks it before its maximise/fullscreen steps.
A player MENU is a layer too, so it closes first and the next Escape reaches
the auto-skip-cancel — fixing the documented PlexPlayer bug.
- AnchoredPopover routes non-hover Escape through the stack (floating-ui's own
escapeKey off there) and registers as a layer, so a player/modal underneath
defers once S4 migrates the in-player menus. FloatingTree still owns
popover-vs-popover ordering; the stack adds the cross-type ordering.
The tag editor's active row barely differed from the menu background. Drive
the highlight off the roving `data-active` attribute (reliable across click-
vs key-open, unlike :focus-visible, which a click-open's programmatic focus
doesn't trip) and tint it with the accent. `bg-accent/NN` can't be used —
the theme's --accent is a hex var and Tailwind v3 can't inject alpha into it
— so use a color-mix arbitrary value, matching the app's glass idiom.
Add the ViewSwitcher-grade menu model to the R8 popover platform, via
Floating UI's idioms rather than a second hand-rolled roving engine:
useListNavigation (↑/↓/Home/End roving, focusItemOnOpen so both click- and
key-opens land on the first item), useTypeahead, and FloatingFocusManager
(trap into the surface, return focus to the trigger on close). A new
<PopoverItem> self-registers for navigation and carries the right role
(menuitem / menuitemradio+aria-checked / option+aria-selected); menu/listbox
surfaces are focus-managed by default.
Wire the ChannelTags editor as the pilot: tag toggles become menuitemradio,
and the inline create-input stopPropagations non-Escape keys so arrows and
typed chars stay in the field (typeahead off for that menu).
Address the two remaining review findings on the tag work:
- extract useChannelTagActions — the optimistic attach/detach + create-and-attach flow that
the channel manager and the channel detail page had copy-pasted; the cache-shape-specific
optimistic update is injected (manager maps a ManagedChannel[]; detail patches one
ChannelDetail), so the two stay in sync;
- add a DB-lane test asserting GET /api/channels/{id} returns exactly this user's personal
tag links — not another user's, not the auto/system (user_id NULL) links.
attach_tag/detach_tag call _user_subscription, which 404s for a channel you don't
follow — but the tag control was rendered for any non-demo channel, so tagging an
un-subscribed/explored channel popped a global error + reverted the optimistic add.
Gate the control on ch.subscribed (personal tags are for your subscriptions anyway).
Bring the same tag control to the channel page, next to Subscribe:
- backend: GET /api/channels/{id} now returns this user's `tag_ids` for the channel
(added to _channel_detail_dict + the ChannelDetail interface), matching the manager
list shape;
- ChannelPage renders the shared ChannelTagsControl, wired to attach/detach/create with
an optimistic update on the channel-detail cache (reverting on failure) and a manager
list invalidation so a tag added here shows there too.
Reuses the R8 popover platform + ChannelTagsControl — no new tag UI.
Address the /code-review findings on the tag cell:
- key the overflow-fit measurement on tag id+name so a RENAME (which changes chip
width) re-runs the fit, not just add/remove;
- reserve the '+N' pill space using the MEASURED gap (gap-1 is rem, scales with the
font slider) instead of a px constant;
- force the reveal-all hover popover's state closed when nothing is collapsed, so it
can't linger open against an unmounted '+N' trigger.
Second UAT pass on the tag cell + popover platform:
- Popover top-left flash (real root cause): the popIn keyframe animates `transform`,
which overrode Floating UI's positioning `transform: translate(x,y)` for the animation's
duration, parking the popover at (0,0). Move the animation to an INNER wrapper so the
positioned element's transform stays clean (verified: menu is at the correct spot from
frame 0). The isPositioned visibility gate is kept as a belt-and-suspenders.
- Table shifted right / not centered: my previous fixed-15rem tags column + nowrap Sync
pushed the table past the max-w-[96rem] wrapper so it overflowed. Trim to 12rem tags and
shorten the long "full history queued/coming" sync labels to "queued"/"coming" so the
table fits the wrapper (1594 <= 1628) and centers again.
- "+N" count wrong/missing while adding tags: replace the iterative shrink (which lagged a
render and could clip the pill's digits) with a deterministic measure — a hidden measurer
lays out all chips + a sample pill, and we compute the exact fit in one pass. The count is
always correct now (verified across varied rows), and a bare "+" with no number is
structurally impossible.
Three UAT refinements on the tag cell + popover platform:
- Fixed tag-column width (w-[15rem], was max-w): under table-layout:auto a content-
driven cell re-solved every column's width on each chip added, so the table visibly
shifted (and the "+N" count flickered) until the cap was hit. A fixed footprint makes
adding/removing tags move nothing (verified: column x unchanged, "+N" count stable).
- No popover flash: gate the floating surface's visibility on Floating UI's isPositioned
so it never paints at (0,0) top-left for a frame before the transform lands.
- Sync cell no longer wraps: flex-nowrap so the recent/full badges stay on one line
(the column grows to fit them instead of breaking to two rows).
Redesign the channel tag cell (the popover pilot's first real consumer) to fix two
UAT-reported bugs and two UX gaps:
- Jumping editor (bug 1): the "+" edit trigger now sits in a STABLE slot at the end,
so toggling a tag (which grows the chip row) no longer moves the anchor — the editor
popover stays put (verified dx/dy = 0).
- Whole-table reflow (bug 2): the cell is width-bounded (max-w inside the cell, since
table-layout:auto ignores the column width — the documented channel/About trap) and
single-line, collapsing overflow into a hoverable "+N" pill. Rows no longer wrap and
reflow (verified uniform row heights).
- The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline
(type + Enter → create + attach) — tagging no longer requires the top-of-page dialog.
New shared ChannelTagsControl (used by table + cards; the detail page is Part 2) over the
R8 popover platform: a `menu` editor popover + a `hover` reveal-all popover. Greedy rem-safe
fit via ResizeObserver. `api.createTag` now returns Promise<Tag>.
Introduce useAnchoredPopover + PopoverSurface over @floating-ui/react — one primitive
replacing the hand-rolled measured engines: flip() (two-way flip), shift() (width clamp),
hide() (hide-and-follow when the trigger leaves its scroller), autoUpdate (scroll follow),
FloatingPortal (escape ancestor stacking contexts). Supports menu + hover modes; focus
management lands in S2.
Wrap the app and the public watch page in <FloatingTree> so Escape / outside-press dispatch
is topmost-only across nested layers (S3 folds in the Modal + players).
Migrate the richest engine, TagsCell, as the pilot — ~140 lines of bespoke positioning
(coords state, flip layout-effect, scroll rAF loop, useDismiss, Overlay) collapse into one
hook call. Verified in-app: click opens, portals to <body>, positions below the trigger,
role=menu + aria-expanded/haspopup, Escape and outside-click close, no console errors.
Rebuild the landing preview into a per-module screenshot gallery (feed hero + channels, playlists, search, player, downloads, Plex), reusing the existing Preview/Lightbox. Rewrite the hero + feature copy in a plainer, concise tone that reflects the current modules (Download Center, Plex, two-way playlists), in EN and HU. Regenerate all screenshots name-free. Bring the README up to date: fix the broken landing image, add the Plex feature, and correct the now-temporary YouTube-search behaviour.
Add facade.test.ts asserting the two invariants that keep lib/api.ts safe:
no method name is defined in two slices (a silent last-wins spread override),
and the `api` object is exactly the union of the slices (nothing added inline
in the barrel, nothing lost). Verified to have teeth — an injected duplicate
key fails the collision test with a named message.
Closes R7.5: the api layer is now per-domain modules behind a mechanically
guarded spread-only façade.
Move the last four inline domains out of lib/api.ts into per-domain slices,
each owning its types + method slice. The barrel now holds ZERO endpoint logic
and zero types of its own — it only re-exports core's HTTP machinery, re-exports
each domain's types, and spreads the 15 slices into the single `api` object.
A new endpoint therefore has nowhere to land but a domain module. The `api`
shape and all call sites are unchanged (every req/beacon call byte-identical
to dev; no method dropped, no key collision).
Move feed, channels, sync, playlists, tags, notifications, saved-views and
quota out of the lib/api.ts god object into per-domain slice modules, each
owning its own types + method slice and spread into the façade. The `api`
object shape and all 54 call sites are unchanged; account/auth/setup/admin
stay inline until S2.
Reunites the interleaved tags read/CRUD that motivated the epic.
Reported: on iPad Brave a shared video's audio still stopped when backgrounded, while youtube.com
keeps playing. Root cause: routing the element's audio through createMediaElementSource pulls it out
of the normal media pipeline the browser's background-playback feature (and iOS) hooks into, and iOS
then suspends the AudioContext on background — the opposite of the goal. YouTube uses a plain media
element + Media Session and lets the browser background it. So drop the Web Audio graph entirely and
keep only the Media Session metadata + play/pause handlers, leaving native background handling in
charge. Frontend-only.
Facebook's scraper rejected our self-hosted poster as "Corrupted Image": the poster is an
ffmpeg-produced JPEG (non-standard marker order) that browsers/iOS/Twitter decode fine but FB's
stricter processor won't. For a YouTube source, point og:image at the video's stable, non-expiring
`i.ytimg.com/vi/<id>/hqdefault.jpg` (a Google-encoded JPEG FB decodes reliably) built from the id in
the stored thumbnail — instead of the stored `sqp`-signed variant, which can expire. Non-YouTube
sources keep the poster fallback (fine everywhere but FB). Declares 480×360 so FB includes it on the
first async scrape. og.py only — no schema change.
- og: guard safe_abs_path's None (missing/gc'd poster) before _jpeg_size, else the public /watch OG
render crashed with an uncaught AttributeError
- channels: hoist the priority step button to module scope so it no longer remounts on every
hold-repeat tick (which dropped the pointer capture and could let a hold run away on pointer drift)
- watch: only route audio through Web Audio on iOS (where a backgrounded <video> is suspended);
other platforms keep their reliable native audio, avoiding a foreground-silence risk if the
AudioContext can't resume
- share: drop the unstable `create` mutation object from the auto-create effect deps (it changes
every render); the links.data trigger + autoTried ref already fire it exactly once
The clipboard-on-focus finding is intended (the user explicitly asked for auto-paste on focus; the
read is best-effort and browsers reject it without a gesture, so no real prompt fires).
A shared /watch video stopped the moment you backgrounded the browser on iOS (Safari/Brave suspend a
<video>), while an audio-only share kept playing — the user wanted YouTube-style background playback
for video too. Route the element's same-origin audio through a Web Audio graph (once that graph is
live, iOS keeps the audio session running in the background while the frame freezes) and add Media
Session metadata + play/pause handlers for lock-screen controls. Set up lazily on first play (the
gesture an AudioContext needs) and only once. Best-effort — any unsupported bit falls back to the
plain <video>. Background continuation itself is an iOS behaviour only real-device UAT can confirm.
The priority arrows changed one step per click, so setting a large value meant many clicks. Now a
press-and-hold auto-repeats at an accelerating rate (a quick tap still steps once; keyboard path
kept), and the PATCH is debounced per channel so a hold sends one request with the final value, not
one per notch (flushed on unmount). The value also sits LEFT of the arrows (cursor never covers it)
and is click-to-edit — type any integer directly, negatives allowed — via an absolutely-positioned
input that overlays without disturbing the table row layout.
The catalog only stores the recent-backfill/RSS subset of a channel's uploads (video_count is
YouTube's raw total), so a subscribed channel looked like it was "missing" videos. Now the channel
page header shows "{stored} / {total} videos" when there's a gap, and a "Full history" action queues
the deep backfill (the same deep_requested trigger the channel manager uses) — showing a loading
state until done. Backend: channel_detail now returns deep_requested/backfill_done.
Failed downloads stored the raw yt-dlp/ffmpeg text and dumped it as a truncated red
one-liner in the queue. Classify the failure into a category (unavailable / login_required /
geo_blocked / format_unavailable / drm / postprocess / source_missing / network / unknown)
and surface it properly:
- backend: app/downloads/errors.classify maps the raw message to a category code; the worker
stores it in the new download_jobs.error_code (migration 0061) at both failure sites; resume
clears it; the serializer exposes it. Raw text kept as technical detail.
- frontend: the queue row shows a clean localized reason + a "details" link opening a modal
with the explanation, the raw text behind a disclosure, and a Retry only for transient
categories (network/postprocess/unknown).
- tests: classify() unit coverage over verbatim yt-dlp/ffmpeg message shapes.
- Always land on the Queue tab (drop the persisted last-tab) so new work/failures are the default view
- Show the download date (relative) on Library rows
- Share dialog opens with a default link already generated when none exists; all link controls stay available
- Thumbnail falls back to the self-hosted poster when a remote (i.ytimg/fbcdn) thumbnail 404s
- Add a clipboard-paste affordance to the add-URL box (explicit button + best-effort on focus)
The S3b api.ts split introduced 5 exported-but-never-imported types
(PlaylistKind/PlaylistSource, DownloadJobKind, YT internal option/data shapes).
The publish gate's knip lane flagged them (my per-step checks ran tsc/eslint/
prettier/vitest but not knip). They're used inside their own modules by the
interfaces, so make them local (consumers compare against literals, never name
the type).
- barrel re-exports domains with `export type *` (not `export *`) so the
internal downloadsApi/plexApi/messagingApi slices stay behind the façade;
plex's runtime value exports (EMPTY_PLEX_FILTERS/plexFilterCount) re-exported
explicitly
- App.tsx: drop the now-redundant `prefs as Record<string, unknown>` self-cast
(Me.preferences is already that type) and its `prefsRec` alias
- plex.ts: comment why plexSetState keeps its status union inline (reusing
VideoStatus would make an api.ts↔plex.ts type cycle)
Behaviour-identical. Gate green: typecheck, eslint 0, prettier, 74 vitest.
The E2EE direct-message types (MessageUser/Message/Conversation/MyKeyBundle/
KeySetup) + the ~9 messaging methods move into a self-contained
api/messaging.ts exporting a `messagingApi` slice (imports only core's req).
api.ts spreads the slice and re-exports the types. api.ts 882 -> 819 lines; no
runtime change. Gate green: typecheck, eslint, prettier, 74 vitest.
All Plex types (browse cards, show/season/item details, collections, native
playlists, filters/facets, watch-link) + EMPTY_PLEX_FILTERS/plexFilterCount/
appendPlexFilters + the ~40 plex methods move into a self-contained api/plex.ts
exporting a `plexApi` slice (imports only core's req/beacon). api.ts spreads the
slice and re-exports the types via `export *`. api.ts 1352 -> 882 lines (halved
from the original 1793); no runtime change. Gate green: typecheck, eslint,
prettier, 74 vitest.
The download-center types (DownloadJob/Spec/Profile/EditSpec/ShareLink/... and
the admin storage/quota shapes) + all ~30 download methods move into a
self-contained api/downloads.ts exporting a `downloadsApi` slice. api.ts spreads
the slice into the `api` façade and re-exports the types via `export *`, so the
53 call sites are unchanged. Cleanly separable — the domain's types reference
only each other and its methods only touch core's req/getActiveAccount. api.ts
1583 -> 1352 lines; no runtime change. Gate green: typecheck, eslint, prettier,
74 vitest.
First step of splitting the api.ts god module behind its façade. Move req,
HttpError, localizeDetail, the connectivity toast, ReqConfig, the per-tab
active-account plumbing (getActiveAccount/setActiveAccount/clearActiveAccount/
beacon), setupHeaders, and setUnauthorizedHandler into lib/api/core.ts. api.ts
imports the machinery it uses and re-exports the public surface, so the 53 call
sites importing from "./api" are unchanged. api.ts 1793 -> 1583 lines; no
runtime change (pure move). Gate green: typecheck, eslint, prettier, 74 vitest.
Removes all 13 remaining `any`s and flips the ESLint rule from off to error so
new ones can't creep back:
- api.ts prefs/data JSON bags: Record<string, any> -> Record<string, unknown>,
narrowed at consumers (App language guard; NotificationsPanel shapes the
notification `data` per type behind its existing typeof guards)
- catch (e: any) -> catch (e) + `e instanceof HttpError` narrowing
(SettingsPanel, SetupWizard, PlexPlayer)
- the YouTube IFrame API boundary: new lib/youtube.ts types (YTPlayer/
YTPlayerEvent/YTNamespace + a Window augmentation) replace the 6 player anys;
tsc surfaced the exact method surface to declare
- NotificationsPanel `t` props: any-options -> i18next TFunction
Gate green: typecheck (app+node+e2e), eslint 0 errors (rule now enforced),
prettier, 74 vitest.
- FeedResponse claimed a required `limit`, but /api/search/youtube never
returns it (only /api/feed does) and nothing in the app reads it. Drop the
field so searchYoutube's Promise<FeedResponse> stops lying; documented for
re-add if a consumer appears.
- pageMeta's hand-written 13-case Page->title switch now derives from the
ModuleDef registry via a new optional `titleKey` (the longer tab title, e.g.
"Channel manager"; falls back to the short nav labelKey). A new page is one
touch (add it to MODULES) instead of also editing a switch. A test pins all
13 title keys byte-identical to the old switch (suite 74).
Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind
were bare `string`; type them as named unions verified against the backend
(VALID_STATES={new,watched,hidden}; kind user|watch_later; source local|
youtube; job_kind download|edit). VideoStatus then propagates through the
optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal
props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a
derived filter, never a stored status) at every call site.
Code-review follow-ups on the S2 factory:
- add queryKeys.test.ts pinning every branch (bare vs param, null-segment
preservation, single-id, variadic composite spreads) — the contract the
epic exists to protect now has a test (suite 68 -> 73)
- unify feed/feedCount/facets on the `!== undefined` guard the rest of the
factory already uses (behaviour-identical for the always-truthy FeedFilters,
removes the collapse-on-null footgun)
Two findings skipped with rationale: variadic composite-key typing (heterogeneous
object/array segments make precise tuples high-effort; root centralized + shapes
now tested) and a pre-existing thread(null) no-op (unreachable in practice).
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.
The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.
Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.
S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.
Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.
Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
- localizeDetail: don't leak FastAPI's snake_case `loc` field names into a
translated 422 toast; return a clean, fully localized validation message
(drops the loc filter/pop parsing entirely)
- me(): drop the dead `r &&` guard now that req<T> types r non-nullable
req() returned Promise<any>, so every api.* return annotation was an
unchecked assertion and the 17 action endpoints leaked `any` to callers.
Make req generic with a single audited cast at the boundary; T flows from
each method's declared return type via contextual inference, so no call
site needs a type arg except where none existed:
- me(): type the /api/me probe ({ authenticated } + Partial<Me>)
- deepAll / syncSubscriptions: real backend-verified response shapes
- HttpError.detailData, localizeDetail(d), inner detailData: any -> unknown
Also teach localizeDetail the FastAPI 422 detail-ARRAY shape (carried in
from R6 S1b review): now that malformed requests return 422, surface the
offending field via a localized errors.validation key instead of falling
through to the generic toast.
No runtime change on normal flows (req is await-identical); the 422 branch
only fires on genuinely malformed input. Gate green: typecheck, eslint,
prettier, 68 vitest. Smoke-tested localdev: /api/me + feed 200, no console
errors.
justify-center + overflow-x-auto stranded the left-end controls (Previous, the
N/M counter) when the bar overflowed a narrow window. justify-start makes the
overflow scroll from the start — and matches the conventional left-aligned
video-player control bar.
The overlaid, auto-hiding bar fought YouTube's seek bar for the same bottom
pixels: while ours showed it covered YT's seek + buttons, and reaching them
meant moving off the area and waiting for our bar to fade — a fiddly, "random"-
feeling dance keyed on mouse movement.
Now the bar is DOCKED and always visible: the video area is inset by the bar's
height (a wrapper around the mount, since YT.Player replaces the mount element
with its iframe and would drop a class set on it), so the bar sits in its own
strip and never overlaps the video. YouTube's seek/controls stay reachable by
hovering the video's bottom edge, just above ours. No auto-hide, no guessing,
no fight. Verified in Chrome: iframe.bottom == bar.top (no overlap), bar opacity 1.
UAT feedback on the F-maximise view — three fixes:
- Own control bar in the big views (maximise + our fullscreen): previous/next +
N/M, auto-advance, loop, add-to-playlist, download, mark-watched, exit, and a
true-fullscreen toggle. Auto-hides on idle. Add/download are hidden in true
fullscreen (their popovers portal to <body>, outside the fullscreen element).
- Wheel volume works in the big views again: the centre interaction overlay is
now armed in maximise/fullscreen (it only yields to YouTube's own settings
menu), so the wheel bubbles to our handler instead of the bare iframe. F also
offers true fullscreen that WE own (Fullscreen API on the stage), so wheel/
arrow volume and our controls work inside it too — YouTube's own fullscreen
button, which we never surface, is not involved.
- Back (mouse/browser) steps out one level at a time via nested history layers
(useBackToExit): fullscreen -> maximise -> the modal -> closed, instead of
closing the whole player at once.
Verified in Chrome: wheel volume in maximise + fullscreen, the control bar, and
the full Back ladder (fullscreen -> maximise -> modal -> closed).