Files
siftlode/frontend/src/components/GlassTuner.tsx
T
peter fd1742e783 refactor(prefs): move Settings auto-save machinery into PrefsProvider
Lift the four editable Settings prefs (theme/perf/hints/notifications), their
live-apply effects, the debounced auto-save + baseline echo-guard, and the
server-adopt-on-login into PrefsProvider, exposing the PrefsController via
usePrefs(). SettingsPanel and GlassTuner read it from the hook instead of props;
App's big adopt effect keeps only the shell prefs (panel layouts, rail collapse,
language). App.tsx 655->489 lines. E2E 17/17 incl. settings auto-save. (Platform S6b.3)
2026-07-19 01:09:20 +02:00

372 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import Overlay from "./Overlay";
import { usePrefs } from "./PrefsProvider";
/**
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
* writes the glass CSS custom properties (index.css `:root`) inline on the document element for
* instant, build-free feedback, and persists to localStorage.
*
* Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy"
* for dialling in the surface values. "Copy CSS" exports the values.
*/
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() {
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
const enabled = usePrefs().theme.showTuner;
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);
const [themeTick, setThemeTick] = useState(0);
// 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);
}
// Re-read the LIVE, theme-driven glass values into the sliders (clearing inline overrides first),
// so the panel reflects whatever theme is active — switch to light, hit this, and the opacity
// sliders show the light-theme values (index.css `html[data-theme="light"]`) ready to tune.
function syncToTheme() {
ALL.forEach((s) => root().style.removeProperty(s.k));
const next: Record<string, number> = { ...vars };
ALL.forEach((s) => {
const n = parseFloat(readVar(s.k));
if (!Number.isNaN(n)) next[s.k] = n;
});
setVars(next);
persist({ vars: {} });
setThemeTick((t) => t + 1);
}
// Export the values that differ from the baseline, headered for the active theme's scope so a
// light-theme tune pastes into the right block.
const cssOut = useMemo(() => {
void themeTick; // re-run when the active theme is re-synced
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]};`);
});
const scope = root().dataset.theme === "light" ? 'html[data-theme="light"]' : ":root";
return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */";
}, [vars, palette, themeTick]);
function copy() {
navigator.clipboard?.writeText(cssOut).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
});
}
function persistOpen(v: boolean) {
setOpen(v);
persist({ open: v });
}
if (!enabled) return null;
return (
<Overlay>
<div className="fixed right-0 top-24 z-tuner 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"
>
<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">lab</span>
<button
onClick={() => persistOpen(false)}
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
title="Collapse"
>
×
</button>
</div>
<div className="overflow-y-auto px-3 py-3 flex flex-col gap-4 text-sm">
{/* Treatment presets */}
<div className="flex flex-col gap-1.5">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Dark treatment</div>
<div className="grid grid-cols-2 gap-1.5">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => applyPreset(p.over)}
title={p.hint}
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
>
<b className="block font-semibold">{p.name}</b>
<span className="text-muted text-[10px]">{p.hint}</span>
</button>
))}
</div>
</div>
{/* Tab switch */}
<div className="flex gap-1.5">
{(["glass", "palette"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
tab === t ? "bg-accent text-[color:var(--accent-fg)] font-semibold" : "glass-card text-muted"
}`}
>
{t}
</button>
))}
</div>
{tab === "glass" && (
<div className="flex flex-col gap-2.5">
{SLIDERS.map((s) => (
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
))}
</div>
)}
{tab === "palette" && (
<div className="flex flex-col gap-2">
<p className="text-[10px] text-muted leading-relaxed">
Overrides the live scheme (inline on &lt;html&gt;). Reset clears these back to the scheme.
</p>
{PALETTE.map((p) => (
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
<input
type="color"
value={swatches[p.k] || "#000000"}
onChange={(e) => setColor(p.k, e.target.value)}
className="w-8 h-6 rounded border border-border bg-transparent cursor-pointer"
/>
<span className="flex-1">{p.label}</span>
<code className="text-[10px] text-muted">{swatches[p.k]}</code>
</label>
))}
</div>
)}
{/* Export */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Export bake in</div>
<button
onClick={syncToTheme}
title="Load the values of the theme you're currently viewing (light or dark) into the sliders"
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-xs text-fg text-left"
>
Sync to current theme
</button>
<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>
</Overlay>
);
}
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>
);
}