mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user