The floating-header → toolbar → filter-chips → content stack carried ~2× the whitespace it
needed. Halve it, app-wide where the chrome is shared:
- PageShell: the --hdr-h gap below the floating header 8px → 4px (every page's header).
- Feed: toolbar wrapper pt-4 → pt-2, the toolbar's own pb-3 → pb-1.5, and the grid's top pt-2
→ pt-1. Measured on the feed: header→toolbar 24→12px, toolbar→chips 12→6px, chips→cards 12→8px.
- Plex / Playlists / ChannelDiscovery / DownloadCenter / Notifications / AdminUsers: toolbar
wrapper pt-3 → pt-2, so every page's top band matches the tighter feed.
PageShell.dom.test updated for the 4px gap.
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent
RING focus), replacing the settings-family border-focus style app-wide:
- deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import
the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match.
- ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/
table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap
bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is.
- refreshed the form.tsx doc-comment (the old two-style split is gone).
The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation.
The ~124 px-frozen text-[Npx] labels didn't track the Settings font-scale slider
while their rem-sized neighbours did. Add text-2xs (0.6875rem) / text-3xs
(0.625rem) font-size-ONLY tokens (no paired line-height -> matches the arbitrary
they replace, zero vertical-rhythm shift) and codemod every text-[Npx] to a rem
class (rare 8/9/12/13px -> rem arbitraries).
Also: --hdr-h 68px -> 4.25rem so header content clears the rem-sized pills at
scale 1.3 instead of sliding under; NavSidebar/SidePanel slot widths + their
icon/badge px sizes go rem so the rails keep pace with their (now-scaling)
labels; a w-24/h-[54px] thumb -> h-13.5 to preserve 16:9 at scale. Media
thumbnails, page-width caps and fixed container widths deliberately stay px.
Verified via getComputedStyle at scale 1.06/1.3: text-2xs 11.66->14.3px, header
clearance 72->88px, nav slot 246->302px — all scale proportionally.
Add theme-scoped --danger/--warn/--success (+ -fg) tokens wired into the v4
@theme inline, replacing ~110 raw red/amber/emerald/green palette refs across
34 files. Fixes light-theme contrast failures (fixed Tailwind shades fell to
~1.7-3:1 on white). Status colors are scheme-independent — recognised by hue,
shifting only by theme to hold contrast (bright on dark bg, darkened on light).
Solid fills use the paired -fg text; @source inline guarantees the full -fg set
exists. IMDb rating gold kept as a deliberate decorative non-status exception.
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.
- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
"rounded-sm" inside a releaseNotes.ts prose sentence.
Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.
The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.
Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
Replaces the plain "Loading…" text in every R3 loading branch with a single shared LoadingState
(a spinning lucide Loader2 in the accent colour + label, centered, role=status/aria-live) — one
component so all loads read the same and can be swapped for skeletons later in one place. Applied
across Feed, Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages, Scheduler, Stats,
PlexBrowse (grid + subviews), PlexPlaylistView, ConfigPanel, DownloadCenter, AdminUsers, ChatThread.
Left the small "load more" footer and lazy Suspense fallbacks as-is.
- Blank-flash (showed an empty state on first load before data): DownloadCenter (queue + library
tabs), AdminUsers (roles + invites), ChatThread, PlaylistsRail now show a loading indicator
(and an error+retry) instead of a false-empty flash — guarded on `&& !data` so live data isn't
disturbed on refetch.
- Forever-loading (`isLoading || !data` loops forever on a failed fetch): ConfigPanel, the three
PlexBrowse subviews (playlist/show/season), and Stats' admin dashboard get an `isError && !data`
error+retry path before the loading branch.
- Playlists' detail pane already had an error path (isError+retry); its list lives in PlaylistsRail
(now covered), so no change needed there.
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
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)
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.
- 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.
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
DownloadDialog (DownloadButton — kept out of the feed chunk), and the
VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
callers can pre-select the admin tab without statically importing the now
lazy-loaded AdminUsers page (which would defeat the split).
Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
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.
components/ui/form.tsx replaces the per-panel copies:
- Switch — was duplicated in SettingsPanel (Switch) + ConfigPanel (Toggle) +
Sidebar (inline labeled toggle).
- Section (with a card variant) — was in SettingsPanel + Stats (plain) and
AdminUsers (glass card).
- SettingRow + HintLabel — the label+hint+control row was identical in SettingsPanel + Stats.
No visual change intended (Sidebar's toggle gains the standard knob shadow).
- errorDialog + hints now use createStore (drop their bespoke listener arrays).
- theme/sidebarLayout/notifications/App filters/Playlists plSort use readMerged/
readJSON/writeJSON instead of inline try/JSON.parse/catch.
- Stats, SettingsPanel and App's channel filter/view tabs use usePersistedState
(the 3 sites that reinvented usePersistedTab inline).
- Every siftlode.* key now sourced from the LS registry (no scattered literals).
The access-requests notice's Review link navigated to the Users page but the
persisted tab (often Demo) loaded instead. Pre-select the access tab before
navigating (focusAccessRequestsTab writes the persisted-tab key, which the page
reads on its fresh mount).
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.
- New Users page tabs (Users & roles / Access requests / Demo) and a tab-ified Configuration
page, both via a reusable Tabs component (persisted active tab).
- Admin can suspend/unsuspend (migration 0023 adds users.is_suspended) and delete accounts
from the Users & roles tab, with guards (demo / self / last admin).
- Email notifications to the affected user: suspended (reactive, on a blocked sign-in),
reinstated, role changed, and account deleted; the approval email now carries a clickable
app link. The scheduled/manual demo reset also seeds sample channel subscriptions.
- Hide the 'unverified email' chip for Google accounts (Google attests the email).
New admin-only Users page (sidebar): Users & roles (list + promote/demote with
confirm; self/demo/last-admin guarded server-side), plus the Access requests
(Invite whitelist) and Demo whitelist+reset migrated out of Settings → Account
(same data/tables — UI relocated only). Settings → Account now holds personal
content only. ConfigPanel learns a boolean field type (toggle) for the new
allow_registration setting. api methods, new 'users' page/route/nav/header, and
EN/HU/DE strings (new users namespace + access group).