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.
Deep-review round 4, targeted at round 3's changes (f569b19c..e7f38396).
Accounts
- Switching, adding or signing out an account moved the tab's pin before
the page unloaded, so the players' resume beacons fired on the way out
under the NEW account: one account's playback position saved into the
other. The pin is now handed to the next page (pinOnNextPage) and taken
over at import; clearActiveAccount is gone.
- Switching to a suspended account was refused but left it in the
switcher, refused on every click; a deactivated one was not refused at
all, and made the default it signed every account in the browser out on
the next request. Every account _session_user would refuse — gone,
deactivated, suspended, revoked — is refused and dropped from the list.
Share page (/watch)
- An IPv6 client is limited by its /64 (client_ip becomes client_key, for
every limiter): keyed on the full address, one subscriber could rotate
through endless identities. The comments now state what the per-link
ceiling costs: guessing from five networks can keep a link shut for its
window, and guesses already running when it fills are still checked.
- The link is looked up before the limiter records the attempt, so made-up
tokens no longer grow it by an entry each.
Forms
- Failing the same way twice is announced again: the sign-in, passphrase
and setup errors are keyed on the attempt (a clear and a set in one
handler left the alert node untouched).
- The install wizard's own validation showed the generic error — it threw
plain objects its catch did not read. It sets the step's message.
Tests: a re-pin leaving the previous account's notices behind; the pin
hand-over; switching to an ended account; the /64 viewer; made-up tokens;
the repeated alerts. Each mutation-checked RED for its own reason.
Deep-review round 3 over main..improvement/watch-background-audio.
Account boot
- The notification history and settings, and the hints toggle, are read
when their modules load, which in a new tab (bookmark, typed URL) is
before BootGate pins the account: they read as nobody's, and the tab's
first notice replaced the account's stored history (up to 100 entries)
with a one-item list. They now read again when the tab is pinned
(onAccountPinned, run by setActiveAccount); a notice raised before the
pin stays, after the history.
- A tab pinned to an account still in this browser's wallet but whose
session ended (suspended, deleted, or revoked by "log out everywhere")
got "signed out" from /api/me while the default account was still
signed in, and signing in again as anyone else left it there. A refused
per-tab account now leaves the wallet, and the probe answers as the
default, so the tab reloads onto it.
Share page (/watch)
- The password unlock is limited per viewer (link + client IP), under a
per-link ceiling that counts only wrong passwords. Keyed on the link
alone, ten wrong guesses every five minutes locked out every viewer,
the ones with the password included. client_ip moves to app.ratelimit
so the public routes can key on it.
- Its failure messages and the load-failure screen are announced
(role=alert, the password input described by its error). The sign-in,
passphrase, password-change and setup form errors get role=alert too.
Comments that still described removed machinery are corrected: App's
collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig.
Tests: the account's history and hints across the pin; a revoked and a
suspended pinned account; the per-viewer limit and the ceiling; the
limiter's exhausted(); the share page's announced failures; a share
link's feed filters persisted. Each mutation-checked RED for its own
reason.
Deep-review of E11 (19 raised, 14 confirmed; 2 refuted, 3 latent test-hardening notes dropped).
- Back from a sub-view (a Plex show page, a Messages thread) restores the item it was opened from
even when focus was still inside the view: the first attempt now waits one task, past the
sub-view's own popstate listener (measured in Chrome: it left focus on <body>).
- The restore listener is installed at load, so the first Back after a reload finds it; a push
cancels a restore still waiting for the last landing's lists.
- Navigating over an overlay asks it again, a render apart, while its entry stays: the Plex
player peels one panel per Back step, so Shift+W over info + stats closed one panel and dropped
the navigation.
- j/k: a native <button> item keeps its own Tab stop; landing twice on the same item no longer
leaks its tabindex; after the focused item is removed (a hidden card) the next j goes on from
its slot when that slot is on screen.
- An abandoned `g` no longer swallows a modifier chord (g, then Mod+K opened the browser search).
- A hover card above a dialog no longer switches its Tab trap off (player, Modal, wizard,
Lightbox): new layerStack `anyBlockingLayerAbove`.
- "Manage views…" closes its menu before opening the dialog, through a shared `closeThenAct`
that ActionMenu now uses too.
- Copy: the EN welcome says "Feed"; the EN unreachable reason no longer claims a change;
stale comments about PlayerModal's scroll lock and dialog contract.
Round 6 widened dialog focus ownership to count focus inside ANY `aria-modal` subtree as ours, so a
dialog and the dialog stacked on it closing together would still hand the focus back. That clause has
no way to tell a layer closing WITH us from one that stays open, so a dialog closing on its own — an
in-flight mutation's `onSuccess`, not a click, since a scrim covers it — under a modal raised above it
pulled the focus onto its own pre-open trigger, behind that scrim, where Enter still activated it.
Three verifiers confirmed the mechanism and two corrected the severity down: it self-heals on the
next Tab, which is why this is a containment violation rather than a broken end state.
`lib/layerStack` cannot refine it. Measured against the real hook: at the instant the lower dialog's
focus cleanup runs, the both-closing case and the sibling-stays-open case give identical readings —
`anyLayerAbove` true, the upper layer topmost — because passive cleanups run parent-first and
`useDialogChrome` is declared before the effect that calls `removeLayer`. Gating on the stack turns
round 6's own test red. What separates them is the DOM: React detaches a deleted subtree before
passive cleanups, so a layer closing with us is already disconnected while one that stays open is
not. Both tests are green, and each was mutation-checked against the other.
Verified in Chrome on localdev: opening the cheat sheet from a sidebar button and closing it restores
that button; with the command palette stacked over the cheat sheet, closing the top one leaves the
focus neither on the background trigger nor on <body>, and closing both returns it to the trigger.
Welcome's Lightbox got the trap and the restore in round 6 but not the layer membership the other
shells have, so its Escape and Tab fired from a raw window listener whatever was on top — the shape
the wizard's comment records being bitten by, unreachable today only by a mount ordering nothing
enforces. It joins the stack. Its comment also read as though the four-shell sweep were finished:
`PlayerModal` is still an `aria-modal` surface with no name, no trap and no restore, which is on
ROADMAP.md now rather than implied to be done.
Round 5 extracted `ui/dialogFocus` so no shell would hand-roll the focus
contract, then introduced four ways to lose the focus anyway — all four measured
in jsdom against the real components.
**Ownership was narrower than the trap.** `ui/Modal` traps Tab over the whole
dialog on purpose, so a toast's Undo or ✕ stays reachable; ownership was tracked
over the panel alone. Tabbing to that ✕ therefore read as "the user left the
dialog", and since removing a toast fires no `focusin` to re-arm, closing
afterwards skipped the restore and left the focus on <body>. Before this hook
existed the restore was unconditional, so it was a regression.
**A dialog stacked above us is still ours.** A nested `ui/Modal` portals into its
own host, so it is outside the outer dialog's root however wide that scope is
drawn — widening alone could not fix it. Both closing in one commit left the
focus on <body>: the outer skipped its restore, and the child's own target (a
control inside the outer panel) was already detached, because passive cleanups
run parent-first. Ownership now means "inside my dialog, or inside a modal layer
above it", which is what the rule always intended.
**The wizard disagreed with `ui/Modal` on its first line.** `trapTab(e, panel)`,
with neither the dialog-root scope (so its own toast host was outside the cycle
— the connection notice never auto-dismisses, and its ✕ was unreachable from the
keyboard) nor the `container` argument the JSDoc exists for: `panel.contains(
panel)` is true, so focus sitting ON the tabIndex=-1 panel matched no branch and
a Shift+Tab walked into the app behind the scrim. Both call sites now pass the
same three arguments.
**The toast handover was a render-phase read-modify-write.** A `useMemo` read a
module global that the next line overwrote; React re-invokes a component body
and every `useMemo` factory in it on a StrictMode double render, and the LAST
invocation commits — so the second pass re-read what the first had just written,
the takeover host classified its OWN new toast as inherited, and the dev build
behaved exactly like the code round 5 replaced. Snapshotted through a ref keyed
on `isTop`, so a repeated invocation of one render is a no-op.
And the fourth `aria-modal` shell, `Welcome`'s screenshot lightbox, is finally
swept into the same contract: it declared itself a modal dialog while leaving
the focus on the thumbnail behind an 80%-black scrim and letting Tab walk the
landing page underneath.
The headline fix of the previous commit — StrictMode's re-mount taking the focus
off an `autoFocus` field — had no test at all; deleting it left all 1009 green.
It has one now.
9 tests, mutation-checked against 8 mutations, each red on the test that names it.
Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical
accent text links, three ghost icon buttons, two ghost text actions),
Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread,
ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd,
PlexCollectionEditor and plexViews.
Moves the font-weight out of Button's BASE and into each variant, so the
`link` variant can be semibold (which is what every text link in this app
looks like) while the action variants stay medium, and no element can end
up with two font-* utilities.
Welcome is the one signed-out surface this session cannot see, so only
its byte-identical link swaps were taken; its primary submit and the
bg-card button family are left for a pass that can UAT the screen.
3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications,
Messages and the nav rail, clean console in a fresh tab.
DX-17, three one-liners the epic's canonical sweep did not reach:
`routes/setup.py::setup_admin` still resolved the wizard's admin by
literal `User.email ==`. The wizard can be re-run, and E9.5's premise is
that two spellings of one Gmail mailbox are one person — so a second run
under the other spelling would have created exactly the duplicate admin
row the epic exists to prevent.
`routes/admin.py::_decide` hand-rolled `db.get` + 404 where the file's
newer endpoints use `get_or_404`. There were TWO such sites, not the one
the review named; both are converted, per the house rule that a
principle gets fixed everywhere in one sweep.
`useVerifyEntry`'s `hashchange` listener recognised `#verify=` but not
`#reset=`, so pasting a reset link into a tab already running the app
was silently ignored — the same silence that made the verify half a bug.
It now notices, and says out loud that reset links need a signed-out
window; the fragment is still deliberately left in the address bar
(review round 4), so signing out from that tab recovers it.
DX-16: `.catch(() => setOutcome("invalid"))` could not tell a spent
token from a 502 mid-deploy or a dropped connection, and by then the
fragment is cleared — so a network blip was reported as "that link is no
longer valid", pushing the user to request a fresh one on the strength
of a false statement. Only a 400 carrying {code:"verify"} is the
server's verdict now; everything else gets its own outcome, with no
resend CTA, because the token is untouched and retrying it works. The
rule was already written one file away for the reset-link probe: "a
network blip must never be cached as 'this link is dead'".
Two existing tests asserted the old conflation by rejecting with a bare
Error("400"); they now reject with the real HttpError. The errors are
built inside the rejection, not hoisted: mountAt calls vi.resetModules(),
so an HttpError imported earlier is a different class identity and
`instanceof` would quietly miss.
Gate: vitest 671 -> 676, pytest 924 -> 925.
Eleven findings survived adversarial verification; these are the fixes.
**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.
**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.
**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.
**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.
**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.
**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
The funnel's common ordering made a person open TWO messages: one saying their access was approved,
another asking them to confirm their address. The approval mail now branches on what they actually
still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows
once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse
than a plain one: telling someone to sign in while their address is unconfirmed contradicts the
screen that will refuse them.
That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval —
it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and
puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in
and sets no password.
The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here
too, and it was never a delivery failure: re-registering an address that already had an unconfirmed
account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends
the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend
already does exactly this for the same input.
Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a
possibility — usually already agreed with the person — and a row can sit approved for weeks with
nobody registering; a mail fired automatically there would be nudging someone who never asked.
Telling them is its own act on its own button, `invited_at` records it so a second click is a
decision rather than an accident, and the button only appears where nobody is behind the address yet:
"the door is open, come in" is meaningless to someone already inside.
Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists
at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may
sign in, and this account still can't", which leaves an undecided request with the queue that owns
it. And the invitation button was offered on every approved row, half of which are existing users.
Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail
always has, while this one left the reader to discover both by meeting a dead link. The card's
"didn't get the email?" moves up under the form, and the three waiting messages name the spam folder,
which is where a first message to a fresh recipient sometimes lands.
Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual
templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a
preference nobody recorded cannot be backfilled later.
UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued
a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed
nobody; the invitation recorded itself and the row then warned that sending again would write twice.
Test data removed.
Reported from UAT: delete an account, then open its password-reset link. The card drew the full "set
a new password" form — because a `#reset=` fragment was present, and nothing more — and only after
the user had typed a password did the server answer 400. Measured first: no security hole. A user's
tokens cascade away with the row, so nothing can be set on a deleted or a re-created account. What
was left is a dead end of exactly the kind this epic exists to remove, and it is not rare: a reset
link lives one hour, mail is slow or lands in spam, and whoever got two mails clicks the older one.
password-reset/check answers whether a link is still usable WITHOUT spending it, so asking can never
cost the user their one real attempt — the property the tests pin hardest. It gets its own limiter,
because opening a link must not eat the budget for requesting one, and a throttled caller is told
"ok", never "dead": this check may only ever save someone a wasted form, so when it cannot answer it
degrades to the old behaviour instead of declaring a live link finished. The confirm endpoint stays
the authority, and its failure raises the same named screen, since the token can die between the two
calls or the probe itself may have failed.
A too-short password is deliberately NOT that screen. The length check runs before the token is
spent, so a typo costs the link nothing, and the card tells the two 400s apart by `reason` so only
one of them takes the form down.
Both email-link confirmations (verify, password-reset/confirm) now answer in the {code, reason}
shape S4 introduced. They were the last refusals a user meets by simply clicking an old link while
still being answered in hardcoded English under a bilingual UI. The seven remaining bare strings in
this module (register, request-access, the demo guards, set-password) sit behind client-side
validation the UI does first; they are logged as a follow-up rather than swept in silently here.
One bug found by the UAT itself, in the new code: the check went out FOUR times per opened link —
StrictMode double-invokes effects and the shell remounts the card as the session probe settles.
Harmless per request, but against a per-IP limit it would exhaust the budget in two reloads and then
silently fall back to drawing the form unchecked: nothing errors, the feature just stops working.
Deduped in a module-level map holding the in-flight promise (a ref dies with the remount that causes
it); a failed probe drops itself so a blip is never cached as "dead". The test renders the card
twice, because with one render the guard would rot undetected.
UAT on localdev: the reported scenario end to end (delete the account, open the link) shows the named
screen and no form; a live link still opens the form and completes; the CTA lands on the request-a-
link form; one probe per opened link. Throwaway accounts removed.
"Your account is awaiting admin approval." was true and useless. It never said WHICH mailbox the
account was on, and on 2026-09-03 that silence hid a Gmail alias mismatch for a day: a registration
as np.temu@gmail.com met a Google claim of nptemu@gmail.com, and every screen in the funnel agreed
to say nothing about it.
Every refusal password-login can answer with now uses the codebase's structured {code, reason}
detail, built in one helper. The three that arrive AFTER the password is proven carry the address on
the account plus both gate flags; the two that answer before it (invalid, rate_limited) carry
nothing, so a wrong password and an unknown account stay byte-identical. The frontend renders the
first three as a named screen in place of the form — there is nothing to retype once the password is
correct — with an icon, the two-gate checklist, the address in monospace, and a resend CTA where one
helps. Reporting each gate from its own flag is the payoff of S1: approved-but-unverified is a real
state now, and a screen that assumed a sequence would send that user off to wait for an admin who
has already decided.
The Google access-request notice names the address too, and says so when the request was already on
file under ANOTHER spelling of the same mailbox — the honest version of "we didn't open a second
one", stated only when it applies. It moved from the query string to the fragment: the notice now
carries an address, and a query string lands in every reverse-proxy access log. Same reasoning that
put the verify/reset tokens there, and the same owner already clears it. urlencode, not an f-string,
because URLSearchParams reads a bare + in a fragment as a space and would mangle exactly the
+tag addresses this epic exists to protect.
upsert_pending_invite returns the row it settled on beside the is-new flag, since the row's stored
address is what the notice has to name and what mail will go to.
Deviation from the PendingScreen mockup, deliberately: no "Sign out" button and no separate "Not
your address?" link. The mockup drew the panel in the app shell; the accepted decision (a) keeps the
pending user session-less, so there is no session to end, and the address is named directly above a
"Back to sign in" that answers the same question.
UAT on localdev, both languages: all three refusals with the typed spelling deliberately differing
from the stored one (the panel named the stored one), the resend CTA carrying the typed address into
the resend form, and both access-request banners with a +tag address surviving the fragment round
trip. Throwaway accounts and the language switch reverted.
The token was only ever consumed inside Welcome.tsx, which App renders ONLY when
there is no session. Opening the emailed link in a browser that already held a
Siftlode session posted nothing, the fragment was then stripped, and the user got
no signal at all -- the link simply did nothing. Incognito worked, which is why it
survived. Confirmation now happens in App, above that branch: signed out the outcome
still drives the auth card's banners, signed in it drives a top-of-app bar.
The entry URL is snapshotted once at import time (lib/entryUrl) instead of per
component, so no future URL writer can strip a token before whoever needs it has
read it -- that ordering race is what broke this in the first place. POST /auth/verify
now names the address it confirmed, which matters when the browser holds a session
for a different account; the same reasoning as password_reset_required already
covers the disclosure.
Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both
senders emit the fragment form, and the last query-token link expired 24h after the
0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and
the auth-card banners get role=status. The verifyOkReset copy no longer blames
admin approval, which E9.5 S1 made false.
SEC-10 revoked any password set while an account's email was unverified, on both
the Google-adoption and the admin-approval path. On the approval path that was
redundant: password_login refuses an unverified account before it ever reads
is_active, so approval hands a pre-emptor's password nothing it could use. All the
revoke achieved there was destroying the password a legitimate registrant had just
chosen -- and since the admin normally decides before the user reads their mail,
'your password was cleared for security' was the DEFAULT experience, not an edge
case. The guard stays on the Google-adoption path, where the real owner does attest
ownership.
password_reset_required and the ok_reset banner stay: accounts stripped on prod
before this change have no other way back in.
Two existing tests pinned the old rule, so they are rewritten rather than deleted,
and the SEC-10 recovery pack now builds the revoked state through the surviving
producer.
Two independent bugs made an email+password registration unrecoverable in prod.
The verification link did nothing. urlWithParams/clearParams rebuilt the URL as
pathname+query and dropped the fragment — but the one-time auth tokens ride the
fragment (#verify=/#reset=, SB3), and the providers that mirror nav params
(useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded
<App> in main.tsx. Their mount effect replaceState'd the token away before the auth
card's chunk had loaded, so the card read an empty hash: the link opened a bare
sign-in page, no request, no banner. Both helpers now carry the fragment over.
The account was then locked out for good. SEC-10 nulls password_hash when an
unverified account is attested (here: admin approval landing before the user could
verify — which the bug above made impossible). That is indistinguishable from a
Google-only account, and reset eligibility keyed off password_hash alone, so no
reset link was ever issued: no password to sign in with, and no way to get one.
users.password_revoked_at records the revocation, reset eligibility honours it, and
setting a password again (reset-confirm or set-password) clears it. An account that
never had a password stays ineligible on purpose — its owner adds one from Settings.
/auth/verify now also answers password_reset_required so the card can say why the
registered password stopped working, instead of leaving the user at the uniform
"Invalid email or password". Telling the holder of a valid verify token leaks
nothing: they have already proved they own the mailbox.
The 0078 migration backfills the stamp for rows carrying the exact lockout signature
(local, non-demo, no password, unverified), so existing victims recover unaided.
Round 2 on the feature diff (15 → 12):
- still_allowed_by_env now rides the invite LIST as well as the delete response,
so the confirmation warns while the admin can still change their mind. It
mattered most for a DENIED row on an env-allow-listed address: deleting it
silently converted a refusal into a grant, and the only warning arrived after
the irreversible click.
- Both inbox bodies claimed to name "the most recent" dropped message. The
throttle records the FIRST of each hour and never updates it, so they now say
"one of them", and the not-configured body finally names where to fix it.
- settings.invites.emailOff told the admin they "get no notice when someone
signs up" — rendered directly above the queue that shows exactly that. Only
the email is lost, and it now says so.
- The disabled delete buttons put their reason out of reach: `disabled` drops a
control from the tab order and suppresses its title. Switched to
aria-disabled + preventDefault, the pattern Playlists.tsx already established
for the same reason.
- deleteInvite is quiet, so the 409 the UI handles with a translated toast no
longer also raises the server's English detail as a modal.
- After a completed password reset, Back landed on the reset form again with a
consumed token — the history change made that state reachable. The token is
cleared on success so the reset view stops being the card's root.
- The hu deleteHasAccount named a tab that renders under a different label.
Tests: a dom test for the Access-requests panel covering both has_account
variants and all three delete outcomes, /api/me flag cases, an ADMIN_EMAILS
case, and the env-allowlist test now patches the raw setting so the parsing
stays under test.
Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.
- DELETE /api/admin/invites/{id} with a delete control on both the pending
requests and the previously-decided list. Deny keeps the row so the person
cannot immediately re-ask; delete forgets the address. Refused with 409 while
a User row exists for it, because is_allowed reads that row and removing it
would revoke a live user's access with no other sign — the list ships
has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
records an email_undelivered notification for every admin when a message is
skipped or rejected, naming the message that was lost, throttled to one an
hour. /api/me carries email_configured, driving a standing warning on
Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
views are history entries (useHistorySubview), so Back returns to the previous
view instead of only moving the address bar.
Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text +
hover:opacity dim) in index.css @layer components, alongside the selection-paint
recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24
files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping
each site's geometry (padding/radius/font/gap/disabled/shadow).
Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble,
selection toggles (tableHeader — R8 popover territory), and the round play FAB
(hover:scale, not the opacity-dim primary idiom). Three previously hover-less
primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover
dim for consistency.
Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated
button computes to accent/accent-fg and both recipe rules load.
Welcome kept the last local `const inputCls` (rounded-xl, py-2.5) after S3b consolidated
the other three, leaving its auth fields the sole inputs off the unified rounded-lg recipe.
Import the shared inputCls and drop the local const — now every input is truly one recipe.
(Auth fields go rounded-xl→rounded-lg, py-2.5→py-2 to match.)
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.
- NotificationsPanel per-row .glass -> glass-card (the rows are cards, not panel surface).
- New .overlay-backdrop recipe (4px blur = backdrop-blur-xs) with a data-perf override that
DROPS the blur. The viewport-sized modal scrim blur (Modal/PlayerModal/Welcome) was a raw
backdrop-blur-xs utility that survived perf mode while every .glass had its blur dropped —
the most expensive blur in the app. Overlays keep their bg-black/NN.
- gotcha: write ONLY the standard backdrop-filter; a manual -webkit- line makes lightningcss
drop the standard prop (webkit-only output -> no blur in Firefox).
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.
Rebuild the landing preview into a per-module screenshot gallery (feed hero + channels, playlists, search, player, downloads, Plex), reusing the existing Preview/Lightbox. Rewrite the hero + feature copy in a plainer, concise tone that reflects the current modules (Download Center, Plex, two-way playlists), in EN and HU. Regenerate all screenshots name-free. Bring the README up to date: fix the broken landing image, add the Plex feature, and correct the now-temporary YouTube-search behaviour.
- OAuth-cancel feedback now keys on the live session, not link_uid: ANY
signed-in cancel (add-another-account via /auth/login, link, or upgrade)
redirects to /?oauth=cancelled and gets a neutral "Google sign-in was
cancelled" toast — the add-account path set no marker and was silent, and
the shared ?link= message wrongly called an upgrade "account linking"
- drop 127.0.0.1 from the dev OAuth host allowlist: only the localhost
callbacks are registered in the Google console, so a 127.0.0.1 origin now
falls back to the fixed callback instead of a redirect_uri_mismatch
- Welcome: extract the shared goResend handler (banner CTA + sign-in link) and
merge the two adjacent signin-only blocks
- link/upgrade OAuth cancel now redirects to /?link=cancelled (a channel App.tsx
surfaces as a toast) instead of the pre-auth /?login=oauth_cancelled banner a
signed-in user never sees; a plain sign-in cancel is unchanged
- the login banner (suspended / oauth_cancelled) is now state, cleared on the
first deliberate action like the verify banner — no more stale lingering
- add a "Didn't get the verification email?" resend entry on the sign-in screen
so a never-arrived verification mail isn't a dead end (was only reachable from
the expired-link banner)
- unit-test _oauth_redirect_uri (dev-host derive, unknown-host fallback, prod
ignores the Host header — the injection guard)
- collapse the 5-deep title/button ternaries into Record<Mode,string> maps
The verifyInvalid banner (a one-time entry notice) lingered on later screens
after the user clicked its "Send a new link" CTA and returned to sign-in.
Clear the verify state in reset(), so any deliberate action — mode switch,
CTA, or submit — dismisses it.
- password reset also sets email_verified (the mail proves the mailbox),
rescuing an account that never clicked the verify link; is_active (admin
approval) is deliberately left untouched
- add POST /auth/verify/resend (rate-limited, off-response-path, anti-enum)
+ a "resend" mode/CTA on the verifyInvalid banner, so an expired/lost
verification link is no longer a dead end (re-register was a silent no-op)
- OAuth cancel/deny now redirects to /?login=oauth_cancelled with a friendly
banner instead of a raw 400 JSON page; pop the oauth_link markers BEFORE the
token exchange so an aborted link flow can't poison the next clean sign-in
House policy is HU+EN for every user-facing string. Modal's shared close button was a
hardcoded title="Close" (in every modal) — now t("common.close") + an aria-label.
Welcome's lightbox close aria-label and Feed's "this video" notification fallback are
translated too (new feed.thisVideo, HU+EN). The other Close hardcodes the review
listed were already fixed since.
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 ~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.
Adversarial re-review of the session-epoch work surfaced:
- WS auth (messages_ws) skipped the epoch check, so a revoked-but-unexpired cookie
could still open the live push channel after a reset/logout-others. Now mirrors
current_user: loads the user once, rejects a stale-epoch cookie before connecting.
- set_password + logout_others re-stamped the cookie BEFORE db.commit(); a failed
commit would strand the current session at a newer epoch than the DB and wrongly
401 it. Commit first, then re-stamp.
- Welcome verify effect could double-POST the single-use token (StrictMode/remount)
and flip the banner to a false 'invalid'. Fire-once useRef guard.
Left as-is (low value, documented): the Plex image proxy authenticates without a DB
load / epoch check (poster/art fetches only); adding one would cost a DB hit per image.
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.
The 3 landing screenshots were 2.3 MB of PNG (feed.png alone 1.6 MB, decoded
at 2400x1350 for a ~400px slot) — the biggest LCP/transfer cost on the public
page. Re-encoded to WebP capped at 1600px (~284 KB total, feed 91% smaller).
Also set Cache-Control: content-hashed /assets/* are immutable for a year,
other SPA-root static files (welcome images, favicon, robots) get a 7-day TTL;
index.html stays no-cache. Register image/webp+avif mimetypes so FileResponse
serves the right Content-Type. Lighthouse (dev): Perf 80->93, LCP 1.8s->1.2s.
Add an accent-tinted 'Free and open source' band between the app preview and the feature grid
— a short line about reading the code / self-hosting / filing an issue, linking to the public
repo. The compact footer link stays. Trilingual.
Tighten the hero + feature cards to punchy one-liners and surface current
capabilities: swap the standalone channel-manager card for live YouTube
search (folding tagging into the readable card). Regenerate the three
preview images (feed, channels, playlists) from a rich real account instead
of the sparse demo, so the landing shows what a full instance looks like.
EN/HU/DE.
- Build the Google OAuth client lazily from sysconfig (DB override, env fallback) and re-register
when the credentials change, so the install wizard / admin can set them without a restart.
- New 'google' config group (google_client_id/secret, encrypted) on the Configuration page; the
token-refresh reads the creds the same way.
- Public GET /auth/config exposes google_enabled; the login page hides 'Continue with Google'
when Google OAuth isn't configured (email+password still works).
- Serve real files at the SPA root (e.g. /welcome/*.png, favicon) from the built app — the
landing screenshots were only ever shown by the Vite dev server, never in production.
- Add the Channel-manager screenshot; the two secondary previews are smaller thumbnails that
open in a custom full-size lightbox (fit-to-viewport, Esc/backdrop/✕ to close).
- Drop a suspended/deleted user to the login page on the next 401 (and poll the session only
while signed in, so the public login page no longer flickers); 'suspended' and 'account
deleted' confirmation banners; strip redirect query params for a clean address bar.
Replace the old Google-only login card with a public landing page (Welcome.tsx):
hero + auth card (email+password Sign in / Create account / Forgot password,
Continue with Google, explicit Try-the-demo) over a feature pitch (6 pain->solution
cards) and demo-account screenshot slots (graceful placeholder until the images are
dropped into frontend/public/welcome/). Auth forms wire to the 5a endpoints; errors
show inline via a new req() quiet flag (no global modal for a wrong password).
Handles ?verify, ?reset (set-new-password form), ?access banners. EN/HU/DE.
Verified end-to-end on a fresh load: render, register->approve->password-login.