- The tag menu's flip was one-way: once anchored above it stayed there, so a row
scrolling toward the top dragged the menu off the screen. It now re-decides both
ways each pass — prefer the side it is on (no flapping), leave it as soon as that
side stops fitting and the other does. Verified: trigger at y=229 (no room above)
flips back below, menuTop 253 = btnBottom 249 + 4, nothing off-screen.
- Extract the card pagination into lib/useCardPager: size (persisted, validated),
page, the out-of-range clamp-commit, the slice and the Pager props lived twice,
and every review round had to fix both copies in lockstep. The two tabs now keep
only what differs — which rows, which storage key, and when to reset.
The clamp is gated on the card layout too, so it no longer rewrites card state
while the table is the one on screen.
- The portalled tag picker now FOLLOWS its trigger on scroll (rAF-coalesced)
instead of closing. Closing on every wheel tick was a regression for the table,
where the menu used to travel with its row; it still closes once the trigger
leaves the viewport (nothing left to anchor to) or on resize.
- Place the menu by MEASUREMENT, not a px estimate: it opens below, then the
layout effect flips it above only if the real box overflows. The old
`rows * 30px` guess read short at font-scale 1.3 (the rows are rem), which
could leave a fixed menu hanging off the bottom with no way to reach it.
- `toggle` only opens once a position exists, so the `open && coords` gate can
never show a stale anchor.
- One owner for "is there anything to page": `hasPagerContent()` lives in Pager
and DataTable calls it, instead of both re-deriving the rule — that drift is
what left empty control bands behind in the first place.
- Reset the card page on the row COUNT too, not just the filter inputs, so an
unsubscribe or a shrinking refetch can't leave a stale page to spring back.
- `parseCardSize` clamps to the sizes the pager actually offers (like clampView /
clampStatus), so the select can't display one value while the grid pages by another.
- VirtualGrid's docblock now carries the stacking-context constraint: its rows are
transformed, so anything floating an item renders must portal out.
- 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).
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.
Cards have no column headers to sort by, so lift the sort out of DataTable into
one shared state that BOTH views drive and reflect. New lib/columnSort.ts holds
the comparator + click-cycle (one source of truth); DataTable gains an optional
controlled sort (sort + onSortChange) and falls back to its internal persistKey
sort when they're omitted, so every other table is untouched. The subscribed
sort lives in ChannelsProvider, discovery keeps its own (its columns differ);
a new ColumnSortControl dropdown (options derived from the columns' sortable
metadata, feed-style + asc/desc toggle) drives it in cards mode. Per-account,
persisted, per-tab.
UAT round 1 feedback:
- Retire the `rows` layout — it barely differed from the table; keep Table + Cards.
- The card now shows the About cell (+ its hover overlay) and the priority stepper
in every layout, not just the table: ChannelLayoutGrid gains named slots
(lead / about / actions) and stops hiding the `hideInCard` columns, since the
roomy desktop card has space the cramped mobile fallback did not.
- Fix the table losing its centering with "Rows per page: All": a long-titled
channel stretched the uncapped Channel column (~289→429px) and overflowed the
centered table. Cap it with a max-w element inside the cell (table-layout:auto
ignores a cell max-width), the same trick AboutCell already uses. Gaps now 163/162.
Self-review follow-ups: the `rows` layout mapped the primary column without a
React key (single-element today, but a warning waiting to happen); wrap it in a
keyed Fragment. Also replace the guessed card/row rowEst with browser-measured
values (176 / 62) so the scrollbar doesn't jump on first scroll.
A generic ViewSwitcher (reused from the feed) drives a per-account layout
shared by the Subscriptions + Discovery tabs. `table` keeps the DataTable
(numbered pager); `cards` and `rows` render a virtualized grid over the same
client-side list via the new ChannelLayoutGrid, which composes cells straight
from each table's Column<T> render closures (cardPrimary/hideInCard/cardLabel),
so both tabs — with their different columns — get the views for free. Status
chips surface in the fixed band when the table's own controls row is gone.
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
- 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
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.
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.
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)
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)
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)
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)
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.
Two UAT nits on the channel page:
- the chip-row-to-cards gap was ~36px (toolbar pb-3 + wrapper pb-2 + grid
pt-3 stacking); drop the wrapper/grid pads so the toolbar's own pb-3 is
the only gap (~15px).
- restore the page scroller's TOP edge fade. It was dropped when the chrome
was a sticky sub-header (the fade bit into it); now the chrome is a fixed
band ABOVE the scroller, so the top fade correctly hints at cards
scrolling up under the toolbar. Reverts the useScrollFade edges option.
Suite 15/15, tsc + knip clean.
Generalise the fixed-chrome pattern to the channel detail page: the
banner + identity now scroll away, while the tab row and the channel
feed's toolbar stick to the top as a sticky sub-header — only the video
grid scrolls.
Mechanism: PageToolbarSlot (the toolbar portal target) is now an exported,
overridable context. ChannelPage provides its own sticky sub-header node
as the target, so the channelScoped Feed portals its toolbar up next to
the tabs instead of into the shell's top band. Feed always portals its
toolbar now (the slot decides where it lands).
Also: the page scroller now fades only its BOTTOM edge — the top is
bounded by the fixed/sticky chrome, so a top fade only bit into it.
E2E: adds a lock that the channel tabs/toolbar stay pinned while the
banner scrolls away; suite 15/15 on :5173, tsc + knip clean.
Locks the five flows the refactor must not break (feed, channel, player,
settings, nav) so later sprints can prove they changed nothing. Adds a thin
data-testid layer and a dedicated seeded non-demo account; the suite runs on
the baked :8080 stack (authoritative) or :5173. No product-behaviour change.
The player is `fixed left-0 top-0 z-50` but rendered from inside the page
scroller, so it only ever covered the viewport because that scroller happened
not to be a stacking context. Giving it the edge-fade made it one, and the mask
scoped the player's z-50 inside it: the rail, the side panel and the header
painted straight over the player, and its edges faded. Measured both ways, then
fixed where every other full-screen overlay here already sits — <body>.
Same for the Suspense fallback that stands in while the player chunk loads, and
for the floating draft save bar on the Configuration page. Swept the rest: every
other `fixed` element is either App-level or already portaled.
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).
Three from UAT: the row's cap goes 1280 -> 1472 (+15%), since with the
rail and filters hidden there was a lot of page left over and the fixed
columns mean the extra width can only go to the title; the views column
drops the word and keeps it in the tooltip (a table doesn't repeat its
unit every line), taking that column 144 -> 64; and the view switcher
names the current mode where the toolbar has room, falling back to the
icon alone below xl. Title: 431 -> 511 at 1440, 706 at 1900.
The bare row becomes a single line — title truncating into the space
left, then channel, badges, meta and the actions each holding a fixed
column — and it drops the 896px reading cap, since with the actions
right-aligned there is no gap for the extra width to stretch. The
actions get a reserved slot: the reset button only exists for videos
with watch state, so without one the meta sat 32px further right on
some rows than others.
Rows and tiles get a permanent container instead of a hover-only fill.
They were transparent at rest and painted an opaque bg on hover, so
running the pointer down the list kept covering and re-revealing the
ambient backdrop — which reads as the background flickering.
Card actions now sit on the bottom edge rather than wherever the title
ends, and the small thumbnails carry icon-only controls: the labelled
Continue / Restart are sized for a card's image and were clipped at
176px.
Dropping Thumb dropped everything it draws ON the image — duration, the
live/stream badges, the saved marker and the resume bar. Those are
metadata, not decoration, so they move into the meta line and the bar
onto the row. Splits the spec's overloaded compact flag into thumb/dense,
which meant one thing per family and nothing on its own.
Tiles answer the row view's wasted space: the actions move under the meta
into vertical slack the thumbnail already reserves, which frees ~168px of
width, so the block survives in a narrow column and the list can go
multi-column. Rows stay single-column on purpose — reading down one edge
is why that view exists.
The column count now comes from a per-view minimum width, so every
multi-column view reflows on the mechanism the card grid already used.
Arrows/Home/End move a roving focus and Escape hands focus back to the
trigger — the menu unmounts under the focused item, which stranded focus
on <body>. Outside-click deliberately does not refocus: that would steal
focus from whatever was just clicked.
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.
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.
`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.
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 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.
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.
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.
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.
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"].