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:
parent
036e2fe7ea
commit
f9f90cf609
15 changed files with 191 additions and 162 deletions
18
frontend/public/backdrops/forest.svg
Normal file
18
frontend/public/backdrops/forest.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f5245" stop-opacity=".95"/><stop offset="100%" stop-color="#0f5245" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#134a2c" stop-opacity=".9"/><stop offset="100%" stop-color="#134a2c" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0d3a52" stop-opacity=".85"/><stop offset="100%" stop-color="#0d3a52" stop-opacity="0"/></radialGradient>
|
||||||
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="#06120e"/>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
frontend/public/backdrops/matrix.svg
Normal file
18
frontend/public/backdrops/matrix.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f4a30" stop-opacity=".95"/><stop offset="100%" stop-color="#0f4a30" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#0c3a3a" stop-opacity=".9"/><stop offset="100%" stop-color="#0c3a3a" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#164a26" stop-opacity=".85"/><stop offset="100%" stop-color="#164a26" stop-opacity="0"/></radialGradient>
|
||||||
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="#020805"/>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
frontend/public/backdrops/midnight.svg
Normal file
18
frontend/public/backdrops/midnight.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#2a4aa0" stop-opacity=".95"/><stop offset="100%" stop-color="#2a4aa0" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#6d8cff" stop-opacity=".5"/><stop offset="100%" stop-color="#6d8cff" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3b2f80" stop-opacity=".9"/><stop offset="100%" stop-color="#3b2f80" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#145e6b" stop-opacity=".85"/><stop offset="100%" stop-color="#145e6b" stop-opacity="0"/></radialGradient>
|
||||||
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="#070b16"/>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
frontend/public/backdrops/slate.svg
Normal file
18
frontend/public/backdrops/slate.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a4d18" stop-opacity=".95"/><stop offset="100%" stop-color="#7a4d18" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#5a2f14" stop-opacity=".9"/><stop offset="100%" stop-color="#5a2f14" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#403820" stop-opacity=".85"/><stop offset="100%" stop-color="#403820" stop-opacity="0"/></radialGradient>
|
||||||
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="#141009"/>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
frontend/public/backdrops/starship.svg
Normal file
18
frontend/public/backdrops/starship.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#123b6b" stop-opacity=".95"/><stop offset="100%" stop-color="#123b6b" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2a72" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2a72" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0f5566" stop-opacity=".85"/><stop offset="100%" stop-color="#0f5566" stop-opacity="0"/></radialGradient>
|
||||||
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="#040914"/>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
frontend/public/backdrops/youtube.svg
Normal file
18
frontend/public/backdrops/youtube.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a1620" stop-opacity=".9"/><stop offset="100%" stop-color="#7a1620" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff3b46" stop-opacity=".4"/><stop offset="100%" stop-color="#ff3b46" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a0d24" stop-opacity=".9"/><stop offset="100%" stop-color="#3a0d24" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2a1010" stop-opacity=".85"/><stop offset="100%" stop-color="#2a1010" stop-opacity="0"/></radialGradient>
|
||||||
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="#0d0708"/>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -381,6 +381,14 @@ export default function App() {
|
||||||
document.documentElement.dataset.perf = perf ? "1" : "";
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||||
setAccountRaw(PERF_KEY, perf ? "1" : "0");
|
setAccountRaw(PERF_KEY, perf ? "1" : "0");
|
||||||
}, [perf]);
|
}, [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 <body> 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(() => setHintsEnabled(hints), [hints]);
|
||||||
useEffect(() => configureNotifications(notif), [notif]);
|
useEffect(() => configureNotifications(notif), [notif]);
|
||||||
|
|
||||||
|
|
@ -726,7 +734,7 @@ export default function App() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex bg-bg text-fg">
|
<div className="h-screen flex text-fg">
|
||||||
<NavSidebar
|
<NavSidebar
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
page={page}
|
page={page}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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.
|
* 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 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[] = [
|
const SLIDERS: Slider[] = [
|
||||||
{ k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 20 },
|
{ 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.8 },
|
{ 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.25 },
|
{ 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: 94 },
|
{ 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: 94 },
|
{ k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 60 },
|
||||||
{ k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 98 },
|
{ 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: 78 },
|
{ 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: 10 },
|
{ 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: 16 },
|
{ 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: 14 },
|
{ 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: "--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 }[] = [
|
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.
|
// Each preset resets every slider to its default, then applies these overrides.
|
||||||
const PRESETS: { id: string; name: string; hint: string; over: Record<string, number> }[] = [
|
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",
|
id: "solid",
|
||||||
name: "Media (glassy)",
|
name: "Solid (flat)",
|
||||||
hint: "For art-backed pages",
|
hint: "As shown w/ image off",
|
||||||
over: {
|
over: {
|
||||||
"--glass-blur": 10,
|
"--glass-blur": 20,
|
||||||
"--glass-saturate": 1.6,
|
"--glass-dark-bright": 1.25,
|
||||||
"--glass-dark-bright": 1.2,
|
"--glass-surface-alpha": 94,
|
||||||
"--glass-surface-alpha": 50,
|
"--glass-card-alpha": 94,
|
||||||
"--glass-card-alpha": 60,
|
"--glass-menu-alpha": 98,
|
||||||
"--glass-menu-alpha": 66,
|
"--glass-edge": 10,
|
||||||
"--glass-border-alpha": 80,
|
"--glass-inset": 16,
|
||||||
"--glass-edge": 25,
|
"--glass-scrim": 14,
|
||||||
"--glass-inset": 15,
|
|
||||||
"--glass-scrim": 30,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -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 ALL = SLIDERS;
|
||||||
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 root = () => document.documentElement;
|
const root = () => document.documentElement;
|
||||||
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
|
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
|
||||||
|
|
||||||
type Saved = {
|
type Saved = {
|
||||||
vars?: Record<string, number>;
|
vars?: Record<string, number>;
|
||||||
palette?: Record<string, string>;
|
palette?: Record<string, string>;
|
||||||
feedbg?: boolean;
|
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -110,7 +103,6 @@ export default function GlassTuner() {
|
||||||
return { ...base, ...(initial.vars || {}) };
|
return { ...base, ...(initial.vars || {}) };
|
||||||
});
|
});
|
||||||
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
|
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 [open, setOpen] = useState<boolean>(() => initial.open ?? true);
|
||||||
const [tab, setTab] = useState<"glass" | "palette">("glass");
|
const [tab, setTab] = useState<"glass" | "palette">("glass");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
@ -207,16 +199,9 @@ export default function GlassTuner() {
|
||||||
persist({ open: v });
|
persist({ open: v });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFeedbg(v: boolean) {
|
|
||||||
setFeedbg(v);
|
|
||||||
persist({ feedbg: v });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!DEV_TUNER) return null;
|
if (!DEV_TUNER) return null;
|
||||||
|
|
||||||
return (
|
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 && (
|
{!open && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -264,17 +249,6 @@ export default function GlassTuner() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Tab switch */}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{(["glass", "palette"] as const).map((t) => (
|
{(["glass", "palette"] as const).map((t) => (
|
||||||
|
|
@ -295,13 +269,6 @@ export default function GlassTuner() {
|
||||||
{SLIDERS.map((s) => (
|
{SLIDERS.map((s) => (
|
||||||
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -347,7 +314,6 @@ export default function GlassTuner() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,17 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||||
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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")}>
|
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
|
||||||
<Switch
|
<Switch
|
||||||
label={t("settings.appearance.listView")}
|
label={t("settings.appearance.listView")}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
"colorScheme": "Farbschema",
|
"colorScheme": "Farbschema",
|
||||||
"display": "Anzeige",
|
"display": "Anzeige",
|
||||||
"darkMode": "Dunkler Modus",
|
"darkMode": "Dunkler Modus",
|
||||||
|
"backgroundImage": "Hintergrundbild",
|
||||||
|
"backgroundImageHint": "Zeigt ein dezentes, themenbezogenes Hintergrundbild hinter der App, damit die Glasflächen etwas zum Brechen haben. Nur im dunklen Design; aus = einfarbig.",
|
||||||
"listView": "Listenansicht",
|
"listView": "Listenansicht",
|
||||||
"listViewHint": "Zeigt den Feed als kompakte Liste statt als Kartenraster an.",
|
"listViewHint": "Zeigt den Feed als kompakte Liste statt als Kartenraster an.",
|
||||||
"performanceMode": "Leistungsmodus",
|
"performanceMode": "Leistungsmodus",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
"colorScheme": "Color scheme",
|
"colorScheme": "Color scheme",
|
||||||
"display": "Display",
|
"display": "Display",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
|
"backgroundImage": "Background image",
|
||||||
|
"backgroundImageHint": "Show a subtle per-theme backdrop image behind the app so the glass surfaces have something to refract. Dark theme only; off = flat colour.",
|
||||||
"listView": "List view",
|
"listView": "List view",
|
||||||
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
|
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
|
||||||
"performanceMode": "Performance mode",
|
"performanceMode": "Performance mode",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
"colorScheme": "Színséma",
|
"colorScheme": "Színséma",
|
||||||
"display": "Megjelenítés",
|
"display": "Megjelenítés",
|
||||||
"darkMode": "Sötét mód",
|
"darkMode": "Sötét mód",
|
||||||
|
"backgroundImage": "Háttérkép",
|
||||||
|
"backgroundImageHint": "Halvány, témánkénti háttérkép az app mögött, hogy az üveges felületeknek legyen mit megtörniük. Csak sötét témában; kikapcsolva sima szín.",
|
||||||
"listView": "Listanézet",
|
"listView": "Listanézet",
|
||||||
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
|
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
|
||||||
"performanceMode": "Teljesítmény mód",
|
"performanceMode": "Teljesítmény mód",
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,17 @@
|
||||||
--glass-scrim: 14%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */
|
--glass-scrim: 14%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */
|
||||||
--ambient: 1.1; /* multiplier on the ambient accent backdrop pools */
|
--ambient: 1.1; /* multiplier on the ambient accent backdrop pools */
|
||||||
|
|
||||||
/* Dev-prototype feed backdrop (see <FeedBackdrop/>): how strongly the faded --bg overlay hides the
|
/* Per-scheme background image dim (see frontend/public/backdrops/): how strongly the faded --bg
|
||||||
blurred thumbnail — higher = fainter image. Only in effect while the backdrop is toggled on. */
|
overlay hides the image — higher = fainter. Only in effect while data-backdrop="on". */
|
||||||
--feedbg-fade: 86%;
|
--bg-fade: 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Rich-backdrop scope: surfaces that float over real imagery (Plex art hero/detail, and
|
/* ===== "Glass over image" — the translucent tier (UAT-tuned 2026-07-12). Applies whenever a
|
||||||
later channel-page banners) want MORE translucency so the picture shows through — the "glass
|
real image sits behind the surfaces: app-wide when the per-scheme background image is on
|
||||||
over content" look. Put `.glass-media` on such a page root; every .glass* inside inherits these
|
(`html[data-backdrop="on"]`), and on the Plex art-backed detail views (`.glass-media`, which
|
||||||
(UAT-tuned 2026-07-12). Global chrome stays solid via the :root defaults above. ===== */
|
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-media {
|
||||||
--glass-blur: 10px;
|
--glass-blur: 10px;
|
||||||
--glass-saturate: 1.6;
|
--glass-saturate: 1.6;
|
||||||
|
|
@ -44,6 +46,24 @@
|
||||||
--glass-scrim: 30%;
|
--glass-scrim: 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-scheme background image, painted on <body> (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. */
|
/* Make native controls (date picker, scrollbars) follow the theme. */
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,15 @@ export interface ThemePrefs {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
scheme: Scheme;
|
scheme: Scheme;
|
||||||
fontScale: number;
|
fontScale: number;
|
||||||
|
/** Show the per-scheme background image (dark theme only); off = flat colour. */
|
||||||
|
bgImage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_THEME: ThemePrefs = {
|
export const DEFAULT_THEME: ThemePrefs = {
|
||||||
mode: "dark",
|
mode: "dark",
|
||||||
scheme: "midnight",
|
scheme: "midnight",
|
||||||
fontScale: 1.06,
|
fontScale: 1.06,
|
||||||
|
bgImage: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function applyTheme(t: ThemePrefs): void {
|
export function applyTheme(t: ThemePrefs): void {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue