- admin._decide: add an idempotency guard (409 unless status was 'pending') so a
repeat/stale approve OR deny can't re-run its side effects — now that deny emails,
this stops duplicate denial mails and a rejection mail to an already-approved user.
- SavedViewsWidget: extract runViewMutation(fn, failMsg) (returns success) — dedups
the five try/catch+refresh blocks AND lets create/rename close their input only on
success, so a failed create keeps the user's typed name.
- worker._notify_download_done: take user_id/title captured BEFORE the caller's commit
(no post-commit refresh SELECT); add a so the inbox labels an edit job
'Edit ready' instead of 'Download ready' (new inbox.editDone i18n, HU+EN).
Gate green (ruff/tsc/eslint-0/prettier/knip/vitest 79/pytest 193); smoke-tested the
saved-views + notifications surfaces render clean on a fresh dev server.
Give equal-weight actions equal-weight feedback (R11).
S1 Plex error toasts: the watched-toggles (card / season mark-all / episode /
info-panel / show mark-all) used to fail silently on 403/404/429; each now surfaces
the server reason via a shared notifyActionError() + resyncs the optimistic flip.
The playback auto-mark-watched stays intentionally silent (a mid-play toast is
noise; the next sync corrects it). New generic lib/actionErrors.ts (Plex + saved
views reuse it; notifyYouTubeActionError delegates its non-403 case to it).
S2 download-done inbox notification: worker.py emits a best-effort type=download_done
notice to the job owner on real completion (regular + edit jobs, not instant
cache-hits), rendered/localized in the inbox with an Open-Downloads link.
S3 optimistic rollbacks: SavedViewsWidget create/rename/toggle-default/remove/reorder
and the inbox revert-state no longer swallow failures — they toast + refetch to
resync (the reorder's optimistic order reverts to server truth).
S4 access-request deny: deny_invite now emails the requester (send_access_denied),
mirroring approve — no more silent limbo.
VERSION bump + chores release note for the R10 .btn-primary/.btn-danger/.btn-warn
recipe unification (refactor; a few primary buttons gained the standard hover).
- Generalize the recipe into a .btn-{tone} family: add .btn-danger / .btn-warn
siblings sharing one hover-dim, and migrate the two hand-rolled tone-action
buttons (ConfirmProvider danger confirm, Banner warning action) onto them, so
primary/danger/warn action paints can't drift.
- Reword the recipe comment: it owns paint + one standard hover-dim (not
'PAINT-ONLY' — that mislabel obscured the hover behaviour).
- Drop the now-redundant hover:opacity-90 from Banner's action-button base (each
tone's recipe supplies the hover).
The 3 previously hover-less primary buttons keep the standard hover-dim gained in
the prior commit (intentional consistency; now accurately documented). Gate green
(tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed btn-danger/btn-warn
compute to their tone/tone-fg tokens.
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.
- Extract the optimistic + debounced-flush feed-priority flow into a shared
useChannelPriority hook (mirrors useChannelTagActions); the manager and the
channel-detail page now share it instead of two drifting copies.
- NavSidebar: drop the always-true `slim` const and its dead expanded-rail
branches; refresh the now-stale pinned/collapsible comments.
- Remove the orphaned `navCollapsed` localStorage key (no readers/writers left
after the always-slim rail).
- Channel page: drop the wrapper native `title` that duplicated PriorityStepper's
own Tooltip.
The keyboard-focus nav-label finding needed no change: the popover platform
already wires useFocus (visibleOnly), so a real Tab press shows the flyout label
(verified in-browser).
Follow-ups on the 7-item batch:
- The slim nav slot was still w-20 while its card is w-14, leaving a ~24px dead
gap the filter tab/bar couldn't dock against. Slot is now w-14 (exactly the
card width), so the filter's flat-left tab sits flush on the rail's right edge
(measured gap 0), both collapsed and expanded.
- The Plex art backdrop was invisible: a position:fixed layer at z-index -1 is
painted BEHIND the body's opaque background here, so nothing showed. Paint the
art on the app root (#root) instead — it's exactly the viewport and a real
ancestor of the transparent nav/header/content, so the art fills the whole page
edge-to-edge and the glass chrome floats over it (verified in-browser).
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
PriorityStepper and add it to the channel detail header (subscribed, non-demo);
backend channel_detail now returns the per-subscription `priority`.
2 Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
transparent no-op here, so it's glass-card that provides the depth); clamp Plex
card titles to a fixed 2-line height so 1- vs 2-line titles align.
3 Nav rail is now always a slim icon-only column, flush to the left edge; each icon
reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
navCollapsed pref plumbing.
4 Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
full-height slot next to the rail), so approaching the nav no longer pops the
filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
grace period + split hover/focus handler groups.
5 Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
transparent app) instead of only the inset <main> box.
6 Plex season binge continues across seasons: the season view's play queue now
spans the whole show (allKeys), so a season's last episode rolls into the next
season's first (seasonKeys stays for "add season to playlist").
7 When a Plex video/episode ends with nothing queued next, the player returns to
the previous page (onEnded → onClose) instead of stranding a black screen.
Two low-severity follow-ups from the third /code-review round:
- .pill's resting border-color is now scoped `.pill:not(.glass-card)`, so a
glass-card-resting pill (PlexSidebar scope/facet, PlexPlaylistView layout)
keeps glass-card's own border incl. its lighter top-edge specular highlight,
instead of having it flattened to a uniform var(--border).
- add `transform` to the recipe transition so the `active:translate-y-px` press
nudge on the Sidebar tag/date pills eases again (it lost the utility transition).
Verified plain pills keep their resting border (Feed + YOUR TAGS) and active
pills still paint solid accent.
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).
Follow-ups from the /code-review pass:
- .is-current[aria-current] now excludes aria-current="false" — the recipe is a
shared primitive and a consumer using the common `x ? "true" : "false"` idiom
would otherwise paint a non-current row as current.
- add box-shadow to the .pill/.is-current transition so the Tabs/SettingsPanel
on-state shadow fades in instead of popping (it lost the utility `transition`).
- document that .chip-solid is intentionally paint-only (composes on a site's own
geometry) so a truncating single-element chip isn't forced into inline-flex.
The 3 intentional visual changes (hover-border 55%, tag-filter transparent
resting, RemovableChip tint+border) were UAT-accepted as-is. PlexSidebar's facet
Chip (glass-card resting) deferred — wants a .pill soft-glass variant.
The ~14 hand-rolled "this is the selected/current/active one" idioms each
repeated the same `sel ? "bg-accent text-accent-fg border-accent" : "…"` inline
ternary, which had drifted. Three attribute-driven recipes in @layer components
now own that paint, keyed on the ARIA the control already carries: .pill
(aria-pressed segments/toggles), .is-current (aria-current nav/list items), and
.chip / .chip-solid (tag & filter tokens). The JSX just toggles the ARIA; the CSS
paints, so one control's selected look can't diverge from the next's. No
focus-visible in the recipes — the @layer base rule already rings every button/a.
Migrated 15 sites (Feed show/content, Channels tag filter, Sidebar or-and +
scope, PlexSidebar match, Tabs, SettingsPanel tab-rail, PlexPlaylistView layout
[soft], NavSidebar nav + settings, ActiveFilterChips, PlexSidebar RemovableChip,
ChannelTags, ChannelPicker). Deliberately left: PlexSidebar facet Chip and
PlexPlaylistView already covered; SavedViews row (paint split across two
elements), the scheme swatch (border-only on a coloured swatch), and
ChannelPicker's option rows (text-only selection) — noted for a follow-up.
A hovered/roving CHECKED row got only a thin ring (R9 split), reading as a weak
afterthought next to an unchecked row's vivid accent fill. Now selected rows get the
SAME bg-accent/35 fill + a STRONGER ring (ring-2 accent/80 vs the unselected ring-1
accent/60); the child checkmark/checkbox stays legible on the tint. The one idiom that
DID rely on the R9 ring-only — the table-filter options, whose selected item paints its
own solid bg-accent ROW — opts back in via a new `keepSelectedPaint` PopoverItem prop,
so its solid paint isn't downgraded to a 35% tint. Scopes the R9 exception to the one
menu that needs it instead of penalising every checkbox menu.
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.)
The header row's three pills now share one shell: SyncStatus was glass/rounded-xl while
ModuleName and SearchBar were glass-menu/rounded-2xl/h-11. Aligned it (glass-menu, rounded-2xl,
h-11) so the pill row reads as one system, per user call.
The accent + warning Banner action buttons had no hover state (flagged during S1 UAT).
Add the house hover:opacity-90 idiom. (The header-pill .glass-chrome consolidation is
deferred — 2 of the 3 pills already share the glass-menu/rounded-2xl look; unifying the
odd one, SyncStatus, needs a visual call on whether it becomes a full-height pill.)
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 app-wide 'glass over image' tier: panels stay near-opaque (surface 90%, menu 100%)
so chrome text is readable, but glass-card drops to 50% so the per-scheme backdrop image
gently shows through the feed cards (blur 22, inset 10). User-tuned via the live GlassTuner
and validated ("the image just barely shows through, text still readable"); supersedes the
uniform-88% pass that had killed the image-through-cards look. Only the data-backdrop=on
tier — :root (image off) and .glass-media (Plex art) unchanged.
- eslint no-restricted-syntax: a new text-[Npx] font size is now an error (matches
strings + template chunks; rem arbitraries like text-[0.75rem] don't match). Keeps
the rem system from silently rotting. Mutation-verified it fires on text-[Npx].
- index.css: document the token-vs-arbitrary split (name the two common small steps;
rare 8/9/12/13px stay text-[Nrem]; never text-xs for 12px — it carries a line-height).
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.
- PlexBrowse PLAYABLE_TINT: transcode was collapsed onto remux's bg-warn; give it
bg-danger so direct/remux/transcode read as a distinct good->heavy green/amber/red
gradient again.
- Live indicators (VideoCard row + thumbnail badge, playlistColumns badge): revert to
fixed live-red + white instead of the --danger token. 'live' is a broadcast marker,
not an error state, and the badge sits over a thumbnail (no app-bg contrast need);
restores the conventional red/white LIVE look and decouples it from error retunes.
- ChannelPage exploring badge: drop the redundant dark:text-warn (no-op after the
token rename — --warn is already theme-scoped).
error/fatal toast shade merge left as-is (both are 'bad'; the 400/500 step was subtle).
- Add --info/--info-fg (blue; dark #60a5fa / light #2563eb) so info notifications
stop borrowing --accent, which collided with a status colour in schemes whose
accent shares its hue (YouTube red = error, Slate orange = warn, Matrix/Forest
green = success). Toaster info tone accent -> info.
- Restore hover feedback the codemod flattened: 'bg-X ... hover:bg-X' /
'text-X ... hover:text-X' collapsed to a no-op once both shades mapped to one
shadeless token. Reuse the house hover:opacity-{80,90} idiom (ConfirmProvider
danger button, DownloadCenter error link).
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.
A full cold sync crashed and left plex_people empty (cast/people missing in
every detail view). enrich_row_people added PlexPerson rows via the ORM with no
ON CONFLICT, and because SessionLocal is autoflush=False a person shared by two
items in the same enrich batch was queued twice -> the single batch commit
violated the unique index ix_plex_people_name and rolled the whole batch back.
Prod dodged it by building up incrementally; dev hits it on every reseed+sync.
Insert missing people via an immediately-executed pg_insert with
on_conflict_do_nothing (deduped within the cast by name, keeping the first
non-null thumb), mirroring app/sync/videos.py:_insert_stubs. The immediate
execute also lets a later row in the batch see the row via the existing-names
query and skip it; the conflict guard additionally covers the concurrent
info-page enrichment path.
Adds a DB-backed regression test: two items with overlapping cast now enrich
without a unique-violation, one row per name, and a prior-NULL headshot is
back-filled. Mutation-checked (reverting the fix reddens it).
The surface's onMouseLeave nulled hoverIndex, so moving the pointer off a menu snapped the highlight
back to activeIndex — the first item in a menu with a free-text input (ChannelTags), where the field
swallows arrow keys so there is no list-nav holding a keyboard position. The keyboard and typeahead
already reclaim the highlight by nulling hoverIndex in onNavigate/onMatch, so the mouseleave reset was
redundant; dropping it keeps the last hover. 0.60.3.
Root cause: html[data-backdrop="on"] and .glass-media shared ONE translucent tier, so a value tuned for
Plex art (meant to show its poster through) was forced onto the whole app — over the feed, the nav and
popovers read straight through it. Split them: the app-wide background-image tier is now near-opaque
(surface 88% / card 90% / menu 93% / blur 22px), while the Plex-art tier stays translucent (72/80/85/14).
Tuned live on the user's prod session (50% -> 72% was still too see-through; 88% reads clean). 0.60.2.
With a background image on, glass surfaces used surface-alpha 50% + 10px blur, so the feed read
straight THROUGH the nav/menus/popovers (user-reported: distractingly transparent). Raised to
72%/80%/85% + 14px blur — the backdrop image still shows as soft colour, but content behind no longer
punches through. Verified in-browser: nav bg alpha 0.5 -> 0.72, no inline override on a clean pref.