The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.
- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.
npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
`n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a
Hungarian browser printed "1 234 567" while a Hungarian UI in an English
browser printed "1,234,567" — the number half of the date defect fixed in
618ef812. One `formatNumber` in lib/format.ts (a per-language cached
Intl.NumberFormat, like the relative-time formatters beside it) now serves
every grouped figure: Stats, Scheduler, the channel columns and the stats
popover, VideoCard's view tooltip, the ItemCount pill, and formatTotalHours.
ReleaseNotes' `toLocaleDateString(undefined, …)` was the one DATE site the
previous sweep missed; it moves onto formatDate.
The new cases assert both languages and that the two differ, instead of
comparing against the runtime's own default: that default is Hungarian on the
dev box and English on a CI image, so a one-language assertion would pass
under the old bug on half the machines. Verified in Chromium on :5173 with the
browser forced to hu-HU and the UI in English — Stats, Scheduler and the
channel table all keep comma grouping.
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.
- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
onError, so it waits a task and skips a failure the site already reported; the toast title comes
from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
structured localized detail, else the status). It marks the error reported (`markReported`), so
nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
Deep-review round 4, the "one report per failure" contract round 3 introduced.
- `req` marked gateway (502/503/504) and network failures "already reported" because they raise
the connection notice. But that notice is about connectivity and removes itself on the very next
answer — which every caller's own resync refetch fetches at once — so a delete that hit a
keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the
durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed.
- A connection notice the user closed swallowed every later one for the rest of the visit; it is
raised again.
- `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a
502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true.
- A taken Plex collection name is a structured 409, localized, and the create/rename calls are
quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone.
- The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve /
deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag
attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes
a level now). The user role / suspend / delete actions had no onError at all: a user deleted in
another tab failed silently and stayed listed — now said, and the list refetched.
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.
Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.
No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
Round three, on round two's diff. The trend across the loop is 47 -> 34 -> 15 raw
findings; the ops half kept producing the sharpest ones because the gate cannot run it.
**`docker compose start` ignores a newly pinned IMAGE_TAG.** Round two removed the
automatic restart because it re-ran `alembic upgrade head` — and then told the operator to
run that exact command by hand, in both scripts and the docs. `start` relaunches the
container that already exists, which still carries the tag being rolled back FROM, so
pinning 0.79.0 and following the printed instruction brings 0.80.0 back up and re-applies
its migrations. Verified against a real daemon: after changing the pinned tag, `start` kept
the old image and only `up -d` picked up the new one. Both scripts and the doc now say
`up -d`, with the pin-first caveat where the operator reads it.
**The reset flag outlived its token.** Reverting round one's "each link clears the other"
went one step too far: a `#verify=` link pasted over a standing reset notice replaces the
whole fragment, so the reset token is gone — but `resetPending` stayed true, and being
checked first it hid the confirmation while promising a recovery that signing out could no
longer complete. The flag is now cleared when the fragment it describes is gone, which is
narrower than what was reverted and does not blank the signed-out card.
**A test that could not fail.** The shared-asset regression test seeded the asset as
`pending`, and `_reset_asset_pending` only acts on `downloading` — so the release half was a
no-op and deleting it kept the suite green. Seeded as the worker actually leaves it now.
Also: the malformed-URL guard answers with urlguard's structured detail rather than a bare
English string, so both halves of one typo class get the same localized copy, and the third
site in that module handing user input to `urlparse` was swept with it; the asset is
released after the job is terminal, not before; the Stats 403 toast gets the wizard handle
its own comment claimed it had; the pasted Windows recipe sets `$ErrorActionPreference`
inside the block (a scriptblock inherits the caller's, so a `SilentlyContinue` profile
swallowed every throw) and cleans up the container-side dump on the failure path too; the
restore warning moved to the warning stream so a `> log` redirect cannot file it away; and
two comments narrowed to what the code does.
Gate green: vitest 685, pytest 950.
Round two of the review loop, on round one's own diff. Two of its findings were
regressions the fixes themselves introduced, which is exactly what the loop is for:
**A per-user refusal was writing onto a shared row.** MediaAssets are deduped across
users, and the generic failure handler puts the message on the asset — right for an
upstream failure, wrong for "your storage is full". Two users queuing the same video share
one asset, so the first user hitting their cap failed the second user's job with the first
one's private reason, telling someone with an empty library to free up space. The refusal
now raises `_OwnerRefused`, caught separately: the job fails, the asset goes back to
`pending` for whoever else holds it. Pinned by a test that drives `_download` directly —
the refusal happens before extraction, so nothing needs stubbing.
**The restore scripts restarted the app, undoing the restore.** `docker compose start`
re-runs the container command, and that runs `alembic upgrade head` — so on the rollback
path this documents as recommended (restore the dump, THEN pin the older tag) the newer
image immediately re-applied the migrations the restore had just undone, and on a FAILED
restore it put the app back on a half-restored schema. Both are the opposite of why the
stop was added. Nothing is restarted automatically now; the scripts say what to run and
when, and the docs' snippets say the same.
**Reverted rather than patched:** the OnboardingWizard toast (the wizard already renders
that failure inline, next to a Retry button — three recoveries for one failure), and
`useVerifyEntry`'s "each new link clears the other". The latter blanked the signed-out
card's only banner, where nothing renders the reset flag to replace it. The two states are
independent again; what changes is that `dismiss` peels ONE layer, which is what the
original finding was actually about.
Also: `Stats` uses the YouTube helper rather than the bare one, so the route's English 403
detail no longer beats the translated fallback under a Hungarian UI; `urlparse` guarded
alongside `.port` (an unclosed bracket raised just as late), the reason renamed `malformed`
since "bad port" misdescribes it; three docstrings narrowed to what the code actually does
(the worker's byte check is per-job, not an aggregate ledger, and not on the edit path;
the redirect residual leaks a reachability oracle, which the note had denied; the
invalidation helper does not cover suspend); the pasted Windows recipe wrapped so `throw`
can stop it; the bash one-liner's failure branch no longer exits 0; and the tests that were
missing for the round-one fixes — the account panel's error paths, the invalidation set,
and the shared-asset case above.
Gate green: vitest 685, pytest 950.
The deep review of the branch raised 47 findings, 18 of which survived adversarial
verification. Grouped by what they actually were:
**The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user
already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one
user over quota was the only one downloading with no ceiling at all. `check_enqueue`
could not catch it: it ran while the job was queued and the user was still under the cap.
The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision
lives, and where it can be tested), and the worker now fails the job outright rather than
proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help
until space is freed.
**Silent failures in the new account panel.** All three actions went through `quiet: true`
endpoints, which suppress the global error dialog on the promise that the caller reports
inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On
the broken-SMTP instance this panel exists for, an admin would read silence as "the mail
went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`,
shared with the pending-access queue, so the two copies of these mutations cannot drift
apart again — which is how half of this was lost in the first place.
**A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps
back one migration, but a release routinely ships several (0.80.0 shipped four): it drops
the newest one's columns and leaves the schema at a revision the older image also cannot
resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is
now the recommended path, and the alembic route asks the target version for its own head
revision instead of counting backwards.
Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they
share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as
a 500; the pull cooldown extended to the sibling route spending the same QuotaAction;
restore stops the app before dropping its tables; the PowerShell cleanup can no longer
decide the script's verdict on 5.1, nor report the wrong exit code; the documented
one-liners hardened to match the scripts; the registry's retention window stated where
pinning is advertised; the two banners made mutually exclusive so neither can shadow the
other; `hover:bg-hover` (a utility this project never generated) replaced by the
`selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to
the two callers that discarded it.
Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset
banner UAT'd on localdev.
Tabs gains an `orientation` prop (+ optional per-tab `icon`/`testId`) so the
two hand-rolled NAV tab-sets that duplicated its active treatment now use it:
- SettingsPanel vertical rail (appearance/notifications/account) -> vertical
Tabs with icons; settings-tab-* testIds preserved
- Stats overview/system tabs -> Tabs (rounded-full+border -> the rounded-lg
tab canonical, per the agreed tab shape)
The underline ChannelPage tabs and the icon NavSidebar rail are intentionally
left as distinct idioms. Self-E2E'd: settings rail switches + persists with
icons, stats tabs toggle. gate green (tsc/eslint-0/vitest 136/knip/prettier).
Branch not merged.
The first S4 pass migrated a subset, which left same-idiom selection pills
hand-rolled and made adjacent controls inconsistent (a /code-review finding).
This completes the sweep so every selected-among-options control shares the
recipe:
- plain pills → .pill (+ aria-pressed): Channels status filters + view tabs,
Sidebar filter-tag chips + date presets + custom-dates, Stats tabs + ranges.
- glass-card-resting pills → .pill with the resting `glass-card glass-hover` kept
in the non-pressed branch (solid accent when pressed): PlexSidebar library
scope + the facet Chip (retired the round-1 "deferred" note — the recipe
already expresses this via the resting branch, no new variant needed).
- custom-dates aria-pressed is coerced with !! so a date string can't leak into
the attribute and miss the [aria-pressed="true"] paint.
- drop the dead `border` on ChannelTags' chip-solid (accent border on accent bg).
Out of scope (a separate concern, NOT selection-among-options): primary/action
buttons that share the accent fill — Banner action, ConfirmProvider primary,
PlexBrowse primary, PlayerModal watched-toggle — these want a future .btn-primary
recipe. Left by-design: tableHeader filter options (PopoverItem + keepSelectedPaint,
owned by the popover platform).
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.
Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.
S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.
Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.
Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
The 'usage' query rendered both its loading and its error state as 'No usage' (the same false-empty
R3 targets), while the sibling 'status' query got a proper state. Split the fallback into
error(+retry) / loading / empty.
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.
The error state didn't exist as a concept: a failed main query rendered as an "empty" list
("No channels" while the request actually died), so a backend restart read as "everything is
empty". Add the convention that an empty state may NEVER show while a query is erroring.
- New components/QueryState.tsx: shared StateMessage (loading / error+Retry, house style, with
role=status/aria-live — the app had zero live regions).
- DataTable gains loading/error/onRetry props (suppresses empty-text on error).
- isError branch (before the empty branch) added across the false-empty pages: Feed (retry),
Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages (conversations + people),
Scheduler, Stats, PlexBrowse grid, PlexPlaylistView. ChannelPage's main content is Feed, now
covered. common.loadError/empty strings (en+hu).
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)
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 Settings > Sync tab moves into the Stats module: Stats is now a per-user
page (Overview tab = sync status + your own API usage + manual actions) with the
admin instance-wide quota dashboard + background-sync pause as an admin-only
System tab. The Stats nav item is visible to all users (was admin-only); the
Settings Sync tab is removed. Sync i18n strings move from settings.sync.* to
stats.sync.* (EN/HU/DE).
Each day now has a full-height track behind a solid accent fill, so low-usage
days no longer render as a near-invisible sliver that only stood out on hover.
Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel,
OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime
helper are now fully translated in Hungarian, English and German, each with its own
locale area file (auto-loaded). Key parity verified across all three languages.
Track who burned how much YouTube API quota. A QuotaEvent audit log (migration
0009) records every spend with the triggering user (NULL = background/system) and
an action label, set via a request/job-scoped contextvar (quota.attribute) so no
call signatures change. User-initiated work (sync subscriptions, unsubscribe,
opt-in recent backfill, manual enrich) attributes to the user; scheduler work to
System, split by action.
- backend: QuotaEvent model + migration 0009; quota.attribute() contextvar;
record_usage logs events; entry points wrapped (routes/sync, routes/channels,
scheduler); GET /api/quota/my-usage + GET /api/quota/admin
- frontend: admin-only Stats page (header nav, page=stats) with daily bars +
per-user breakdown by action and range picker; 'Your API usage' in Settings ->
Sync for every user
Verified: attribution + endpoints compute correctly; events are per-user vs System.