Merge feature/glass-consistency: variable-driven glass, per-scheme backgrounds, Starship/Matrix schemes (0.42.0)

This commit is contained in:
npeter83 2026-07-12 22:29:07 +02:00
commit 7e85ee430b
18 changed files with 660 additions and 29 deletions

View file

@ -1 +1 @@
0.41.0 0.42.0

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -50,6 +50,7 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import BackToTop from "./components/BackToTop"; import BackToTop from "./components/BackToTop";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock"; import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
@ -380,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]);
@ -725,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}
@ -903,6 +912,9 @@ export default function App() {
<ErrorDialog /> <ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */} {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} /> <BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
{/* DEV-ONLY live appearance tuner (localhost-gated; self-returns null on prod). Delete when
the glass-consistency epic bakes the final values into index.css. */}
<GlassTuner />
</div> </div>
); );
} }

View file

@ -0,0 +1,343 @@
import { useEffect, useMemo, useState } from "react";
/**
* DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page.
* It writes the glass CSS custom properties (index.css `:root`) inline on <html> for instant,
* build-free feedback, and persists to localStorage so tweaks survive an F5 during UAT.
*
* Gated to localhost/127.0.0.1 so it renders on the :8080 UAT build and :5173 HMR but NEVER on
* prod (siftlode.b1fr0st.eu). It is a scaffold for the glass-consistency epic: dial in values
* here, hit "Copy CSS", hand them over to bake into index.css then this component is deleted.
*/
const DEV_TUNER =
typeof window !== "undefined" && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
const LS_KEY = "siftlode.devGlassTuner";
type Slider = {
k: string;
label: string;
min: number;
max: number;
step: number;
unit: string;
def: number;
};
// 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: 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 },
];
const PALETTE: { k: string; label: string }[] = [
{ k: "--bg", label: "Background" },
{ k: "--surface", label: "Surface" },
{ k: "--card", label: "Card" },
{ k: "--border", label: "Border" },
{ k: "--fg", label: "Text" },
{ k: "--muted", label: "Muted" },
{ k: "--accent", label: "Accent" },
];
// 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: "Glass/image", hint: "The default (over image)", over: {} },
{
id: "solid",
name: "Solid (flat)",
hint: "As shown w/ image off",
over: {
"--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,
},
},
{
id: "readable",
name: "Max readable",
hint: "Solid + strong scrim",
over: { "--glass-edge": 42, "--glass-scrim": 45, "--glass-surface-alpha": 96, "--glass-card-alpha": 96 },
},
];
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>;
open?: boolean;
};
function loadSaved(): Saved {
try {
return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved;
} catch {
return {};
}
}
export default function GlassTuner() {
const initial = useMemo(loadSaved, []);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
return { ...base, ...(initial.vars || {}) };
});
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
const [open, setOpen] = useState<boolean>(() => initial.open ?? true);
const [tab, setTab] = useState<"glass" | "palette">("glass");
const [copied, setCopied] = useState(false);
// Seed the colour pickers from the live scheme for display (without forcing them onto <html>).
const [swatches, setSwatches] = useState<Record<string, string>>({});
// Apply saved var overrides (and any saved palette edits) once on mount.
useEffect(() => {
Object.entries(initial.vars || {}).forEach(([k, v]) => {
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
root().style.setProperty(k, v + unit);
});
Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v));
const sw: Record<string, string> = {};
PALETTE.forEach((p) => {
const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
setSwatches(sw);
}, [initial]);
function persist(next: Partial<Saved>) {
const cur = loadSaved();
localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next }));
}
function setVar(k: string, value: number) {
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
root().style.setProperty(k, value + unit);
setVars((prev) => {
const next = { ...prev, [k]: value };
persist({ vars: next });
return next;
});
}
function setColor(k: string, hex: string) {
root().style.setProperty(k, hex);
setSwatches((s) => ({ ...s, [k]: hex }));
setPalette((prev) => {
const next = { ...prev, [k]: hex };
persist({ palette: next });
return next;
});
}
function applyPreset(over: Record<string, number>) {
const next: Record<string, number> = { ...vars };
SLIDERS.forEach((s) => {
const v = over[s.k] ?? s.def;
next[s.k] = v;
root().style.setProperty(s.k, v + s.unit);
});
setVars(next);
persist({ vars: next });
}
function reset() {
[...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k));
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
setVars(base);
setPalette({});
localStorage.removeItem(LS_KEY);
const sw: Record<string, string> = {};
PALETTE.forEach((p) => {
const v = readVar(p.k);
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
setSwatches(sw);
}
// Export only what differs from the shipped defaults, ready to paste into index.css.
const cssOut = useMemo(() => {
const lines: string[] = [];
ALL.forEach((s) => {
if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`);
});
PALETTE.forEach((p) => {
if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`);
});
return lines.length ? `:root {\n${lines.join("\n")}\n}` : "/* all values at defaults */";
}, [vars, palette]);
function copy() {
navigator.clipboard?.writeText(cssOut).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
});
}
function persistOpen(v: boolean) {
setOpen(v);
persist({ open: v });
}
if (!DEV_TUNER) return null;
return (
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
{!open && (
<button
onClick={() => persistOpen(true)}
style={{ pointerEvents: "auto" }}
className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
title="Open the glass tuner (dev only)"
>
<span style={{ writingMode: "vertical-rl" }}> GLASS</span>
</button>
)}
{open && (
<div
style={{ pointerEvents: "auto" }}
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden shadow-2xl"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg"> Glass Tuner</span>
<span className="text-[10px] uppercase tracking-wider text-muted">dev</span>
<button
onClick={() => persistOpen(false)}
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
title="Collapse"
>
×
</button>
</div>
<div className="overflow-y-auto px-3 py-3 flex flex-col gap-4 text-sm">
{/* Treatment presets */}
<div className="flex flex-col gap-1.5">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Dark treatment</div>
<div className="grid grid-cols-2 gap-1.5">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => applyPreset(p.over)}
title={p.hint}
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
>
<b className="block font-semibold">{p.name}</b>
<span className="text-muted text-[10px]">{p.hint}</span>
</button>
))}
</div>
</div>
{/* Tab switch */}
<div className="flex gap-1.5">
{(["glass", "palette"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
tab === t ? "bg-accent text-[color:var(--accent-fg)] font-semibold" : "glass-card text-muted"
}`}
>
{t}
</button>
))}
</div>
{tab === "glass" && (
<div className="flex flex-col gap-2.5">
{SLIDERS.map((s) => (
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
))}
</div>
)}
{tab === "palette" && (
<div className="flex flex-col gap-2">
<p className="text-[10px] text-muted leading-relaxed">
Overrides the live scheme (inline on &lt;html&gt;). Reset clears these back to the scheme.
</p>
{PALETTE.map((p) => (
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
<input
type="color"
value={swatches[p.k] || "#000000"}
onChange={(e) => setColor(p.k, e.target.value)}
className="w-8 h-6 rounded border border-border bg-transparent cursor-pointer"
/>
<span className="flex-1">{p.label}</span>
<code className="text-[10px] text-muted">{swatches[p.k]}</code>
</label>
))}
</div>
)}
{/* Export */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Export bake in</div>
<div className="flex gap-1.5">
<button
onClick={copy}
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-[color:var(--accent-fg)]"
>
{copied ? "Copied ✓" : "Copy CSS"}
</button>
<button onClick={reset} className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg">
Reset
</button>
</div>
<pre className="text-[9.5px] leading-relaxed bg-[color:var(--bg)] border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
{cssOut}
</pre>
</div>
</div>
</div>
)}
</div>
);
}
function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) {
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 gap-y-1">
<label className="text-xs text-fg">{s.label}</label>
<input
type="number"
min={s.min}
max={s.max}
step={s.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 text-right text-[11px] tabular-nums rounded bg-card border border-border px-1.5 py-0.5 text-accent"
/>
<input
type="range"
min={s.min}
max={s.max}
step={s.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="col-span-2 w-full accent-accent"
/>
</div>
);
}

View file

@ -905,7 +905,7 @@ function PlexShowView({
} }
return ( return (
<div className="p-4 max-w-[1200px] mx-auto"> <div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} /> <BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isLoading || !d || !show ? ( {q.isLoading || !d || !show ? (
@ -1103,7 +1103,7 @@ function PlexSeasonView({
} }
return ( return (
<div className="p-4 max-w-[1200px] mx-auto"> <div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} /> <BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d || !se ? ( {q.isLoading || !d || !se ? (

View file

@ -90,12 +90,11 @@ export default function PlexInfo({
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
return ( return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}> <div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}> <div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */} {/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div <div
className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"} className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}
style={overlay ? undefined : { background: "color-mix(in srgb, var(--surface) 55%, transparent)" }}
> >
{/* Header: close (overlay) / customize */} {/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3"> <div className="mb-4 flex items-start gap-3">
@ -302,7 +301,6 @@ export default function PlexInfo({
{cast.length > 0 && ( {cast.length > 0 && (
<div <div
className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"} className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
> >
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}> <h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")} {t("plex.info.cast")}
@ -365,7 +363,6 @@ export default function PlexInfo({
<div <div
key={col.id} key={col.id}
className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"} className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
> >
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2> <h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>

View file

@ -158,6 +158,35 @@ 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>
{theme.mode === "dark" && theme.bgImage && (
<div className="pl-1 pb-1">
<div className="flex items-center justify-between text-xs text-muted mb-1">
<span>{t("settings.appearance.backgroundFade")}</span>
<span className="tabular-nums">{theme.bgFade}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={theme.bgFade}
onChange={(e) => setTheme({ ...theme, bgFade: Number(e.target.value) })}
aria-label={t("settings.appearance.backgroundFade")}
className="w-full accent-accent"
/>
</div>
)}
<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")}

View file

@ -33,6 +33,9 @@
"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.",
"backgroundFade": "Bildabblendung",
"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",

View file

@ -33,6 +33,9 @@
"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.",
"backgroundFade": "Image fade",
"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",

View file

@ -33,6 +33,9 @@
"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.",
"backgroundFade": "Kép elhalványítása",
"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",

View file

@ -4,6 +4,64 @@
:root { :root {
--font-scale: 1.06; --font-scale: 1.06;
/* ===== Glass tunables every value that materially drives the look lives here so it can be
tuned in ONE place (and live-driven by the dev GlassTuner during UAT). ===== */
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
(nav/header/dialogs/filters/settings where there's nothing behind the glass to refract).
Baked from UAT 2026-07-12. Rich-backdrop surfaces override these via `.glass-media` below. */
--glass-blur: 20px; /* backdrop blur radius */
--glass-saturate: 1.8; /* backdrop saturation */
--glass-dark-bright: 1.25; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */
--glass-surface-alpha: 94%; /* .glass panel translucency (higher = more solid = "adaptive") */
--glass-card-alpha: 94%; /* .glass-card translucency */
--glass-menu-alpha: 98%; /* .glass-menu translucency */
--glass-border-alpha: 78%; /* .glass border opacity */
--glass-inset: 16%; /* top specular highlight strength (#fff N%) */
--glass-edge: 10%; /* extra edge-light mixed into the TOP border (#fff N%) — 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 */
/* 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%;
}
/* ===== "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;
--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%;
}
/* 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. */
@ -26,9 +84,9 @@ body {
subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav, subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav,
header and dialogs are all frosted glass and refract it. */ header and dialogs are all frosted glass and refract it. */
background: background:
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 60%), radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent), transparent 60%),
radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 55%), radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent), transparent 55%),
radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 11%, transparent), transparent 60%), radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60%),
var(--bg); var(--bg);
background-attachment: fixed; background-attachment: fixed;
} }
@ -37,34 +95,44 @@ body {
.glass { .glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows /* Frosted overlay surface: translucent enough that the blurred backdrop shows
through (the glassy look), opaque enough that the background isn't distracting. through (the glassy look), opaque enough that the background isn't distracting.
The strong blur turns whatever is behind into soft colour, not a sharp image. */ The strong blur turns whatever is behind into soft colour, not a sharp image.
background: color-mix(in srgb, var(--surface) 78%, transparent); Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
backdrop-filter: blur(30px) saturate(1.8); background:
-webkit-backdrop-filter: blur(30px) saturate(1.8); linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent));
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 16%, transparent), inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6); 0 18px 44px -16px rgba(0, 0, 0, 0.6);
} }
.glass-card { .glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background /* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
where blur adds almost nothing visually but is GPU-expensive and triggers reflow. where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */ Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
background: color-mix(in srgb, var(--card) 84%, transparent); background:
linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 65%, transparent));
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent), inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45); 0 8px 22px -14px rgba(0, 0, 0, 0.45);
} }
.glass-menu { .glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs), /* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable only a hint of translucency + the blur. */ so they must be near-opaque to stay readable only a hint of translucency + the blur. */
background: color-mix(in srgb, var(--surface) 92%, transparent); background:
backdrop-filter: blur(30px) saturate(1.8); linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
-webkit-backdrop-filter: blur(30px) saturate(1.8); color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent));
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent), inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6); 0 18px 44px -16px rgba(0, 0, 0, 0.6);
} }
.glass-hover:hover { .glass-hover:hover {
@ -80,9 +148,9 @@ body {
(The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */ (The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */
html[data-theme="dark"] body { html[data-theme="dark"] body {
background: background:
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 60%), radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent), transparent 60%),
radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 24%, transparent), transparent 55%), radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent), transparent 55%),
radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%), radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60%),
var(--bg); var(--bg);
background-attachment: fixed; background-attachment: fixed;
} }
@ -90,8 +158,8 @@ html[data-theme="dark"] body {
the frosted sheen shows even over dark UI (over colourful content it just looks richer). */ the frosted sheen shows even over dark UI (over colourful content it just looks richer). */
html[data-theme="dark"] .glass, html[data-theme="dark"] .glass,
html[data-theme="dark"] .glass-menu { html[data-theme="dark"] .glass-menu {
backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright));
-webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright));
} }
/* Toasts surface bottom-left (by the nav's notification bell), off the conventional /* Toasts surface bottom-left (by the nav's notification bell), off the conventional
top-right; a brighter rim draws the eye there. Most needed in dark mode, where the top-right; a brighter rim draws the eye there. Most needed in dark mode, where the
@ -252,6 +320,50 @@ html[data-scheme="youtube"][data-theme="light"] {
--accent-fg: #ffffff; --accent-fg: #ffffff;
} }
/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */
html[data-scheme="starship"][data-theme="dark"] {
--bg: #050b16;
--surface: #0b1728;
--card: #101f36;
--border: #1f3557;
--fg: #dfeaff;
--muted: #8aa6cc;
--accent: #38b6ff;
--accent-fg: #04101f;
}
html[data-scheme="starship"][data-theme="light"] {
--bg: #eef4fb;
--surface: #ffffff;
--card: #ffffff;
--border: #d3e0f0;
--fg: #0b1b30;
--muted: #4a5f7d;
--accent: #0b6bd6;
--accent-fg: #ffffff;
}
/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */
html[data-scheme="matrix"][data-theme="dark"] {
--bg: #030a06;
--surface: #08130d;
--card: #0b1c12;
--border: #163d27;
--fg: #cfe8d6;
--muted: #6fae86;
--accent: #46b87c;
--accent-fg: #04170c;
}
html[data-scheme="matrix"][data-theme="light"] {
--bg: #f0f7f1;
--surface: #ffffff;
--card: #ffffff;
--border: #cfe6d6;
--fg: #08130b;
--muted: #4c6b56;
--accent: #12a150;
--accent-fg: #ffffff;
}
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */ /* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@keyframes toastbar { @keyframes toastbar {
from { from {

View file

@ -14,6 +14,18 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.42.0",
date: "2026-07-12",
summary: "A refreshed “glass over image” look for dark theme, plus two new colour schemes.",
features: [
"New background image: in dark theme a subtle backdrop, tuned to your colour scheme, now sits behind the app — so the frosted-glass surfaces have real depth to refract. Turn it on or off, and dial its strength, with the new controls in Settings → Appearance.",
"Two new colour schemes: Starship (deep-space blue) and Matrix (terminal green).",
],
chores: [
"Reworked the translucent “glass” surface system so the whole app shares one consistent, tunable look.",
],
},
{ {
version: "0.41.0", version: "0.41.0",
date: "2026-07-12", date: "2026-07-12",

View file

@ -1,25 +1,33 @@
import { LS, readAccountMerged, writeAccount } from "./storage"; import { LS, readAccountMerged, writeAccount } from "./storage";
type Mode = "dark" | "light"; type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube"; export type Scheme = "midnight" | "forest" | "slate" | "youtube" | "starship" | "matrix";
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" }, { id: "midnight", name: "Midnight", swatch: "#6d8cff" },
{ id: "forest", name: "Forest", swatch: "#2dd4bf" }, { id: "forest", name: "Forest", swatch: "#2dd4bf" },
{ id: "slate", name: "Slate", swatch: "#e8913c" }, { id: "slate", name: "Slate", swatch: "#e8913c" },
{ id: "youtube", name: "YouTube", swatch: "#ff3b46" }, { id: "youtube", name: "YouTube", swatch: "#ff3b46" },
{ id: "starship", name: "Starship", swatch: "#38b6ff" },
{ id: "matrix", name: "Matrix", swatch: "#46b87c" },
]; ];
export interface ThemePrefs { 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;
/** How strongly the background image is faded out, 0100% (higher = fainter). */
bgFade: number;
} }
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,
bgFade: 60,
}; };
export function applyTheme(t: ThemePrefs): void { export function applyTheme(t: ThemePrefs): void {
@ -27,6 +35,7 @@ export function applyTheme(t: ThemePrefs): void {
el.dataset.theme = t.mode; el.dataset.theme = t.mode;
el.dataset.scheme = t.scheme; el.dataset.scheme = t.scheme;
el.style.setProperty("--font-scale", String(t.fontScale)); el.style.setProperty("--font-scale", String(t.fontScale));
if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`);
} }
export function loadLocalTheme(): ThemePrefs { export function loadLocalTheme(): ThemePrefs {