feat: add theme switcher, dark theme

This commit is contained in:
2026-08-25 14:32:13 +05:00
parent 5b22409d41
commit f1f5bd4409
8 changed files with 274 additions and 5 deletions
+3 -1
View File
@@ -102,7 +102,9 @@ export const en: Dict = {
decrease: 'Decrease',
increase: 'Increase',
reset: 'Reset',
pipette: 'Eyedropper'
pipette: 'Eyedropper',
themeLight: 'Light theme',
themeDark: 'Dark theme'
},
errors: {
noImageRun: 'This tool does not process images',
+3 -1
View File
@@ -102,7 +102,9 @@ export const ru: Dict = {
decrease: 'Уменьшить',
increase: 'Увеличить',
reset: 'Сбросить',
pipette: 'Пипетка'
pipette: 'Пипетка',
themeLight: 'Светлая тема',
themeDark: 'Тёмная тема'
},
errors: {
noImageRun: 'Этот инструмент не обрабатывает изображения',
+95
View File
@@ -0,0 +1,95 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
type Tokens = Record<string, string>;
function parseBlock(css: string, selector: string): Tokens {
const escaped = selector.replace('[', '\\[').replace(']', '\\]');
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
const tokens: Tokens = {};
for (const [, name, value] of (match?.[1] ?? '').matchAll(/--([a-z-]+):\s*([^;]+);/g)) {
tokens[name] = value.trim();
}
return tokens;
}
function resolveVar(tokens: Tokens, value: string): string {
const ref = /^var\(--([a-z-]+)\)$/.exec(value);
return ref ? (tokens[ref[1]] ?? value) : value;
}
function luminance(hex: string): number {
const full =
hex.length === 4
? hex
.slice(1)
.split('')
.map((c) => c + c)
.join('')
: hex.slice(1);
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255).map((v) =>
v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function ratio(fgHex: string, bgHex: string): number {
const a = luminance(fgHex);
const b = luminance(bgHex);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function mix(fgHex: string, alpha: number, bgHex: string): string {
const parse = (h: string) => [0, 2, 4].map((i) => parseInt(h.slice(1 + i, 3 + i), 16));
const f = parse(fgHex);
const b = parse(bgHex);
const out = f.map((v, i) => Math.round(alpha * v + (1 - alpha) * b[i]));
return `#${out.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
}
const css = readFileSync(join(__dirname, '..', 'app.css'), 'utf8');
const light = parseBlock(css, ':root');
const darkRaw = parseBlock(css, "[data-theme='dark']");
const dark: Tokens = { ...light, ...darkRaw };
function colorOf(theme: Tokens, name: string): string {
return resolveVar(theme, theme[name]);
}
function bannerBg(theme: Tokens): string {
return mix(colorOf(theme, 'danger'), 0.08, colorOf(theme, 'surface'));
}
describe.each([
['light', light],
['dark', dark]
])('контраст палитры (%s)', (_name, theme) => {
const c = (n: string) => colorOf(theme, n);
it('основной текст на поверхности ≥ 7', () => {
expect(ratio(c('text'), c('surface'))).toBeGreaterThanOrEqual(7);
});
it('вторичный текст на фоне и поверхности ≥ 4.5', () => {
expect(ratio(c('text-muted'), c('surface'))).toBeGreaterThanOrEqual(4.5);
expect(ratio(c('text-muted'), c('bg'))).toBeGreaterThanOrEqual(4.5);
});
it('цвет ссылок на поверхности и фоне ≥ 4.5', () => {
expect(ratio(c('link'), c('surface'))).toBeGreaterThanOrEqual(4.5);
expect(ratio(c('link'), c('bg'))).toBeGreaterThanOrEqual(4.5);
});
it('белая подпись на акцентной кнопке ≥ 4.5', () => {
expect(ratio(c('accent-contrast'), c('accent'))).toBeGreaterThanOrEqual(4.5);
});
it('текст баннера ошибки ≥ 4.5', () => {
expect(ratio(c('danger-strong'), bannerBg(theme))).toBeGreaterThanOrEqual(4.5);
});
it('бордер панели различим ≥ 1.15', () => {
expect(ratio(c('border'), c('surface'))).toBeGreaterThanOrEqual(1.15);
});
});
+41
View File
@@ -0,0 +1,41 @@
export type ThemeChoice = 'light' | 'dark';
const STORAGE_KEY = 'theme';
let theme = $state<ThemeChoice>('light');
function storage(): Storage | null {
return typeof localStorage === 'undefined' ? null : localStorage;
}
function systemTheme(): ThemeChoice {
if (typeof window === 'undefined' || !window.matchMedia) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function apply(): void {
if (typeof document === 'undefined') return;
document.documentElement.dataset.theme = theme;
}
export function getTheme(): ThemeChoice {
return theme;
}
export function setTheme(next: ThemeChoice): void {
theme = next;
storage()?.setItem(STORAGE_KEY, next);
apply();
}
/**
* Догоняет состояние после гидрации: фактический атрибут уже выставлен
* инлайн-скриптом в app.html; здесь он читается и синхронизируется с runes,
* чтобы кнопка в шапке отражала реальную тему.
*/
export function initTheme(): void {
if (typeof window === 'undefined') return;
const attr = typeof document !== 'undefined' ? document.documentElement.dataset.theme : undefined;
theme = attr === 'dark' || attr === 'light' ? attr : systemTheme();
apply();
}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getTheme, initTheme, setTheme } from './theme.svelte';
type Store = Record<string, string>;
function stubEnv(opts: { attr?: string; system?: boolean } = {}) {
const store: Store = {};
vi.stubGlobal('localStorage', {
getItem: (k: string) => (k in store ? store[k] : null),
setItem: (k: string, v: string) => {
store[k] = v;
}
});
const doc: { documentElement: { dataset: Record<string, string | undefined> } } = {
documentElement: { dataset: {} }
};
if (opts.attr !== undefined) doc.documentElement.dataset.theme = opts.attr;
vi.stubGlobal('document', doc);
vi.stubGlobal('window', {
matchMedia: (_q: string) => ({ matches: opts.system ?? false })
});
return { store, doc };
}
beforeEach(() => {
vi.unstubAllGlobals();
});
describe('initTheme', () => {
it('доверяет атрибуту от анти-вспышки скрипта', () => {
stubEnv({ attr: 'dark' });
initTheme();
expect(getTheme()).toBe('dark');
});
it('без атрибута берёт системную тему', () => {
stubEnv({ system: true });
initTheme();
expect(getTheme()).toBe('dark');
stubEnv({ system: false });
initTheme();
expect(getTheme()).toBe('light');
});
it('мусорный атрибут игнорируется в пользу системной', () => {
stubEnv({ attr: 'sepia', system: false });
initTheme();
expect(getTheme()).toBe('light');
});
});
describe('setTheme', () => {
it('сохраняет выбор и применяет атрибут на html', () => {
const { store, doc } = stubEnv({});
initTheme();
setTheme('dark');
expect(store['theme']).toBe('dark');
expect(doc.documentElement.dataset.theme).toBe('dark');
setTheme('light');
expect(store['theme']).toBe('light');
expect(doc.documentElement.dataset.theme).toBe('light');
});
});