Commit Graph
139 Commits
Author SHA1 Message Date
peter 7acdf5d577 feat(admin): surface the sign-ups that are stuck outside, and let an admin finish letting them in (E9.5 S5)
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen sat empty, the tab badge
read zero, and the only working move was re-typing the address into the whitelist box. Nobody would
guess that, and on 2026-09-03 nobody did.

This is roadmap UX-25, where two earlier attempts were reverted after review. Both moved AUTHORITY:
auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever
registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up
REVOKE a standing approval. What was actually missing was visibility, so this changes nothing about
what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the
address; they just had no way to know they had to.

The section lists every account that cannot sign in yet and says WHICH gate is shut, because the two
dead ends look the same from the admin's chair: waiting for activation, or waiting on a confirmation
mail. Naming the unconfirmed ones is not decoration — on a no-SMTP instance that is the difference
between the owner and whoever registered the address first (SEC-15), and an admin about to let
someone in should see it. A person the queue above already covers is deliberately NOT listed here;
two buttons for one person is worse than one.

Activate is gated on `is_allowed` — the same function the sign-in paths consult, in core, so the
button that appears and the badge that counts cannot disagree. Without that gate it would
manufacture an inconsistent account: password sign-in only checks `is_active` and would let them in,
while their Google sign-in would be turned away and silently reopened as a fresh access request.
Letting in someone with no approved invite stays the whitelist box's job, which says so out loud.

The badge counts activations SEPARATELY from pending invites rather than widening an existing
field's meaning, and only counts what genuinely waits on the admin: an unconfirmed account waits on
its owner, so it is listed with a re-send but does not nag. The admin-side resend says plainly what
happened, including refusing when the instance has no working SMTP — mail is fail-soft everywhere
else, and that silence is exactly what left the 0.78.5 sign-ups with nothing to fall back on.

`send_verification_link` is now one function with three callers (registration, the public resend,
this one), so the link's shape and TTL cannot drift by who triggered it.

UAT on localdev, both languages: the UX-25 shape reproduced end to end (approve the address, then
register), the badge counting it, Activate letting them in and dropping the badge, the row losing
its Activate but keeping the re-send, a fresh token issued by the re-send, and both actions in the
audit log. Test data removed.
2026-09-04 03:27:48 +02:00
peter c134b514c1 fix(auth): confirm an emailed verification link wherever it is opened (E9.5 S2)
The token was only ever consumed inside Welcome.tsx, which App renders ONLY when
there is no session. Opening the emailed link in a browser that already held a
Siftlode session posted nothing, the fragment was then stripped, and the user got
no signal at all -- the link simply did nothing. Incognito worked, which is why it
survived. Confirmation now happens in App, above that branch: signed out the outcome
still drives the auth card's banners, signed in it drives a top-of-app bar.

The entry URL is snapshotted once at import time (lib/entryUrl) instead of per
component, so no future URL writer can strip a token before whoever needs it has
read it -- that ordering race is what broke this in the first place. POST /auth/verify
now names the address it confirmed, which matters when the browser holds a session
for a different account; the same reasoning as password_reset_required already
covers the disclosure.

Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both
senders emit the fragment form, and the last query-token link expired 24h after the
0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and
the auth-card banners get role=status. The verifyOkReset copy no longer blames
admin approval, which E9.5 S1 made false.
2026-09-03 23:45:31 +02:00
peter 1801a58f6b feat(layout): centre the header on each page's content width, centre the playlist table
Every page's floating header (search bar + sync chip) and the toolbar band now centre on the SAME
max-width the content column uses, instead of spanning the whole column while a max-w page centres
its grid. So the search bar lines up with the left edge of the cards/table on every page, and sits
centred with the content — not against the full page width.

- PageShell takes a `maxWidth`, published as --content-mw; the header adds max-w-[--content-mw] mx-auto
  inside its left-4 / right-(1rem+scrollbar) span, so it centres on that width (`none` = full width).
- moduleRegistry PAGE_CONTENT carries each page's content max width (plex 1600px, channels 96rem,
  playlists 64rem, downloads 56rem, notifications/users 48rem; feed stays full width) — one source.
- Playlists: its content (header, filter row, table) was left-aligned max-w-5xl → now mx-auto, so the
  table is centred and the header lines up with it.

