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 <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"].
This commit is contained in:
npeter83 2026-07-12 21:20:40 +02:00
parent 036e2fe7ea
commit f9f90cf609
15 changed files with 191 additions and 162 deletions

View file

@ -1,93 +0,0 @@
import { useEffect, useState } from "react";
/**
* DEV prototype (toggled by GlassTuner): a faint, blurred, slowly-rotating feed thumbnail painted
* as a fixed backdrop so the glass surfaces have real content to refract the same trick that makes
* the Plex detail page pop, brought to the YT/Plex feeds. The flat `--bg` then reads as "perf mode".
*
* It writes `<main>`'s background (NOT a separate fixed layer): a fixed z-index:-1 layer is occluded
* by the app root's opaque `bg-bg`, whereas `<main>` 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<string[]>([]);
const [idx, setIdx] = useState(0);
const [art, setArt] = useState<string | null>(null);
// Sample the video thumbnails currently on screen.
useEffect(() => {
const found: string[] = [];
document.querySelectorAll<HTMLImageElement>("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 <main>.
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;
}

View file

@ -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<string, number> }[] = [
{ 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<string, nu
},
];
// Dev-prototype feed-backdrop control (only meaningful while the backdrop toggle is on).
const FEEDBG_SLIDERS: Slider[] = [
{ k: "--feedbg-fade", label: "Backdrop fade", min: 60, max: 97, step: 1, unit: "%", def: 86 },
];
const ALL = [...SLIDERS, ...FEEDBG_SLIDERS];
const ALL = SLIDERS;
const root = () => document.documentElement;
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
type Saved = {
vars?: Record<string, number>;
palette?: Record<string, string>;
feedbg?: boolean;
open?: boolean;
};
@ -110,7 +103,6 @@ export default function GlassTuner() {
return { ...base, ...(initial.vars || {}) };
});
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
const [feedbg, setFeedbg] = useState<boolean>(() => Boolean(initial.feedbg));
const [open, setOpen] = useState<boolean>(() => 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 && <FeedBackdrop />}
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
{!open && (
<button
onClick={() => persistOpen(true)}
@ -264,17 +249,6 @@ export default function GlassTuner() {
</div>
</div>
{/* Feed backdrop (dev prototype) */}
<label className="flex items-center gap-2 text-xs text-fg cursor-pointer">
<input
type="checkbox"
checked={feedbg}
onChange={(e) => toggleFeedbg(e.target.checked)}
className="accent-accent"
/>
Feed backdrop <span className="text-muted">(blurred thumbnail)</span>
</label>
{/* Tab switch */}
<div className="flex gap-1.5">
{(["glass", "palette"] as const).map((t) => (
@ -295,13 +269,6 @@ export default function GlassTuner() {
{SLIDERS.map((s) => (
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
))}
{feedbg && (
<div className="flex flex-col gap-2.5 pt-1 border-t border-border/50">
{FEEDBG_SLIDERS.map((s) => (
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
))}
</div>
)}
</div>
)}
@ -346,8 +313,7 @@ export default function GlassTuner() {
</div>
</div>
)}
</div>
</>
</div>
);
}

View file

@ -158,6 +158,17 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</SettingRow>
<SettingRow
label={t("settings.appearance.backgroundImage")}
hint={t("settings.appearance.backgroundImageHint")}
>
<Switch
label={t("settings.appearance.backgroundImage")}
checked={theme.bgImage}
disabled={theme.mode !== "dark"}
onChange={(v) => setTheme({ ...theme, bgImage: v })}
/>
</SettingRow>
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch
label={t("settings.appearance.listView")}