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:
parent
59aa727715
commit
22cce807a0
4 changed files with 520 additions and 20 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
374
frontend/src/components/GlassTuner.tsx
Normal file
374
frontend/src/components/GlassTuner.tsx
Normal 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 <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/MosaicBackdrop.tsx
Normal file
58
frontend/src/components/MosaicBackdrop.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 ~8–16px; 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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue