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"].
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).
- 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).
- scheduler _run_changed: count a run as "changed" only on a truthy NUMERIC
value — status-string no-op dicts like {"skipped":"disabled"} (Plex off, the
default) or {"skipped":"no demo account"} were flooding the trail every interval.
- audit.record: truncate target_id to the column width (128) like summary[:255],
so an over-long target (e.g. a 128+ char demo email) can't abort the mutation
the audit row is committed alongside.
- config set/reset: redact URL userinfo (user:pass@) from logged non-secret
values — a proxy setting can embed credentials that shouldn't land in the trail.
- config set: skip the audit row when a non-secret value didn't actually change
(no-op re-save shouldn't add noise); secrets are write-only so always logged.
- audit page title (pageMeta): add the missing "audit" case so the browser tab
reads "Audit log · Siftlode" instead of falling back to "Feed".
- channels CB3: sort discovery rows NULLs-last (title is None) to match the DB
ORDER BY exactly — coercing NULL title to "" floated untitled channels to the top.
- AdminUsers SC1: track in-flight rows in a Set, not a single id — per-row disabling
now lets the admin start concurrent row actions, and a shared id was cleared by
whichever settled first (re-enabling a still-pending row → possible double-submit).
- ChatThread CT4: reset the incoming-count ref when partnerId changes — the Messages
page reuses one ChatThread across conversation switches, so a same-count switch
could skip the open-marks-read badge refresh.
- messages MB3: push a bare {type:"unread"} (drop the now-dead count query — the
client re-fetches on the signal and ignored the number anyway).
- api.ts: extract the shared keepalive `beacon()` helper (saveProgressBeacon +
plexProgressBeacon were near-verbatim copies).
- 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.
Auth security round: SB3 (email tokens out of URL query → fragment), SA4 (server-side
session revocation + 'Log out other sessions'), SA3 (trusted-proxy X-Forwarded-For so
rate limits can't be bypassed via a forged header).
Signed client-side session cookies had no server-side kill switch: logout + password
reset couldn't evict a stolen/copied cookie (valid until expiry). Add User.session_epoch
(migration 0053), record it in the cookie at every login, and reject in current_user any
cookie whose recorded epoch is behind the account's current one.
- Bump the epoch on: password reset (kills ALL sessions — a reset is a compromise response),
password change + a new 'Log out other sessions' action (both re-stamp the CURRENT cookie
so the caller stays signed in, evicting only the others).
- Per-account epoch map in the session so one account's revocation doesn't evict the other
signed-in accounts in the same browser wallet.
- Missing epoch (pre-SA4 cookie) is treated as 0, so the first bump revokes grandfathered
sessions too.
- New POST /auth/logout-others + a Settings → Account 'Active sessions' button (trilingual).
Secret email tokens now ride the URL fragment (#reset=/#verify=), never the query
(?reset=/?token=): a fragment isn't sent to the server, so the token can't leak into
proxy/access logs or a Referer header.
- Reset: link → /#reset=; the SPA reads the token from location.hash and POSTs it
(unchanged /password-reset/confirm).
- Verify: link → /#verify=; new POST /auth/verify (token in body). The legacy GET
/auth/verify?token= is kept so pre-deploy emails in flight still work until they
expire. The SPA reads the fragment token, POSTs it, shows ok/invalid.
- Welcome: read secret tokens from the fragment, status flags from the query; strip
both after capture so nothing lingers in history.
- format.ts: new formatRuntime() ("1h 30m"); PlexBrowse dur() + PlexInfo
fmtDur() were byte-identical copies, now both call it.
- PlexPlayer fmt() delegates to formatDuration (keeps the NaN/negative clamp
the live media clock needs); local reimplementation gone.
- Register LS.plexPlayerPrefs; PlexPlayer uses it instead of a bare string.
- Remove the dead PlexPlayerPrefs.wasPlaying field (written on play/pause, never
read) + its two patchPrefs writes.
- PlexBrowse item page: drop the redundant onStateChange={() => q.refetch()} —
PlexInfo.setState already invalidates ["plex-item", id], so q refetches itself.
- ChatThread send had no onError: a 404 (recipient gone) or 429 (rate-limited)
send failed silently (api.req shows no modal for those "caller-handled" statuses).
Added onError → toast for 404/429 (400/409/500 already raise the global dialog);
draft is kept for retry. New trilingual messages.sendFailed key.
- ChatThread scroll-to-bottom effect keyed on `plain` too, so every 20s poll's
decrypt pass yanked a scrolled-up reader back to the bottom. Key on items.length.
- ChatThread + Messages decrypt effects keyed on `items` (a fresh `?? []` array
every render) → a render storm while loading. Key on q.dataUpdatedAt.
- chatDock.ts: use LS.chatDock(userId) instead of the bare "siftlode.chatDock.*"
literal (matches the storage LS registry).
tsc green, re-review clean, localdev boots, Messages renders (locked-state) w/o console errors.
setup() encrypted BOTH the wrapped private key AND the key_check under the same
wrapKey with the SAME wrapIv — a GCM nonce-reuse bug (leaks keystream + enables
GHASH/tag forgery, which matters under E2EE's malicious-server threat model).
Fixes:
- setup() gives key_check its own independent nonce (checkIv).
- unlock() no longer decrypts key_check to verify the passphrase — that was
redundant (a wrong passphrase already fails the wrapped_private_key GCM auth
tag) AND was the second consumer of the reused nonce.
Backward-compatible: the uploaded bundle shape is unchanged, and existing users'
bundles still unlock (unlock only presence-checks key_check now; the private-key
decrypt path with wrap_iv is untouched). Also removed the unused, architecturally
ineffective lock() export (re-unlocks from IndexedDB on next mount; the meaningful
primitive is clearDevice). Re-review clean; tsc green.