Verified at 1920px: header left edge == content left edge — feed 340, channels 213, playlists 487.
2026-08-21 21:38:49 +02:00
peter 45d8b47366 feat(toolbar): fixed-width ItemCount pill, lead the toolbar with it, align chrome to the cards
- New ItemCount: a generic, FIXED-WIDTH count pill (glass, non-interactive) — "192,050 items"
  (neutral noun, dynamic plural), min-w-[9rem] so a changing number never shifts the controls
  beside it; a million or more collapses to "1M+" with the exact figure in the title tooltip.
- Feed toolbar: ItemCount is the fixed leading element now; dropped the mx-1 separators so every
  gap is a uniform 1.5 (Content-types↔Source used to be wider than Watch-state↔Content-types).
- Chrome-to-card alignment (app-wide, via the shared Header + PageShell): the floating header and
  the toolbar band now span exactly the card grid's width — left-4, and a right edge of
  1rem + the scrollbar gutter (measured once into --sbw), so the fixed chrome lines up with the
  scrolled cards instead of running ~15px past them. Wraps cleanly to multiple rows on mobile.

Renames the count testid feed-result-count → item-count (E2Es updated).
2026-08-21 20:33:55 +02:00
peter 8bfc01684f feat(nav): full bookmarkable URL model — retire minimalist-URL + Share-view (E4 S2)
The query string is now the source of truth for the current view, so every URL is
bookmarkable/shareable. Reverses the early minimalist-URL rule (user's call).

- urlState: urlWithParams (read-modify-write delta on the live query, each provider
  owns its keys) + clearParams (strip only transient auth params) replace the whole-
  query stripUrlParams; shareUrl deleted; filterParamUpdates helper.
- NavigationProvider: page + ?chan mirrored to the address bar (pushState URL arg;
  history.state mechanics untouched); loadInitialPage resolves deep links
  (?v->plex, ?pl->playlists, filter/?chan->feed, bare->stored landing).
- FeedFiltersProvider: feed filters mirrored to the URL, gated on page===feed.
- useHistorySubview: optional SubviewUrl mapping; PlexBrowse mirrors the sub-view to
  ?v=<kind>:<args> (show/season/info/person/playlist; grid+player stay ephemeral).
- PlaylistsProvider: selected playlist mirrored to ?pl on the Playlists page.
- App: the 5 stripUrlParams calls become targeted clearParams (link/oauth/admin/
  access/verify/reset); the share-link hydrate effect removed.
- Retire the "Share view" feature: Sidebar + SavedViewsWidget buttons + i18n keys.

Browser tab title stays the short module/channel name (document.title untouched) —
the query never leaks into it, per the user's request.

Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest (urlState.dom.test
+9). No browser-E2E yet (most visual sprint — flagged for the epic-end review).
2026-08-19 04:47:36 +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 3575237382 fix(header): dynamic --hdr-h so a wrapped mobile header can't overlap content
The floating header pads the content below it by a FIXED `--hdr-h` (4.25rem,
sized for one row). On mobile the flex-wrap header grows to 2–3 rows, so the
content toolbar (sort / count / filter chips) slid up UNDER the header — the top
of the page was a pile of overlapping controls.

- Header.tsx: measure the real header box with a ResizeObserver and publish its
  height (offsetTop + offsetHeight + gap) as `--hdr-h` on the content column, so
  the content pad always tracks the actual (wrapped) header height at any width.
  Verified live at 440 (mobile) and desktop: the toolbar now sits cleanly below
  the header, no overlap; desktop unchanged (still ~4rem, one row).

Also addresses the S7 /code-review findings:
- App.tsx: note that `container-type: inline-size` makes the column a containing
  block for `position: fixed` descendants too (safe today — floating-ui menus
  body-portal, toasts are absolute).
- SearchBar.tsx: move the responsive-rules comment above `return` and note the
  rules are on the SHARED search box (every module, not just Plex).
- SearchBar.dom.test.tsx: lock the container-query classes (min-w floor +
  basis-full/order-last) so the wrap-not-collapse fix can't silently regress.

FE gate: tsc / knip / lint clean, 218 vitest pass.
2026-08-17 21:31:22 +02:00
peter 14913a6447 feat(header): S7 — container-query fix for the narrow Plex header
The floating header's search box collapsed to ~0 width when the filter sidebar
was open on a narrow window (~768px): the content column shrinks with the
SIDEBAR, not the viewport, so Tailwind viewport breakpoints can't see it, and
SearchBar's `flex-1 min-w-0` shrank rather than wrapped.

Fix with Tailwind v4 container queries (no plugin):
- App.tsx: mark the content column `@container/appmain` (container-type:
  inline-size) — the width that actually shrinks when the sidebar opens.
- SearchBar: once the column has room (≥420px) enforce a `min-w-[15rem]` floor
  so the box WRAPS to its own line via the header's flex-wrap instead of
  collapsing the input; on a narrow column (≤640px) it takes a full line of its
  own (basis-full) and sorts last, so the small pills (module name + Plex scope
  toggle / feed sync chip) share the line above it — a tidy two-row header.

Verified live at 375 / 768 / 1024 / 1280 on Plex (search usable, scope toggle
reachable at every width) and on Feed (shared SearchBar, no regression). Desktop
stays a single row. tsc / knip / lint clean.
2026-08-17 21:06:33 +02:00
peter 9d7a2a75d8 feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch)
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
   PriorityStepper and add it to the channel detail header (subscribed, non-demo);
   backend channel_detail now returns the per-subscription `priority`.
