From f9f90cf6094cebfe8c249d89ead2bb654bf816ae Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 21:20:40 +0200 Subject: [PATCH] feat(glass): per-scheme background images + glass-over-image mode 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 (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"]. --- frontend/public/backdrops/forest.svg | 18 +++++ frontend/public/backdrops/matrix.svg | 18 +++++ frontend/public/backdrops/midnight.svg | 18 +++++ frontend/public/backdrops/slate.svg | 18 +++++ frontend/public/backdrops/starship.svg | 18 +++++ frontend/public/backdrops/youtube.svg | 18 +++++ frontend/src/App.tsx | 10 ++- frontend/src/components/FeedBackdrop.tsx | 93 ---------------------- frontend/src/components/GlassTuner.tsx | 88 +++++++------------- frontend/src/components/SettingsPanel.tsx | 11 +++ frontend/src/i18n/locales/de/settings.json | 2 + frontend/src/i18n/locales/en/settings.json | 2 + frontend/src/i18n/locales/hu/settings.json | 2 + frontend/src/index.css | 34 ++++++-- frontend/src/lib/theme.ts | 3 + 15 files changed, 191 insertions(+), 162 deletions(-) create mode 100644 frontend/public/backdrops/forest.svg create mode 100644 frontend/public/backdrops/matrix.svg create mode 100644 frontend/public/backdrops/midnight.svg create mode 100644 frontend/public/backdrops/slate.svg create mode 100644 frontend/public/backdrops/starship.svg create mode 100644 frontend/public/backdrops/youtube.svg delete mode 100644 frontend/src/components/FeedBackdrop.tsx diff --git a/frontend/public/backdrops/forest.svg b/frontend/public/backdrops/forest.svg new file mode 100644 index 0000000..5e8f0f4 --- /dev/null +++ b/frontend/public/backdrops/forest.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg new file mode 100644 index 0000000..3e699a7 --- /dev/null +++ b/frontend/public/backdrops/matrix.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/midnight.svg b/frontend/public/backdrops/midnight.svg new file mode 100644 index 0000000..217531f --- /dev/null +++ b/frontend/public/backdrops/midnight.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/slate.svg b/frontend/public/backdrops/slate.svg new file mode 100644 index 0000000..b5c4889 --- /dev/null +++ b/frontend/public/backdrops/slate.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/starship.svg b/frontend/public/backdrops/starship.svg new file mode 100644 index 0000000..2a8d704 --- /dev/null +++ b/frontend/public/backdrops/starship.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/youtube.svg b/frontend/public/backdrops/youtube.svg new file mode 100644 index 0000000..6d50059 --- /dev/null +++ b/frontend/public/backdrops/youtube.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d178b4..5dc1d0c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -381,6 +381,14 @@ export default function App() { document.documentElement.dataset.perf = perf ? "1" : ""; setAccountRaw(PERF_KEY, perf ? "1" : "0"); }, [perf]); + + // The per-scheme background image (the "glass over image" look) is on only in dark theme, when + // the user hasn't opted out, and not in perf mode. Drives `html[data-backdrop]` (see index.css), + // which both paints the image on and switches the glass to its translucent tier. + useEffect(() => { + const on = theme.mode === "dark" && theme.bgImage && !perf; + document.documentElement.dataset.backdrop = on ? "on" : "off"; + }, [theme.mode, theme.bgImage, perf]); useEffect(() => setHintsEnabled(hints), [hints]); useEffect(() => configureNotifications(notif), [notif]); @@ -726,7 +734,7 @@ export default function App() { ); return ( -
+
`'s background (NOT a separate fixed layer): a fixed z-index:-1 layer is occluded - * by the app root's opaque `bg-bg`, whereas `
` is a transparent descendant whose own background - * paints above it — this is exactly how `useArtBackdrop` (Plex) works, reused here. - * - * Blur: downscale the thumbnail into a tiny canvas and let `background-size: cover` upscale it back — - * a cheap gaussian-ish blur with no CSS filter (which can't touch a background-image). Falls back to - * the raw url if the image isn't CORS-readable (still soft once upscaled + heavily faded). - */ -export default function FeedBackdrop() { - const [srcs, setSrcs] = useState([]); - const [idx, setIdx] = useState(0); - const [art, setArt] = useState(null); - - // Sample the video thumbnails currently on screen. - useEffect(() => { - const found: string[] = []; - document.querySelectorAll("img").forEach((img) => { - const s = img.currentSrc || img.src; - if (/i\.ytimg\.com\/vi\//.test(s)) found.push(s); - }); - setSrcs([...new Set(found)]); - }, []); - - // Slowly cycle through them. - useEffect(() => { - if (srcs.length < 2) return; - const t = setInterval(() => setIdx((i) => (i + 1) % srcs.length), 14000); - return () => clearInterval(t); - }, [srcs]); - - // Blur the current pick via a tiny-canvas downscale (fallback: raw url on CORS taint). - useEffect(() => { - const url = srcs[idx]; - if (!url) return; - let cancelled = false; - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = () => { - if (cancelled) return; - try { - const c = document.createElement("canvas"); - c.width = 40; - c.height = 22; - c.getContext("2d")!.drawImage(img, 0, 0, 40, 22); - setArt(c.toDataURL("image/jpeg", 0.6)); - } catch { - setArt(url); - } - }; - img.onerror = () => { - if (!cancelled) setArt(url); - }; - img.src = url; - return () => { - cancelled = true; - }; - }, [srcs, idx]); - - // Paint (and clean up) the faded backdrop on
. - useEffect(() => { - const main = document.querySelector("main"); - if (!main) return; - const s = main.style; - const clear = () => { - s.backgroundImage = ""; - s.backgroundSize = ""; - s.backgroundPosition = ""; - s.backgroundRepeat = ""; - s.backgroundAttachment = ""; - }; - if (art) { - s.backgroundImage = - `linear-gradient(color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent), ` + - `color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent)), url("${art}")`; - s.backgroundSize = "cover"; - s.backgroundPosition = "center"; - s.backgroundRepeat = "no-repeat"; - s.backgroundAttachment = "fixed"; - } else { - clear(); - } - return clear; - }, [art]); - - return null; -} diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index f287cba..888ac88 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import FeedBackdrop from "./FeedBackdrop"; /** * DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page. @@ -26,18 +25,20 @@ type Slider = { }; // def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it. +// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css. const SLIDERS: Slider[] = [ - { k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 20 }, - { k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.8 }, - { k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.25 }, - { k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 94 }, - { k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 94 }, - { k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 98 }, - { k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 78 }, - { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 10 }, - { k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 16 }, - { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 14 }, + { k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 10 }, + { k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 }, + { k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.2 }, + { k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 50 }, + { k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 60 }, + { k: "--glass-menu-alpha", label: "Menu opacity", min: 40, max: 100, step: 1, unit: "%", def: 66 }, + { k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 80 }, + { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 25 }, + { k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 15 }, + { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 }, { k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 }, + { k: "--bg-fade", label: "Backdrop fade", min: 40, max: 97, step: 1, unit: "%", def: 60 }, ]; const PALETTE: { k: string; label: string }[] = [ @@ -52,22 +53,20 @@ const PALETTE: { k: string; label: string }[] = [ // Each preset resets every slider to its default, then applies these overrides. const PRESETS: { id: string; name: string; hint: string; over: Record }[] = [ - { id: "current", name: "Current", hint: "The baked baseline", over: {} }, + { id: "current", name: "Glass/image", hint: "The default (over image)", over: {} }, { - id: "media", - name: "Media (glassy)", - hint: "For art-backed pages", + id: "solid", + name: "Solid (flat)", + hint: "As shown w/ image off", over: { - "--glass-blur": 10, - "--glass-saturate": 1.6, - "--glass-dark-bright": 1.2, - "--glass-surface-alpha": 50, - "--glass-card-alpha": 60, - "--glass-menu-alpha": 66, - "--glass-border-alpha": 80, - "--glass-edge": 25, - "--glass-inset": 15, - "--glass-scrim": 30, + "--glass-blur": 20, + "--glass-dark-bright": 1.25, + "--glass-surface-alpha": 94, + "--glass-card-alpha": 94, + "--glass-menu-alpha": 98, + "--glass-edge": 10, + "--glass-inset": 16, + "--glass-scrim": 14, }, }, { @@ -78,19 +77,13 @@ const PRESETS: { id: string; name: string; hint: string; over: Record document.documentElement; const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); type Saved = { vars?: Record; palette?: Record; - feedbg?: boolean; open?: boolean; }; @@ -110,7 +103,6 @@ export default function GlassTuner() { return { ...base, ...(initial.vars || {}) }; }); const [palette, setPalette] = useState>(() => initial.palette || {}); - const [feedbg, setFeedbg] = useState(() => Boolean(initial.feedbg)); const [open, setOpen] = useState(() => initial.open ?? true); const [tab, setTab] = useState<"glass" | "palette">("glass"); const [copied, setCopied] = useState(false); @@ -207,17 +199,10 @@ export default function GlassTuner() { persist({ open: v }); } - function toggleFeedbg(v: boolean) { - setFeedbg(v); - persist({ feedbg: v }); - } - if (!DEV_TUNER) return null; return ( - <> - {feedbg && } -
+
{!open && (
- {/* Feed backdrop (dev prototype) */} - - {/* Tab switch */}
{(["glass", "palette"] as const).map((t) => ( @@ -295,13 +269,6 @@ export default function GlassTuner() { {SLIDERS.map((s) => ( setVar(s.k, v)} /> ))} - {feedbg && ( -
- {FEEDBG_SLIDERS.map((s) => ( - setVar(s.k, v)} /> - ))} -
- )}
)} @@ -346,8 +313,7 @@ export default function GlassTuner() {
)} - - + ); } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 0399ea3..6675db0 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -158,6 +158,17 @@ function Appearance({ prefs }: { prefs: PrefsController }) { onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} /> + + setTheme({ ...theme, bgImage: v })} + /> + ): how strongly the faded --bg overlay hides the - blurred thumbnail — higher = fainter image. Only in effect while the backdrop is toggled on. */ - --feedbg-fade: 86%; + /* Per-scheme background image dim (see frontend/public/backdrops/): how strongly the faded --bg + overlay hides the image — higher = fainter. Only in effect while data-backdrop="on". */ + --bg-fade: 60%; } -/* ===== Rich-backdrop scope: surfaces that float over real imagery (Plex art hero/detail, and - later channel-page banners) want MORE translucency so the picture shows through — the "glass - over content" look. Put `.glass-media` on such a page root; every .glass* inside inherits these - (UAT-tuned 2026-07-12). Global chrome stays solid via the :root defaults above. ===== */ +/* ===== "Glass over image" — the translucent tier (UAT-tuned 2026-07-12). Applies whenever a + real image sits behind the surfaces: app-wide when the per-scheme background image is on + (`html[data-backdrop="on"]`), and on the Plex art-backed detail views (`.glass-media`, which + have their own art backdrop even when the global image is off). When there's nothing behind the + glass (image off / light theme / perf), the :root defaults keep it solid & readable. ===== */ +html[data-backdrop="on"], .glass-media { --glass-blur: 10px; --glass-saturate: 1.6; @@ -44,6 +46,24 @@ --glass-scrim: 30%; } +/* Per-scheme background image, painted on (the app root is transparent so it shows through + the whole app and the glass refracts it). A faded --bg overlay keeps it subtle; `fixed` so it + doesn't scroll. Only when the backdrop is on (dark theme + the "Background image" setting). */ +html[data-backdrop="on"][data-scheme="midnight"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/midnight.svg"); } +html[data-backdrop="on"][data-scheme="forest"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/forest.svg"); } +html[data-backdrop="on"][data-scheme="slate"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/slate.svg"); } +html[data-backdrop="on"][data-scheme="youtube"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/youtube.svg"); } +html[data-backdrop="on"][data-scheme="starship"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/starship.svg"); } +html[data-backdrop="on"][data-scheme="matrix"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/matrix.svg"); } +/* [data-theme="dark"] added purely to out-specify the `html[data-theme="dark"] body` ambient rule + below, whose `background` shorthand would otherwise reset background-size back to auto. */ +html[data-backdrop="on"][data-theme="dark"] body { + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-attachment: fixed; +} + /* Make native controls (date picker, scrollbars) follow the theme. */ html[data-theme="dark"] { color-scheme: dark; diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index a6d080c..e3a97e6 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -16,12 +16,15 @@ export interface ThemePrefs { mode: Mode; scheme: Scheme; fontScale: number; + /** Show the per-scheme background image (dark theme only); off = flat colour. */ + bgImage: boolean; } export const DEFAULT_THEME: ThemePrefs = { mode: "dark", scheme: "midnight", fontScale: 1.06, + bgImage: true, }; export function applyTheme(t: ThemePrefs): void {