feat: add theme switcher, dark theme
This commit is contained in:
+22
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('theme');
|
||||
const theme =
|
||||
saved === 'dark' || saved === 'light'
|
||||
? saved
|
||||
: matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -102,7 +102,9 @@ export const ru: Dict = {
|
||||
decrease: 'Уменьшить',
|
||||
increase: 'Увеличить',
|
||||
reset: 'Сбросить',
|
||||
pipette: 'Пипетка'
|
||||
pipette: 'Пипетка',
|
||||
themeLight: 'Светлая тема',
|
||||
themeDark: 'Тёмная тема'
|
||||
},
|
||||
errors: {
|
||||
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Locale, string> = { ru: 'RU', en: 'EN' };
|
||||
</script>
|
||||
@@ -37,6 +41,30 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="theme-switch" role="group" aria-label="Theme / Тема">
|
||||
<button
|
||||
type="button"
|
||||
class="lang-btn"
|
||||
class:active={getTheme() === 'light'}
|
||||
aria-pressed={getTheme() === 'light'}
|
||||
title={t('ui.themeLight')}
|
||||
aria-label={t('ui.themeLight')}
|
||||
onclick={() => setTheme('light')}
|
||||
>
|
||||
☀
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="lang-btn"
|
||||
class:active={getTheme() === 'dark'}
|
||||
aria-pressed={getTheme() === 'dark'}
|
||||
title={t('ui.themeDark')}
|
||||
aria-label={t('ui.themeDark')}
|
||||
onclick={() => setTheme('dark')}
|
||||
>
|
||||
☾
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -97,7 +125,8 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
.lang-switch,
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: var(--space-2);
|
||||
|
||||
Reference in New Issue
Block a user