`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.
The chip is about YouTube subscription syncing, so it only earns its place on
the feed those subscriptions fill and the manager where you curate them. On
Plex — a different source entirely — plus playlists, downloads and stats it was
just noise.
Its tooltips were written for a roomy sidebar block and read like documentation
on a pill: the counts explanation went 120 chars -> 48, the full-history one
142 -> 46, and pause/resume drop "background".
Pinning it to the header's right edge read fine on a narrow window and stranded
it on a wide one — it's a status, but it's still part of the pill row. It now
follows the search bar.
Its tooltip was the long explanation of the counts, which is redundant next to a
popover that spells them out and overflowed the viewport from the right edge.
The chip gets the counts themselves; the explanation moves onto the popover's
counts row, where there's room for it.
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.
A channel page renders the Feed from its own baseline — channel-scoped,
whole-catalog, show-all, live included — so measuring it against the feed's
defaults labelled all five as "filters you applied" when they are just the page
being itself. It also has no filter sidebar (App hides it), and revealing what
the collapsed panel hides is the chip row's only job.
With the filter panel now collapsed to a tab, there was no way to see what was
narrowing the feed without opening it. Every applied filter is now a chip you
can drop on its own, with a Clear all when more than one is on.
The panel's count badge and the chip row read the SAME list (lib/useActiveFilters),
so they can't disagree about what's applied; that list also owns the reset both
"Clear all"s share. Replaces the hand-summed activeCount in Sidebar.
The tab stays mounted under the open card on purpose, but the card's 17px corner
radius cuts away its own pixels and the tab's square docked corner sits right
under that arc — showing a hairline of tab glass. Fade the tab out while the
card is open; opacity-0 rather than invisible/hidden, so it stays hoverable and
focusable (that is the whole reason it stays mounted).
The peek handlers sat on the rail's layout slot, which is 80px wide while the
card it holds is only 56px — so the card's own margin was a live hover zone with
nothing visible in it. Leaving the filter tab or panel to the left put the
cursor straight into it and the rail popped open unprompted; the gap between the
two hover zones was literally 0px. Moving the handlers onto the card leaves a
16px no man's land (x=69..85) where neither opens.
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.
The rail's hover/focus-to-peek logic — including the portal-proof focus tracking
(React-tree onFocus/onBlur plus a safety net for portals that unmount while
focused) — is about to be needed verbatim by the side panels. Lift it to
useHoverFocusWithin now that a second consumer exists. No behaviour change.
Opening the language switcher (or the account popover) and then dismissing it
left the unpinned rail stuck expanded. Those menus are portaled and unmount
while focused, and browsers fire no blur when a focused node is removed — so the
wrapper's onBlur never arrived and `focused` stayed true forever.
Keep the React-tree onFocus/onBlur (portals bubble through it, which is what
correctly holds the rail open while its own menu has focus) and add a safety net
for the one case they miss: when focus has landed nowhere, clear it.
GIT_SHA/BUILD_DATE were stamped into an ENV at the top of the runtime stage, and
BUILD_DATE is a per-second timestamp — so that layer differed on every build and
invalidated everything below it. The ffmpeg/deno apt install and the full pip
install silently re-ran on EVERY build, and every layer got a fresh digest, so
docker push re-uploaded the whole image each publish. Moving the version stamps
to the end of each stage lets those layers cache and keep their digests.
Also: npm ci off the (previously uncopied) lockfile, BuildKit cache mounts for
pip/npm so a dep bump only fetches the delta, and COPY --chown instead of a
trailing recursive chown.
Measured on a one-line backend edit: 70s -> 7s, with ffmpeg/deno/pip/npm all
CACHED. Image contents verified identical (appuser uid 1000, /app + /downloads
ownership, ffmpeg + deno on PATH).
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.
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.
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.
Fold a burst of identical notifications (same level+title+message within a
short window) into one entry with a ×N count instead of stacking copies — tames
a crash-retry loop or a repeatedly-failing poll. Shown in the toast and centre.
Only English and Hungarian remain. Drops the de locale files, the LANGUAGES
entry, the Google-locale mapping in auth, and the German welcome-message
template. A legacy "de" preference falls back to English gracefully. Content-
language detection and subtitle languages are untouched (media, not UI).
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.
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.
Add a light set of the 6 per-scheme backdrops (public/backdrops/light/*.svg) —
soft, pastel-mid, non-figurative washes tuned to each accent so they read through
the light translucent glass. Wire them up centrally:
- App: the backdrop is on whenever bgImage && !perf (was dark-only), so light
gets it too.
- index.css: per-scheme rules are now [data-theme]-qualified — dark uses the dark
set, light the /light/ set; shared cover/fixed size rule for both.
- Settings: the Background image toggle + Image-fade slider work in light now
(removed the dark-only gates); hint no longer says 'dark only'. Trilingual.
The light glass (62% translucent tier) refracts the light backdrop just like dark
refracts its own; one fade slider controls both.
The tuner writes inline vars (global), so its sliders always showed the dark/
backdrop-on baseline even when viewing light. Add a 'Sync to current theme'
button that clears the inline overrides, reads the live theme-driven values into
the sliders, and headers the Copy-CSS export for the active scope (:root for
dark, html[data-theme="light"] for light) — so light-theme glass can be dialled
in and exported straight into the right block.
Light theme has no background image, so the adaptive :root glass (94%) rendered
as flat snow-white islands, while the Plex filter rail (still on bg-surface/40)
showed the ambient tint — an inconsistency. Fix centrally, not per-component:
- index.css: one html[data-theme="light"] token override (surface 62 / card 74 /
menu 86) so every .glass* surface is translucent in light and picks up the
ambient tint, like it refracts the backdrop in dark.
- PlexSidebar: bg-surface/40 -> glass, so all filter rails bind to the same token.
One central lever now controls the light-theme glass translucency for the whole app.
Code review (public-repo rule): the GlassTuner + index.css/App.tsx comments named
the prod hostname and dev-process jargon (UAT/HMR/dev ports/epic), which ship in
the public bundle. Genericised the comments — no behaviour change.
The standalone /watch share page was on a hard-coded slate/teal palette (it
renders outside <App> with no theme). Add a baseline theme (dark/midnight) in
main.tsx so the token CSS vars resolve on every route incl. the public pages,
then swap all of WatchPage's slate/teal/hex to theme tokens (bg-bg/text-fg/
text-muted/accent, surface/border), with the password card as glass. Now
consistent with the app's default look; the app still overrides with the user's
saved theme when it mounts.
Medium container surfaces -> the glass system so they refract the backdrop
consistently: Playlists row (bg-card->glass-card), DownloadCenter user-picker +
ShareDialog share-picker dropdowns (bg-card->glass-menu), DownloadCenter stat
cards / download rows / usage block, ShareDialog share-link block, VideoEditor
selection bar, ProfileEditor profile rows (bg-card/40|bg-surface/60->glass-card).
Tight admin rows and tiny inputs left solid on purpose.
Convert the always-visible flat rails to the frosted glass system so they sit
consistently over the new background image (like the nav/header already do):
- Feed filter Sidebar + CollapsedFilterRail: bg-surface/40 -> glass
- Playlists left rail: bg-surface/40 -> glass
- BackToTop FAB: bg-card+border -> glass-card glass-hover
Small controls inside (filter pills/inputs) stay solid — a solid-on-glass
hierarchy, not everything translucent.
Replaces the dev-only backdrop-fade knob with a real control: when Background
image is on (dark theme), a 0-100% 'Image fade' slider appears beneath it and
drives --bg-fade live (higher = fainter). Persists to DB + localStorage via the
existing theme prefs path (ThemePrefs.bgFade, applyTheme sets the CSS var).
Removed --bg-fade from the GlassTuner so the two don't fight over the var.
Land the UAT-approved backdrop direction as a real feature (replacing the dev
thumbnail prototype):
- 6 generated abstract SVG backdrops in public/backdrops/, one tuned to each
accent scheme (dark, mixed-colour, few light areas). Non-figurative + generated
= repo-safe, tiny, accent-matched.
- New data-backdrop="on" state (dark theme + the setting + not perf) paints the
per-scheme image on <body> (app root now transparent so it shows app-wide and
the glass refracts it) AND switches the glass to its translucent tier (the
user's tuned values: surface 50 / card 60 / scrim 30 / blur 10 / edge 25).
Off / light / perf -> flat colour + solid glass (the readable fallback).
- Settings > Appearance 'Background image' toggle (bgImage pref, dark-only),
trilingual. Flat is the fallback + opt-out.
- Remove the FeedBackdrop prototype + its tuner controls; tuner baseline resynced
to the glass-over-image tier, add a Backdrop-fade slider.
GOTCHA fixed: the dark-mode ambient 'background' shorthand reset background-size
to auto (image rendered un-covered); out-specified it with [data-theme="dark"].
Idea from UAT-2: give the feeds the same 'content behind glass' lift the Plex
detail page gets. New FeedBackdrop paints a faint, slowly-rotating feed thumbnail
as a fixed backdrop on <main> (the proven art-backdrop path — a fixed z:-1 layer
is occluded by the root's opaque bg-bg). Blur = tiny-canvas downscale + cover
upscale (no CSS filter; ytimg is CORS-readable so it uses a data-URL, else raw).
Flat --bg stays as the 'perf mode' look. Behind the GlassTuner toggle + a
Backdrop-fade slider (--feedbg-fade) so it's judged/tuned in UAT before anything
is committed for real.
From UAT: glass reads strongest where there's content behind it, so split the
look into two tiers instead of one global compromise.
- index.css: global :root defaults are now the 'Adaptive' baseline (solid-enough
dark glass for content-less chrome). New .glass-media scope class carries the
UAT-tuned translucent values (blur 10 / surface 50% / card 60% / scrim 30% /
edge 25%) for surfaces that float over real artwork.
- Apply .glass-media to the art-backed Plex detail views (PlexInfo page variant,
PlexShowView, PlexSeasonView) and drop PlexInfo's hard-coded inline 55%
overrides so those panels are scope-driven and consistent (hero no longer
reads solid while the season strips are translucent).
- Two new colour schemes: Starship (deep-space blue, azure accent) and Matrix
(near-black, phosphor green), each with dark+light tokens + swatches.
- Remove the thumbnail-mosaic backdrop (did nothing usefully; its home is the
future 'videos behind glass' phase) — deletes MosaicBackdrop, mosaic vars/CSS,
and the tuner's mosaic controls. GlassTuner presets resynced to the new
baseline (Current / Media / Max-readable).
Foundation for the app-wide glass-consistency epic:
- index.css: every look-driving value (blur/saturate/dark-brightness/panel-card-
menu opacity/border/inset/edge/scrim/ambient + mosaic) is now a CSS custom
property defaulting to the historical hard-coded value — one-place tunable, no
visual change until a value is bumped.
- GlassTuner.tsx: dev-only (localhost-gated) always-on corner panel that live-
drives those vars, with treatment presets (Adaptive/Gradient+/Edge+scrim),
per-scheme palette editor, muted thumbnail-mosaic toggle, localStorage persist,
and Copy-CSS export. Deleted once the tuned finals are baked in.
- MosaicBackdrop.tsx: opt-in muted (blur+darken+desaturate) mosaic of on-screen
thumbnails so dark glass has real content to refract.
About tab now shows country/language/topics/keywords (migration 0055_channel_keywords);
Discovery tab links each channel to its in-app page; fixes: tab-switch header shift
(scrollbar-gutter:stable), hide the Source filter on the channel-scoped feed, recover a
throttled channel banner (retry + cache-buster), and match the no-banner fallback bar to
the banner height so the avatar no longer overlaps Back. /code-review findings fixed;
E2E + user UAT passed.
The no-banner placeholder was a short h-14 (56px) bar while a real banner renders
~150px, so the -mt-8 avatar rode up into the absolutely-positioned Back button on
banner-less channels (e.g. Billet Box). Give the fallback the same aspect-ratio +
maxHeight as the banner container so the overlapping avatar has the same room and
the header layout is consistent whether or not a channel has a banner.
The banner URL is valid, but googleusercontent intermittently drops the banner
request when the channel page fires the banner + avatar + ~16 thumbnails at once;
the browser then negative-caches the miss, so the banner stays broken with no
auto-retry (even though the same URL loads fine on a fresh request). On error we
now retry a few times with a ?r= cache-buster (a fresh URL that bypasses the
negative cache), and only fall back to the blank bar if it keeps failing (a
genuinely dead URL). Fixes the broken-banner on channel pages like BASSHUNTER.
The Source selector (organic / include-search / search-only) is a global-catalog
concept — it picks how videos ENTERED the library. On a single channel's page the
view is pinned to librarySource=all and would only offer a near-empty, confusing
slice, so hide the selector there (the main feed keeps it).
- apply_channel_details: (branding.get("channel") or {}) so a "channel": null
in brandingSettings can't AttributeError-abort the enrich batch.
- topicLabels: drop empty labels so a malformed topic URL can't render a blank chip.
- 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).