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)
Replace ~48 magic z-index values (z-10..z-[9999] + 2 inline zIndex) with named
tailwind zIndex tokens (base/menu/chrome/paneltab/panel/rail/overlay/popover/
tooltip/tuner). Values are UNCHANGED — a rename, not a renumber — so layering is
identical; the names give one source of truth for the stack.
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.
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.
Lift the whole subscribed-view header — tabs, sync stats + actions, the
intro, the tag chips and the sync-status chips — into the shell's fixed
band via PageToolbar. Combined with the sticky table header row, only the
channel rows scroll now; everything above them stays put.
The status chips move out of DataTable's controlsLeading into the fixed
chrome (they belong above the header, not in the scrolling controls row).
Note: the DataTable pager (rows-per-page / pagination) stays in the
scroller for now — it rarely shows on the manager (few channels = one
page). The discovery sub-view's chrome is still inline (separate
component, handled in the sweep). Suite 15/15, tsc + knip clean.
The sweep's own three (the thread, the Plex collections filter, the tag manager)
plus the seven the spec missed — the playlist picker, the row tag menu, the Plex
playlist/collection dialogs, the player's track menu, the video description and
the table's multi-select filter. Same idiom throughout: a height-capped list that
silently clipped its content now says so, and hides its scrollbar like the rail
and the panels already do.
The table's option list is its own module-level component because it owns a hook:
FilterPopover is redeclared per DataTable render, so React remounts it.
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.
- fix: reserve the scrollbar gutter (scrollbar-gutter:stable) on the channel
page's scroll container, so switching between the tall Videos tab and the
short About tab no longer shifts the banner/avatar/buttons a few px sideways
(the vertical scrollbar was appearing/disappearing between the two tabs).
- About tab now shows Country (flag + localized name), Language, Topics
(topicCategories → readable chips), and Keywords (brandingSettings keywords
parsed into chips, quoted multi-word tags kept whole). country/language/topics
were already stored; keywords is new (migration 0055_channel_keywords, mapped
in apply_channel_details, returned by channel_detail).
- Discovery → channel page: the Channel-manager "Discover from playlists" tab now
links each channel name to its in-app channel page (ChannelLink onView), so a
discovered channel's About/videos can be inspected BEFORE subscribing (was
subscribe-only, plain text before).
Note: the channel info-card epic itself was already delivered in v0.19.0; this is
the About-tab enrichment follow-up + the header-shift fix. external_links stays
empty by design (YouTube removed the field from the Data API ~2023).
- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
+ 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
(+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
count actually changed, not on every 20s poll; MB3 (client): react to the live
unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
- CollapsedFilterRail: the byte-identical 31-line collapsed sidebar rail was in
both Sidebar (feed) and PlexSidebar → one shared component.
- VideoCard: the title + channel-button + meta block was duplicated across the
list-row and card layouts → a local `textBlock` element (only one layout branch
renders, so reusing the element is safe).
- subsColumn<T>(t): the identical 'subscribers' DataTable column in Channels +
ChannelDiscovery → a generic factory in channelColumns.tsx.
All behavior-neutral; jscpd now reports 0 clones (was 8 at Phase 0 baseline).
tsc + knip green.
- Extract notifyYouTubeActionError (new lib/youtubeErrors.ts): the "403 → Connect
your YouTube account, else fallback toast" logic was duplicated in Channels.tsx
and ChannelDiscovery.tsx. (Separate file, not lib/notifications, to avoid the
api ↔ notifications import cycle.)
- Extract format.formatCountOrDash(): the `n != null ? formatViews(n) : "—"` count
cell was repeated across the subs/videos columns in Channels + ChannelDiscovery.
- Register channelsTable / channelDiscoveryTable in storage.ts LS instead of bare
"siftlode.*" string literals.
- Delete 7 dead channels.json keys (filterPlaceholder, tags.newTag/createTag,
row.stored/subs/getFullHistory/getFullHistoryHint) from en/hu/de — verified 0
refs (createTag matches were the unrelated api.createTag method, not the key).
Behavior-neutral. tsc green, knip no new unused, localdev boots.
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
- Live-search view: a results-count selector (20/40/60/100) replaces manual load-more (the free
scrape source pages until that many are gathered); an 'these results are temporary' banner with
a 'Clear now' button that discards them 'as if never added' (api.clearSearch) and returns to
the feed.
- Channel blocklist: a Block/Unblock toggle + 'Blocked' badge on the channel page (blocked
channels don't auto-explore and their videos are hidden), and a 'Blocked channels' section in
the Channel manager with one-click unblock. ChannelDetail.blocked from the backend.
- Admin: a 'Purge discovery' button on the Scheduler page (immediate un-kept search/explore
cleanup). EN/HU/DE throughout.
The per-row tag '+' menu always opened downward (top-full) and clipped against the scroll
container for rows near the viewport bottom. Measure the button on open and flip it upward
(bottom-full) when there isn't room below but there is above; cap its height with an internal
scroll so a long tag list can't overflow either way.
- Channels manager: prominent channel-name search box and the 'Your tags' chips are now
clickable to filter the table by tag (replacing the hidden per-column DataTable popovers).
Both filter client-side over the status-filtered list; a focus-channel intent seeds the
search, the reset intent clears both.
- Feed 'Your tags' sidebar: count user tags in the facet endpoint (the 'other' category) and
make the widget contextual like language/topic — counts reflect the current filter and
zero-count chips hide (e.g. a source=search view with no tagged channels shows 'no matching
tags' instead of the full static list). EN/HU/DE searchPlaceholder.
- formatEta was hardcoded English (violated the trilingual rule) -> time.eta.* keys
in EN/HU/DE; Scheduler's countdown 'now' likewise.
- relativeFromMs added to format.ts; NotificationsPanel drops its duplicate
relativeTime/relativeFromMs and the now-orphaned notifications.time.* keys.
- Channels' fmtTotalDuration moved to format.ts as formatTotalHours.
lib/useDismiss.ts replaces the identical mousedown-outside + Escape effect that
DataTable (filter popover), Channels (tag picker) and AddToPlaylist each hand-rolled.
Positioning stays with each caller (it genuinely varies); AddToPlaylist keeps its
own resize/scroll reposition effect.
Eight confirmations stated a bare fact without saying what they acted on. Pass
the entity through the mutation variables and interpolate it: channel
unsubscribe/reset name the channel, whitelist/demo-whitelist adds name the
email, tag delete names the tag, and playlist revert/push name the playlist.
EN/HU/DE.
A focus-channel deep-link sets (and persists) the channel-name column filter
via the DataTable's externalFilter. That persisted value then survived into
unrelated visits: clicking the header's "N without full history" link landed
on the right tab with the right status chip, but a leftover name filter (e.g.
a channel focused in a past session) hid every row — "No channels".
Add a resetFiltersToken to DataTable that clears its column filters when it
advances, and bump it from the header intent. The token's ref starts at 0 so
the clear fires even when the table remounts on navigation, while a plain
reload (token still 0) keeps the user's persisted filters.
The Channel manager's tab (subscriptions vs playlist discovery) was persisted
locally, so the header's "N without full history" link and the focus-channel
jump (subscribe notice, tag manager) could dump the user on the Discovery tab
while quietly applying a status/name filter that only affects the subscriptions
table — the targeted channel was there, just on the hidden tab.
Lift the tab state to App alongside the status filter it pairs with, and have
those navigation intents switch it back to "subscribed".
The "avatar + name + open-on-YouTube" cell and the @handle-or-/channel/<id>
URL were copy-pasted across the channel manager, the discovery tab, the
subscribe notice and the player. Extract a single ChannelLink component
(optional in-app onView, middle-click opens YouTube) and a channelYouTubeUrl
helper, and route all four through them. Removes the NameCell / DiscoveryNameCell
duplication (the latter introduced with the discovery tab).
Add a "Discover from playlists" tab to the Channel manager that lists
channels appearing in the user's playlists they don't subscribe to, with
a one-click Subscribe.
- GET /api/channels/discovery: local join (playlist_items -> videos ->
channels) minus the user's subscriptions and their own channel. Enriches
stub channels' metadata up front (title/thumbnail/subscriber count via the
API key) so the user can judge a channel before subscribing; videos are
not pulled (the scheduler picks those up).
- POST /api/channels/{id}/subscribe: write-scope gated, subscriptions.insert
+ local Subscription with the returned resource id.
- YouTubeClient.insert_subscription / get_my_channel_id.
- users.yt_channel_id (migration 0019) caches the user's own channel id so
discovery can exclude it.
- Frontend: ChannelDiscovery DataTable, Channels tab toggle (persisted),
api methods, trilingual strings. Subscribe ships a typed notification
payload (ChannelSubscribedMeta) for the inbox to act on.
- Channel-row tags show only what's attached; a '+' opens a per-channel picker. Clicking a tag
filters the feed by it; a 'Your tags' sidebar widget makes that filter visible/clearable.
- Tag manager dialog (add/rename/delete; delete only confirms when the tag is in use), reachable
from the Channel manager and the feed sidebar; hovering a tag's count lists its channels, each a
link that focuses it in the Channel manager (DataTable gains an external filter input).
- Video cards get a reset action that clears all watch state (incl. an in-progress position).
- api.req() now raises the error dialog on 5xx and 400/409/422 with the server's reason.
- Add Last upload, total Length and a Normal/Shorts/Live breakdown, computed on read in the
existing grouped query (~35ms over the full catalog — measured, so no denormalization).
- Subscribers shown compact (919K), since the YouTube API already rounds them.
- Tag cell now shows only the tags actually attached to a channel; a '+' opens a per-channel
picker to attach/detach, instead of listing every user tag on every row.
Replace the stacked card rows with the reusable DataTable: sortable columns, in-header
Channel/Tags filters, status chips + page controls in one row, wider layout. Tag toggle
is now optimistic (no full refetch). Sync column collapses to one 'fully synced' chip;
Actions column carries hide/unsubscribe + a backfill control (admin reset / per-user
full-history opt-in). Channel status filter persists across reloads.
- Channel name: middle-click opens the channel's YouTube page in a new tab
(left-click still opens the in-app detail) plus an explicit external-link icon.
- recent/full sync badges show a check icon when the state is reached.
- New i18n key channels.row.openOnYouTube (HU/EN/DE).
Add a promise-based ConfirmProvider + useConfirm() hook (built on the Modal shell,
liquid-glass styling, danger variant, Esc/backdrop = cancel, Enter = confirm) so
confirmations match the app instead of the native browser dialog. Wire it in at the
app root and replace both window.confirm call sites (playlist delete, channel
unsubscribe). Trilingual common.confirm/confirmTitle strings.
The channel manager already styled the two deep-backfill-pending states
differently (solid clickable chip when you requested full history vs a faint
outline when another subscriber did), but both used the same "full history
queued" label, so a channel queued by someone else looked identical to one you
queued yourself. Give the by-other case its own label ("full history coming")
so the distinction is legible at a glance, not just on hover. Trilingual.
api: HttpError now carries the server's detail. Channels sync/backfill/unsubscribe
now detect a 403 (no YouTube grant) and show a 'connect your YouTube account'
message with a Connect action that opens the onboarding wizard, instead of a vague
'failed' toast. Translated HU/EN/DE.
Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel,
OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime
helper are now fully translated in Hungarian, English and German, each with its own
locale area file (auto-loaded). Key parity verified across all three languages.
- header: per-user "N without full history" count (channels_deep_pending),
clickable with a hint -> opens the channel manager filtered to those.
- channel manager: status filter chips (All / Needs full history / Fully synced
/ Hidden); the header link deep-links to "Needs full history".
- fix: priority up/down is now an optimistic in-place cache update (no refetch /
re-sort), so the list no longer jumps to the top and loses your scroll position;
the new order applies on the next page load.
Channel/account avatars come from Google's image CDNs (yt3.ggpht.com,
lh3.googleusercontent.com). On a feed page dozens load at once; the CDN
rate-limits the referrer-bearing burst (429), so a random subset rendered the
browser's broken-image icon (the URLs themselves are valid — verified 200).
Add a shared <Avatar> that sets referrerPolicy="no-referrer" (which the CDNs
serve without throttling) and falls back to a neutral initial placeholder on
error instead of the broken-image icon. Use it for video-card, player, channel
manager, header and settings avatars.
deep_requested is per-subscription, but deep backfill is channel-wide and shared:
once any subscriber requests full history, the whole back-catalog arrives for
everyone. The chip only looked at the current user's flag, so a second subscriber
saw a misleading 'get full history' on a channel already queued by someone else.
Add a channel-level deep_in_queue to /api/channels and show an informational
'full history queued' badge (vs the owner's cancelable button) in that case.
When a user requests full history for a channel whose recent uploads aren't
fetched yet, run a one-channel recent backfill synchronously in the request so
the feed populates at once instead of waiting for the scheduler. Deep paging
still follows on the scheduler (recent-then-deep). The deep-toggle mutation now
also refreshes my-status and the feed.
Per-user opt-in to full-history (deep) backfill so a new user's unique
channels no longer trigger a big shared-quota burst.
- migration 0007: Subscription.deep_requested (default false); seed admins'
existing subscriptions to preserve today's global-backfill behaviour
- run_deep_backfill is now demand-driven: only channels at least one user has
requested are deep-backfilled; recent backfill stays unconditional (cheap)
- estimate_deep_backfill ETA helper (quota-bound) surfaced in /api/sync/my-status
- POST /api/sync/deep-all to opt all my channels in; PATCH channels accepts
deep_requested
- UI: per-channel Full history toggle, Backfill everything action, deep
progress + ETA in Channels header and Settings - Sync
- Tooltip: render in a portal with fixed positioning + edge-flip so hints are never
clipped by overflow/stacking ancestors (fixes mispositioned/hidden bubbles app-wide).
- Glass: raise opacity so overlay menus/panels stay readable over content.
- SettingsPanel: vertical tab rail (no wrapping/jumping), content grid-stacked so the
panel sizes to the tallest tab (stable height) and floats to its content height.
- Notifications: the test toast is now a normal auto-dismissing toast (with countdown
bar) that also plays the sound via a new force-sound flag.
- Channel manager: explain priority/tags/hide and what 'Sync subscriptions' does;
add a 'Channel priority' feed sort so priority is actually meaningful.
- Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop,
performance-mode opt-out) and apply it across panels, popovers, toasts, cards,
sidebar widgets, channel rows, video cards and login.
- SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no
horizontal scrollbar) with a prominent active state.
- Notifications: auto-dismiss can be switched off (stays until closed); the test
notification now also triggers the alert sound; resume a suspended AudioContext.
- Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire
hints across the settings and channel-manager surfaces; persisted per account.
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.