Platform-prefixed handles (x/twitter/instagram/facebook/tiktok/youtube/twitch/threads/
telegram/soundcloud) now link to the platform's profile URL — only the @handle is
linked, the '<platform>:' label stays as text. A required colon/arrow separator + the
known-platform list keep false positives out; a bare @handle with no platform stays plain.
The description linkifier only caught http(s):// URLs; extend it to www.* hosts and
bare domain.tld/path URLs (both get an https:// href prefix), so links like
www.amelielens.com and facebook.com/amelielensmusic become clickable too. A required
path + letters-only TLD keep false positives (version numbers, file names) out; bare
@handles and #hashtags stay plain (the platform is ambiguous).
- linkify: bare http(s) URLs inside channel descriptions become clickable links
(new tab, rel=noopener noreferrer) — new lib/linkify.tsx, used in the shared
ChannelAboutContent so both the About overlay and the channel detail page get it
- Country flag: the flag EMOJI never renders on Windows/Chrome (showed the small-caps
'hu' fallback), so swap it for a real self-hosted SVG flag via the flag-icons package;
applies to the About overlay + the channel detail page
- The About overlay now shows the FULL channel About (description, external links,
country, language, topics, keywords) — the same content as the detail page's About
tab, via a new shared ChannelAboutContent component (reused by ChannelPage + AboutCell)
- AboutCell fetches the channel detail ON DEMAND on hover (shared [channel,id] react-query
cache with the channel page), so the list stays light; the overlay is interactive
(scrollable + clickable links) via a small hover-bridge, and closes on page scroll/resize
- Center the content: max-w-7xl -> max-w-[96rem] on the channel tables + header, so the
wider About table stays symmetric with the header (mx-auto handles the responsive centering)
- Topic chips are display-only for now; the future search epic makes them clickable
- Backend: add channel description to the shared _channel_summary projection, so both
the subscribed list and the discovery list carry it (no migration; description is
already stored from the channels.list call)
- New AboutCell: one truncated line in the table + full text in a portalled, always-on
glass hover/focus overlay (wider than Tooltip, flips above the anchor near the bottom,
closes on scroll/resize)
- About column (after Channel) on both the subscribed and discovery tables; hidden in the
mobile card fallback
- Drop the intro blurb (+ its orphan channels.intro i18n key and Trans import)
- Header is now two tight rows: tabs + stats (right), your-tags + sync/backfill (right),
leaving room on the tab row for future tabs
- Add a funnel filter on the Prio / Channel / Sync / Tags columns (client-side, like
the discovery table's Channel filter)
- Move the Actions column to 2nd position (after Prio, before Channel), left-aligned
DataTable gains a working controlsPosition="both": the leading controls
(e.g. the status chips) stay ONLY at the top; the bottom row is pager-only
(no duplicated chips). The subscribed + discovery channel tables opt in, so
the 33-page list has a pager at the bottom too — no scroll back up.
- Plex player fills the screen in native fullscreen (was 720p-capped, centred)
- YouTube modal reverted to its smaller base size
- iOS-safe clipboard copy helper, adopted across all 5 copy sites
Bug 5: in browser fullscreen the video stayed small and centred instead
of filling the screen. Two causes:
1. The wrapper carries 80vw/80vh + transform:scale(1.25) for the windowed
HTPC look; in native FS that scaled layer never filled. Size the
wrapper to 100vw/100vh with NO transform while fs is on.
2. The real culprit: the <video> was `w-auto h-auto max-h-full max-w-full`,
which caps at the stream's intrinsic resolution (720p -> 1280x720) and
never scales UP. The windowed scale(1.25) hid this. Switch to
`w-full h-full object-contain` so it fills the wrapper by aspect.
Verified visually in real Chrome native fullscreen: video rect 1920x1024
(fills), was 1280x720 centred.
navigator.clipboard.writeText is intermittent on iOS Safari (bug 9: the
download share-link copy 'often does neither'). Add lib/clipboard.ts
copyText(): a gesture-safe execCommand path FIRST (before any await can
spend the transient user activation), async Clipboard API as fallback.
Migrate all five copy call sites to it.
The max-w-6xl bump was a quality lever (bigger pixel box → higher ABR);
the user prefers the smaller max-w-4xl base back. A user-selectable size
preset stays a future item.
Cuts the release for the unshipped stack since 0.44.0: E3 feed view modes, the
Platform refactor (S1–S6b, internal), and the Plex cast-explore epic. VERSION
0.44.0 -> 0.45.0; user-facing releaseNotes entry.
Fixes the pre-existing Plex bug where clicking a lower-billed cast member emptied
the grid (the sync only stored the top ~3 cast, AND-matched). Now: the full cast
is persisted on info-view (lazy) + a batched rich re-sync backfills the whole
library (marker 0057); the actor filter defaults to OR with an AND toggle
(feed-rail pill style); the filtered actors show as person cards (photo + count +
remove); orphaned headshot-cache rows are pruned each sync. Shared app/plex/people.py
backs both enrichment paths. E2E 17/17; /code-review clean (2 fixed). UAT passed.
Lazy enrichment only covers viewed titles, so an actor's count/results were
partial. Add a background backfill: each plex_sync rich-fetches a bounded batch
(60) of not-yet-enriched movies/shows and persists their FULL cast, stamping
people_enriched_at so each is fetched once — the whole library is covered over
successive runs (migration 0057 adds the marker). Extracted the enrichment into a
shared app/plex/people.py (cast_from_meta / enrich_row_people / prune_orphan_people
+ the person-image host/proxy) so the info-page lazy path and the batch share ONE
implementation — no duplication; the info page also stamps the marker so the batch
skips it. Verified: an 8-item batch grows those movies' cast_names 3->24 and fills
plex_people (32->224); the info-page cast is intact after the _rich_meta refactor.
(Plex cast-explore S1b)
The genre + actor MATCH toggles were a boxed button row (rounded-lg border, no
track); restyle them as the feed rail's tag-match toggle — a rounded-full track
(border + bg-card + p-0.5) with an inset rounded-full active pill, plus a MATCH
label. Extracted a shared MatchToggle so both read identically. i18n
plex.filter.matchLabel (en/hu). (Plex cast-explore polish)
Guard the prune's NOT IN against the classic NULL trap: a JSON-null cast array
element would surface as SQL NULL in the referenced set, making name NOT IN
(…, NULL) evaluate NULL for every row → silently prune nothing. Filter NULLs out
of the subquery. (Plex cast-explore S4 review)
The plex_people headshot cache can outlive its subjects — when a title leaves the
Plex library, cast/crew who appear in nothing else are orphaned. prune_orphan_people
deletes rows whose name no longer appears in ANY movie/show cast_names/directors,
run at the end of every plex_sync (best-effort; a jsonb_typeof='array' guard skips
scalar/null JSONB values). The filterable cast_names self-clean with their row;
this cleans the only per-person cache. Verified: an injected orphan is dropped, real
people kept. (Plex cast-explore S4)
The actors currently in the filter now render as cards above the poster grid —
headshot + name + in-scope title count + a remove x — so you SEE who you're
exploring, not just chips in the rail (generalises the old search-only person
cards to the applied filter). New GET /plex/people/cards (photo from the
plex_people cache, count live against cast_names); PlexBrowse fetches + renders
them; i18n en/hu. Verified: John Goodman (11) / Douglas M. Griffin (1) / Bradley
Cooper (14) with photos + counts. (Plex cast-explore S3)
Multiple selected actors now combine as OR (any) by default — click several cast
and see films featuring ANY of them — instead of the old hardcoded AND that
emptied the grid. An AND (all) toggle in the ACTIVE filter section (shown for 2+
actors, mirroring the genre match toggle) narrows to films with ALL of them.
Backend actor_mode param mirrors genre_mode. Verified: OR John Goodman|Douglas
M. Griffin = 11 films; AND = 1 (10 Cloverfield Lane). (Plex cast-explore S2)
The cheap section listing that drives the main sync only stores the top ~3 cast,
so any cast member below that was unfilterable (clicking them AND-emptied the
grid). Now the movie/show info fetch (/item, /show) — which already pulls the full
rich cast — persists it onto cast_names + people_text (so every displayed cast
member becomes filterable) and caches each headshot in the new plex_people table
(migration 0056). Idempotent (only grows a thinner stored set); best-effort (never
500s a read). Verified: 10 Cloverfield Lane 3->8 cast after viewing; plex_people
populated with photos. (Plex cast-explore S1a)
Closes the Platform epic DoD. Three cross-cutting App states move into scoped
providers (WizardProvider, FeedViewProvider, PrefsProvider) and the module
registry (components/moduleRegistry) drives the render: App looks up
PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate — the page
ternary and the three side-rail page conditionals are gone. Content modules and
rails are prop-free (read me via useMe(), module state from providers).
App.tsx 796->431 lines, zero page===X chrome conditionals. E2E 17/17;
/code-review clean; UAT passed.
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)
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)
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)
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)
S6b.1: lib/modules.ts is an ordered ModuleDef registry (single source for nav
label/icon/order/gate), absorbing NavSidebar's ICON map.
S6b.2: module-local state moved out of App into PlaylistsProvider / PlexProvider /
ChannelsProvider (split-context, mirroring FeedFiltersProvider). Header, rails,
PlexBrowse, Channels, NotificationsPanel, Sidebar read from providers; modules take
a uniform `me` prop. App.tsx 796->655 lines. Render still on the ternary (S6b.3
flips it). E2E 17/17; /code-review clean; UAT passed.
Review follow-up: the ordered MODULES array can't double as a compiler-
enforced exhaustive Page map, so document that adding a Page means adding it
here (an unregistered Page returns undefined from moduleDef/moduleLabelKey and
throws in NavSidebar). No behavior change. (Platform S6b.2)
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)
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)
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)
Extend lib/modules.ts into an ordered ModuleDef registry (id/label/icon/
system/badge/gate) as the single source for the nav rail and the header
stepper. Absorb NavSidebar's per-page ICON map; derive moduleOrder /
moduleLabelKey / SYSTEM_PAGES from the one array. Render still on the App
ternary — this is the scaffold S6b.3 will drive from. (Platform S6b.1)
Name the stacking layers as a tailwind zIndex token scale (a value-preserving
rename of ~48 magic z-values) and add an Overlay helper (portals to <body>) that
the in-tree full-screen holdouts (ChatDock, OnboardingWizard, GlassTuner) now use
to escape ancestor stacking contexts.
Add components/Overlay.tsx (portals to <body>) and route the in-tree full-screen/
floating holdouts through it — ChatDock, OnboardingWizard, GlassTuner — so they
escape any ancestor stacking context (mask/transform/backdrop-filter) instead of
relying on a bare high z-index. Toaster stays in-tree (it's content-column-scoped
by design, outside the masked scroller); Welcome is a page, not an overlay.
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.
FeedFiltersProvider lifts the global feed-filter god-state out of App (mirrors
NavigationProvider) so Sidebar/Header/NotificationsPanel read it via hooks; <Feed>
stays prop-driven for the channel-scoped case. Opening a channel now inherits the
feed's content-type prefs instead of forcing Live/Upcoming on (bug 2). Two E2E
locks: content-filter inherit (both ways) + no-leak.
The prior wording claimed NotificationsPanel subscribes to actions only, but it
reads filters (spreads them into setFilters). No consumer is actions-only today;
describe the split's intent without the wrong example.
Add feed-content-<key> testids to the toolbar's content pills and two channel
specs: the channel view inherits the feed's Live/Upcoming both ways, and a
content toggle made in the channel view does not leak back into the feed.
Opening a channel hardcoded includeLive:true (+ normal on / shorts off), so a
channel view silently showed live/upcoming even when the feed had them off. Seed
the content triad from the global feed once at mount; the channel toolbar can
still toggle them locally.
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.
S4: PageShell/PageToolbar contract, one page scroller, scroll restore on
Back + reset on new filter (bugs 1,3), pinned feed toolbar (bug 4), and
the fix for the ref-churn 'max update depth' loop.
S4b: the fixed-header / scrollable-content pattern applied across nine
modules (feed, channel detail, channel manager + discovery, playlist,
plex, notifications, downloads, admin users/config), plus the sticky
DataTable header with the header+fade 3D-depth edge the user liked.
E2E 17 specs green (incl. bug 1/3/4 locks); tsc + knip clean. Not shipped.
Apply the fixed-chrome pattern to the two admin pages with a horizontal
tab bar: the tabs (and Config's intro) pin in the shell's fixed band while
the tab content scrolls under them. Config's floating save bar is
unaffected. Suite 15/15, tsc + knip clean.
Apply the fixed-chrome pattern to three more list modules:
- Notifications: the inbox header (title + mark-all / clear-all) pins in
the band; only the notification list scrolls.
- Downloads: the title + subtitle + tabs pin; the tab content scrolls.
- Channel discovery: the intro + table controls pin (controlsInBand), the
table rows scroll under the sticky header (fills out the manager's
Discover tab to match Subscriptions).
Verified: headers land in the fixed band, content in the scroller. Suite
15/15, tsc + knip clean.
Lift Plex browse's title-count line into the shell's fixed band via
PageToolbar, so the count stays visible while only the poster grid scrolls
(the filters already live in the left rail). Verified with a 3.5k-title
library: the count holds at a fixed offset while the grid scrolls. Suite
15/15, tsc + knip clean.
Lift the playlist detail header — cover/title/actions (Play all, sync,
delete) and the sort/group controls row — into the shell's fixed band via
PageToolbar, so only the video list scrolls under it. Matches the
feed/channel/manager pattern. Verified with a 40-item playlist: the header
holds at a fixed offset while the list scrolls. Suite 15/15, tsc + knip.
The rows now dissolve as they slide up under the sticky header (matching
the bottom fade), while the header stays crisp. A short gradient painted
by the header's ::after sits just below it and fades the rows — this
replaces the scroller's top mask-fade, which is off on these pages so it
can't touch the header. Barely visible at rest (it covers the first row's
top padding), clear once scrolling. Suite 15/15, tsc + knip clean.
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.