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.