2  Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
   hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
   inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
   glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
   transparent no-op here, so it's glass-card that provides the depth); clamp Plex
   card titles to a fixed 2-line height so 1- vs 2-line titles align.
3  Nav rail is now always a slim icon-only column, flush to the left edge; each icon
   reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
   a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
   navCollapsed pref plumbing.
4  Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
   full-height slot next to the rail), so approaching the nav no longer pops the
   filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
   grace period + split hover/focus handler groups.
5  Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
   transparent app) instead of only the inset <main> box.
6  Plex season binge continues across seasons: the season view's play queue now
   spans the whole show (allKeys), so a season's last episode rolls into the next
   season's first (seasonKeys stays for "add season to playlist").
7  When a Plex video/episode ends with nothing queued next, the player returns to
   the previous page (onEnded → onClose) instead of stranding a black screen.
2026-08-11 02:20:52 +02:00
peter 1223df6ccc refactor(api): code-review fixes (R7 S3b review round 1)
- 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.
2026-07-27 23:45:02 +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
peter 34297b696f refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)
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.
2026-07-27 22:10:03 +02:00
peter bacef11e33 fix(auth): address second-round review findings
- OAuth-cancel feedback now keys on the live session, not link_uid: ANY
  signed-in cancel (add-another-account via /auth/login, link, or upgrade)
  redirects to /?oauth=cancelled and gets a neutral "Google sign-in was
  cancelled" toast — the add-account path set no marker and was silent, and
  the shared ?link= message wrongly called an upgrade "account linking"
- drop 127.0.0.1 from the dev OAuth host allowlist: only the localhost
  callbacks are registered in the Google console, so a 127.0.0.1 origin now
  falls back to the fixed callback instead of a redirect_uri_mismatch
- Welcome: extract the shared goResend handler (banner CTA + sign-in link) and
  merge the two adjacent signin-only blocks
2026-07-23 01:24:34 +02:00
peter c837b59373 fix(auth): address R4 S1 code-review findings
- link/upgrade OAuth cancel now redirects to /?link=cancelled (a channel App.tsx
  surfaces as a toast) instead of the pre-auth /?login=oauth_cancelled banner a
  signed-in user never sees; a plain sign-in cancel is unchanged
- the login banner (suspended / oauth_cancelled) is now state, cleared on the
  first deliberate action like the verify banner — no more stale lingering
- add a "Didn't get the verification email?" resend entry on the sign-in screen
  so a never-arrived verification mail isn't a dead end (was only reachable from
  the expired-link banner)
- unit-test _oauth_redirect_uri (dev-host derive, unknown-host fallback, prod
  ignores the Host header — the injection guard)
- collapse the 5-deep title/button ternaries into Record<Mode,string> maps
2026-07-23 01:04:09 +02:00
peter 1c6b7a9a5a feat(ui): R3 S3 — surface swallowed errors
- notifyYouTubeActionError: on any non-403 error, show the server's own reason (err.detail, e.g.
  "Not enough quota left today") instead of the caller's generic fallback, which was discarded
  (U-3.2.8). 403 still offers the Connect affordance.
- App boot error now offers a Retry (StateMessage) instead of a dead "Something went wrong" (U-4.2).
- deletePlaylist prefers the server's reason over a blanket "couldn't delete on YouTube" that
  misleads when the failure wasn't the YouTube side.
- Already covered in earlier sprints (verified): the live-search "Load more" already surfaces the
  quota error inline via err.detail, and ChannelPage subscribe/unsubscribe already route through
  notifyYouTubeActionError.
