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:
2026-09-24 04:33:13 +02:00
parent 9c4a999e54
commit ad136c3fe8
26 changed files with 326 additions and 851 deletions
-465
View File
@@ -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>
);
}
+3 -3
View File
@@ -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
-10
View File
@@ -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")}>