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:
@@ -32,7 +32,6 @@ import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import DebugRecorder from "./components/DebugRecorder";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
@@ -475,8 +474,6 @@ export default function App() {
|
||||
<ErrorDialog />
|
||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
||||
<GlassTuner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -21,17 +21,9 @@ import {
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { Btn } from "./batchA";
|
||||
import { hexContrast, hueGap } from "./labColor";
|
||||
import { hexContrast, hueGap } from "../lib/colorMath";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import {
|
||||
FADE_ABOVE_PANEL,
|
||||
FADE_BELOW,
|
||||
FADE_BELOW_PANEL,
|
||||
FADE_EDGES_X,
|
||||
FILLED,
|
||||
H,
|
||||
LAYER,
|
||||
} from "./labTokens";
|
||||
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
|
||||
|
||||
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
|
||||
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
|
||||
@@ -57,7 +49,7 @@ type SchemeDef = { name: string; dark: Pal; light: Pal };
|
||||
|
||||
const STATUS: Record<Mode, Record<"danger" | "dangerFg" | "warn" | "success", string>> = {
|
||||
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
|
||||
light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
|
||||
light: { danger: "#b91c1c", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
|
||||
};
|
||||
|
||||
const pal = (
|
||||
@@ -70,7 +62,8 @@ const pal = (
|
||||
accentFg: string,
|
||||
): Pal => ({ bg, surface, border, fg, muted, accent, accentFg });
|
||||
|
||||
// Backgrounds and text are today's (index.css); only the accents are new.
|
||||
// A literal copy of index.css's scheme tokens, so the four tiles render side by side (the live CSS
|
||||
// switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it.
|
||||
const SCHEMES: SchemeDef[] = [
|
||||
{
|
||||
name: "Midnight · blue",
|
||||
@@ -458,10 +451,7 @@ function Fades() {
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="4 · An overflowing chip row">
|
||||
<div
|
||||
className="overflow-x-auto no-scrollbar"
|
||||
style={{ maskImage: FADE_EDGES_X, WebkitMaskImage: FADE_EDGES_X }}
|
||||
>
|
||||
<div className="overflow-x-auto no-scrollbar fade-edges-x">
|
||||
<div className="flex w-max gap-1.5 px-6">
|
||||
{CHIPS.map((c) => (
|
||||
<span key={c} className="chip px-2.5 py-1 text-xs">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). They are
|
||||
// the lab's stand-ins until S3 moves each into index.css / the ui/ primitives; then these go.
|
||||
// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). The colour
|
||||
// rules are real now (E28 S3a): each constant below names the index.css utility or token that holds
|
||||
// it, so the lab renders what the app ships. The height ladder moves into ui/Button in S3b.
|
||||
|
||||
/** B1 compact ladder: explicit heights, so one toolbar row lines up at 28. */
|
||||
export const H = {
|
||||
@@ -12,25 +13,15 @@ export const H = {
|
||||
bar: "h-7",
|
||||
} as const;
|
||||
|
||||
/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode. */
|
||||
export const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
|
||||
/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */
|
||||
export const STRONG = "var(--edge-strong)";
|
||||
|
||||
/** B3 state layers: the text colour at 10% (hover) / 16% (pressed) over whatever is underneath;
|
||||
* a filled control mixes its own text colour into the fill at 14% / 24%. */
|
||||
export const LAYER =
|
||||
"hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]";
|
||||
export const FILLED =
|
||||
"bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]";
|
||||
export const DANGER =
|
||||
"bg-danger text-danger-fg hover:bg-[color-mix(in_srgb,var(--danger-fg)_14%,var(--danger))] active:bg-[color-mix(in_srgb,var(--danger-fg)_24%,var(--danger))]";
|
||||
/** B3 state layers (index.css utilities). */
|
||||
export const LAYER = "state-layer";
|
||||
export const FILLED = "state-filled";
|
||||
export const DANGER = "state-danger";
|
||||
|
||||
/** B9: the header + fade edge — a crisp band and a 1rem gradient of the colour it sits on. Literal
|
||||
* strings per colour: Tailwind only generates classes it can read whole. */
|
||||
export const FADE_BELOW =
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
|
||||
export const FADE_BELOW_PANEL =
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
|
||||
export const FADE_ABOVE_PANEL =
|
||||
"before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
|
||||
export const FADE_EDGES_X =
|
||||
"linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)";
|
||||
/** B9: the header + fade edge (index.css utilities; the host is positioned). */
|
||||
export const FADE_BELOW = "fade-below";
|
||||
export const FADE_BELOW_PANEL = "fade-below-panel";
|
||||
export const FADE_ABOVE_PANEL = "fade-above-panel";
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
||||
"showHints": "Show hints",
|
||||
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
||||
"showTuner": "Show the glass tuner",
|
||||
"showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
|
||||
"startup": "Startup",
|
||||
"startOnHome": "Start on Home",
|
||||
"startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
|
||||
@@ -249,60 +247,5 @@
|
||||
"clear": "Clear",
|
||||
"empty": "No event matches the filter.",
|
||||
"emptyAll": "No events yet."
|
||||
},
|
||||
"tuner": {
|
||||
"open": "Open the glass tuner",
|
||||
"tab": "◐ GLASS",
|
||||
"title": "◐ Glass Tuner",
|
||||
"lab": "lab",
|
||||
"collapse": "Collapse",
|
||||
"treatment": "Dark treatment",
|
||||
"tabs": {
|
||||
"glass": "Glass",
|
||||
"palette": "Palette"
|
||||
},
|
||||
"paletteHint": "Overrides the live scheme (inline on <html>). Reset clears these back to the scheme.",
|
||||
"export": "Export → bake in",
|
||||
"syncHint": "Load the values of the theme you're currently viewing (light or dark) into the sliders",
|
||||
"sync": "⟳ Sync to current theme",
|
||||
"copy": "Copy CSS",
|
||||
"copied": "Copied ✓",
|
||||
"reset": "Reset",
|
||||
"slider": {
|
||||
"blur": "Blur radius",
|
||||
"saturate": "Saturation",
|
||||
"darkBright": "Dark brightness",
|
||||
"surfaceAlpha": "Panel opacity",
|
||||
"cardAlpha": "Card opacity",
|
||||
"menuAlpha": "Menu opacity",
|
||||
"borderAlpha": "Border opacity",
|
||||
"edge": "Edge-light rim",
|
||||
"inset": "Top highlight",
|
||||
"scrim": "Under-text scrim",
|
||||
"ambient": "Ambient intensity"
|
||||
},
|
||||
"color": {
|
||||
"bg": "Background",
|
||||
"surface": "Surface",
|
||||
"card": "Card",
|
||||
"border": "Border",
|
||||
"fg": "Text",
|
||||
"muted": "Muted",
|
||||
"accent": "Accent"
|
||||
},
|
||||
"preset": {
|
||||
"current": {
|
||||
"name": "Glass/image",
|
||||
"hint": "The default (over image)"
|
||||
},
|
||||
"solid": {
|
||||
"name": "Solid (flat)",
|
||||
"hint": "As shown w/ image off"
|
||||
},
|
||||
"readable": {
|
||||
"name": "Max readable",
|
||||
"hint": "Solid + strong scrim"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
|
||||
"showHints": "Tippek megjelenítése",
|
||||
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
||||
"showTuner": "Glass tuner megjelenítése",
|
||||
"showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.",
|
||||
"startup": "Indulás",
|
||||
"startOnHome": "Indítás a kezdőlapon",
|
||||
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
|
||||
@@ -249,60 +247,5 @@
|
||||
"clear": "Ürítés",
|
||||
"empty": "Nincs a szűrésnek megfelelő esemény.",
|
||||
"emptyAll": "Még nincs esemény."
|
||||
},
|
||||
"tuner": {
|
||||
"open": "Glass tuner megnyitása",
|
||||
"tab": "◐ ÜVEG",
|
||||
"title": "◐ Glass tuner",
|
||||
"lab": "labor",
|
||||
"collapse": "Összecsukás",
|
||||
"treatment": "Sötét téma",
|
||||
"tabs": {
|
||||
"glass": "Üveg",
|
||||
"palette": "Paletta"
|
||||
},
|
||||
"paletteHint": "Felülírja az élő színsémát (inline a <html>-en). A Visszaállítás a séma színeit adja vissza.",
|
||||
"export": "Exportálás → beégetés",
|
||||
"syncHint": "A most látott téma (világos vagy sötét) értékeinek betöltése a csúszkákba",
|
||||
"sync": "⟳ Igazítás a jelenlegi témához",
|
||||
"copy": "CSS másolása",
|
||||
"copied": "Másolva ✓",
|
||||
"reset": "Visszaállítás",
|
||||
"slider": {
|
||||
"blur": "Elmosás sugara",
|
||||
"saturate": "Telítettség",
|
||||
"darkBright": "Sötét fényerő",
|
||||
"surfaceAlpha": "Panel fedése",
|
||||
"cardAlpha": "Kártya fedése",
|
||||
"menuAlpha": "Menü fedése",
|
||||
"borderAlpha": "Keret fedése",
|
||||
"edge": "Élfény-perem",
|
||||
"inset": "Felső csúcsfény",
|
||||
"scrim": "Szöveg alatti sötétítés",
|
||||
"ambient": "Környezeti intenzitás"
|
||||
},
|
||||
"color": {
|
||||
"bg": "Háttér",
|
||||
"surface": "Felület",
|
||||
"card": "Kártya",
|
||||
"border": "Keret",
|
||||
"fg": "Szöveg",
|
||||
"muted": "Halvány",
|
||||
"accent": "Kiemelés"
|
||||
},
|
||||
"preset": {
|
||||
"current": {
|
||||
"name": "Üveg/kép",
|
||||
"hint": "Az alapérték (kép fölött)"
|
||||
},
|
||||
"solid": {
|
||||
"name": "Tömör (lapos)",
|
||||
"hint": "Ahogy kép nélkül látszik"
|
||||
},
|
||||
"readable": {
|
||||
"name": "Legolvashatóbb",
|
||||
"hint": "Tömör + erős sötétítés"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
-95
@@ -46,7 +46,6 @@
|
||||
--z-index-overlay: 50;
|
||||
--z-index-popover: 60;
|
||||
--z-index-tooltip: 100;
|
||||
--z-index-tuner: 9999;
|
||||
}
|
||||
|
||||
/* Extra-small text steps in rem, so these labels scale with --font-scale like every other
|
||||
@@ -62,6 +61,90 @@
|
||||
--text-3xs: 0.625rem; /* 10px @ base */
|
||||
}
|
||||
|
||||
/* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design
|
||||
lab both read, so a decided value is never restated as an arbitrary class. ===== */
|
||||
|
||||
/* B3: a transparent control's hover / pressed layer. */
|
||||
@utility state-layer {
|
||||
&:hover {
|
||||
background-color: var(--hover);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--press);
|
||||
}
|
||||
}
|
||||
/* B3: a filled control mixes its own text colour into its fill (14% hover, 24% pressed). */
|
||||
@utility state-filled {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent));
|
||||
}
|
||||
&:active {
|
||||
background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent));
|
||||
}
|
||||
}
|
||||
@utility state-danger {
|
||||
background-color: var(--danger);
|
||||
color: var(--danger-fg);
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger));
|
||||
}
|
||||
&:active {
|
||||
background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger));
|
||||
}
|
||||
}
|
||||
/* A4: a control's ≥ 3:1 edge. */
|
||||
@utility edge-strong {
|
||||
border-color: var(--edge-strong);
|
||||
}
|
||||
|
||||
/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
|
||||
dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
|
||||
@utility fade-below {
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 100%;
|
||||
height: 1rem;
|
||||
background: linear-gradient(to bottom, var(--bg), transparent);
|
||||
}
|
||||
}
|
||||
@utility fade-below-panel {
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 100%;
|
||||
height: 1rem;
|
||||
background: linear-gradient(to bottom, var(--surface), transparent);
|
||||
}
|
||||
}
|
||||
@utility fade-above-panel {
|
||||
&::before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 100%;
|
||||
height: 1rem;
|
||||
background: linear-gradient(to top, var(--surface), transparent);
|
||||
}
|
||||
}
|
||||
/* B9: the clipped ends of an overflowing chip row. */
|
||||
@utility fade-edges-x {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 1.75rem,
|
||||
#000 calc(100% - 1.75rem),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
@@ -225,22 +308,6 @@ html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body {
|
||||
),
|
||||
url("/backdrops/slate.svg");
|
||||
}
|
||||
html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
|
||||
),
|
||||
url("/backdrops/youtube.svg");
|
||||
}
|
||||
html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
|
||||
),
|
||||
url("/backdrops/starship.svg");
|
||||
}
|
||||
html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
@@ -273,22 +340,6 @@ html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body {
|
||||
),
|
||||
url("/backdrops/light/slate.svg");
|
||||
}
|
||||
html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
|
||||
),
|
||||
url("/backdrops/light/youtube.svg");
|
||||
}
|
||||
html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
|
||||
),
|
||||
url("/backdrops/light/starship.svg");
|
||||
}
|
||||
html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
@@ -320,11 +371,17 @@ html {
|
||||
jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */
|
||||
font-size: round(calc(16px * var(--font-scale)), 1px);
|
||||
background: var(--bg);
|
||||
/* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`),
|
||||
so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens
|
||||
on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every
|
||||
hoverable row (the .selectable recipe + DataTable rows). */
|
||||
--hover: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
/* State layers (docs/DESIGN.md B3): hover and pressed are the text colour laid over whatever the
|
||||
control sits on — the page, a card or glass — so they read in every scheme and theme (it lightens
|
||||
on dark, darkens on light). Never an opacity dim or a bg-card swap (invisible on a light card).
|
||||
Applied through the state-layer / state-filled / state-danger utilities below. */
|
||||
--hover: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
--press: color-mix(in srgb, var(--fg) 16%, transparent);
|
||||
/* A control's edge (DESIGN.md A4): ≥ 3:1 against the page in every scheme and mode, where --border
|
||||
(a decorative divider) is not. theme.contract.test.ts holds the ratio. */
|
||||
--edge-strong: color-mix(in srgb, var(--fg) 55%, var(--bg));
|
||||
/* The coarse-pointer target (DESIGN.md A3): one token, read by .touch-target and the primitives. */
|
||||
--touch-target: 2.75rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -598,8 +655,8 @@ body {
|
||||
would be the one control that ignores the user's text-size setting. */
|
||||
@media (pointer: coarse) {
|
||||
.touch-target {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
min-width: var(--touch-target);
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,7 +915,7 @@ html[data-scheme="midnight"][data-theme="light"] {
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
/* Forest — dark slate with teal/green accent */
|
||||
/* Forest — dark green-slate with a cyan accent (E28 B2: teal read as success) */
|
||||
html[data-scheme="forest"][data-theme="dark"] {
|
||||
--bg: #0a1512;
|
||||
--surface: #0f1f1a;
|
||||
@@ -866,7 +923,7 @@ html[data-scheme="forest"][data-theme="dark"] {
|
||||
--border: #1f3a30;
|
||||
--fg: #e6f0ec;
|
||||
--muted: #90b1a4;
|
||||
--accent: #2dd4bf;
|
||||
--accent: #22d3ee;
|
||||
--accent-fg: #04110d;
|
||||
}
|
||||
html[data-scheme="forest"][data-theme="light"] {
|
||||
@@ -876,11 +933,11 @@ html[data-scheme="forest"][data-theme="light"] {
|
||||
--border: #d6e7e0;
|
||||
--fg: #102a22;
|
||||
--muted: #4d6b60;
|
||||
--accent: #0d9488;
|
||||
--accent: #0e7490;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
/* Slate — neutral grey with warm orange accent (muted in dark) */
|
||||
/* Slate — neutral grey with a fuchsia accent (E28 B2: orange read as warn) */
|
||||
html[data-scheme="slate"][data-theme="dark"] {
|
||||
--bg: #15171c;
|
||||
--surface: #1b1e25;
|
||||
@@ -888,8 +945,8 @@ html[data-scheme="slate"][data-theme="dark"] {
|
||||
--border: #2c313b;
|
||||
--fg: #e7e9ee;
|
||||
--muted: #9aa1ad;
|
||||
--accent: #e8913c;
|
||||
--accent-fg: #1a1206;
|
||||
--accent: #e879f9;
|
||||
--accent-fg: #1a0620;
|
||||
}
|
||||
html[data-scheme="slate"][data-theme="light"] {
|
||||
--bg: #f6f7f9;
|
||||
@@ -898,55 +955,11 @@ html[data-scheme="slate"][data-theme="light"] {
|
||||
--border: #e3e6eb;
|
||||
--fg: #1c1f26;
|
||||
--muted: #5d636e;
|
||||
--accent: #d97706;
|
||||
--accent: #a21caf;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
/* YouTube — near-black with red accent */
|
||||
html[data-scheme="youtube"][data-theme="dark"] {
|
||||
--bg: #0f0f0f;
|
||||
--surface: #181818;
|
||||
--card: #1f1f1f;
|
||||
--border: #303030;
|
||||
--fg: #f1f1f1;
|
||||
--muted: #aaaaaa;
|
||||
--accent: #ff3b46;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
html[data-scheme="youtube"][data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--surface: #f9f9f9;
|
||||
--card: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--fg: #0f0f0f;
|
||||
--muted: #606060;
|
||||
--accent: #ff0033;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */
|
||||
html[data-scheme="starship"][data-theme="dark"] {
|
||||
--bg: #050b16;
|
||||
--surface: #0b1728;
|
||||
--card: #101f36;
|
||||
--border: #1f3557;
|
||||
--fg: #dfeaff;
|
||||
--muted: #8aa6cc;
|
||||
--accent: #38b6ff;
|
||||
--accent-fg: #04101f;
|
||||
}
|
||||
html[data-scheme="starship"][data-theme="light"] {
|
||||
--bg: #eef4fb;
|
||||
--surface: #ffffff;
|
||||
--card: #ffffff;
|
||||
--border: #d3e0f0;
|
||||
--fg: #0b1b30;
|
||||
--muted: #4a5f7d;
|
||||
--accent: #0b6bd6;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */
|
||||
/* Matrix — near-black with a lime accent (E28 B2: the terminal green read as success) */
|
||||
html[data-scheme="matrix"][data-theme="dark"] {
|
||||
--bg: #030a06;
|
||||
--surface: #08130d;
|
||||
@@ -954,7 +967,7 @@ html[data-scheme="matrix"][data-theme="dark"] {
|
||||
--border: #163d27;
|
||||
--fg: #cfe8d6;
|
||||
--muted: #6fae86;
|
||||
--accent: #46b87c;
|
||||
--accent: #a3e635;
|
||||
--accent-fg: #04170c;
|
||||
}
|
||||
html[data-scheme="matrix"][data-theme="light"] {
|
||||
@@ -964,7 +977,7 @@ html[data-scheme="matrix"][data-theme="light"] {
|
||||
--border: #cfe6d6;
|
||||
--fg: #08130b;
|
||||
--muted: #4c6b56;
|
||||
--accent: #12a150;
|
||||
--accent: #4d7c0f;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
@@ -986,7 +999,9 @@ html[data-theme="dark"] {
|
||||
--info-fg: #08172e;
|
||||
}
|
||||
html[data-theme="light"] {
|
||||
--danger: #dc2626;
|
||||
/* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted
|
||||
light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */
|
||||
--danger: #b91c1c;
|
||||
--danger-fg: #ffffff;
|
||||
--warn: #b45309;
|
||||
--warn-fg: #ffffff;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── Fixed-palette maths ──────────────────────────────────────────────────────────────────────────
|
||||
// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on
|
||||
// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far
|
||||
// apart, unlike HSL's.
|
||||
// WCAG contrast and OKLCH hue distance on literal hex colours. The design constitution's colour rules
|
||||
// (docs/DESIGN.md, Tokens) are measured with these: theme.contract.test.ts runs them over every scheme
|
||||
// in index.css, and the design lab's scheme tiles render the same numbers. OKLCH, because equal hue
|
||||
// angles there look equally far apart, unlike HSL's.
|
||||
|
||||
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
||||
@@ -31,3 +31,13 @@ export function hueGap(a: string, b: string): number {
|
||||
const d = Math.abs(hue(a) - hue(b)) % 360;
|
||||
return Math.min(d, 360 - d);
|
||||
}
|
||||
|
||||
/** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the
|
||||
* browser does in the srgb space. */
|
||||
export function mixHex(a: string, b: string, w: number): string {
|
||||
const [x, y] = [rgb(a), rgb(b)];
|
||||
return `#${x
|
||||
.map((v, i) => Math.round((v * w + (y[i] ?? 0) * (1 - w)) * 255))
|
||||
.map((n) => n.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hexContrast, hueGap, mixHex } from "./colorMath";
|
||||
import { SCHEMES } from "./theme";
|
||||
|
||||
// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css:
|
||||
// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on
|
||||
// an accent fill [WCAG 1.4.3];
|
||||
// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11];
|
||||
// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never
|
||||
// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did).
|
||||
// The status colours are text too, so they are held to 4.5:1 on every scheme's page as well.
|
||||
// A new scheme, or a retuned colour, has to pass this before it ships.
|
||||
|
||||
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
type Mode = "dark" | "light";
|
||||
const MODES: Mode[] = ["dark", "light"];
|
||||
|
||||
/** The `--name: #hex;` declarations of every block whose selector is exactly `selector`, merged in
|
||||
* source order (the same selector also carries non-colour blocks, e.g. `color-scheme`). */
|
||||
function block(selector: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (let at = css.indexOf(`${selector} {`); at >= 0; at = css.indexOf(`${selector} {`, at + 1)) {
|
||||
const body = css.slice(at, css.indexOf("}", at));
|
||||
for (const m of body.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) out[m[1]!] = m[2]!;
|
||||
}
|
||||
expect(Object.keys(out), `no colour tokens for ${selector} in index.css`).not.toHaveLength(0);
|
||||
return out;
|
||||
}
|
||||
|
||||
const status = (mode: Mode) => block(`html[data-theme="${mode}"]`);
|
||||
const scheme = (id: string, mode: Mode) => block(`html[data-scheme="${id}"][data-theme="${mode}"]`);
|
||||
|
||||
it("styles exactly the schemes the app offers", () => {
|
||||
const inCss = new Set(
|
||||
[...css.matchAll(/html\[data-scheme="(\w+)"\]\[data-theme="dark"\]/g)].map((m) => m[1]),
|
||||
);
|
||||
expect([...inCss].sort()).toEqual(SCHEMES.map((s) => s.id).sort());
|
||||
// The edge token is defined the way this test measures it.
|
||||
expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/);
|
||||
});
|
||||
|
||||
describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
|
||||
it.each(MODES)("%s: backdrop rule and file", (mode) => {
|
||||
const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
|
||||
expect(css).toContain(`url("/${file}")`);
|
||||
expect(existsSync(new URL(`../../public/${file}`, import.meta.url)), file).toBe(true);
|
||||
});
|
||||
|
||||
it.each(MODES)("%s: text and control contrast", (mode) => {
|
||||
const t = scheme(id, mode);
|
||||
const s = status(mode);
|
||||
const bg = t.bg!;
|
||||
const measured = {
|
||||
fg: hexContrast(t.fg!, bg),
|
||||
muted: hexContrast(t.muted!, bg),
|
||||
accentText: hexContrast(t.accent!, bg),
|
||||
onAccent: hexContrast(t["accent-fg"]!, t.accent!),
|
||||
edge: hexContrast(mixHex(t.fg!, bg, 0.55), bg),
|
||||
danger: hexContrast(s.danger!, bg),
|
||||
warn: hexContrast(s.warn!, bg),
|
||||
success: hexContrast(s.success!, bg),
|
||||
};
|
||||
const failing = Object.entries(measured).filter(([k, v]) => v < (k === "edge" ? 3 : 4.5));
|
||||
expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(MODES)("%s: the accent never looks like a status", (mode) => {
|
||||
const accent = scheme(id, mode).accent!;
|
||||
const s = status(mode);
|
||||
const close = (["danger", "warn", "success"] as const)
|
||||
.map((k) => [k, hueGap(accent, s[k]!)] as const)
|
||||
.filter(([, gap]) => gap < 30);
|
||||
expect(close.map(([k, gap]) => `${k} ${gap.toFixed(0)}°`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { DEFAULT_THEME, normalizeTheme } from "./theme";
|
||||
|
||||
// YouTube and Starship were retired in E28 with no data migration (owner, 2026-09-24): an account or
|
||||
// a browser that still stores one must land on the default scheme, not on an html[data-scheme] that no
|
||||
// CSS block styles (an unstyled page), and the retired tuner flag must not ride along into the next save.
|
||||
it("maps a retired scheme to the default and keeps only the known keys", () => {
|
||||
const t = normalizeTheme({
|
||||
mode: "light",
|
||||
scheme: "youtube",
|
||||
fontScale: 1.1,
|
||||
bgImage: false,
|
||||
bgFade: 35,
|
||||
...{ showTuner: true },
|
||||
});
|
||||
expect(t).toEqual({
|
||||
mode: "light",
|
||||
scheme: "midnight",
|
||||
fontScale: 1.1,
|
||||
bgImage: false,
|
||||
bgFade: 35,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a live scheme and fills what is missing or malformed from the defaults", () => {
|
||||
expect(normalizeTheme({ scheme: "matrix", mode: "sepia", fontScale: Number.NaN })).toEqual({
|
||||
...DEFAULT_THEME,
|
||||
scheme: "matrix",
|
||||
});
|
||||
});
|
||||
+25
-11
@@ -1,15 +1,16 @@
|
||||
import { LS, readAccountMerged, writeAccount } from "./storage";
|
||||
|
||||
type Mode = "dark" | "light";
|
||||
export type Scheme = "midnight" | "forest" | "slate" | "youtube" | "starship" | "matrix";
|
||||
export type Scheme = "midnight" | "forest" | "slate" | "matrix";
|
||||
|
||||
// The scheme set (docs/DESIGN.md, decision B2): each earns its place with its own hue family, and its
|
||||
// accent stays ≥ 30° of OKLCH hue away from danger / warn / success. theme.contract.test.ts measures
|
||||
// every scheme's tokens in index.css against that rule and WCAG contrast.
|
||||
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
|
||||
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
|
||||
{ id: "forest", name: "Forest", swatch: "#2dd4bf" },
|
||||
{ id: "slate", name: "Slate", swatch: "#e8913c" },
|
||||
{ id: "youtube", name: "YouTube", swatch: "#ff3b46" },
|
||||
{ id: "starship", name: "Starship", swatch: "#38b6ff" },
|
||||
{ id: "matrix", name: "Matrix", swatch: "#46b87c" },
|
||||
{ id: "forest", name: "Forest", swatch: "#22d3ee" },
|
||||
{ id: "slate", name: "Slate", swatch: "#e879f9" },
|
||||
{ id: "matrix", name: "Matrix", swatch: "#a3e635" },
|
||||
];
|
||||
|
||||
export interface ThemePrefs {
|
||||
@@ -20,8 +21,6 @@ export interface ThemePrefs {
|
||||
bgImage: boolean;
|
||||
/** How strongly the background image is faded out, 0–100% (higher = fainter). */
|
||||
bgFade: number;
|
||||
/** Show the floating glass/theme tuner (a secondary "geek toy"); off by default. */
|
||||
showTuner: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME: ThemePrefs = {
|
||||
@@ -30,19 +29,34 @@ export const DEFAULT_THEME: ThemePrefs = {
|
||||
fontScale: 1.06,
|
||||
bgImage: true,
|
||||
bgFade: 60,
|
||||
showTuner: false,
|
||||
};
|
||||
|
||||
/** A stored theme (this browser's copy or the account's server prefs) as the app can apply it: only
|
||||
* the known keys, and a scheme or mode that no longer exists (YouTube and Starship were retired in
|
||||
* E28) falls back to the default instead of painting an unstyled page. */
|
||||
export function normalizeTheme(raw: Partial<Record<keyof ThemePrefs, unknown>>): ThemePrefs {
|
||||
const d = DEFAULT_THEME;
|
||||
const num = (v: unknown, fallback: number) =>
|
||||
typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||||
return {
|
||||
mode: raw.mode === "light" || raw.mode === "dark" ? raw.mode : d.mode,
|
||||
scheme: SCHEMES.some((s) => s.id === raw.scheme) ? (raw.scheme as Scheme) : d.scheme,
|
||||
fontScale: num(raw.fontScale, d.fontScale),
|
||||
bgImage: typeof raw.bgImage === "boolean" ? raw.bgImage : d.bgImage,
|
||||
bgFade: num(raw.bgFade, d.bgFade),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTheme(t: ThemePrefs): void {
|
||||
const el = document.documentElement;
|
||||
el.dataset.theme = t.mode;
|
||||
el.dataset.scheme = t.scheme;
|
||||
el.style.setProperty("--font-scale", String(t.fontScale));
|
||||
if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`);
|
||||
el.style.setProperty("--bg-fade", `${t.bgFade}%`);
|
||||
}
|
||||
|
||||
export function loadLocalTheme(): ThemePrefs {
|
||||
return readAccountMerged(LS.theme, DEFAULT_THEME);
|
||||
return normalizeTheme(readAccountMerged(LS.theme, DEFAULT_THEME));
|
||||
}
|
||||
|
||||
export function saveLocalTheme(t: ThemePrefs): void {
|
||||
|
||||
Reference in New Issue
Block a user