2026-07-22 04:43:02 +02:00
peter 3d5662df22 perf(frontend): keep the changelog out of the eager App chunk (C-3.19)
App.tsx imported CURRENT_VERSION from releaseNotes, which pulled its ~900-line
changelog into the eager App chunk and defeated the lazy ReleaseNotes split. App now
uses FRONTEND_VERSION (the built VITE_APP_VERSION, equal to RELEASE_NOTES[0].version
by release convention) from lib/version, and CURRENT_VERSION is removed. Verified in
the built bundle: the changelog text now lives only in the ReleaseNotes lazy chunk.
2026-07-21 21:47:45 +02:00
peter e257e2aca0 style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
2026-07-21 02:26:40 +02:00
peter 04ecd8b3a7 build(frontend): add ESLint flat config as a gate lane
ESLint 9 flat config, pinned. Narrow by design: the load-bearing rule is
react-hooks (rules-of-hooks + exhaustive-deps) — 33 disable comments existed for a
rule nothing ran. tsc already owns unused vars / undefined / types, so those are
turned off here to report each finding once. no-explicit-any is off: all 19 live in
the api.ts god-module that R7 rewrites, and it flips the rule back on then.

reportUnusedDisableDirectives is an error, which immediately caught two inert
`eslint-disable` comments (App.tsx, AddToPlaylist.tsx) whose effects have stable
deps — removed. no-unused-expressions allows the side-effect ternary idiom the
codebase already uses (`v.paused ? v.play() : v.pause()`).

Errors: 0. Remaining 38 are warnings (28 react-refresh DX, 10 exhaustive-deps) —
pre-existing, visible in the lint output, tracked to their epics.
2026-07-21 02:25:12 +02:00
peter be9d17ebb1 fix(managers): address the E4 S3 code-review findings
- DataTable: keep `pagerNode` NULLABLE. Extracting Pager made it an always-truthy
  element, so the `controls` / `bottomControls` guards always fired and a table with
  nothing to page rendered empty `my-3` bands (plus an empty in-band PageToolbar).
- ChannelsProvider: `setView` bails when the tab is unchanged (re-selecting the
  active tab, or goToFullHistory forcing "subscribed", stacked dead Back entries),
  and pushes a FRESH state like setPage does instead of spreading `_chan`/`_yt`
  markers into the tab entry.
- Lift the layout into its own narrow context (`useChannelLayout`): App read the
  whole ChannelsState for the fade, so every keystroke in the manager's search box
  re-rendered the app root.
- Reset the card page when the row set or sort changes (both tabs) — the clamp only
  hid a stale page, which came back when the filter was cleared.
- Sort + slice the card rows only in cards mode; table mode was copy-sorting ~300
  rows per render for a result nothing read.
- Validate persisted `cardSize` / `sort` (`parseCardSize`, `parseSortState`), matching
  the clamp-on-read convention the module's other persisted values already follow;
  a corrupt size made the page count NaN and emptied the grid.
- Pager: page-size changes scroll to the top too (they re-slice from a new first item).
- ColumnSortControl: a stored sort naming a column this table no longer offers is
  treated as no sort, instead of showing a direction arrow for a sort that isn't applied.
- Drop the stale "rows" layout comments (that variant was retired this sprint).
2026-07-20 00:34:38 +02:00
peter dff4152522 feat(managers): paginate the card layout + its top scroll-fade
UAT round 3 feedback on the card layout:
- Add a pager + "Cards per page" to the card view (it infinitely-scrolled before).
  Extracted the pager cluster out of DataTable into a reusable Pager component
  (the table now renders it too — no behaviour change); the card view slices the
  sorted rows and drives its own per-account page size (subscribed + discovery).
- Restore the top edge-fade in the card layout. The channels page registers
  fadeTop:false for its sticky-header table; the card layout has no sticky header,
  so App now re-enables the top fade when the channel layout is cards.
