feat(design): the scheme set, the colour contract and the token utilities (E28 S3a)
Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube and Starship retired with no data migration: normalizeTheme maps a stored retired scheme to Midnight. A vitest contract measures every scheme x mode (text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap); it caught light-mode danger at 4.43:1, now red-700. The state layers, the strong edge, the touch target and the fades are index.css tokens and utilities the lab now reads. Backdrops recoloured to the new accents, with a grey overlay grain that actually breaks the banding (longest flat run 15-26px -> 4-8px). The glass tuner is retired.
This commit is contained in:
@@ -1,465 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { usePrefs } from "./prefsContext";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */
|
||||
id: 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", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 },
|
||||
{ k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
|
||||
{
|
||||
k: "--glass-dark-bright",
|
||||
id: "darkBright",
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
unit: "",
|
||||
def: 1.2,
|
||||
},
|
||||
{
|
||||
k: "--glass-surface-alpha",
|
||||
id: "surfaceAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 50,
|
||||
},
|
||||
{
|
||||
k: "--glass-card-alpha",
|
||||
id: "cardAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 60,
|
||||
},
|
||||
{
|
||||
k: "--glass-menu-alpha",
|
||||
id: "menuAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 66,
|
||||
},
|
||||
{
|
||||
k: "--glass-border-alpha",
|
||||
id: "borderAlpha",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 80,
|
||||
},
|
||||
{ k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 },
|
||||
{ k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 },
|
||||
{ k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
|
||||
{ k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 },
|
||||
];
|
||||
|
||||
const PALETTE: { k: string; id: string }[] = [
|
||||
{ k: "--bg", id: "bg" },
|
||||
{ k: "--surface", id: "surface" },
|
||||
{ k: "--card", id: "card" },
|
||||
{ k: "--border", id: "border" },
|
||||
{ k: "--fg", id: "fg" },
|
||||
{ k: "--muted", id: "muted" },
|
||||
{ k: "--accent", id: "accent" },
|
||||
];
|
||||
|
||||
// Each preset resets every slider to its default, then applies these overrides.
|
||||
// Named under `settings.tuner.preset.<id>`.
|
||||
const PRESETS: { id: string; over: Record<string, number> }[] = [
|
||||
{ id: "current", over: {} },
|
||||
{
|
||||
id: "solid",
|
||||
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",
|
||||
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 { t } = useTranslation();
|
||||
// Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list:
|
||||
// a memo is a caching hint React may discard and recompute, while "the saved values as they were
|
||||
// when this mounted" is state — and the effect below applies them exactly once.
|
||||
const [initial] = useState(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";
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit.
|
||||
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]);
|
||||
|
||||
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 (
|
||||
<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={t("settings.tuner.open")}
|
||||
>
|
||||
<span style={{ writingMode: "vertical-rl" }}>{t("settings.tuner.tab")}</span>
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div
|
||||
style={{ pointerEvents: "auto" }}
|
||||
className="glass-menu rounded-l-2xl rounded-r-none w-75 max-h-[82vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<span className="text-sm font-semibold text-fg">{t("settings.tuner.title")}</span>
|
||||
<span className="text-3xs uppercase tracking-wider text-muted">
|
||||
{t("settings.tuner.lab")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => persistOpen(false)}
|
||||
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
|
||||
title={t("settings.tuner.collapse")}
|
||||
aria-label={t("settings.tuner.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-3xs uppercase tracking-wider text-muted font-semibold">
|
||||
{t("settings.tuner.treatment")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => applyPreset(p.over)}
|
||||
title={t(`settings.tuner.preset.${p.id}.hint`)}
|
||||
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
|
||||
>
|
||||
<b className="block font-semibold">
|
||||
{t(`settings.tuner.preset.${p.id}.name`)}
|
||||
</b>
|
||||
<span className="text-muted text-3xs">
|
||||
{t(`settings.tuner.preset.${p.id}.hint`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab switch */}
|
||||
<div className="flex gap-1.5">
|
||||
{(["glass", "palette"] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
aria-pressed={tab === id}
|
||||
className={`flex-1 rounded-lg px-2 py-1 text-xs ${
|
||||
tab === id
|
||||
? "bg-accent text-(--accent-fg) font-semibold"
|
||||
: "glass-card text-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`settings.tuner.tabs.${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "glass" && (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{SLIDERS.map((s) => (
|
||||
<Ctl
|
||||
key={s.k}
|
||||
s={s}
|
||||
label={t(`settings.tuner.slider.${s.id}`)}
|
||||
value={vars[s.k] ?? s.def}
|
||||
onChange={(v) => setVar(s.k, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "palette" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-3xs text-muted leading-relaxed">
|
||||
{t("settings.tuner.paletteHint")}
|
||||
</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-sm border border-border bg-transparent cursor-pointer"
|
||||
/>
|
||||
<span className="flex-1">{t(`settings.tuner.color.${p.id}`)}</span>
|
||||
<code className="text-3xs 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-3xs uppercase tracking-wider text-muted font-semibold">
|
||||
{t("settings.tuner.export")}
|
||||
</div>
|
||||
<button
|
||||
onClick={syncToTheme}
|
||||
title={t("settings.tuner.syncHint")}
|
||||
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-xs text-fg text-left"
|
||||
>
|
||||
{t("settings.tuner.sync")}
|
||||
</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-(--accent-fg)"
|
||||
>
|
||||
{copied ? t("settings.tuner.copied") : t("settings.tuner.copy")}
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg"
|
||||
>
|
||||
{t("settings.tuner.reset")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.59375rem] leading-relaxed bg-(--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,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
s: Slider;
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
// The label names the number box, and the range beside it says the same thing: both controls
|
||||
// are one setting, and the label was not tied to either before (a screen reader read "spin
|
||||
// button" and "slider" with no name at all).
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-xs text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
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-2xs tabular-nums rounded-sm 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))}
|
||||
aria-label={label}
|
||||
className="col-span-2 w-full accent-accent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import {
|
||||
applyTheme,
|
||||
DEFAULT_THEME,
|
||||
loadLocalTheme,
|
||||
normalizeTheme,
|
||||
saveLocalTheme,
|
||||
type ThemePrefs,
|
||||
} from "../lib/theme";
|
||||
@@ -28,7 +28,7 @@ const PERF_KEY = LS.perfMode;
|
||||
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
|
||||
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
|
||||
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
|
||||
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs() (prefsContext.ts).
|
||||
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
|
||||
type EditablePrefs = {
|
||||
theme: ThemePrefs;
|
||||
performanceMode: boolean;
|
||||
@@ -44,7 +44,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
||||
const [seed] = useState<EditablePrefs>(() => {
|
||||
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
|
||||
return {
|
||||
theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||
theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
|
||||
performanceMode:
|
||||
typeof prefs?.performanceMode === "boolean"
|
||||
? prefs.performanceMode
|
||||
|
||||
@@ -213,16 +213,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||
>
|
||||
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.appearance.showTuner")}
|
||||
hint={t("settings.appearance.showTunerHint")}
|
||||
>
|
||||
<Switch
|
||||
label={t("settings.appearance.showTuner")}
|
||||
checked={theme.showTuner}
|
||||
onChange={(v) => setTheme({ ...theme, showTuner: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t("settings.appearance.startup")}>
|
||||
|
||||
Reference in New Issue
Block a user