- 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.
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.
- 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.
- 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.
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.
- #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).
- Global :focus-visible fallback (@layer base): one accent outline for keyboard users on every
button/link/[role=button]/[role=tab]/summary that doesn't define its own. Non-input controls had no
visible keyboard focus app-wide; this closes the void with a single rule. More specific focus styles
(.selectable, an input's focus:ring, a menu item's data-active highlight) override it.
- PopoverItem now carries outline-hidden so menu items keep their data-active highlight without also
drawing the global outline (no double ring).
- Nav rail rows adopt .selectable (via rowBase); rail chrome buttons (logo/pin/about/sign-out) get a
focus-visible ring; the settings row already uses rowBase. Deduped the account trigger's selectable.
Deferred to R10: the chip/pill selected-PAINT unification (a .chip/.pill/.is-current repaint of the
~14 selection idioms) — the R10 token redefinition repaints those surfaces anyway, at less risk than a
hand migration now. R9's job (the focus/a11y foundation) is complete.
Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style confirms the global ring
renders (2px accent, offset 2px) on a plain button, .selectable overrides it with an inset ring, and
an outline-hidden menu button draws no global outline.
S1 — recipe foundation:
- Move .glass/.glass-card/.glass-menu into @layer components so a JSX shadow-* utility overrides the
glass box-shadow again; the ~9 dead shadow-* on glass surfaces (modals, menus, save-bar, chat) now
paint their intended drop-shadow. Perf-mode and :hover overrides stay unlayered so they still win.
- Add a .selectable recipe: the hover tint menu/list rows already use, PLUS the app-wide
focus-visible ring that was missing on non-input controls (the biggest a11y gap).
- Split the popover roving highlight from the selected state (AnchoredPopover): a selected/checked
PopoverItem now gets a ring ONLY, so its resting paint (bg-accent fill / text-accent / checkbox)
survives being highlighted instead of downgrading to a 35% tint or being recoloured. Fixes the 4
review findings deferred from R10-S0 generically; drops the leftover per-consumer data-active ring
in tableHeader.
S2 — worst offenders:
- Account-menu trigger + switch/add/sign-out rows adopt .selectable (visible keyboard focus).
- Saved-views rows: group-focus-within reveals the hover-only action buttons for keyboard users;
aria-current on the active row; focus-visible ring on every button.
- The 3 invisible-focus inputs get an accent focus ring: SearchBar + ShareDialog via focus-within on
the glass pill/row, AboutCell via focus-visible on the trigger.
Deferred to a later R9 sprint: the full unification of all ~14 selection idioms, a shared <NavItem>,
and .is-current/.chip recipes (no consumer yet in this pass).
Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style checks confirm the glass
shadow now wins, the focus outlines render (2px accent), and the split renders (selected ring-only,
unselected fill).
R10 S0 audit finding: both are unreferenced — overlays and side panels
unmount with no exit animation, only their `…In` counterparts are used
(0 `animate-[…]` / `animation:` consumers). Dead code, removed.
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.
The worst one was, again, the previous round's own fix.
- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
live-session set BEFORE listing the root and rmtree'd outside the lock. Since
`start_session` reuses a directory path for the same (key, offset, tag), a stale
name can become a live session at any moment. Two guards now: nothing whose mtime
is newer than this process's start is ever deleted, and the live check + rmtree
happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
0.0 read as "never ran" — unreachable in production, but `now` exists to be
injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
failed" is also what ONE dead feed id looks like on a small instance, and a red
card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
too — it had the same unprefixed-only reads in three places, including an Escape
branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
still on the element and wins. Comment now states the real mechanism.
Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
band and overlaps NO YouTube control — the collision this round suspected does not
reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
change listener fires exactly once per transition, exit returns to 889.
Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
Five of them sit where round 6 turned F from a Fullscreen API call into a CSS
maximise: the guards around it still asked `isFullscreen`, which that pivot made
permanently false.
- Arrow-key volume was dead in the maximised view: the scroll-deference checked
only `isFullscreenRef`, so the (now hidden but still scrollable) card kept
swallowing Up/Down. Both flags now count as "the video fills the screen".
Measured: 87px of scroll room on the focused card, ArrowDown moves 80 -> 75
and scrolls nothing.
- The maximised view had no way out once the pointer touched the video. Reaching
YouTube's controls means clicking into the cross-origin iframe, which takes
keyboard focus with it, and the re-arm paths (stage mouseleave, window focus)
need the pointer to leave the browser entirely while the stage fills it. So
the exit now lives on screen, in our DOM: a button that un-maximises AND pulls
focus back, re-arming every shortcut.
- WebKit fires only `webkitfullscreenchange`/`webkitFullscreenElement`, so on
Safari/iPadOS YouTube's own fullscreen never registered — no overlay yield, no
focus reclaim. One `fullscreenEl()` reader, both event spellings.
- The stage claimed `z-index: 9999`, the glass tuner's reserved level, for a job
that only needs to beat the modal chrome it is nested in (its siblings top out
at z-menu). Dropped to rail-level; nothing escapes the modal's stacking
context either way.
- `inset: 0` already sizes a fixed layer — the inherited `100vw`/`100vh`
over-constrained it, which is how a classic scrollbar gets a horizontal
overflow and a mobile URL bar hides the bottom edge (YouTube's control bar).
- The shortcut hint still promised "F: fullscreen" in both locales.
Backend, both "the fix stopped one layer short":
- The startup HLS sweep was awaited inside lifespan, so uvicorn still served
nothing until it finished — a thread doesn't help when nothing is listening
yet. Fire-and-forget task instead, cancelled on shutdown. Measured with 200
planted segments: "Application startup complete" now precedes "swept 1".
- An RSS poll where every channel failed returned normally, so the scheduler
recorded `ok` with the outage buried in the summary string. `failed == total`
now raises: a run that reached nothing is a failed run.
Suites: backend 147 -> 150.
Root cause, measured in Chrome/Brave/Firefox alike (so: spec behaviour, not a
browser bug): a cross-origin embed reads its fullscreen state from ITS OWN
document. While we hold the browser's fullscreen lock — on our wrapper OR on the
iframe element — the YouTube player never learns it is fullscreen, so its own
fullscreen button stays in the "enter" state and clicking it changes nothing
visible. Every other YouTube control works, which is exactly what the user saw.
So we stop taking the lock: F now toggles a CSS layer (`player-stage--max`) that
fills the viewport. The page stays non-fullscreen, so the embed's own fullscreen
button behaves natively, and our own overlays and shortcuts keep working because
our DOM is what's on screen. Escape steps out of maximise before closing.
Verified end to end in real Chrome:
- F -> stage 1920x889, document.fullscreenElement null (no lock taken)
- YouTube's own fullscreen button -> real fullscreen (innerHeight 889 -> 1024,
fullscreenElement becomes the IFRAME) and its icon flips to "exit"
- clicking it again -> back to the maximised view, our class still applied
- wheel volume while maximised: 100 -> 85, with the level flash visible
Drops the "YouTube fullscreen" toolbar button added earlier today: with the lock
gone, YouTube's own button does that job correctly.
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 the full-height filter rails with one shared floating glass SidePanel
used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav;
its sections are reorderable "islands" (drag / hide / per-group collapse) with a
per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge
fade. The nav rail becomes a matching floating rounded card.
- New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook.
- Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps
its storage key; plex/playlists get their own; persisted per account).
- Lift the Playlists rail to App level (selected playlist is now App state) so it
floats and collapses like the filter rails; remove the old CollapsedFilterRail.
- The floating top header's fixed offset accounts for the panels' margins.
Replace the full-width top bar with a fixed floating header of glass pills: a
fixed-position ModuleName (accent dot + label + prev/next cyclic module stepper)
and a shared SearchBar. The header's left edge is anchored as if the nav rail
and filter sidebar were both open, so it never shifts on collapse or module
change; the right edge leaves a 10% margin.
- ModuleName label width is measured live from the reachable modules' titles in
the current language, recomputing on a language or account change.
- Module names come from a shared moduleLabelKey (the same short labels as the
nav rail); NavSidebar and the header now read from one source (lib/modules.ts).
- The arrows step cyclically through moduleOrder(me) — dynamic, no hard-coded
targets, wrapping at both ends.
- Search is hoisted into the SearchBar for feed (YouTube search), plex, channels
(including the Discovery tab) and playlists (new); Go button, Enter triggers it.
- The Playlists left rail now reaches the top; header clearance via --hdr-h.
Add a light set of the 6 per-scheme backdrops (public/backdrops/light/*.svg) —
soft, pastel-mid, non-figurative washes tuned to each accent so they read through
the light translucent glass. Wire them up centrally:
- App: the backdrop is on whenever bgImage && !perf (was dark-only), so light
gets it too.
- index.css: per-scheme rules are now [data-theme]-qualified — dark uses the dark
set, light the /light/ set; shared cover/fixed size rule for both.
- Settings: the Background image toggle + Image-fade slider work in light now
(removed the dark-only gates); hint no longer says 'dark only'. Trilingual.
The light glass (62% translucent tier) refracts the light backdrop just like dark
refracts its own; one fade slider controls both.
Light theme has no background image, so the adaptive :root glass (94%) rendered
as flat snow-white islands, while the Plex filter rail (still on bg-surface/40)
showed the ambient tint — an inconsistency. Fix centrally, not per-component:
- index.css: one html[data-theme="light"] token override (surface 62 / card 74 /
menu 86) so every .glass* surface is translucent in light and picks up the
ambient tint, like it refracts the backdrop in dark.
- PlexSidebar: bg-surface/40 -> glass, so all filter rails bind to the same token.
One central lever now controls the light-theme glass translucency for the whole app.
Code review (public-repo rule): the GlassTuner + index.css/App.tsx comments named
the prod hostname and dev-process jargon (UAT/HMR/dev ports/epic), which ship in
the public bundle. Genericised the comments — no behaviour change.
Land the UAT-approved backdrop direction as a real feature (replacing the dev
thumbnail prototype):
- 6 generated abstract SVG backdrops in public/backdrops/, one tuned to each
accent scheme (dark, mixed-colour, few light areas). Non-figurative + generated
= repo-safe, tiny, accent-matched.
- New data-backdrop="on" state (dark theme + the setting + not perf) paints the
per-scheme image on <body> (app root now transparent so it shows app-wide and
the glass refracts it) AND switches the glass to its translucent tier (the
user's tuned values: surface 50 / card 60 / scrim 30 / blur 10 / edge 25).
Off / light / perf -> flat colour + solid glass (the readable fallback).
- Settings > Appearance 'Background image' toggle (bgImage pref, dark-only),
trilingual. Flat is the fallback + opt-out.
- Remove the FeedBackdrop prototype + its tuner controls; tuner baseline resynced
to the glass-over-image tier, add a Backdrop-fade slider.
GOTCHA fixed: the dark-mode ambient 'background' shorthand reset background-size
to auto (image rendered un-covered); out-specified it with [data-theme="dark"].
Idea from UAT-2: give the feeds the same 'content behind glass' lift the Plex
detail page gets. New FeedBackdrop paints a faint, slowly-rotating feed thumbnail
as a fixed backdrop on <main> (the proven art-backdrop path — a fixed z:-1 layer
is occluded by the root's opaque bg-bg). Blur = tiny-canvas downscale + cover
upscale (no CSS filter; ytimg is CORS-readable so it uses a data-URL, else raw).
Flat --bg stays as the 'perf mode' look. Behind the GlassTuner toggle + a
Backdrop-fade slider (--feedbg-fade) so it's judged/tuned in UAT before anything
is committed for real.
From UAT: glass reads strongest where there's content behind it, so split the
look into two tiers instead of one global compromise.
- index.css: global :root defaults are now the 'Adaptive' baseline (solid-enough
dark glass for content-less chrome). New .glass-media scope class carries the
UAT-tuned translucent values (blur 10 / surface 50% / card 60% / scrim 30% /
edge 25%) for surfaces that float over real artwork.
- Apply .glass-media to the art-backed Plex detail views (PlexInfo page variant,
PlexShowView, PlexSeasonView) and drop PlexInfo's hard-coded inline 55%
overrides so those panels are scope-driven and consistent (hero no longer
reads solid while the season strips are translucent).
- Two new colour schemes: Starship (deep-space blue, azure accent) and Matrix
(near-black, phosphor green), each with dark+light tokens + swatches.
- Remove the thumbnail-mosaic backdrop (did nothing usefully; its home is the
future 'videos behind glass' phase) — deletes MosaicBackdrop, mosaic vars/CSS,
and the tuner's mosaic controls. GlassTuner presets resynced to the new
baseline (Current / Media / Max-readable).
Foundation for the app-wide glass-consistency epic:
- index.css: every look-driving value (blur/saturate/dark-brightness/panel-card-
menu opacity/border/inset/edge/scrim/ambient + mosaic) is now a CSS custom
property defaulting to the historical hard-coded value — one-place tunable, no
visual change until a value is bumped.
- GlassTuner.tsx: dev-only (localhost-gated) always-on corner panel that live-
drives those vars, with treatment presets (Adaptive/Gradient+/Edge+scrim),
per-scheme palette editor, muted thumbnail-mosaic toggle, localStorage persist,
and Copy-CSS export. Deleted once the tuned finals are baked in.
- MosaicBackdrop.tsx: opt-in muted (blur+darken+desaturate) mosaic of on-screen
thumbnails so dark glass has real content to refract.
Adds player-modal shortcuts and keeps keyboard focus on the modal (not the
cross-origin player iframe) on open and on player-ready, so they work until you
click into YouTube's native controls:
- F: toggle fullscreen (the player stage element, so the volume flash stays
visible; a :fullscreen CSS rule drops the 16:9 letterbox). Esc in fullscreen
exits fullscreen only, without also closing the modal.
- Space: play/pause (ignored while a button/input is focused).
- Scroll wheel over the video: volume up/down with an auto-fading volume-bar
overlay. A transparent interaction layer over the iframe catches the wheel
(the cross-origin iframe otherwise swallows it) and maps click to play/pause;
it leaves the bottom strip uncovered so YouTube's native control bar (seek /
settings / captions / fullscreen) stays usable, and is hidden on an embed
error so the 'Open on YouTube' CTA stays clickable.
i18n shortcutsHint EN/HU/DE.
The ring-based flash relied on ring-accent and a CSS transition that never
restarted on a repeat message, so it was effectively invisible. Replace it with
a keyed inset-glow overlay (animation replays on every flash counter bump, and
inset shadow isn't clipped by the window's overflow-hidden) — flashes whether
the window is expanded, freshly auto-opened, or minimised.
Toasts rose top-right, far from the notification bell which now lives bottom-left.
Anchor them bottom-left inside the content column (clears the sidebar at any width),
newest nearest the bell. Add a ~50% white border in dark mode so they stand out off
the conventional top-right spot.
Dark glass looked nearly flat because the blurred backdrop is itself dark. Add a
brightness lift to backdrop-filter in dark mode for .glass/.glass-menu so the frosted
sheen reads even over dark surfaces (over colourful content it just looks richer).
Perf-mode still disables the blur (later rule wins).
Floating menus hover over undimmed content (no backdrop scrim like dialogs), so the
frosted .glass (78%) let the content bleed through and hurt readability. Add a near-opaque
.glass-menu (surface 92%, keeps blur) and use it for the account, language, notification
and add-to-playlist popovers. Dialogs/chrome keep the frostier .glass (they sit over a dark
scrim / the ambient bg). perf-mode disables its blur too.
The dark-mode experiment made .glass globally more translucent (surface 66%), which
also hit content-overlapping surfaces like the account popover — text became hard to
read over the feed behind it. Drop that override so .glass is back to the readable 78%
everywhere; keep the richer dark ambient (harmless to readability). Docked chrome loses
the marginal extra frost (which was barely noticeable anyway).
In dark mode the faint accent pools left the docked frosted chrome (nav/header) looking
flat — no content behind it to refract, unlike dialogs over the feed. Boost the dark-mode
ambient pools and make .glass a bit more translucent there so the chrome reads as glass
near the accent pools. Light mode unchanged. The full app-wide dark effect still needs
Phase 2 (ambient content/mosaic), deferred.
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.
Three low-risk wins for large filtered feeds:
- content-visibility:auto on cards (.cv-card/.cv-row) so the browser skips
layout/paint for off-screen cards; contain-intrinsic-size keeps the scrollbar
stable and is remembered per card after first render.
- memo(VideoCard) + stable onState/onChannelFilter callbacks (onState reads the
loaded list via a ref) so appending a page only renders the ~60 new cards
instead of reconciling every card already on screen.
- Prefetch the next page earlier (sentinel rootMargin 800px → 1500px) so the
'Loading more…' flash is far less likely during fast scrolling.
backdrop-filter blur does work here; the previous 94% opacity made .glass nearly
solid so the blurred backdrop never showed. Dial back to 82% with a strong blur so
overlays read as frosted glass again, while staying less see-through than the old
72% that made the account menu look too transparent.
Evidence (sharp video behind overlays) shows backdrop-filter is a no-op here, most
likely because browser hardware acceleration is off — so the glass relied on a blur
that never rendered. Lean on opacity instead (94% surface) so overlay menus/panels
read as frosted glass regardless of GPU; keep the blur as a bonus when available.
Revert the settings backdrop to a plain dim scrim (no whole-page blur) and make the
header a solid bar (no backdrop-filter ancestor).
- Increase .glass blur (32px) so overlay menus (account) read as proper frosted glass.
- Blur the settings modal backdrop (overlay backdrop-blur) so the panel reliably
frosts the content behind it instead of showing a sharp video through.
- Remove the now-redundant header buttons (dark/light, color scheme, grid/list) —
they live in Settings → Appearance. Header keeps search, sync, bell, account.
Video cards / channel rows / sidebar widgets render in bulk over a solid background
where backdrop blur adds little but is GPU-expensive and triggers forced reflow
(browser perf 'Violation' logs). Keep translucency + depth; reserve blur for the few
.glass overlay surfaces (menus, panels, toasts, tooltips).
- Tooltip: render in a portal with fixed positioning + edge-flip so hints are never
clipped by overflow/stacking ancestors (fixes mispositioned/hidden bubbles app-wide).
- Glass: raise opacity so overlay menus/panels stay readable over content.
- SettingsPanel: vertical tab rail (no wrapping/jumping), content grid-stacked so the
panel sizes to the tallest tab (stable height) and floats to its content height.
- Notifications: the test toast is now a normal auto-dismissing toast (with countdown
bar) that also plays the sound via a new force-sound flag.
- Channel manager: explain priority/tags/hide and what 'Sync subscriptions' does;
add a 'Channel priority' feed sort so priority is actually meaningful.
- Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop,
performance-mode opt-out) and apply it across panels, popovers, toasts, cards,
sidebar widgets, channel rows, video cards and login.
- SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no
horizontal scrollbar) with a prominent active state.
- Notifications: auto-dismiss can be switched off (stays until closed); the test
notification now also triggers the alert sound; resume a suspended AudioContext.
- Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire
hints across the settings and channel-manager surfaces; persisted per account.
- NotificationCenter closes on outside click (document listener, not an overlay
that the blurred header trapped) and no longer needs a second bell click.
- Toasts show a level-colored countdown bar and auto-dismiss faster (6s default).
- Hidden-video notifications carry structured meta so the center offers an in-app
'Find in feed' (jump to that channel's hidden videos) and a one-click 'Unhide',
working even after a reload when the live Undo callback is gone.