Design option C, chosen by the user 2026-09-05. Nothing in the admin UI
ever showed an account's GATE STATE: an admin could see a "Pending
approval" chip but not whether the address was confirmed, whether SEC-10
had cleared the password, or which Google account was attached — so
every support conversation started by guessing. Clicking a row now opens
a drawer that says which gate the person is actually stuck behind, with
the recovery actions under that readout rather than in the row.
The drawer is `Modal placement="drawer"`, not a second dialog: the layer
stack, ESC, the focus trap, Back-to-close and the scroll lock are the
hard parts, and duplicating them is how two shells drift apart. It
slides in, and honours prefers-reduced-motion like the rest.
The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone
forgot theirs") reuses the existing token machinery and the SAME
`#reset=` entry the mailed link uses, so the signed-out card consumes it
with no new flow. It is a distinct token KIND for one reason: a mailed
reset arrives in the mailbox, so completing it proves ownership and
confirms the address; an admin-issued link proves nothing about the
mailbox — the admin hands it over by whatever means they choose — so it
must not satisfy the email gate. Claiming otherwise would be the same
false attestation SEC-15 is about.
Issuing does not revoke the current password: a misclick must not lock
anyone out. USING it rewrites the hash and bumps the session epoch,
which is what evicts a squatter — SEC-16's actual remedy. Spending
either kind burns the siblings of both, so a completed reset cannot
leave a live admin link behind. The non-consuming probe learned the new
kind too, or every admin link would have opened onto "this link is
spent". The audit log records that a link was issued, by whom, for whom
— never the link, which is a live credential-setting secret and the last
thing that belongs in a long-lived log a human reads.
Not gated on account state on purpose: it is useful for a pre-empted
address, a revoked password, a Google-only account that wants one, and a
plain forgotten password. Narrowing it would only make it refuse exactly
when the admin needs it. The shared demo account is the one exception.
Verified on localdev, both languages, end to end: issue from the panel
-> the signed-in tab shows the honest "reset links open in a signed-out
window" banner with the token still in the address bar -> signing out
draws the "Set a new password" form, which is only possible because the
probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676.
Deep-review (6 dimensions, 3-verifier adversarial pass) surfaced 7 findings, all
verified; fixed here.
- NavigationProvider + history.ts (medium, correctness): the popstate handlers
restored the open channel / Plex sub-view from history.state ONLY, but a fresh
?chan / ?v bookmark lives in the URL with no marker in state — so any overlay's
programmatic history.back() (closing a modal/player) silently closed the
bookmarked channel or collapsed the sub-view to the grid. Give onPop the same
URL fallback the initializers already use (history.ts extracts a shared readView
so seed and popstate can't diverge again).
- plexCards SeasonCard (medium, a11y): the "In progress" season badge was a
reveal-idle swap-badge → display:none on touch, and a season has no progress bar,
so an in-progress season was indistinguishable from unwatched on touch. Moved it
to the top-left (the toggle owns top-right) and made it always visible.
- plexCards watched toggle (low, a11y / WCAG 1.4.1): watched state was color-only
on touch (green vs white CheckCircle2). New WatchedIcon distinguishes by SHAPE —
CheckCircle2 (watched) vs an empty Circle (not) — used by all three card toggles.
- Modal.tsx (low, a11y): the focus trap leaked on Shift+Tab when focus rested on
the panel container itself (Node.contains(self) is true, so the pull-back branch
never ran). Guard now also checks `active === panel`.
- index.css (low, a11y): .reveal-idle faded only on .group:hover though .reveal is
revealed on hover AND focus-within → the badge overlapped the control on keyboard
focus. Added the focus-within fade.
- PlexBrowse.tsx (low, correctness): the ?v=playlist decoder accepted a non-integer
id (Number.isFinite); tightened to Number.isInteger(n) && n > 0, matching the ?pl
guard.
Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): ?v=show and
?chan bookmarks survive a modal open+close (were collapsing/closing before); Modal
Shift+Tab from the panel stays inside; in-progress season badge visible on touch;
watched toggle shows an empty-circle-vs-check shape difference; 0 console errors.
UX-7 — hover-only card actions were `opacity-0 group-hover:opacity-100`: fine on
a mouse, but invisible AND still tappable on a touch device (no hover). New
`.reveal` primitive (index.css) gates the hide on `@media (hover: hover)`, so a
touch device keeps the control visible; its partner `.reveal-idle` is the idle
badge a control replaces (watched/in-progress pill under the watched-toggle),
faded on hover and dropped on touch so the two don't collide at the same corner.
Migrated the true controls — feed card action toolbar (VideoCard), Plex poster
card watched-toggle/info/add across all card variants + their swap badges
(plexCards), message pop-out (Messages), edit-interval pencil (Scheduler), and
saved-view star/rename/delete (SavedViewsWidget). Pure play-scrim overlays (the
whole card is the tap target) stay hover-only by design.
FE-9 — Modal.tsx had aria-modal but no focus management. Adds: a Tab/Shift+Tab
focus trap (topmost layer only, wraps at the edges and pulls escaped focus back);
initial focus into the dialog unless an autoFocus child already placed it; focus
restore to the trigger on close (captured in a useState initializer during render,
before commit runs autoFocus and moves activeElement inside); and aria-labelledby
wiring the dialog to its title.
Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): FE-9 trap
both directions + pull-back + Escape-restore-to-trigger + aria-labelledby verified;
UX-7 feed toolbar opacity 0 on desktop / 1 under mobile (hover:none) emulation,
Plex reveal buttons opacity 1 + reveal-idle badges display:none on touch.
- 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).
- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass
box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset
top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the
inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment.
- #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action
buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so
they were redundant. Verified: the pin button rings from the global rule alone.
- #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills.
- #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix
(has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured:
no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a
focus indicator); the overlapping ring is an accepted cosmetic edge-case.
- #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation.
Gate green (tsc/eslint/prettier/knip/vitest 79).
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.
Two R8 S5 (player big-view control-bar) follow-ups:
- Modal now portals into `document.fullscreenElement ?? document.body` (reactive), the
same fullscreen-aware root the popover platform uses. A node under <body> is painted
beneath whatever holds the fullscreen lock, so the download dialog — a Modal, opened
from the player's control bar — would open invisibly in true fullscreen. With that
fixed, the `!isFullscreen` guard on the player's DownloadButton is dropped, so download
joins add-to-playlist as working in true fullscreen. (Every Modal is now fullscreen-
aware; the fix is at the primitive, not the call site.)
- The queue transport cluster (prev / counter / next + auto-advance + loop toggles) was
duplicated inline in two places — the modal queue bar and the big-view docked control
bar — with identical logic and only theme + label-visibility differing. Extracted a
`<PlayerTransportControls variant="modal"|"docked">`; a mode/icon change now lands once.
Behaviour-preserving (the docked bar's trailing divider and the wrapper rows stay at the
call sites). Note the modal variant keeps its `bg-accent/15` active tint, which is a
latent no-op under Tailwind v3's hex --accent — left as-is for R10 S0's styling audit.
Gate green (tsc/eslint/76 vitest). Device-UAT'd in real Chrome: transport controls render
correctly in both the modal queue bar and the docked bar; the download button now shows in
true fullscreen and its dialog portals into the fullscreen stage, fully visible and
centered. No console errors.
Replace the three mutually-unaware Escape systems — Modal's private modalStack,
the popovers' document listeners with a blanket stopPropagation, and the two
players' modalCount() checks — with one shared lib/layerStack.ts. Every open
dismissable layer registers in open order; a layer acts on Escape only when it
is topmost, and reads anyLayerAbove() to decide whether to defer. Coordination
is now a stack lookup, not event-propagation order, so it no longer depends on
which listener happens to fire first.
- Modal + useDismiss register via the stack; useDismiss drops the blanket
stopPropagation (its documented side effect: it swallowed a player's own
Escape while any popover was open).
- Both players register as a layer and defer via anyLayerAbove() instead of
modalCount(); PlayerModal checks it before its maximise/fullscreen steps.
A player MENU is a layer too, so it closes first and the next Escape reaches
the auto-skip-cancel — fixing the documented PlexPlayer bug.
- AnchoredPopover routes non-hover Escape through the stack (floating-ui's own
escapeKey off there) and registers as a layer, so a player/modal underneath
defers once S4 migrates the in-player menus. FloatingTree still owns
popover-vs-popover ordering; the stack adds the cross-type ordering.
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.
Three player/overlay interaction fixes (PlexPlayer changes share a file, hence one
commit):
- U-C (Escape double-close): useDismiss's Escape now stopPropagation()s (its document
listener bubbles before a player's window listener, so one Escape closes only the
popover, not the popover AND the player). Modal exports modalCount(); both players'
Escape defers when a Modal is open above them, so Escape closes the dialog, not the
player under it and then the dialog.
- U-3.1.4: PlexPlayer's Space now defers to a focused BUTTON/A (e.g. "Skip intro")
instead of toggling playback — matching PlayerModal.
- U-3.3.12: the audio/subtitle menus used theme-adaptive glass-menu + forced
text-white (white-on-white in light theme); they now use the player's own explicit
dark panel (border-white/15 + bg-neutral-900/95), readable in both themes.
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.
Pressing the mouse inside a dialog (e.g. to select text in an input), dragging
out, and releasing on the backdrop wrongly dismissed it: the click event fires
on the common ancestor of mousedown+mouseup (the backdrop), so the outside-click
handler ran. Track whether the press started on the backdrop and only close when
it both starts and ends there. Fixes all shared-Modal dialogs at once (downloads
editor/share, confirm, Plex collections + add-to-playlist, …).
In-app history only tracked the top-level page, so Back from a module sub-view
(e.g. a Messages thread) or with a modal open jumped straight to the previous
module. Add two history primitives: useHistorySubview (a module's sub-view rides
in history.state, so Back returns to its root first) and useBackToClose (a
mounted overlay occupies one history entry; Back closes the topmost, nesting-safe
so a button-close doesn't trip the modals underneath). Apply to the Messages page
views and to PlayerModal + the shared Modal. setPage now pushes a clean entry so
each page starts at its root.
- Duplicate-tag create/rename hit the uq_tags_user_name constraint and 500'd; now caught and
returned as a clear 409 ('You already have a tag named …').
- New global error dialog (errorDialog store + ErrorDialog modal) for definitive server refusals;
the api layer wiring + mount land with the rest of the round.
- Modal now keeps a stack so ESC dismisses only the top modal — an error over the tag editor
closes the error and leaves the editor open.
Apply the real frosted .glass surface (blur) to the few large chrome surfaces — the nav
sidebar, the header and all Modal-based dialogs (was glass-card / plain surfaces) — and
enrich the ambient backdrop a touch (three soft accent pools) so the glass has more to
refract app-wide. Popovers (notifications, language, account, add-to-playlist) were
already glass. Bulk feed cards stay glass-card (no per-card blur) for performance; the
existing perf-mode still disables blur. Phase 2 (ambient thumbnail mosaic / bg image +
toggle) deferred to end-of-project polish.
About (in the account menu) shows frontend/backend/database versions + build.
Release Notes renders per-version highlights with a commit-SHA reference; a
dismissible banner appears once after the running build's version changes and
links into the notes. Adds a reusable Modal shell and the release-notes data
(detailed v0.1.0).