2026-07-19 23:28:43 +02:00
peter a42e0a9e3e feat(app): the module registry drives the render (S6b DoD)
Replace App's page-render ternary and the three side-rail page conditionals with
a data-driven lookup: components/moduleRegistry maps each page to a prop-free
content component (+ optional side rail + shell fadeTop), and App renders
PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate (an unreachable
gated page falls back to the feed, as the old ternary did). Content modules and
the two rails are now prop-free: they read `me` via the new useMe() hook and
their module-local state from the scoped providers; FeedPage/MessagesPage are the
only wrappers (they wire the global feed scope / meId). App.tsx 796->431 lines,
zero `page===X` chrome conditionals — closes the Platform epic DoD. (Platform S6b.3)
2026-07-19 01:25:32 +02:00
peter fd1742e783 refactor(prefs): move Settings auto-save machinery into PrefsProvider
Lift the four editable Settings prefs (theme/perf/hints/notifications), their
live-apply effects, the debounced auto-save + baseline echo-guard, and the
server-adopt-on-login into PrefsProvider, exposing the PrefsController via
usePrefs(). SettingsPanel and GlassTuner read it from the hook instead of props;
App's big adopt effect keeps only the shell prefs (panel layouts, rail collapse,
language). App.tsx 655->489 lines. E2E 17/17 incl. settings auto-save. (Platform S6b.3)
2026-07-19 01:09:20 +02:00
peter 36e4419c98 refactor(feed-view): move the feed view mode into FeedViewProvider
Lift the feed's view-mode state (server-synced: persists on pick, adopted from
prefs on login) out of App into FeedViewProvider. App reads it via useFeedView()
to hand to Feed/ChannelPage; the view adopt lines leave App's big prefs-adopt
effect. Removes another App-owned cross-cutting state ahead of the render flip.
(Platform S6b.3)
2026-07-19 01:00:05 +02:00
peter dd921e3209 refactor(wizard): move onboarding wizard state into WizardProvider
Lift the onboarding wizard's open state, first-login auto-open effect, and the
wizard render itself out of App into WizardProvider. Feed, Channels, and
SettingsPanel trigger it via useWizard() instead of an onOpenWizard prop; App
and ChannelPage drop the prop plumbing. First step of S6b.3 — removing the
per-module props that block a uniform registry render. (Platform S6b.3)
2026-07-19 00:55:34 +02:00
peter a303f307cd refactor(channels): scope module state in ChannelsProvider
Lift the Channels manager's shared local state (name filter, status chip,
active tab, column-reset token, focus-a-channel intent) out of App into a
split-context ChannelsProvider, nested inside NavigationProvider so its
focusChannel/goToFullHistory actions can drive navigation. The Channels page,
the header search box + sync chip, the feed filter sidebar, and the
notifications inbox all read it via useChannels()/useChannelsActions() instead
of props drilled through App. Channels also internalises onViewChannel /
onFilterByTag / onFocusChannel (nav + feed-filters straight from providers) and
takes only { me, onOpenWizard } now. App loses five useStates, the focusChannel
helper, the focus-clear effect, and a large block of per-page prop plumbing.
(Platform S6b.2)
2026-07-19 00:08:34 +02:00
peter 6c32e5c8d6 refactor(plex): scope module state in PlexProvider
Lift the Plex module's shared local state (scope / watch-state / sort / expanded
filters / search term / open-playlist hand-off) out of App into a split-context
PlexProvider with stable useCallback setters. PlexSidebar, PlexBrowse, and the
header search box read it via usePlex()/usePlexActions() instead of props drilled
through App; PlexBrowse is now prop-free. App loses six useStates + the filters
JSON memo/serialise helpers. (Platform S6b.2)
2026-07-18 23:53:10 +02:00
peter d0fc061795 refactor(playlists): scope module state in PlaylistsProvider
Lift the Playlists module's shared local state (rail name filter + selected
playlist) out of App into a split-context PlaylistsProvider, mirroring
FeedFiltersProvider. Header, PlaylistsRail, and the Playlists detail pane read
it via usePlaylists()/usePlaylistsActions() instead of props drilled through
App; the modules take only a uniform `me` prop now. App loses two useStates and
the selected-playlist persistence helper. (Platform S6b.2)
2026-07-18 23:43:19 +02:00
peter da143fd70d feat(filters): scope global feed filters in FeedFiltersProvider
Lift the feed's filter god-state (per-account persistence, the account-load
effect, share-link capture) out of App into a split-context provider mirroring
NavigationProvider. Sidebar/Header/NotificationsPanel read it via hooks instead
of prop-drilling; <Feed> keeps its filters prop since ChannelPage renders it with
its own channel-scoped state.
2026-07-18 18:27:04 +02:00
peter af915883f9 fix(datatable): the sticky header no longer shifts or fades on first scroll
The sticky <thead> jumped up ~8px and picked up the scroller's top fade
the moment you scrolled 1px — so it read as 'not fixed'. Two causes:
- the table sat under a pt-2 gap, so its natural position was 8px below
  the sticky top:0 it snapped to. Drop the top padding (the fixed band's
  controls row already gives the visual gap) → no shift.
