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);