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)
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
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 <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>
|
||
<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>
|
||
);
|
||
}
|