- the page scroller's top edge fade turned on with the first scroll and
  masked the header's top. Add a fadeTop flag (PageShell → PageScroller →
  useScrollFade), off for the Channels + Audit pages whose scroll region
  starts with a sticky header. Feed/channel keep the top fade (it fades
  cards under the fixed toolbar, as intended).

Verified: header stays at a fixed offset with no fade change from scroll 0
through scrolled, on both the manager and the audit log. Suite 15/15.
2026-07-18 05:43:19 +02:00
peter 867daa0dcf feat(channels): pager into the chip row, Blocked as its own tab
Address manager UAT:
- The DataTable controls row (status chips + pager) now portals into the
  fixed band via a new controlsInBand prop, so it stays put above the
  sticky header instead of scrolling away — and the pager fills the empty
  space next to the status chips, on one row.
- The tab bar is always fixed chrome now (not just on Subscriptions), so
  it doesn't jump when switching tabs; on Subscriptions the stats/intro/
  tags ride with it.
- Blocked channels moves off the bottom of the Subscriptions list into its
  own 'Blocked' tab (with an empty state), alongside Subscriptions and
  Discover.

Verified with a 90-channel test account: everything above the rows stays
fixed while the rows scroll under the sticky header. Suite 15/15, tsc +
knip clean.
2026-07-18 05:34:07 +02:00
peter 10f4baab3b fix(shell): scroll restore on Back + reset on new filter (bugs 1, 3)
The shell owns the scroller, so it owns the scroll policy. PageScroller
now takes a scrollKey identifying the logical view and keeps a per-view
offset in a module-level map, recorded live on scroll:
- same key returning (feed -> channel -> Back, filters unchanged) restores
  where you were (bug 1) -- the feed's scroller unmounts, but the saved
  offset survives and is re-applied, retried over a few frames so the
  virtualized list has time to reach its full height;
- a new key (feed filters changed) starts at the top of the fresh result
  set (bug 3). q is normalised out of the feed key so typing doesn't reset.

Recording on scroll rather than at unmount matters: by cleanup time the
scroller's scrollTop is already zeroed, so a cleanup-read saved 0.

Also fixes a Maximum-update-depth loop this introduced: the combined
element/fade ref was an inline callback, so React re-attached it every
commit and re-ran useScrollFade's state setter. Bound it with useCallback.

Adds E2E locks for bugs 1, 3 and 4 (restore on Back, reset on filter,
toolbar stays pinned). Full suite 14/14 green on :5173; tsc + knip clean.
2026-07-18 03:51:40 +02:00
peter d81f10b1cf refactor(shell): channel page is a PageShell config, not a 2nd scroller
The channel page rendered its own PageScroller as a separate App branch —
the source of the app's two <main>s. Make it a headerless/rail-less
PageShell config (its banner scrolls from the top) so there is exactly
ONE page scroller. Verified: single <main>, banner scrolls, in-page and
browser Back both return to the feed; E2E 11/11.
2026-07-18 03:19:34 +02:00
peter dc6e62e822 refactor(shell): introduce PageShell for the normal-page chrome
Collapse App's hand-assembled normal-page chrome (floating Header +
top-padded wrapper + PageScroller + banners) into a single PageShell
contract with declared fixed vs scrollable regions. No behaviour change;
the channel page still renders its own scroller (dissolved next).

Groundwork for the S4 fixed-chrome/scroll-ownership seam.
2026-07-18 03:15:41 +02:00
peter a1bdfece6d refactor(nav): read navigation from context, drop the prop-drilling
VideoCard/PlayerModal read openChannel from useNavigationActions (the memo'd
VideoCard subscribes to the STABLE actions context, so a page/channel change
no longer re-renders it); NavSidebar/Header read page/setPage; ChannelPage
reads closeChannel; NotificationsPanel reads setPage. App sheds the matching
props, and Feed/VirtualFeed no longer thread onOpenChannel at all.
2026-07-18 02:19:52 +02:00
peter 41bb1b2ab3 refactor(nav): lift navigation + history into NavigationProvider
Move page/channelView/ytSearch and the history/popstate/title effects out of the
App god component into a new NavigationProvider (split state/actions contexts so
the memo'd VideoCard doesn't re-render on a page/channel switch). App consumes it
via useNavigation/useNavigationActions but still passes the props down for now;
the consumer migration that removes the prop-drilling follows.
2026-07-18 02:06:07 +02:00
peter c855924fdc feat(settings): auto-save preferences instead of draft + Save
Every Settings change now persists on its own (debounced ~500ms so a slider drag
is one PUT), with a small transient indicator; the draft/dirty-save UI and the
navigation leave-guard are gone. Also unblocks the navigation extraction — there
is no longer a cross-page "unsaved settings" guard to thread through routing.
2026-07-18 01:44:25 +02:00
peter a032324513 improvement(scroll): fade the page scroller's clipped edges
The subdued edge-fade the rail and side panels use now hints at more content on
the page scroller too, which the feed, the channel page and Plex all scroll
inside. Both <main>s move into one PageScroller so the invariants they share —
the load-bearing scrollbar gutter, and the bar staying visible because hiding it
would zero that gutter — are stated once.

