Merge feature/glass-consistency: variable-driven glass, per-scheme backgrounds, Starship/Matrix schemes (0.42.0)
This commit is contained in:
commit
7e85ee430b
18 changed files with 660 additions and 29 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.41.0
|
||||
0.42.0
|
||||
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 |
|
|
@ -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 <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(() => configureNotifications(notif), [notif]);
|
||||
|
||||
|
|
@ -725,7 +734,7 @@ export default function App() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-bg text-fg">
|
||||
<div className="h-screen flex text-fg">
|
||||
<NavSidebar
|
||||
me={meQuery.data!}
|
||||
page={page}
|
||||
|
|
@ -903,6 +912,9 @@ export default function App() {
|
|||
<ErrorDialog />
|
||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
343
frontend/src/components/GlassTuner.tsx
Normal file
343
frontend/src/components/GlassTuner.tsx
Normal 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 <html>). 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -905,7 +905,7 @@ function PlexShowView({
|
|||
}
|
||||
|
||||
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")} />
|
||||
|
||||
{q.isLoading || !d || !show ? (
|
||||
|
|
@ -1103,7 +1103,7 @@ function PlexSeasonView({
|
|||
}
|
||||
|
||||
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")} />
|
||||
|
||||
{q.isLoading || !d || !se ? (
|
||||
|
|
|
|||
|
|
@ -90,12 +90,11 @@ export default function PlexInfo({
|
|||
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
|
||||
|
||||
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"}>
|
||||
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
||||
<div
|
||||
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 */}
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
|
|
@ -302,7 +301,6 @@ export default function PlexInfo({
|
|||
{cast.length > 0 && (
|
||||
<div
|
||||
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" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
|
|
@ -365,7 +363,6 @@ export default function PlexInfo({
|
|||
<div
|
||||
key={col.id}
|
||||
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">
|
||||
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>
|
||||
|
|
|
|||
|
|
@ -158,6 +158,35 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.appearance.backgroundImage")}
|
||||
hint={t("settings.appearance.backgroundImageHint")}
|
||||
>
|
||||
<Switch
|
||||
label={t("settings.appearance.backgroundImage")}
|
||||
checked={theme.bgImage}
|
||||
disabled={theme.mode !== "dark"}
|
||||
onChange={(v) => setTheme({ ...theme, bgImage: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
{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")}>
|
||||
<Switch
|
||||
label={t("settings.appearance.listView")}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@
|
|||
"colorScheme": "Farbschema",
|
||||
"display": "Anzeige",
|
||||
"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",
|
||||
"listViewHint": "Zeigt den Feed als kompakte Liste statt als Kartenraster an.",
|
||||
"performanceMode": "Leistungsmodus",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@
|
|||
"colorScheme": "Color scheme",
|
||||
"display": "Display",
|
||||
"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",
|
||||
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
|
||||
"performanceMode": "Performance mode",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@
|
|||
"colorScheme": "Színséma",
|
||||
"display": "Megjelenítés",
|
||||
"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",
|
||||
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
|
||||
"performanceMode": "Teljesítmény mód",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,64 @@
|
|||
|
||||
:root {
|
||||
--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. */
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue