Commit Graph
110 Commits
Author SHA1 Message Date
peter e16cdbd16f fix(ui): a card's Play clears its hover row at a large text size; Downloads links and the player description
At text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the
hover row (the Play grows with the root font, the picture's height does
not). A picture narrower than 14rem centres the Play and the resume pair
above the row; the picture box is a size container for it.

The Downloads title and channel links were 21px tall inline boxes; they
stand on a 24px line now (B1). The player's description says a failed
load, with a retry, instead of 'No description' (both the side panel
and the popover).
2026-10-02 23:42:31 +02:00
peter 78fdcda6bb feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel
- Channel page: the banner and identity block fold away once the grid scrolls
  (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds
  at the very top) and the name rides in the tab row. One Back button in both
  states keeps its focus; folded at a tablet width the actions wrap instead of
  pushing the page sideways.
- Plex movie and show pages: the TMDB facts over the key crew are the hero's
  second column (plexDetailAside), the keywords sit under the text; the show
  page shows them for the first time, with the same three toggles. A
  collection's hero is sized to its text.
- YouTube player, 1280px and wider: the title, channel, actions, description
  and queue sit in a side panel beside the video, which takes the window's
  height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel
  while it can and turns the volume elsewhere.
- DESIGN.md decision log: the keywords' place under the text.
2026-10-02 02:13:17 +02:00
peter ec22eefd31 fix(ui): E31 S2 — the tab names the object, the channel header wraps, the player fits a short window
- The browser tab names the place and the object: PageHeading reports the
  view's own title (lib/documentTitle), so a channel, playlist or any Plex
  view reads "<object> · <module> · Siftlode" however it was opened; the
  Feed is named too; a season adds its show (tabTitle).
- The channel page's tab + action row wraps and lets the tag lane fold into
  "+N": at 1024 it ran past the edge and the page scrolled sideways.
- The YouTube player card's width is capped from the window height less the
  chrome measured under the video: at 133% zoom (685px) it cut its channel row.
- Plex certificate words (Not Rated, Approved, Passed) are named through the
  vocabulary on the info badge, show page, collection, filter and chips.
- DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log).
2026-10-01 21:17:20 +02:00
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
2026-09-29 22:39:57 +02:00
peter 89e65ca331 feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.

A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
2026-09-27 02:12:54 +02:00
peter 7455fff401 feat(design): a playlist's one action list (C1) — the row's "…", Delete + undo, the header's "…" (E28 S3e2)
- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
  remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
  The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
  renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
  offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
  delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
  the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
  fixed widths had left the title no width.
2026-09-26 04:05:39 +02:00
peter eddeca82d3 feat(design): the page band (B4) — one h1 per page, section headings, skip to content, filters fold on a short window (E28 S3d4)
- PageShell names the page with a visually hidden h1; a view with its own title renders it as
  PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell).
- Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included.
- "Skip to content" is the first Tab stop and focuses the scroller's <main>.
- Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log),
  the same row shown in place.
- The band holds controls only: Downloads and Notifications lose their title cards, the Config and
  channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band,
  and the server log's toolbar sticks with the fade under it.
2026-09-26 00:39:01 +02:00
peter ff80cc96fd fix(design): rating and live colour tokens, the player's menus over its notices, the info overlay on a light theme (E28)
- F16: --rating (theme-scoped gold, 4.5:1 on the page and its chip), --rating-media, --live/--live-fg,
  --media-chrome replace 14 palette literals; a lint rule refuses palette classes; the theme contract
  measures them.
- The Plex player's notices drew over its open settings/tracks popovers: notices z-base, popovers z-menu.
- The player's info overlay rendered theme tokens on its always-dark panel: on a light theme the score
  (2.6:1), collection years (3.2:1) and cast initials (1.05:1) were unreadable; onDark variants now.
- The lint-rule contract tests share one ESLint with a one-off warm-up (a cold first lint took 8 s,
  past vitest's 5 s).
2026-09-25 13:55:26 +02:00
peter 295c1db5f1 fix(design): claim audit part 1b — whole-row switches, the field-family edge, state-layer hovers, icon links, reason-disabled controls, 24/44 targets (E28)
- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title
  and hint (a label pointed at the switch).
- F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge);
  labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows.
- F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP)
  refuses the swap, contract-tested.
- F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>.
- F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler
  runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the
  Config tests say why while dirty (testDirty, HU/EN).
- F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target
  is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle,
  Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest.
- F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips.
- F15: the "+N" / "More..." open state worded as it renders.
2026-09-25 03:58:17 +02:00
peter 7fa8f4ebde feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5) 2026-09-25 00:47:46 +02:00
peter c47c193ad1 feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1) 2026-09-25 00:34:35 +02:00
peter 81936e9f56 feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.

- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
  floor, 44 on touch) replace five chip geometries; FilterChipGroup
  renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
  round, overlay; 44 on touch); the YT modal bar is on the sm rung with
  pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
  new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
  add) announce checked/mixed; a one-letter tag chip was 21px wide;
  descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
  control-primitive files is a lint error (contract-tested, seen red).