Two fixes the hook needed first: it set fresh state on every scroll event, which
above a page tree would re-render it per frame; and it watched a single child
captured at mount, so a lazy page swapping out its Suspense fallback left the
observer on a detached node and the fade frozen at its pre-load value (measured
on the Plex page: no bottom fade over a 3119px list).
2026-07-17 03:14:37 +02:00
peter ace4a144e8 fix(feed): stop the layout juddering when content lands on the scroll threshold
The feed takes its column count from main's clientWidth and its
thumbnails are aspect-ratio boxes, so the two feed each other once the
content height sits within a scrollbar's width of the viewport: the bar
appears, 15px goes, thumbs shorten, the content fits, the bar leaves,
and round again — every frame. Measured: scrollbar width alternating
0/15 and clientWidth 987/972 on consecutive frames. Reserving the gutter
holds clientWidth constant and breaks it.
2026-07-16 04:14:27 +02:00
peter eae65456ac feat(feed): pick the feed's view from its own toolbar
The view mode moves out of Settings into a switcher next to sort, on the
feed and channel pages alike. It leaves the Settings prefs draft for its
own eager save: a toolbar click must not mark that page dirty and trip
its leave-page guard.

lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.
2026-07-16 02:29:32 +02:00
peter 34fd88faf6 fix(channel): "Show in my feed" adds to the picked channels, not replaces
Sprint 5 made the channel filter multi-select, but this handler still wrote a
single-element array — so arriving from a second channel page silently dropped
the first. Union the id in (deduped, so repeating it for an already-picked
channel is a no-op).
2026-07-16 01:18:23 +02:00
peter 36581f2380 fix(channel): "Show in my feed" lands on the feed, not where you came from
It called closeChannel() before setPage("feed"). closeChannel runs
history.back(), and the popstate that restores the page fires AFTER the
synchronous setPage — so the restore won, dropping you back on whatever page the
channel was opened from. Opening from the feed hid it: the page it restored
happened to be the feed.

setPage already closes an open channel page and pushes a clean history entry, so
the closeChannel() call was both redundant and the bug.
2026-07-16 01:13:08 +02:00
peter 0d10e0f0a5 feat(filters): filter the feed by several channels at once (OR)
Pick any number of channels; they OR together and every other filter applies
across the lot — "the last week's unwatched from these three".

`channelId`/`channelName` are replaced outright by `channelIds: string[]`, no
legacy reader (standing rule: no back-compat unless asked — a saved view written
against the old field simply loses its channel filter). The wire and the share
URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via
`Video.channel_id.in_()`; the count endpoint shares the same params object.

Two things the migration exposed:
- SavedViewsWidget fed DB blobs straight into serializers that now index an
  array — the FeedFilters type says nothing about what an older build wrote, so
  the blob is coerced where it enters. It white-screened the app before this.
- Chips carried channelName to label themselves. Without it they resolve names
  from the channel cache, so they now fall back to the id, not a shared
  "This channel" that would render N identical chips while the cache loads.
2026-07-16 00:54:22 +02:00
peter 3caef7f6f9 feat(filters): make the feed's channel filter reachable
`filters.channelId` was settable by exactly one thing: a "?channel=" URL from a
shared view. The panel still rendered a CHANNEL card whose only control was
"remove" — a remove button for a filter the UI could not apply.

Two ways in now. The panel gets a real Channel group (searchable picker,
first-class alongside date/tags/language, so the chip row surfaces it for free);
normalizeLayout appends it to saved layouts, so nobody loses their arrangement.
And a subscribed channel's page gets "Show in my feed", which applies the filter
and keeps the rest of your filters — the point is "this channel, the way I
normally read", which the channel page itself can't answer (it runs show=all,
scope=all over the whole catalog).

