import { useEffect, useMemo, useState } from "react"; import Overlay from "./ui/Overlay"; import { copyText } from "../lib/clipboard"; 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 }[] = [ { 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; palette?: Record; 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>(() => { const base: Record = {}; ALL.forEach((s) => (base[s.k] = s.def)); return { ...base, ...(initial.vars || {}) }; }); const [palette, setPalette] = useState>(() => initial.palette || {}); const [open, setOpen] = useState(() => 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 ). const [swatches, setSwatches] = useState>({}); // 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 = {}; 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) { 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) { const next: Record = { ...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 = {}; ALL.forEach((s) => (base[s.k] = s.def)); setVars(base); setPalette({}); localStorage.removeItem(LS_KEY); const sw: Record = {}; 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 = { ...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]); async function copy() { if (await copyText(cssOut)) { setCopied(true); setTimeout(() => setCopied(false), 1200); } } function persistOpen(v: boolean) { setOpen(v); persist({ open: v }); } if (!enabled) return null; return (
{!open && ( )} {open && (
◐ Glass Tuner lab
{/* Treatment presets */}
Dark treatment
{PRESETS.map((p) => ( ))}
{/* Tab switch */}
{(["glass", "palette"] as const).map((t) => ( ))}
{tab === "glass" && (
{SLIDERS.map((s) => ( setVar(s.k, v)} /> ))}
)} {tab === "palette" && (

Overrides the live scheme (inline on <html>). Reset clears these back to the scheme.

{PALETTE.map((p) => ( ))}
)} {/* Export */}
Export → bake in
                  {cssOut}
                
)}
); } function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) { return (
onChange(Number(e.target.value))} className="w-16 text-right text-2xs tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent" /> onChange(Number(e.target.value))} className="col-span-2 w-full accent-accent" />
); }