diff --git a/web/src/app.css b/web/src/app.css
index 94fbb1c..f6bb4e4 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -1,4 +1,6 @@
:root {
+ color-scheme: light;
+
--bg: #f6f7f9;
--surface: #ffffff;
--text: #1b1e24;
@@ -6,6 +8,7 @@
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-contrast: #ffffff;
+ --link: var(--accent);
--border: #dfe2e8;
--danger: #e5484d;
--danger-strong: #b3261e;
@@ -40,6 +43,24 @@
--font-mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
}
+[data-theme='dark'] {
+ color-scheme: dark;
+
+ --bg: #12151a;
+ --surface: #1b2027;
+ --text: #e6e9ee;
+ --text-muted: #98a1ad;
+ --accent-hover: #4c85f0;
+ --link: #6ea0ff;
+ --border: #303842;
+ --danger: #ff7479;
+ --danger-strong: #ff8589;
+ --check-a: #232830;
+ --check-b: #171b21;
+
+ --shadow-card: 0 4px 14px rgb(0 0 0 / 45%);
+}
+
*,
*::before,
*::after {
@@ -79,7 +100,7 @@ label {
}
a {
- color: var(--accent);
+ color: var(--link);
text-decoration: none;
}
diff --git a/web/src/app.html b/web/src/app.html
index 6a2bb58..4d88af5 100644
--- a/web/src/app.html
+++ b/web/src/app.html
@@ -4,6 +4,22 @@
+
%sveltekit.head%
diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts
index 9378eba..f0eccbc 100644
--- a/web/src/lib/i18n/en.ts
+++ b/web/src/lib/i18n/en.ts
@@ -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',
diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts
index f8617b3..5bb862a 100644
--- a/web/src/lib/i18n/ru.ts
+++ b/web/src/lib/i18n/ru.ts
@@ -102,7 +102,9 @@ export const ru: Dict = {
decrease: 'Уменьшить',
increase: 'Увеличить',
reset: 'Сбросить',
- pipette: 'Пипетка'
+ pipette: 'Пипетка',
+ themeLight: 'Светлая тема',
+ themeDark: 'Тёмная тема'
},
errors: {
noImageRun: 'Этот инструмент не обрабатывает изображения',
diff --git a/web/src/lib/palette.test.ts b/web/src/lib/palette.test.ts
new file mode 100644
index 0000000..4b60cfd
--- /dev/null
+++ b/web/src/lib/palette.test.ts
@@ -0,0 +1,95 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+type Tokens = Record;
+
+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);
+ });
+});
diff --git a/web/src/lib/theme.svelte.ts b/web/src/lib/theme.svelte.ts
new file mode 100644
index 0000000..aaaa42a
--- /dev/null
+++ b/web/src/lib/theme.svelte.ts
@@ -0,0 +1,41 @@
+export type ThemeChoice = 'light' | 'dark';
+
+const STORAGE_KEY = 'theme';
+
+let theme = $state('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();
+}
diff --git a/web/src/lib/theme.test.ts b/web/src/lib/theme.test.ts
new file mode 100644
index 0000000..ff36acb
--- /dev/null
+++ b/web/src/lib/theme.test.ts
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { getTheme, initTheme, setTheme } from './theme.svelte';
+
+type Store = Record;
+
+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 } } = {
+ 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');
+ });
+});
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 9b0171b..e803327 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -4,12 +4,16 @@
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
+ import { getTheme, initTheme, setTheme } from '$lib/theme.svelte';
import { t } from '$lib/i18n/t';
import { LOCALES, type Locale } from '$lib/i18n/dict';
let { children } = $props();
- onMount(() => initLocale());
+ onMount(() => {
+ initLocale();
+ initTheme();
+ });
const LANG_LABELS: Record = { ru: 'RU', en: 'EN' };
@@ -37,6 +41,30 @@
{/each}
+
+
+
+
@@ -97,7 +125,8 @@
text-decoration: none;
}
- .lang-switch {
+ .lang-switch,
+ .theme-switch {
display: flex;
gap: 2px;
margin-left: var(--space-2);