The picker owns its channel query so a few-hundred-entry list loads when the
panel is actually opened, not on every feed visit, and says "showing 50 of N"
rather than quietly stopping.
2026-07-16 00:11:30 +02:00
peter 104183118d feat(header): move the sync status into a chip on the header
The rail spent a two-row block plus a divider on per-user sync status. It's
global, not per-module, so it now rides the right end of the floating header as
a pill: the headline count at a glance, an accent dot when something wants you
(paused / missing history), and the counts, state, the channel-manager link and
pause in a popover.

Frees the rail's whole top block. Also drops SyncStatus's two now-dead layouts:
"bar" was a leftover of the top bar removed in 0.42.0, and "rail" lost its only
caller here — the component is chip-only, ~60 lines lighter, and the popover
reuses useDismiss.
2026-07-15 22:42:31 +02:00
peter 79d9bca290 feat(panels): peek-open side panels with a docked tab
The shared SidePanel now follows the nav rail's model, so Filters, Plex and
Playlists all inherit it: unpinned it sits as a small tab docked flush against
the rail (replacing the full-height vertical strip) and expands into the card on
hover or keyboard focus without pushing content; pinned it stays expanded
in-flow. The collapse toggle became pin/unpin, and panels now default to
unpinned. The tab carries the active-filter count and an accent ring.

The tab stays mounted under the open card on purpose: swapping it out from under
the pointer drops the hover that opened the card, and unmounting it while
focused strands keyboard focus.

Measured: feed content starts at 123px instead of 336px when unpinned.
2026-07-15 20:57:21 +02:00
peter 6a9bf948f9 feat(appearance): opt-in glass tuner toggle in Settings
Graduate the floating glass tuner from a localhost-only dev tool to a
user-facing opt-in: it's now gated on a per-user `theme.showTuner` pref (off by
default) with a "Show the glass tuner" switch in Settings → Appearance, riding
the existing theme draft-save. Softened the dev-only framing. Behaviour and look
are unchanged; the full preset system is a separate epic.
2026-07-14 21:39:53 +02:00
peter ad1cc4586a feat(nav): slim hover-expand sidebar, role tooltip, logout button
The rail now sits slim by default and expands to a labelled overlay on hover
or keyboard focus without pushing content; the manual toggle became a pin
(pinned = the old in-flow expanded rail). The header no longer reserves a fixed
504px left offset — it's absolutely positioned inside the content column, so it
tracks the actual left zone and reclaims the dead gap. The role badge is gone;
role now shows via a title tooltip on the username, and a logout quick-button
joins the language/about cluster.
2026-07-14 21:39:30 +02:00
peter facb3ac9cb feat(ui): unified floating side panels for filters and playlists
Replace the full-height filter rails with one shared floating glass SidePanel
used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav;
its sections are reorderable "islands" (drag / hide / per-group collapse) with a
per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge
fade. The nav rail becomes a matching floating rounded card.

- New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook.
- Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps
  its storage key; plex/playlists get their own; persisted per account).
- Lift the Playlists rail to App level (selected playlist is now App state) so it
  floats and collapses like the filter rails; remove the old CollapsedFilterRail.
- The floating top header's fixed offset accounts for the panels' margins.
2026-07-13 05:13:40 +02:00
peter 076a2fbe6f fix(header): restore inner scroll broken by the floating-header wrapper
The wrapper that pads content below the fixed header lacked min-h-0, so as a
flex child with min-height:auto it grew to its content's height instead of
staying bounded to the viewport. That let the whole document scroll (the fixed
header/nav stayed put while content ran off-screen). Add min-h-0 so the wrapper
stays viewport-height and the inner <main> keeps its own overflow scroll.
2026-07-13 02:39:43 +02:00
peter d159f35f31 feat(header): floating module header with cyclic nav and shared search
Replace the full-width top bar with a fixed floating header of glass pills: a
fixed-position ModuleName (accent dot + label + prev/next cyclic module stepper)
and a shared SearchBar. The header's left edge is anchored as if the nav rail
and filter sidebar were both open, so it never shifts on collapse or module
change; the right edge leaves a 10% margin.

- ModuleName label width is measured live from the reachable modules' titles in
  the current language, recomputing on a language or account change.
- Module names come from a shared moduleLabelKey (the same short labels as the
  nav rail); NavSidebar and the header now read from one source (lib/modules.ts).
- The arrows step cyclically through moduleOrder(me) — dynamic, no hard-coded
  targets, wrapping at both ends.
- Search is hoisted into the SearchBar for feed (YouTube search), plex, channels
  (including the Discovery tab) and playlists (new); Go button, Enter triggers it.
- The Playlists left rail now reaches the top; header clearance via --hdr-h.
2026-07-13 02:28:06 +02:00