feat(glass): variable-drive the glass system + dev GlassTuner (Phase 0/0b)

Foundation for the app-wide glass-consistency epic:
- index.css: every look-driving value (blur/saturate/dark-brightness/panel-card-
  menu opacity/border/inset/edge/scrim/ambient + mosaic) is now a CSS custom
  property defaulting to the historical hard-coded value — one-place tunable, no
  visual change until a value is bumped.
- GlassTuner.tsx: dev-only (localhost-gated) always-on corner panel that live-
  drives those vars, with treatment presets (Adaptive/Gradient+/Edge+scrim),
  per-scheme palette editor, muted thumbnail-mosaic toggle, localStorage persist,
  and Copy-CSS export. Deleted once the tuned finals are baked in.
- MosaicBackdrop.tsx: opt-in muted (blur+darken+desaturate) mosaic of on-screen
  thumbnails so dark glass has real content to refract.
This commit is contained in:
npeter83 2026-07-12 19:14:40 +02:00
parent 59aa727715
commit 22cce807a0
4 changed files with 520 additions and 20 deletions

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";
@ -903,6 +904,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,374 @@
import { useEffect, useMemo, useState } from "react";
import MosaicBackdrop from "./MosaicBackdrop";
/**
* 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;
};
const SLIDERS: Slider[] = [
{ k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 30 },
{ k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.8 },
{ k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.4 },
{ k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 78 },
{ k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 84 },
{ k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 92 },
{ k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 78 },
{ k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 0 },
{ k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 16 },
{ k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 0 },
{ k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1 },
];
const MOSAIC_SLIDERS: Slider[] = [
{ k: "--mosaic-blur", label: "Mosaic blur", min: 20, max: 100, step: 1, unit: "px", def: 64 },
{ k: "--mosaic-sat", label: "Mosaic saturation", min: 0, max: 1.5, step: 0.05, unit: "", def: 0.7 },
{ k: "--mosaic-bright", label: "Mosaic brightness", min: 0.2, max: 1, step: 0.05, unit: "", def: 0.5 },
{ k: "--mosaic-opacity", label: "Mosaic opacity", min: 0, max: 1, step: 0.05, unit: "", def: 0.5 },
];
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: "Current", hint: "The shipped defaults", over: {} },
{
id: "adaptive",
name: "Adaptive (rec.)",
hint: "Solid-enough dark glass",
over: {
"--glass-surface-alpha": 94,
"--glass-card-alpha": 94,
"--glass-menu-alpha": 98,
"--glass-scrim": 14,
"--glass-edge": 10,
"--ambient": 1.1,
"--glass-dark-bright": 1.25,
"--glass-blur": 20,
},
},
{ id: "gradient", name: "Gradient+", hint: "Stronger accent pools", over: { "--ambient": 1.6, "--glass-scrim": 6 } },
{
id: "wcag",
name: "Edge + scrim",
hint: "Max readability",
over: { "--glass-edge": 42, "--glass-scrim": 45, "--glass-surface-alpha": 84, "--glass-card-alpha": 90 },
},
];
const ALL = [...SLIDERS, ...MOSAIC_SLIDERS];
const root = () => document.documentElement;
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
type Saved = {
vars?: Record<string, number>;
palette?: Record<string, string>;
mosaic?: boolean;
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 [mosaic, setMosaic] = useState<boolean>(() => Boolean(initial.mosaic));
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 toggleMosaic(v: boolean) {
setMosaic(v);
persist({ mosaic: v });
}
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({});
toggleMosaic(false);
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 (
<>
{mosaic && <MosaicBackdrop />}
<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>
{/* Mosaic toggle */}
<label className="flex items-center gap-2 text-xs text-fg cursor-pointer">
<input type="checkbox" checked={mosaic} onChange={(e) => toggleMosaic(e.target.checked)} className="accent-accent" />
Thumbnail-mosaic backdrop <span className="text-muted">(muted)</span>
</label>
{/* 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)} />
))}
{mosaic && (
<div className="flex flex-col gap-2.5 pt-1 border-t border-border/50">
{MOSAIC_SLIDERS.map((s) => (
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
))}
</div>
)}
</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

@ -0,0 +1,58 @@
import { useEffect, useMemo, useState } from "react";
/**
* DEV-ONLY, OPT-IN backdrop: a heavily blurred + darkened + desaturated mosaic of the real
* thumbnails currently on screen, so dark "glass" surfaces have actual content to refract.
* Deliberately muted (see the --mosaic-* vars in index.css) a soft texture, never a collage.
*
* Thumbnails are sampled straight from the rendered DOM (the feed/channel image tags), so it
* needs no data wiring and works on whatever page mounts it. Looks richest on the feed; on a
* thumbnail-less page it simply falls back to the plain background.
*
* Toggled by the GlassTuner. Not shipped to prod (the tuner that mounts it is localhost-gated).
*/
export default function MosaicBackdrop({ cols = 8, rows = 6 }: { cols?: number; rows?: number }) {
const [srcs, setSrcs] = useState<string[]>([]);
// Snapshot the on-screen thumbnails once per mount (toggling the tuner off/on re-samples on a
// content-rich page). Looks richest on the feed; elsewhere it just falls back to the plain bg.
useEffect(() => {
const found = new Set<string>();
document.querySelectorAll<HTMLImageElement>("img").forEach((img) => {
const s = img.currentSrc || img.src;
if (!s) return;
// YouTube thumbnails / channel avatars — skip our own tiny UI icons/data URIs.
if (/ytimg\.com|ggpht\.com|googleusercontent\.com/.test(s)) found.add(s);
});
setSrcs([...found]);
}, []);
useEffect(() => {
document.documentElement.dataset.mosaic = "1";
return () => {
delete document.documentElement.dataset.mosaic;
};
}, []);
const tiles = useMemo(() => {
const n = cols * rows;
if (srcs.length === 0) return [];
return Array.from({ length: n }, (_, i) => srcs[i % srcs.length]);
}, [srcs, cols, rows]);
return (
<div className="mosaic-backdrop" aria-hidden="true">
<div
className="mosaic-tiles"
style={{
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
}}
>
{tiles.map((src, i) => (
<span key={i} style={{ backgroundImage: `url("${src}")` }} />
))}
</div>
</div>
);
}

View file

@ -4,6 +4,29 @@
: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). Defaults below equal
the historical hard-coded values, so nothing changes until a value is bumped. When the user
signs off on tuned values, bake the finals right here. ===== */
--glass-blur: 30px; /* backdrop blur radius (2026 sweet spot is ~816px; 30 is heavy) */
--glass-saturate: 1.8; /* backdrop saturation */
--glass-dark-bright: 1.4; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */
--glass-surface-alpha: 78%; /* .glass panel translucency (higher = more solid = "adaptive") */
--glass-card-alpha: 84%; /* .glass-card translucency */
--glass-menu-alpha: 92%; /* .glass-menu translucency */
--glass-border-alpha: 78%; /* .glass border opacity */
--glass-inset: 16%; /* top specular highlight strength (#fff N%) */
--glass-edge: 0%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */
--glass-scrim: 0%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */
--ambient: 1; /* multiplier on the ambient accent backdrop pools */
/* Optional muted thumbnail-mosaic backdrop (off unless <MosaicBackdrop/> mounts). Conservative
defaults: heavy blur + low saturation + darkened so it reads as subtle texture, never a rainbow. */
--mosaic-blur: 64px;
--mosaic-sat: 0.7;
--mosaic-bright: 0.5;
--mosaic-opacity: 0.5;
} }
/* Make native controls (date picker, scrollbars) follow the theme. */ /* Make native controls (date picker, scrollbars) follow the theme. */
@ -26,9 +49,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 +60,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 +113,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 +123,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
@ -100,6 +133,37 @@ html[data-theme="dark"] .toast-card {
border-color: color-mix(in srgb, #fff 50%, transparent); border-color: color-mix(in srgb, #fff 50%, transparent);
} }
/* ===== Optional muted thumbnail-mosaic backdrop (mounted by <MosaicBackdrop/>) =====
Gives dark glass real content to refract. Kept conservative: heavy blur + low saturation +
darkened, so it reads as a soft texture behind everything, never a colourful collage. When it
is present, the ambient accent pools on <body> are dimmed so the two don't fight. */
.mosaic-backdrop {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.mosaic-backdrop .mosaic-tiles {
position: absolute;
inset: -10%;
display: grid;
gap: 0;
filter: blur(var(--mosaic-blur)) saturate(var(--mosaic-sat)) brightness(var(--mosaic-bright));
opacity: var(--mosaic-opacity);
}
.mosaic-backdrop .mosaic-tiles > span {
display: block;
background-size: cover;
background-position: center;
}
html[data-mosaic="1"] body {
background: var(--bg);
}
html[data-perf="1"] .mosaic-backdrop {
display: none;
}
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */ /* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
html[data-perf="1"] .glass, html[data-perf="1"] .glass,
html[data-perf="1"] .glass-menu, html[data-perf="1"] .glass-menu,