2026-09-24 23:03:23 +02:00
peter 86f467bd65 feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.

Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
2026-09-24 05:25:55 +02:00
peter 332d24a646 fix(frontend): four state defects the deep-review found in the E27 S2 sweep
Every one of them is the same shape: a value derived correctly, and a WRITE to
the thing it derives from left unattended. Deriving hides a stale value; it does
not consume it.

PlayerModal — the watch toggle inherited the previous video's auto-watch. Moving
the queue-advance sync into render was right for `currentVideoId`, but the same
block also synced `status`, and that one had an ordering constraint of its own:
useYtProgress's cleanup for the OUTGOING item can still call setWatched(true)
near the end of a clip, and cleanups run after the commit. As an effect the sync
landed last and corrected it; from render it lands first and the stray write
wins. Press Next in the final ten seconds and the NEXT video showed as watched —
one click on the toggle then sent onState(next, "new") for an item already new.
Fixed by keying the mirror to the id it describes, so a write for another item is
ignored rather than displayed, and no ordering has to be argued about at all.

ChannelsProvider — a focus-channel intent was masked by the page rather than
consumed, and nothing ever cleared it. The manager is a page chunk: it unmounts
on every navigation, and its seed effect runs again on each mount, so returning
to it re-applied a channel name from earlier in the session over whatever the
user had typed since. The consumed marker now lives in the provider, keyed on the
token — which also closes the case the old clear-on-leave effect never handled,
opening a channel page (that does not change `page` at all).

TagFilterChips + ChannelTags x2 — `open = openRaw && hidden > 0` hides the
overflow popover when the lane stops overflowing, but floating-ui only reports a
close for a real event on a mounted surface, so the raw flag stayed true and the
menu came back by itself on the next re-fit — a window resize or the text-size
slider is enough, with nobody near it. The flag is now reset in render against
the same condition that hides it.

Avatar + downloadRow — remembering a failed image BY URL fixed the recycled-row
frame but never forgot it, so a transient 429 pinned a channel to its initial for
the life of the instance, never re-requesting. The memory is now dropped once
`src` moves off it, which keeps both properties.

And playerModalHooks' last exhaustive-deps suppression claimed "nothing in the
body reads a render value" while reading five. The invariant that is actually
load-bearing is that each of them is a per-item snapshot — written down instead.

All four fixes are mutation-checked: each new test was seen red, against the
fixed line only, naming the real defect.
2026-09-21 23:32:22 +02:00
peter 4065ac7e6a refactor(frontend): the two players — one real ordering bug avoided, five suppressions gone (E27 S2)
The interesting part of this batch is a change that was WRONG and the test suite said so.

PlayerModal synced its local watch-status mirror and `currentVideoId` from an effect on
`active.id`. Moving that into render is right — as an effect there was a frame per queue advance
showing the new episode's chrome over the previous item's watch state, with `navigated` still
true so the "you have navigated away" affordance flashed. But the same effect also advanced
`currentIdRef`, and that line must NOT move: useYtProgress's per-item cleanup persists the
OUTGOING item's position and guards it on `currentIdRef.current === id`. React runs every
cleanup before any setup, so in an effect the ref still names the item being left. Advanced to
render time the flip happens first, the checkpoint is skipped, and stepping next→prev back to an
item resumes from the frozen launch queue's stale position. "resumes an item played earlier in
the SAME session" caught it immediately (748 instead of 830); the ref write stays in its own
effect with the ordering written down.

The rest:
- PlayerModal's keyboard handler is a useEffectEvent, so the "bound once, reads live state"
  contract is enforced instead of asserted. `focusModal` is a useCallback now — it reads a ref
  and nothing else, and three hooks take it as a parameter where a fresh identity would spread.
- PlexPlayer's session reclaim called `loadSession` from inside loadSession's own useCallback,
  which the checker reads as a forward reference. Routed through a ref, the file's existing
  answer for exactly this (detachSessionRef, cancelAutoSkipRef).
- PlexPlayer's item-load effect excluded `prefs` by hand so a track pick from the player's own
  menu would not double-load the session. The two language prefs go through a ref instead —
  "latest value, not a trigger", said in code rather than in a comment.
- PlexPlayer closes its up-next card during the render that changes `id`, not a commit later.
- The one PlexPlayer directive that stays is the live session counter, whose cleanup must bump
  the CURRENT value: the rule's advice to copy it into a variable is precisely wrong there, and
  the comment now says why rather than pointing at another comment.

Suppressions 75 -> 70; exhaustive-deps 20 -> 17.
2026-09-21 00:27:15 +02:00
peter 7a47223a58 fix(ui): no button drops the keyboard focus when its own action disables it
A focused button that turns natively disabled loses the focus to <body> in
Chrome. The app disables the very button that was pressed on ~60 sites — a
mutation pending, a pager at its last page, undo with nothing left, a player
at the end of its queue, a form gone clean — so a keyboard user was thrown
out of the page on every such press.

One primitive instead of a fix per site: ui/FocusSafeButton renders a
focused-and-disabled (or busy) button as aria-disabled with a click guard,
and applies the native attribute again once the focus is elsewhere. It asks
the document at render time as well as listening for focus events, because
a window without focus fires none (measured). ui/Button and ui/form's Switch
are built on it; 57 hand-rolled buttons swapped their tag for it keeping
their classes, and three hand-copies of the rule (the channel page's Block
and Hide, ChannelDiscovery's Subscribe) now pass `busy`. The soft state's
dim look is one rule in index.css.

`local/focus-safe-disabled` refuses a new raw <button disabled>, pinned by
focusSafe.contract.test.ts. lib/plexDetailUi moved to components/: it is
components, and lib may not import the primitive.

Measured on localdev in Chrome: the audit log's Next button keeps the focus
on reaching the last page, reads aria-disabled, and the guarded click does
nothing.
2026-09-19 08:07:25 +02:00
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00
peter 569190480d refactor: hook dependencies name everything the hooks read
The 12 react-hooks/exhaustive-deps warnings the gate let through:
- ShareDialog and YearFilter built a fresh [] per render while their query loaded, so their
  memos recomputed on every render; they share one empty list now.
- VideoEditor's drag effect called a seek/moveBoundary recreated each render; both are stable
  callbacks in its deps.
- WizardProvider re-checks onboarding on exactly the fields it reads (not the me object, whose
  60 s refetch would reopen a closed wizard).
- Channels, Feed and PlexPlayer list the stable values they already used.
- The player's keydown effect is bound once on purpose (its helpers read refs): said so, with a
  disable, as the codebase's other deliberate sites do.
- dialogFocus and the YouTube player's cleanup read refs React may already have let go of; they
  use what they captured when the effect ran.
2026-09-17 02:45:19 +02:00
peter f130a1c05a fix(keys): epic-end review round 1 — Back, menus and dialogs keep the focus where it belongs
Deep-review of E11 (19 raised, 14 confirmed; 2 refuted, 3 latent test-hardening notes dropped).

- Back from a sub-view (a Plex show page, a Messages thread) restores the item it was opened from
  even when focus was still inside the view: the first attempt now waits one task, past the
  sub-view's own popstate listener (measured in Chrome: it left focus on <body>).
- The restore listener is installed at load, so the first Back after a reload finds it; a push
  cancels a restore still waiting for the last landing's lists.
- Navigating over an overlay asks it again, a render apart, while its entry stays: the Plex
  player peels one panel per Back step, so Shift+W over info + stats closed one panel and dropped
  the navigation.
- j/k: a native <button> item keeps its own Tab stop; landing twice on the same item no longer
  leaks its tabindex; after the focused item is removed (a hidden card) the next j goes on from
  its slot when that slot is on screen.
- An abandoned `g` no longer swallows a modifier chord (g, then Mod+K opened the browser search).
- A hover card above a dialog no longer switches its Tab trap off (player, Modal, wizard,
  Lightbox): new layerStack `anyBlockingLayerAbove`.
- "Manage views…" closes its menu before opening the dialog, through a shared `closeThenAct`
  that ActionMenu now uses too.
- Copy: the EN welcome says "Feed"; the EN unreachable reason no longer claims a change;
  stale comments about PlayerModal's scroll lock and dialog contract.
2026-09-13 17:17:49 +02:00
peter 78a9324381 fix(ui): the palette ranks what you typed, and lib stops leaning on components
Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle
`@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed
matches in pure group order and always highlighted the first one.

- CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint,
  keywords or group name; an item ranks by its worst word, then the sum. The group order stays
  fixed, rows are ranked inside each group, and the best row overall starts highlighted. The
  highlight is held by id and follows the best match until the arrows or the pointer pick a row,
  so a channel list landing mid-typing no longer shifts it.
- A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with
  nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing
  element, and "Nothing matches" sits outside it; a polite status announces the count once the
  typing pauses.
- `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`,
  and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib
  helpers imported the column contract from a ui component, and `playerModalHooks` its types from a
  feature component.
- eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included;
  the boundary contract test proves it fires. CLAUDE.md and the ui README say so.
- keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
2026-09-13 02:14:07 +02:00
peter b4aea7fa5b fix(ui): dialogs, menus and busy buttons keep the keyboard focus where it belongs
Measured in Chrome before each change:

- A dialog opened from a menu lost its focus: the menu row ran its action
  and closed in one batch, and floating-ui's focus return (a microtask)
  moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after
  it opened, where Enter activated the row. MenuItemList now closes first
  and runs the action a task later; the dialog also captures the row, not
  a dying menu item, as its restore target.
- A side panel opened as a flyout collapsed the moment a dialog opened
  from inside it, unmounting the row the dialog hands the focus back to.
  useHoverFocusWithin holds while the focus is inside an aria-modal dialog.
- ui/Button's loading state set native disabled; Chrome moved the focus of
  the pressed button to <body> and never gave it back. Loading is now
  aria-disabled with a click guard that also cancels implicit submission.
- Escape/Enter in the playlist rail's rename field dropped the focus on
  <body>. The row refocuses from its ref callback when it re-attaches (a
  post-commit focus sticks; the earlier attempt ran before the button
  existed).
- PlayerModal was the one aria-modal shell without the dialog contract:
  it is now named by its title, traps Tab, restores the focus to its
  opener and shares one counted body scroll lock with every dialog.
2026-09-13 01:04:11 +02:00
peter 92c305bab9 fix(nav): a navigation closes every overlay before it pushes
The overlay history stack let a navigation land ABOVE open overlays and
"buried" their entries with a counter. Measured in Chrome: Shift+W over
About navigated under the still-open dialog, the next Back both undid the
navigation and closed About, and the Back after that did nothing; Shift+W
on Home with the player open did nothing at all. The counter could also be
spent by a different overlay closing first, and callers that could not
report whether they pushed were assumed to have.

setPage and openChannel now close every open overlay and push only once
the history is back in sync (one entry per live overlay, no programmatic
back() in flight, no reconcile queued), so nothing is ever buried and the
whole mechanism goes: navigateOverOverlays, closeThenNavigate, buryUnder,
setPage's boolean, and the three test files that pinned them. The palette
closes only itself before running a row, so a theme picked over the player
keeps the player. Shift+W on Home with the player open now closes it
(owner decision 2026-09-09).

The wait is on state, not on "a popstate": the old 250 ms fallback could
run with back()s still in flight. An overlay that refuses to close within
1.5 s drops the command instead of being navigated out from under.
2026-09-13 00:17:09 +02:00
peter 545996f807 fix(ui): a toast host ranks by when its overlay opened, and an overlay keeps its Escape, its dialog and its navigation straight
Deep-review round 4, the overlays.

- Toast hosts were ranked by mount time, and PlayerModal swaps its host on every fullscreen change:
  leaving true fullscreen with the download dialog open put the player's host back on top, so toasts
  were drawn under that dialog's scrim and a click on one closed the dialog. Each overlay takes a
  rank once, when it opens (`useOverlayOrder`), and hands it to every host it renders.
- A dialog open while fullscreen is left keeps a stable portal container (moved, not re-created):
  React tore the portal down when `fsRoot` changed, resetting a half-filled form and dropping focus
  to <body>. Focus is put back after the move.
- The onboarding wizard is a layer, not a raw document listener: one Escape used to dismiss the
  error dialog over it AND end onboarding, skipping the write-scope step.
- `navigateTo` (a toast's "View", clicked inside the player's own host) goes over the overlays it
  unmounts, so the player's Back-to-close cleanup no longer pops the page it just opened — the same
  fix Shift+W already had. The access-requests toast action too.
- The live region arms itself a task after it takes over inherited toasts: React flushes effects in
  the same task as that render, so the "silent inheritance" arming was a no-op.
- The overlay-host guard matches class tokens in any order (PlexPlayer's root was never checked),
  globs `ui/` too (ui/Modal was not checked either), and requires the rank.
2026-09-11 23:43:23 +02:00
peter 33fe895539 fix(ui): every full-screen overlay hosts its own toasts, above the players' controls, without re-announcing them
Deep-review round 3 of bug hunt 2026-09 (the round-2 overlay toast host, finished).

- Inside an overlay the toast stack sits above the bottom band (bottom-24, the UpNextCard rung) and
  lets clicks between cards through: at the app's own spot it covered the Plex transport row and
  PlayerModal's docked bar and took their clicks while a toast was up (measured: play/pause, ±10,
  stop, prev/next, mute all hit the toast).
- ui/Modal and the onboarding wizard mount an overlay host too, so a toast raised under any dialog
  is readable and a click on it no longer closes the dialog; the Modal's Tab trap spans the whole
  dialog, so a toast's action and dismiss are keyboard-reachable. A test now enforces the
  convention: a component that covers the viewport at z-overlay must mount <Toaster overlay />.
- PlayerModal's host moves into the stage in true fullscreen (outside the element holding the lock it
  was painted under it).
- A host that takes over shows the toasts it inherits silently (no second role=alert, its region
  aria-live=off until they are in), and each countdown bar starts as far in as its toast already is
  instead of restarting while the real dismiss timer does not.
- The overlay-host registry uses lib/store instead of a hand-rolled listener triad.
2026-09-11 18:04:54 +02:00
peter 6de2cd950d fix(ui): toasts raised while a full-screen player is open show inside it, not under its scrim
The app's Toaster lives in the content column, which is a stacking context (its `@container` implies
layout containment), so both full-screen players — portalled to <body> at the same z-index — painted
over every toast. Round 1's "a failed Remove is reported" fix was therefore still silent inside the
player: its toast sat under an 80% black, blurred scrim (elementFromPoint returned the scrim), and a
click on it closed the player.

An overlay now mounts its own `<Toaster overlay />`, and exactly one host shows the toasts: the most
recently mounted overlay, else the app's. Same component, same look — and inside the aria-modal
dialog, where a screen reader can still reach it. A click on a toast inside an overlay stays there.
Both players get one (the Plex player's also works in native fullscreen, where nothing outside the
fullscreen element is drawn). Verified on localdev: the removal failure reads above the scrim,
bottom-left, and clicking it leaves the player open.
2026-09-11 03:18:55 +02:00
peter 060deb89d2 fix(playlists): "remove" inside the player targets the list it came from, reports failure, and keeps the cursor and focus honest
Deep-review R5 — five defects in the Remove button f76a2afb put inside the YT player:

- It removed from whichever playlist was selected by the time it was pressed, not the one the player
  was launched from; the selection can change under an open player (the command palette opens over
  it). The launch list is now part of the player snapshot, and the optimistic row removal only
  touches the list actually on screen.
- A 403/404 failed in total silence: the catch claimed the global dialog had reported it, but `req`
  deliberately leaves those to the caller. It now notifies, as moveItemTo already did (the silent
  catch predates this round; the full-screen consumer did not). New key playlists.removeFailed, HU+EN.
- It computed the next item from the cursor as it was when the button was pressed, but prev/next and
  auto-advance keep working under the confirm dialog: the viewer was thrown back onto a video they had
  moved past. It reads the live cursor, and if they moved on it just drops the item from the queue.
- The button was disabled for the confirm's lifetime, so the dialog's focus restore hit a disabled
  element and a keyboard user who cancelled was left on <body>, outside the aria-modal player. A ref
  guards the double press instead.
- Its accessible name was a bare "Remove"; it now names its object, like the row control.

The call-site wiring (step on, drop from the queue, close on the last item, leave everything alone
when the removal did not happen) finally has tests. Verified on localdev in the real YT player: a row
reopened at the 136 s it had just stored; Cancel returned focus to the Remove button; a confirmed
removal stepped 1/3 -> 1/2. 10 mutations, each red on its assertion. Scratch list and positions reset.
2026-09-11 01:28:27 +02:00
peter f76a2afbf1 feat(ui): the four small gaps — player delete, repeat-move, a real channel link, YT search sorting
Items 2, 4, 5 and 7 of the bug hunt, one round. They are independent, but they are all the same
kind of thing: an action that exists somewhere else in the app and is missing where you actually
want it.

**5 — the channel name in the YT player is a link again.** It was a `<button>`, so middle-click and
Ctrl/Cmd-click did nothing on a thing that looks exactly like a link (the YouTube icon beside it IS
an anchor and opens a tab). It is now a real `<a>` whose href is the same `?chan=<id>` URL
`openChannel` pushes, with a plain left click still taking the in-app path — that path does more
than navigate, since the player must close and reclaim its own Back-to-close history entry first.
The "is this click ours or the browser's" test is now ONE predicate (`isPlainLeftClick`), which
VideoCard's card link also uses, so a second such link cannot forget the middle-click arm.

**2 — "Remove" from inside the player.** The parent owns the removal (the SAME `removeItem` the row
menu and the inline ✕ use: one confirm, one optimistic update, one undo-stack rebase), and the
player owns what happens next, because it is the only side that knows which item is playing — its
launch queue is frozen at mount. It drops the item, plays what followed, and closes when the list
runs out. That forced a real fix in Playlists: the player's mount was derived from `shown` every
render, so anything that dropped the launch row yanked the player off the screen mid-playback. That
was a latent edge (change the view and it happens) and the normal case for this feature. The parent
now snapshots what it launched with, and only onClose takes the player down.

**4 — "Move to <the last list you moved to>".** The submenu is two levels deep and moving a run of
videos into the same list is the shape this gets used in. The remembered id is a per-account pref;
the row is derived from `moveTargets`, which already excludes the list you are looking at, so it
cannot offer a move to where the video already is. Remembered only on a SUCCESSFUL move — a
shortcut to a move that failed is a shortcut to failing again. Absent, not disabled, when there is
nothing to repeat: there is nothing to explain.

**7 — the live-YouTube results can be sorted.** Client-side, over the page already loaded. Not a
new `order=` param, and this is the decision worth recording: the owner's case is "even one page of
results", i.e. reorder what is on screen; a re-query would REPLACE the results, which is not what
"sort these" means; and the zero-quota scrape source has no order parameter at all, so a
server-side control would silently do nothing (or quietly cost 100 quota units) depending on which
source served the page. Only keys a search hit carries are offered — `subscribers`/`priority`
describe a subscription, which a search hit is not, and `shuffle` on a ranked list is noise. Its
own pref, so sorting a search page never re-sorts the library behind it.

Verified in the browser on localdev against the real account: the channel name renders as
`<a href="…&chan=…">` and a ctrl-click / middle-click is left to the browser while a plain click
still opens the channel in-app; removing from the player stepped 1/3 → 1/2 onto the next video and
closed on the last one; the row menu grew "Move to “…”" only after a move and only on a list that
is not the target; and the results sort reordered by views 41.7M → 29.8M → 24.2M → 15.8M. (The
pane's own ctrl-click arrives without the modifier — a harness limit, so the modifier cases were
driven as real MouseEvents.) Scratch playlists and the touched prefs were cleaned up afterwards.

22 vitest, mutation-checked against 14 mutations (no href at all · href without the rest of the
query · every click hijacked in-app · the middle-click arm dropped from the predicate · plain click
no longer in-app · always jumping to the top of the list · the removed item left in the queue · no
clamp when the last item goes · an empty queue not reported as "nothing left" · sort-then-reverse
flipping ties · unknown values first · titles compared numerically · shuffle offered on search
results · direction ignored) — each red on exactly the test that names it. Item 4 has no test of
its own: its rule is enforced by construction, not by a check.
2026-09-10 22:34:12 +02:00
peter 1630a3df3c fix(player): the YT progress checkpoint reaches the cache, so a reopen cannot rewind it
The player checkpointed every 5s and the server stored it, but nothing wrote the new
position back into react-query: the close path only marked qk.feed() stale with
refetchType "none", and nothing refetches the feed until it remounts (staleTime 30s,
refetchOnWindowFocus off). So the resume read the position the cache had been FETCHED
with, and the next checkpoint then persisted that older value over the newer one.

Measured on localdev: played to 830s, closed (server had 830), reopened the same card
without a reload -> the row was back to 751s. 79 seconds of progress destroyed by a
reopen, and the card's progress bar never moved either.

The Plex player already solved this for its detail query, with a comment describing the
identical failure; this is the feed-side twin that was missed in that sweep. patchCachedProgress
writes the position the SERVER stored (the endpoint clears trivially-early and near-finished
checkpoints to 0, so mirroring our own reading would disagree with the row) into every cached
feed page and playlist detail, keeping array identity where nothing changed. A session map
covers the second half: the launch queue is frozen at mount, so stepping next->prev back to an
item played earlier in the same session resumed from that snapshot.

Verified in the browser: 811 -> close 816 -> reopen 825, and the card now shows Continue/Restart.
2026-09-09 23:55:51 +02:00
peter cc8cf96c19 feat(e10): a jump-to palette on Ctrl/Cmd+K
Reaching a channel three modules away cost a rail, a page and a scroll.
Now it costs a keystroke and half a word: Mod+K searches modules,
channels, playlists, the saved views applicable where you stand, and the
app-wide actions.

Scope agreed with the product owner: navigation + actions, but only the
reversible local ones (the shortcuts sheet, the theme). "Sync from
YouTube" is deliberately absent — it is a long-running job against a
quota, it wants the progress and error surface the Channels page already
gives it, and one mistyped Enter is a worse failure than the walk it
would have saved.

Matching is word-wise and accent-folded, because this is a bilingual app
and nobody reaches for the accent keys in a hurry: "pod thevr" finds
TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat
result list so crossing a group heading is invisible, and the field
carries the combobox/listbox aria tree that virtual selection needs.

ui/CommandPalette is domain-free (items are a prop, per the ui/ import
rule); AppCommandPalette supplies them. The shell is Modal again, with a
new `top` placement so a search-first dialog grows downwards instead of
jumping as results change.

Also fixes a bug the palette hit on its first run, and that PlayerModal
had already hit once: closing a dialog reclaims its Back-to-close
history entry with a programmatic history.back(), which undoes any
pushState made before it lands — so a command that navigates only closed
the palette. The rule is now closeThenNavigate() in history.ts with a
test, and PlayerModal's inline copy is gone.

The global `h` moves out of KeyboardHelp into useGlobalKeymap beside
Mod+K, so one listener owns the whole `global` scope and dispatch's
exhaustiveness keeps meaning what it says.
2026-09-05 21:33:29 +02:00
peter 86163958d6 feat(e10): one keyboard cheat sheet for the whole app
The Plex player had a shortcuts overlay nobody else did, so the YouTube
player's Space/F/arrows and the Plex browser's Backspace were documented
nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a
global `h` - not the conventional `?`, which is a Shift chord on a
Hungarian layout, while `h` is the key this app's reader already has in
their fingers from the player.

It renders the shared table filtered to the scopes that are LIVE: a
surface registers its scope with useKeyScope while mounted, and only
while its keys really do something - PlexBrowse deactivates its own
scope inside the player, where its Backspace no-ops, so the sheet cannot
promise a "Back one level" the handler declines to perform. Same rule
for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer
the player publishes because the sheet cannot ask.

No new overlay primitive, after checking: Modal already portals into
document.fullscreenElement ?? document.body (so this works over a
fullscreen player), registers an Escape layer, traps focus and handles
browser Back. Third time this epic that the thing was already built.

Shift+F10 is in the table as `handledLocally` - documented and sharing
the chord parser, but matched by useContextMenu on the focused row.
dispatch neither routes it nor demands a handler for it, so the
exhaustiveness check stays honest about what it actually covers.

Also: A/S have cycled the Plex audio and subtitle tracks since E5, but
silently - the only way to see where you landed was to open the tracks
menu, the exact round-trip the shortcut exists to avoid. cycleAudio and
cycleSubtitle now return the track they moved to, and the player flashes
it over the video.
2026-09-05 21:15:42 +02:00
peter 12822a9e04 feat(e10): put every keyboard shortcut in one table
Six surfaces each owned a private keydown listener with its own key
literals and its own idea of "am I typing?", and they had drifted:
PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A
(select all) cycled the audio track, and its typing guard missed
<select> and contentEditable where the other two surfaces covered both.

lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own
listener and closures - PlexPlayer's refs are not worth re-plumbing -
but the chords, the modifier rules and the typing guard live in one
place, and dispatch() binds a scope's rows to handlers through a mapped
type, so a row without a handler (or a handler without a row) is a tsc
error rather than a comment someone forgets.

Matching is now exact on modifiers, which is what kills the Ctrl+A bug
and lets Shift+arrow be its own row beside the bare arrow. Escape opts
out of the typing guard (allowInInput) so it stays the way out of a
player's own subtitle-offset field.

The player's shortcut sheet renders helpLines() instead of a hand-copied
array beside the handler - the two used to be edited separately, which
is how a sheet ends up documenting a key that no longer exists. Labels
move to the new `keys` i18n namespace (EN + HU), and a test asserts
every row is named in both.
2026-09-05 20:58:17 +02:00
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00
peter 5051809940 fix(a11y): touch/keyboard reveal tail + single-Escape layer coordination (UX-7/UX-8)
UX-8: one Escape must close only the topmost layer, never both an open
popover AND the player beneath it. The popover's Escape listener is on
`document` and fires before the player's on `window`; closing the popover
flushes its layer off the shared stack synchronously, so the player's
`anyLayerAbove()` lookup read "nothing above" and closed too. Add a
consumed-event flag to layerStack (`markEscapeHandled`/`escapeHandled`):
the acting topmost layer stamps the shared KeyboardEvent, and both players
check the stamp before the (now-mutated) stack. Immune to listener order
and mid-dispatch stack mutation. Cover with layerStack.dom.test.ts.

UX-7: the E4 `.reveal` sweep already covered the card action rows; two
residual hover-only traps remained — invisible-but-tappable/Tab-focusable
on touch. Add a group-agnostic `.reveal-hover` recipe (display:none under
`(hover: none)`) paired with each site's own hover/focus-within reveal:
- VideoCard play/Continue/Restart overlay: also reveal on
  group-focus-within (keyboard), and drop it on touch — the card tap
  already plays/resumes, so no permanent scrim over every mobile thumbnail.
- Carousel prev/next arrows: gone on touch, where the strip swipes.
2026-08-23 21:59:58 +02:00
peter 4800df4208 refactor(player): split PlayerModal god-component into hooks + controls (E4 S3)
Mirror the Plex player split (plexPlayerHooks.ts / plexPlayerControls.tsx). The shell
keeps owning the shared refs and passes them into each hook; the once-bound keyboard
handler + layer-stack registration stay in the shell (they orchestrate every cluster).
Behaviour-preserving code-motion — PlayerModal 1332 -> 890 lines.

New lib/playerModalHooks.ts:
- useYtVolume (per-account wheel/arrow volume, on shared playerVolume.ts)
- useNativeControlsYield (iframe-focus overlay yield)
- useYtBigView (F-maximise + our true-fullscreen; YT-specific, not Plex's useFullscreen)
- useYtProgress (player create + resume + 5s checkpoint + auto-watch + auto-advance)

New components/playerModalControls.tsx: PlayerTransportControls (moved as-is) + the
AutoMode/LoopMode types.

Self-E2E (localdev): play a feed video -> plays; transport bar (Auto/Loop); wheel volume
persists ({"volume":100}); F maximises + docked bar; exit big view; Close destroys the
player. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest.

Noted a pre-existing bug (kept byte-identical here, flagged separately): the native-yield
effect removes a "blur-sm" listener but adds "blur" — the blur listener leaks.
2026-08-19 05:41:52 +02:00
peter 48aa1a25f5 feat(prefs): usePref — one per-account+server preference hook (E4 S1)
Fold the hand-rolled server-pref plumbing (savePrefs + local mirror + me-cache
merge + adopt-on-login) into a single usePref<T>(serverKey, default, opts) hook,
plus useSavePref for dynamic multi-key patches.

Migrated (6 savePrefs call sites + 2 duplicated adopt effects -> the hook):
- FeedViewProvider: view
- App: filterCollapsed, playlistsCollapsed (+ their adopt block removed)
- PlayerModal: playerAutoAdvance, playerLoop
- plexDetailUi: plexInfoArtBg/Cast/Related (savePref via useSavePref)

Left as-is by design: panel layouts (a dynamically-indexed Record, not one key),
language (i18n store, no React state), PrefsProvider (debounced multi-field
auto-save + baseline echo-suppression - a different model).

Behaviour-equivalent (net -84 LOC at the call sites). usePref.dom.test covers
the three seed sources, persist path, debounce, adopt, parse, and useSavePref.
Frontend gate green: typecheck, knip, eslint (0 err), prettier, 238 vitest.
2026-08-19 03:07:25 +02:00
peter 943143ef45 perf(feed): stop the card-action + player-close feed refetch storm (E2 S5, PERF-2)
Marking a card watched/hidden/reset and closing the player each fired a full
`invalidateQueries(qk.feed())`, refetching EVERY loaded infinite-query page — 10
pages x a few quick marks = ~50 feed refetches, each a full scan (x PERF-1). The
optimistic `overrides` layer + the matchesView filter already render the correct
state (a hidden/watched card drops out instantly), so the refetch was pure waste.

Switch these paths to `refetchType: "none"`: the queries are marked stale but not
refetched now; the next filter change / remount pulls fresh server data. Applies
to the two Feed card-action paths (setState/clearState) and the PlayerModal
close (feed + playlists — progress is already persisted server-side, only the
on-screen resume bar waits for the next natural refetch).

Left as immediate refetch on purpose: channel/tag/playlist mutations (one-off, on
pages where the main feed is unmounted anyway) and the onboarding / notification-
undo paths (which must reveal the changed content right away). Gate green:
frontend tsc/eslint/knip/prettier/222 vitest (backend unchanged, 344 pytest).
2026-08-18 15:49:49 +02:00
peter 76f8de258c refactor(ui): R10 — .btn-primary recipe for the primary/action buttons
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.
2026-08-11 04:04:06 +02:00
peter 1955fc87ee refactor(glass): R10 S3a — glass-card notification rows + perf-aware .overlay-backdrop
- 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).
2026-08-09 17:43:42 +02:00
peter ab873bbcaa feat(theme): R10 S2 — re-close the rem system (px-frozen labels/chrome -> rem)
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.
2026-08-09 03:18:04 +02:00
peter d794524adf feat(theme): R10 S1 — semantic status colors (danger/warn/success)
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.
2026-08-09 01:03:53 +02:00
peter 205f18a2e1 fix(a11y): address R9 code-review findings (glass shadow, focus dedup)
- #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).
2026-08-08 09:28:23 +02:00
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
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.
2026-08-08 00:50:05 +02:00
peter edc09e3651 revert(player): drop the HD-latch resolution trick, keep the windowed fullscreen button
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.
2026-08-02 02:02:31 +02:00
peter 8f1d576341 feat(player): drop to native sizing after 1080p latches (full-size YouTube controls)
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.
2026-07-31 05:47:20 +02:00
peter 8349f9338c feat(player): start videos at 1080p + add a windowed fullscreen button (R8 S5)
YouTube picks an embed's quality at player-creation from the iframe's internal
viewport and latches it; a windowed-size embed was stuck ~360-480p, forcing a
manual fullscreen + gear trip to 1080p. Create the iframe at a fixed 1920x1080
logical size and CSS transform-scale it DOWN to fit (a CSS transform doesn't
shrink the internal viewport, so YouTube still serves 1080p, latched for the
session even while it paints small). A letterbox-contain fit via a
ResizeObserver handles both the windowed and big-view boxes. Gated behind
FORCE_HD_LOGICAL_SIZE for an easy revert if a restrictive network overrides it.

Also add a maximise (fullscreen) button to the windowed player's meta row: the
only fullscreen control reachable in the windowed video was YouTube's own, which
hides our chrome. It mirrors the F key and leads to the docked bar + our true
fullscreen. Corrects the stale code note that claimed the scale trick fails
(that was scaling a SMALL player up; scaling a LARGE player down works).
2026-07-31 05:03:34 +02:00
peter a6e3ef8122 feat(player): download works in fullscreen + de-dup the transport controls (R8 S5)
Two R8 S5 (player big-view control-bar) follow-ups:

- Modal now portals into `document.fullscreenElement ?? document.body` (reactive), the
  same fullscreen-aware root the popover platform uses. A node under <body> is painted
  beneath whatever holds the fullscreen lock, so the download dialog — a Modal, opened
  from the player's control bar — would open invisibly in true fullscreen. With that
  fixed, the `!isFullscreen` guard on the player's DownloadButton is dropped, so download
  joins add-to-playlist as working in true fullscreen. (Every Modal is now fullscreen-
  aware; the fix is at the primitive, not the call site.)

- The queue transport cluster (prev / counter / next + auto-advance + loop toggles) was
  duplicated inline in two places — the modal queue bar and the big-view docked control
  bar — with identical logic and only theme + label-visibility differing. Extracted a
  `<PlayerTransportControls variant="modal"|"docked">`; a mode/icon change now lands once.
  Behaviour-preserving (the docked bar's trailing divider and the wrapper rows stay at the
  call sites). Note the modal variant keeps its `bg-accent/15` active tint, which is a
  latent no-op under Tailwind v3's hex --accent — left as-is for R10 S0's styling audit.

Gate green (tsc/eslint/76 vitest). Device-UAT'd in real Chrome: transport controls render
correctly in both the modal queue bar and the docked bar; the download button now shows in
true fullscreen and its dialog portals into the fullscreen stage, fully visible and
centered. No console errors.
2026-07-31 03:24:49 +02:00
peter 2984aa3240 fix(popover): resolve the R8 platform code-review findings
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.
2026-07-31 02:10:14 +02:00
peter 2727b59fa5 feat(popover): player description card on the platform, add-to-playlist works in fullscreen (R8 S4d)
The description hover card loses its two hand-managed timers and its fixed `bottom`
anchor. It now opens above the title through the platform, which means `flip` can rescue
it when there is no room up there either — the old fixed anchor could only grow upward.
Its width moves from a px clamp to a rem one, so it follows the font-scale slider like
the rest of the app.

Add-to-playlist is no longer disabled in the player's true fullscreen: its menu rides
the platform, which portals into the fullscreen element rather than <body>. The download
button stays hidden there on purpose — it opens a Modal, and Modal still portals to
<body>, so its dialog would open invisibly. Lifting that needs the same fullscreen-aware
root inside Modal.

NOT migrated, deliberately: PlexPlayer's gear and tracks panels. They are the one place
where portalling would be a visual regression — the control bar sits inside the player's
`transform: scale(1.25)` windowed subtree, so a panel portaled out of it renders at 80%
of the surrounding UI's size. Positioning would still be correct (floating-ui reads
post-transform viewport rects); the panel's own type and padding would not. Doing this
properly means either portalling into that scaled wrapper or dropping the scale, and
either way it needs a UAT with a playing video that this environment cannot run.

Verified in-browser: the description card opens after its hover-intent delay, anchored
8px above the title and left-aligned to it, with the description loaded.
2026-07-31 00:16:41 +02:00
peter 7ed98956cc feat(overlay): shared layer-stack for topmost-only Escape across modals, popovers, players (R8 S3)
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.
2026-07-30 23:33:50 +02:00
peter 90749a9e45 refactor(fe): eliminate every explicit any; enforce no-explicit-any (R7 S3a·4)
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.
2026-07-27 22:57:33 +02:00