feat(appearance): add theme, accent, contrast, and typography personalization (#1307)

* feat(appearance): add theme, accent, contrast, and typography personalization

Expand Settings to Appearance into a full personalization surface and add a
quick switcher to the top bar (the palette button between search and
notifications). Choices are saved to the browser, sync across tabs, and apply
before first paint so there is no flash on reload.

- Themes: Dim (the default raised charcoal), OLED true black, Light, and Auto
  (follows the OS and re-resolves live when it flips).
- Accent: an eight-hue wheel (cyan default) that drives the one data color
  across charts, rails, focus rings, active states, and the ambient glow.
- Fine-tune sliders: a master Contrast that spreads page, ink, and borders
  together, plus Border brightness and Ambient glow.
- Typography: swappable interface (Geist / IBM Plex Sans / Hanken Grotesk) and
  data (Geist Mono / IBM Plex Mono / Fira Code) faces, and a text-size control
  (continuous slider in Settings, S/M/L/XL presets in the popover, kept in
  sync). The display serif stays locked as the signature face.

Surfaces, borders, and ink derive from per-theme lightness values so the live
knobs scale the whole UI through CSS, and the opaque directional borders read on
every panel including true black. A live preview reflects changes in real time.
Adds a documentation page under Features.

* fix(appearance): keep contrast-driven tokens in gamut and add picker keyboard nav

Clamp the contrast and knob driven surface, border, and ink lightness so the
full slider range stays a valid color. The page now always sits below the card
tone, so page/card separation no longer collapses at high contrast in Light; the
lit border edge never reaches white in Light at the low end of the knobs; and the
OLED and Dim extremes resolve to valid black/white instead of out-of-range values.

Add the radiogroup keyboard model (roving tabindex plus Arrow / Home / End) to the
accent and type pickers through a shared hook, matching the segmented control. One
item is tabbable and arrow keys move focus and selection together.
This commit is contained in:
Anso
2026-06-04 01:50:41 -04:00
committed by GitHub
parent 0683aa9395
commit c0a252026d
26 changed files with 1524 additions and 400 deletions
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// The store reads localStorage once at module import, so the read/validate/migrate
// path can only be exercised by re-importing the module after seeding storage.
// vi.resetModules() + a dynamic import gives a fresh store per test.
const KEY = 'sencho.appearance.theme';
describe('use-theme read / validate / migrate', () => {
beforeEach(() => {
vi.resetModules();
localStorage.clear();
const root = document.documentElement;
root.removeAttribute('style');
root.removeAttribute('data-theme');
root.removeAttribute('data-accent');
root.classList.remove('dark');
});
async function applyStored(): Promise<HTMLElement> {
const mod = await import('./use-theme');
mod.initializeTheme();
return document.documentElement;
}
it('clamps out-of-range knobs and rejects non-finite / non-number values', async () => {
localStorage.setItem(KEY, JSON.stringify({ glow: 999, contrast: 'nope', typeScale: 0, borderBoost: NaN }));
const root = await applyStored();
expect(root.style.getPropertyValue('--glow')).toBe('0.4'); // 999 clamped to max
expect(root.style.getPropertyValue('--contrast')).toBe('0'); // 'nope' -> default
expect(root.style.getPropertyValue('--type-scale')).toBe('0.88'); // 0 clamped to min
expect(root.style.getPropertyValue('--border-boost')).toBe('0'); // NaN serializes to null -> default
});
it('falls back to defaults for unknown mode / accent (e.g. the removed teal)', async () => {
localStorage.setItem(KEY, JSON.stringify({ theme: 'banana', accent: 'teal' }));
const root = await applyStored();
expect(root.dataset.theme).toBe('dim');
expect(root.dataset.accent).toBe('cyan');
});
it('migrates the legacy sencho-theme key (dark -> dim)', async () => {
localStorage.setItem('sencho-theme', 'dark');
const root = await applyStored();
expect(root.dataset.theme).toBe('dim');
expect(root.classList.contains('dark')).toBe(true);
});
it('migrates the legacy light value through to the light theme', async () => {
localStorage.setItem('sencho-theme', 'light');
const root = await applyStored();
expect(root.dataset.theme).toBe('light');
expect(root.classList.contains('dark')).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTheme, initializeTheme, THEME_MODES, ACCENTS, UI_FONTS, MONO_FONTS, TYPE_SIZES } from './use-theme';
const STORAGE_KEY = 'sencho.appearance.theme';
function readBlob(): Record<string, unknown> {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
}
describe('useTheme', () => {
beforeEach(() => {
// Sync <html> to the current store state (the pre-paint script does this
// in the browser; tests have no pre-paint).
initializeTheme();
});
afterEach(() => {
// Reset the shared module store so tests stay order-independent.
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setTheme('dim');
result.current.setAccent('cyan');
result.current.setBorderBoost(0);
result.current.setGlow(0.16);
result.current.setContrast(0);
result.current.setUiFont('Geist');
result.current.setMonoFont('Geist Mono');
result.current.setTypeScale(1);
});
});
it('exposes four modes and the distinct eight accents (no teal/indigo)', () => {
expect(THEME_MODES.map((m) => m.id)).toEqual(['dim', 'oled', 'light', 'auto']);
expect(ACCENTS).toHaveLength(8);
expect(ACCENTS.map((a) => a.id)).toEqual(
expect.arrayContaining(['cyan', 'blue', 'violet', 'magenta', 'orange', 'amber', 'lime', 'steel']),
);
expect(ACCENTS.map((a) => a.id)).not.toContain('teal');
expect(ACCENTS.map((a) => a.id)).not.toContain('indigo');
});
it('defaults to Dim with the dark class applied', () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('dim');
expect(result.current.accent).toBe('cyan');
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.dataset.theme).toBe('dim');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('light mode drops the dark class and persists', () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setTheme('light'));
expect(result.current.isDarkMode).toBe(false);
expect(document.documentElement.dataset.theme).toBe('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);
expect(readBlob().theme).toBe('light');
});
it('OLED keeps the dark class', () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setTheme('oled'));
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.dataset.theme).toBe('oled');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('applies accent and the three knobs to <html> and persists them', () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setAccent('violet');
result.current.setBorderBoost(0.05);
result.current.setGlow(0.3);
result.current.setContrast(0.6);
});
expect(document.documentElement.dataset.accent).toBe('violet');
expect(document.documentElement.style.getPropertyValue('--border-boost')).toBe('0.05');
expect(document.documentElement.style.getPropertyValue('--glow')).toBe('0.3');
expect(document.documentElement.style.getPropertyValue('--contrast')).toBe('0.6');
const blob = readBlob();
expect(blob.accent).toBe('violet');
expect(blob.borderBoost).toBe(0.05);
expect(blob.glow).toBe(0.3);
expect(blob.contrast).toBe(0.6);
});
it('exposes 3 interface faces, 3 data faces, and 4 size presets with Geist defaults', () => {
expect(UI_FONTS.map((f) => f.id)).toEqual(['Geist', 'IBM Plex Sans', 'Hanken Grotesk']);
expect(MONO_FONTS.map((f) => f.id)).toEqual(['Geist Mono', 'IBM Plex Mono', 'Fira Code']);
expect(TYPE_SIZES.map((s) => s.id)).toEqual(['S', 'M', 'L', 'XL']);
const { result } = renderHook(() => useTheme());
expect(result.current.uiFont).toBe('Geist');
expect(result.current.monoFont).toBe('Geist Mono');
expect(result.current.typeScale).toBe(1);
});
it('applies font faces and text scale to <html> and persists them', () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setUiFont('IBM Plex Sans');
result.current.setMonoFont('Fira Code');
result.current.setTypeScale(1.16);
});
expect(document.documentElement.style.getPropertyValue('--ui-font')).toBe("'IBM Plex Sans'");
expect(document.documentElement.style.getPropertyValue('--mono-font')).toBe("'Fira Code'");
expect(document.documentElement.style.getPropertyValue('--type-scale')).toBe('1.16');
const blob = readBlob();
expect(blob.uiFont).toBe('IBM Plex Sans');
expect(blob.monoFont).toBe('Fira Code');
expect(blob.typeScale).toBe(1.16);
});
it('shares state across two consumers (one store)', () => {
const a = renderHook(() => useTheme());
const b = renderHook(() => useTheme());
act(() => a.result.current.setAccent('lime'));
expect(a.result.current.accent).toBe('lime');
expect(b.result.current.accent).toBe('lime');
});
});
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useSyncExternalStore } from 'react';
import { Moon, Zap, Sun, Monitor } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
// Shared theme store. Two live consumers (the topbar quick switch and the
// Settings → Appearance section) must reflect each other instantly, so the
// state lives in a module-level external store exposed via useSyncExternalStore
// rather than per-component state. The apply-to-<html> logic mirrors
// public/theme-init.js (the pre-paint script); keep the two in sync.
export type ThemeMode = 'dim' | 'oled' | 'light' | 'auto';
export type ResolvedTheme = 'dim' | 'oled' | 'light';
export type AccentId =
| 'cyan' | 'blue' | 'violet' | 'magenta'
| 'orange' | 'amber' | 'lime' | 'steel';
// Display (Instrument Serif) is the locked signature face. Only the interface
// (sans) and data (mono) faces are user-swappable, plus a root text-size scale.
export type UiFont = 'Geist' | 'IBM Plex Sans' | 'Hanken Grotesk';
export type MonoFont = 'Geist Mono' | 'IBM Plex Mono' | 'Fira Code';
export interface ThemeState {
theme: ThemeMode;
accent: AccentId;
borderBoost: number;
glow: number;
contrast: number;
uiFont: UiFont;
monoFont: MonoFont;
typeScale: number;
}
export const THEME_MODES: { id: ThemeMode; label: string; icon: LucideIcon }[] = [
{ id: 'dim', label: 'Dim', icon: Moon },
{ id: 'oled', label: 'OLED', icon: Zap },
{ id: 'light', label: 'Light', icon: Sun },
{ id: 'auto', label: 'Auto', icon: Monitor },
];
// Pre-mapped for SegmentedControl so the popover and the settings section stay identical.
export const THEME_MODE_OPTIONS = THEME_MODES.map((m) => ({ value: m.id, label: m.label, icon: m.icon }));
// h/c match the [data-accent] rules in index.css; lightness comes from the theme.
// A tighter, well-spaced 8-hue wheel (ordered for the 2x4 picker). Cyan default.
export const ACCENTS: { id: AccentId; label: string; h: number; c: number }[] = [
{ id: 'orange', label: 'Orange', h: 42, c: 0.165 },
{ id: 'amber', label: 'Amber', h: 88, c: 0.165 },
{ id: 'lime', label: 'Lime', h: 132, c: 0.195 },
{ id: 'cyan', label: 'Cyan', h: 196, c: 0.130 },
{ id: 'blue', label: 'Blue', h: 252, c: 0.165 },
{ id: 'violet', label: 'Violet', h: 298, c: 0.190 },
{ id: 'magenta', label: 'Magenta', h: 344, c: 0.180 },
{ id: 'steel', label: 'Steel', h: 250, c: 0.040 },
];
// Personalization-knob bounds (also enforced by the sliders).
export const CONTRAST = { min: -0.6, max: 1.2, step: 0.05, default: 0 } as const;
export const BORDER_BOOST = { min: -0.06, max: 0.12, step: 0.01, default: 0 } as const;
export const GLOW = { min: 0, max: 0.4, step: 0.01, default: 0.16 } as const;
// Continuous text-size bounds for the Settings slider (the popover uses presets).
export const TYPE_SCALE = { min: 0.88, max: 1.2, step: 0.02, default: 1 } as const;
// Swappable faces (the family string is the CSS value; name is the display label).
export const UI_FONTS: { id: UiFont; name: string }[] = [
{ id: 'Geist', name: 'Geist' },
{ id: 'IBM Plex Sans', name: 'IBM Plex' },
{ id: 'Hanken Grotesk', name: 'Hanken' },
];
export const MONO_FONTS: { id: MonoFont; name: string }[] = [
{ id: 'Geist Mono', name: 'Geist Mono' },
{ id: 'IBM Plex Mono', name: 'Plex Mono' },
{ id: 'Fira Code', name: 'Fira Code' },
];
// Text-size presets: id label + the --type-scale multiplier + a preview px.
export const TYPE_SIZES: { id: string; scale: number; px: number }[] = [
{ id: 'S', scale: 0.92, px: 11 },
{ id: 'M', scale: 1.0, px: 13 },
{ id: 'L', scale: 1.08, px: 15 },
{ id: 'XL', scale: 1.16, px: 17 },
];
const STORAGE_KEY = 'sencho.appearance.theme';
const LEGACY_KEY = 'sencho-theme';
const DEFAULT_STATE: ThemeState = {
theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0,
uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1,
};
const MODE_IDS = new Set<string>(THEME_MODES.map((m) => m.id));
const ACCENT_IDS = new Set<string>(ACCENTS.map((a) => a.id));
const UI_FONT_IDS = new Set<string>(UI_FONTS.map((f) => f.id));
const MONO_FONT_IDS = new Set<string>(MONO_FONTS.map((f) => f.id));
function isMode(v: unknown): v is ThemeMode {
return typeof v === 'string' && MODE_IDS.has(v);
}
function isAccent(v: unknown): v is AccentId {
return typeof v === 'string' && ACCENT_IDS.has(v);
}
function isUiFont(v: unknown): v is UiFont {
return typeof v === 'string' && UI_FONT_IDS.has(v);
}
function isMonoFont(v: unknown): v is MonoFont {
return typeof v === 'string' && MONO_FONT_IDS.has(v);
}
// Numeric knobs: a persisted value must be finite and in range, otherwise fall
// back to the default (a NaN/Infinity/out-of-range value would silently no-op
// in CSS, which is harder to diagnose than a reset to default).
function readNumber(value: unknown, bounds: { min: number; max: number; default: number }): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return bounds.default;
return Math.min(bounds.max, Math.max(bounds.min, value));
}
function readStored(): ThemeState {
if (typeof window === 'undefined') return { ...DEFAULT_STATE };
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const p = JSON.parse(raw) as Partial<ThemeState> | null;
if (p && typeof p === 'object') {
return {
theme: isMode(p.theme) ? p.theme : DEFAULT_STATE.theme,
accent: isAccent(p.accent) ? p.accent : DEFAULT_STATE.accent,
borderBoost: readNumber(p.borderBoost, BORDER_BOOST),
glow: readNumber(p.glow, GLOW),
contrast: readNumber(p.contrast, CONTRAST),
uiFont: isUiFont(p.uiFont) ? p.uiFont : DEFAULT_STATE.uiFont,
monoFont: isMonoFont(p.monoFont) ? p.monoFont : DEFAULT_STATE.monoFont,
typeScale: readNumber(p.typeScale, TYPE_SCALE),
};
}
}
// Legacy migration: the old key held only the mode string (dark → dim).
const legacy = window.localStorage.getItem(LEGACY_KEY);
if (legacy === 'light' || legacy === 'auto') return { ...DEFAULT_STATE, theme: legacy };
if (legacy === 'dark') return { ...DEFAULT_STATE, theme: 'dim' };
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
}
return { ...DEFAULT_STATE };
}
function systemPrefersDark(): boolean {
return (
typeof window !== 'undefined' &&
!!window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
);
}
function resolveWith(theme: ThemeMode, systemDark: boolean): ResolvedTheme {
if (theme === 'auto') return systemDark ? 'dim' : 'light';
return theme;
}
/** Resolve a mode to a concrete theme using the live OS preference. */
export function resolveTheme(theme: ThemeMode): ResolvedTheme {
return resolveWith(theme, systemPrefersDark());
}
function applyToDom(s: ThemeState, systemDark: boolean) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const resolved = resolveWith(s.theme, systemDark);
root.dataset.theme = resolved;
root.dataset.accent = s.accent;
root.style.setProperty('--border-boost', String(s.borderBoost));
root.style.setProperty('--glow', String(s.glow));
root.style.setProperty('--contrast', String(s.contrast));
root.style.setProperty('--ui-font', `'${s.uiFont}'`);
root.style.setProperty('--mono-font', `'${s.monoFont}'`);
root.style.setProperty('--type-scale', String(s.typeScale));
root.classList.toggle('dark', resolved !== 'light');
}
// ── store ──────────────────────────────────────────────────────────────────
export interface ThemeSnapshot extends ThemeState {
systemDark: boolean;
}
let persisted: ThemeState = readStored();
let systemDark = systemPrefersDark();
let snapshot: ThemeSnapshot = { ...persisted, systemDark };
const listeners = new Set<() => void>();
function rebuild() {
snapshot = { ...persisted, systemDark };
}
function emit() {
for (const l of listeners) l();
}
function sameState(a: ThemeState, b: ThemeState): boolean {
return a.theme === b.theme && a.accent === b.accent
&& a.borderBoost === b.borderBoost && a.glow === b.glow && a.contrast === b.contrast
&& a.uiFont === b.uiFont && a.monoFont === b.monoFont && a.typeScale === b.typeScale;
}
function setState(patch: Partial<ThemeState>) {
const next: ThemeState = { ...persisted, ...patch };
if (sameState(next, persisted)) return;
persisted = next;
rebuild();
applyToDom(persisted, systemDark);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted));
} catch {
// ignore
}
emit();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): ThemeSnapshot {
return snapshot;
}
if (typeof window !== 'undefined') {
// Cross-tab sync: another tab wrote a new look.
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY) return;
const incoming = readStored();
if (sameState(incoming, persisted)) return;
persisted = incoming;
rebuild();
applyToDom(persisted, systemDark);
emit();
});
// Auto mode re-resolves live when the OS flips.
if (window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', (e) => {
if (e.matches === systemDark) return;
systemDark = e.matches;
rebuild();
if (persisted.theme === 'auto') applyToDom(persisted, systemDark);
emit();
});
}
}
/** Re-assert the stored look on <html> at startup (idempotent; the pre-paint
* script already applied it, this keeps the store and DOM in agreement). */
export function initializeTheme() {
applyToDom(persisted, systemDark);
}
export function useTheme() {
const s = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const setTheme = useCallback((theme: ThemeMode) => setState({ theme }), []);
const setAccent = useCallback((accent: AccentId) => setState({ accent }), []);
const setBorderBoost = useCallback((borderBoost: number) => setState({ borderBoost }), []);
const setGlow = useCallback((glow: number) => setState({ glow }), []);
const setContrast = useCallback((contrast: number) => setState({ contrast }), []);
const setUiFont = useCallback((uiFont: UiFont) => setState({ uiFont }), []);
const setMonoFont = useCallback((monoFont: MonoFont) => setState({ monoFont }), []);
const setTypeScale = useCallback((typeScale: number) => setState({ typeScale }), []);
const resolvedTheme = resolveWith(s.theme, s.systemDark);
return {
theme: s.theme,
accent: s.accent,
borderBoost: s.borderBoost,
glow: s.glow,
contrast: s.contrast,
uiFont: s.uiFont,
monoFont: s.monoFont,
typeScale: s.typeScale,
resolvedTheme,
isDarkMode: resolvedTheme !== 'light',
setTheme,
setAccent,
setBorderBoost,
setGlow,
setContrast,
setUiFont,
setMonoFont,
setTypeScale,
} as const;
}