diff --git a/VERSION b/VERSION
index ca59bb0..57c1868 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.41.0
\ No newline at end of file
+0.42.0
\ No newline at end of file
diff --git a/frontend/public/backdrops/forest.svg b/frontend/public/backdrops/forest.svg
new file mode 100644
index 0000000..5e8f0f4
--- /dev/null
+++ b/frontend/public/backdrops/forest.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg
new file mode 100644
index 0000000..3e699a7
--- /dev/null
+++ b/frontend/public/backdrops/matrix.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/backdrops/midnight.svg b/frontend/public/backdrops/midnight.svg
new file mode 100644
index 0000000..217531f
--- /dev/null
+++ b/frontend/public/backdrops/midnight.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/backdrops/slate.svg b/frontend/public/backdrops/slate.svg
new file mode 100644
index 0000000..b5c4889
--- /dev/null
+++ b/frontend/public/backdrops/slate.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/backdrops/starship.svg b/frontend/public/backdrops/starship.svg
new file mode 100644
index 0000000..2a8d704
--- /dev/null
+++ b/frontend/public/backdrops/starship.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/backdrops/youtube.svg b/frontend/public/backdrops/youtube.svg
new file mode 100644
index 0000000..6d50059
--- /dev/null
+++ b/frontend/public/backdrops/youtube.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4f0892a..5dc1d0c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -50,6 +50,7 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import BackToTop from "./components/BackToTop";
+import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog";
@@ -380,6 +381,14 @@ export default function App() {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
+
+ // The per-scheme background image (the "glass over image" look) is on only in dark theme, when
+ // the user hasn't opted out, and not in perf mode. Drives `html[data-backdrop]` (see index.css),
+ // which both paints the image on
and switches the glass to its translucent tier.
+ useEffect(() => {
+ const on = theme.mode === "dark" && theme.bgImage && !perf;
+ document.documentElement.dataset.backdrop = on ? "on" : "off";
+ }, [theme.mode, theme.bgImage, perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
@@ -725,7 +734,7 @@ export default function App() {
);
return (
-
+
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
+ {/* 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. */}
+
);
}
diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx
new file mode 100644
index 0000000..eb59791
--- /dev/null
+++ b/frontend/src/components/GlassTuner.tsx
@@ -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 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
}[] = [
+ { 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;
+ palette?: Record;
+ 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>(() => {
+ const base: Record = {};
+ ALL.forEach((s) => (base[s.k] = s.def));
+ return { ...base, ...(initial.vars || {}) };
+ });
+ const [palette, setPalette] = useState>(() => initial.palette || {});
+ const [open, setOpen] = useState(() => 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 ).
+ const [swatches, setSwatches] = useState>({});
+
+ // 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 = {};
+ 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) {
+ 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) {
+ const next: Record = { ...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 = {};
+ ALL.forEach((s) => (base[s.k] = s.def));
+ setVars(base);
+ setPalette({});
+ localStorage.removeItem(LS_KEY);
+ const sw: Record = {};
+ 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 (
+
+ {!open && (
+
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)"
+ >
+ ◐ GLASS
+
+ )}
+ {open && (
+
+
+ ◐ Glass Tuner
+ dev
+ persistOpen(false)}
+ className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
+ title="Collapse"
+ >
+ ×
+
+
+
+
+ {/* Treatment presets */}
+
+
Dark treatment
+
+ {PRESETS.map((p) => (
+ applyPreset(p.over)}
+ title={p.hint}
+ className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
+ >
+ {p.name}
+ {p.hint}
+
+ ))}
+
+
+
+ {/* Tab switch */}
+
+ {(["glass", "palette"] as const).map((t) => (
+ 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}
+
+ ))}
+
+
+ {tab === "glass" && (
+
+ {SLIDERS.map((s) => (
+ setVar(s.k, v)} />
+ ))}
+
+ )}
+
+ {tab === "palette" && (
+
+
+ Overrides the live scheme (inline on <html>). Reset clears these back to the scheme.
+
+ {PALETTE.map((p) => (
+
+ setColor(p.k, e.target.value)}
+ className="w-8 h-6 rounded border border-border bg-transparent cursor-pointer"
+ />
+ {p.label}
+ {swatches[p.k]}
+
+ ))}
+
+ )}
+
+ {/* Export */}
+
+
Export → bake in
+
+
+ {copied ? "Copied ✓" : "Copy CSS"}
+
+
+ Reset
+
+
+
+ {cssOut}
+
+
+
+
+ )}
+
+ );
+}
+
+function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) {
+ return (
+
+ {s.label}
+ 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"
+ />
+ onChange(Number(e.target.value))}
+ className="col-span-2 w-full accent-accent"
+ />
+
+ );
+}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 4409b5a..3f112a1 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -905,7 +905,7 @@ function PlexShowView({
}
return (
-
+
{q.isLoading || !d || !show ? (
@@ -1103,7 +1103,7 @@ function PlexSeasonView({
}
return (
-
+
{q.isLoading || !d || !se ? (
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 1c8a28a..f05bcdf 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -90,12 +90,11 @@ export default function PlexInfo({
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
return (
-
+
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
{/* Header: close (overlay) / customize */}
@@ -302,7 +301,6 @@ export default function PlexInfo({
{cast.length > 0 && (
{t("plex.info.cast")}
@@ -365,7 +363,6 @@ export default function PlexInfo({
{col.title}
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 0399ea3..8ebd4cc 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -158,6 +158,35 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
+
+ setTheme({ ...theme, bgImage: v })}
+ />
+
+ {theme.mode === "dark" && theme.bgImage && (
+
+
+ {t("settings.appearance.backgroundFade")}
+ {theme.bgFade}%
+
+
setTheme({ ...theme, bgFade: Number(e.target.value) })}
+ aria-label={t("settings.appearance.backgroundFade")}
+ className="w-full accent-accent"
+ />
+
+ )}
(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. */
@@ -26,9 +84,9 @@ body {
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. */
background:
- radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 60%),
- radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 55%),
- radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 11%, 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) calc(14% * var(--ambient)), transparent), transparent 55%),
+ radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60%),
var(--bg);
background-attachment: fixed;
}
@@ -37,34 +95,44 @@ body {
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
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. */
- background: color-mix(in srgb, var(--surface) 78%, transparent);
- backdrop-filter: blur(30px) saturate(1.8);
- -webkit-backdrop-filter: blur(30px) saturate(1.8);
- border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
+ The strong blur turns whatever is behind into soft colour, not a sharp image.
+ Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
+ 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(--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:
- 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);
}
.glass-card {
/* 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.
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-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 65%, transparent));
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);
}
.glass-menu {
/* 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. */
- background: color-mix(in srgb, var(--surface) 92%, transparent);
- backdrop-filter: blur(30px) saturate(1.8);
- -webkit-backdrop-filter: blur(30px) saturate(1.8);
+ 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(--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-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent));
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);
}
.glass-hover:hover {
@@ -80,9 +148,9 @@ body {
(The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */
html[data-theme="dark"] body {
background:
- radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 60%),
- radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 24%, transparent), transparent 55%),
- radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 18%, 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) calc(24% * var(--ambient)), transparent), transparent 55%),
+ radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60%),
var(--bg);
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). */
html[data-theme="dark"] .glass,
html[data-theme="dark"] .glass-menu {
- backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
- -webkit-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(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
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;
}
+/* 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) */
@keyframes toastbar {
from {
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index e035467..9fb890e 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,18 @@ export interface 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",
date: "2026-07-12",
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts
index db8caac..12a7324 100644
--- a/frontend/src/lib/theme.ts
+++ b/frontend/src/lib/theme.ts
@@ -1,25 +1,33 @@
import { LS, readAccountMerged, writeAccount } from "./storage";
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 }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
{ id: "forest", name: "Forest", swatch: "#2dd4bf" },
{ id: "slate", name: "Slate", swatch: "#e8913c" },
{ id: "youtube", name: "YouTube", swatch: "#ff3b46" },
+ { id: "starship", name: "Starship", swatch: "#38b6ff" },
+ { id: "matrix", name: "Matrix", swatch: "#46b87c" },
];
export interface ThemePrefs {
mode: Mode;
scheme: Scheme;
fontScale: number;
+ /** Show the per-scheme background image (dark theme only); off = flat colour. */
+ bgImage: boolean;
+ /** How strongly the background image is faded out, 0–100% (higher = fainter). */
+ bgFade: number;
}
export const DEFAULT_THEME: ThemePrefs = {
mode: "dark",
scheme: "midnight",
fontScale: 1.06,
+ bgImage: true,
+ bgFade: 60,
};
export function applyTheme(t: ThemePrefs): void {
@@ -27,6 +35,7 @@ export function applyTheme(t: ThemePrefs): void {
el.dataset.theme = t.mode;
el.dataset.scheme = t.scheme;
el.style.setProperty("--font-scale", String(t.fontScale));
+ if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`);
}
export function loadLocalTheme(): ThemePrefs {