Compare commits
5
Commits
6d770dc642
...
365af6ef95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
365af6ef95 | ||
|
|
5e512f0bdd | ||
|
|
f1f5bd4409 | ||
|
|
5b22409d41 | ||
|
|
96b8c00129 |
@@ -0,0 +1,46 @@
|
|||||||
|
# План: светлая и тёмная тема
|
||||||
|
|
||||||
|
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.**
|
||||||
|
|
||||||
|
## 1. Что получается
|
||||||
|
|
||||||
|
Переключатель light/dark в шапке рядом с языковым. Выбор в localStorage (`theme`), первый визит — по `prefers-color-scheme`. Тёмная палитра — переопределение токенов селектором `[data-theme='dark']`; компоненты не правятся.
|
||||||
|
|
||||||
|
## 2. Решения
|
||||||
|
|
||||||
|
- Два состояния, без «авто»: ключ хранит только явный выбор, отсутствие ключа = системная тема.
|
||||||
|
- Анти-вспышка: инлайн-скрипт в `app.html` до гидрации ставит `data-theme` из localStorage или `matchMedia`; пререндеренная разметка остаётся светлой.
|
||||||
|
- `color-scheme: dark` на корне — нативные контролы (input[type=color], range, select, скроллбары) темнеют без правок.
|
||||||
|
- Модуль `theme.svelte.ts` по образцу locale: состояние, персист, применение атрибута. Стартовую установку делает скрипт из `app.html`, модуль синхронизирует runes-состояние после гидрации.
|
||||||
|
|
||||||
|
## 3. Палитра
|
||||||
|
|
||||||
|
- Переопределяем: `--bg`, `--surface`, `--border`, `--text`, `--text-muted`.
|
||||||
|
- Акцент `#2563eb` не меняется; `--accent-hover` — светлее для тёмного фона.
|
||||||
|
- `--danger` осветлить для читаемости.
|
||||||
|
- `--check-a`/`--check-b` шахматки — приглушённые серые: паттерн прозрачности остаётся узнаваемым, но не слепит.
|
||||||
|
- `--shadow-card` — почти чёрная с низкой альфой вместо синей.
|
||||||
|
- Все `color-mix(… var(--surface))` пересчитаются сами — проверить визуально легенды, recalc-бейдж, drag-оверлеи.
|
||||||
|
|
||||||
|
## 4. Этапы и проверки
|
||||||
|
|
||||||
|
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||||
|
|
||||||
|
- **A. Инфраструктура** — `theme.svelte.ts`, скрипт в `app.html`, переключатель в шапке, черновая тёмная палитра. Тесты: персист выбора, игнор мусора в localStorage, применение атрибута (стаб document).
|
||||||
|
- **B. Палитра** — выверка значений: герой, каталог, страница инструмента, цепочка, баннер ошибки, маска, текстовые инструменты; фокус-ринги и hover; обе локали.
|
||||||
|
|
||||||
|
## 5. Смоук
|
||||||
|
|
||||||
|
1. Переключение мгновенное, без перезагрузки; выбор переживает её же.
|
||||||
|
2. При системной тёмной теме первый визит открывается сразу тёмным.
|
||||||
|
3. Нативные контролы выглядят тёмными.
|
||||||
|
4. Шахматка различима, легенды и бейджи читаемы.
|
||||||
|
5. Баннер ошибки и пипетка заметны на тёмных панелях.
|
||||||
|
|
||||||
|
## 6. Критерий готовности
|
||||||
|
|
||||||
|
Grep: ни одного цвета вне токенов в компонентах и маршрутах.
|
||||||
|
|
||||||
|
## 7. Не делаем
|
||||||
|
|
||||||
|
Три позиции в переключателе; пользовательские цветовые схемы; автосмену по времени суток; отдельную тему для превью изображений.
|
||||||
+23
-1
@@ -1,3 +1,25 @@
|
|||||||
# Backlog
|
# Backlog
|
||||||
|
|
||||||
(пусто — выполненные волны лежат в docs/archive/; кандидат на следующую волну: текст и водяные знаки — add-text, watermark, штампы, см. roadmap фаза 1.5 и план wave3 §7.)
|
## Кандидат следующей волны
|
||||||
|
|
||||||
|
Текст и водяные знаки — add-text, watermark, штампы (см. roadmap фаза 1.5 и план wave3 §7).
|
||||||
|
|
||||||
|
## Идеи
|
||||||
|
|
||||||
|
1. **Менять первый инструмент цепочки** — оценка M.
|
||||||
|
Сейчас базовый инструмент зафиксирован при открытии страницы; хочется заменить его на другой без разборки цепочки: шаги и их параметры сохраняются, вход перечитывается. Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔ file).
|
||||||
|
|
||||||
|
2. **UI-эксперимент: параметры между исходником и результатом** — оценка S/M.
|
||||||
|
Раскладка «Исходник → Параметры → Результат» в один ряд вместо параметров отдельным блоком снизу. Проверить на узких экранах; возможно за флагом/A-B, чтобы сравнить с текущей.
|
||||||
|
|
||||||
|
3. **Сворачивать инструменты в chain** — оценка S.
|
||||||
|
Тоггл сворачивания звена до заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости помнить в workspace-pipeline.
|
||||||
|
|
||||||
|
4. **Несколько цепочек: сохранение и загрузка** — оценка M.
|
||||||
|
Именованные цепочки в localStorage (список, создать/переименовать/удалить), быстрое переключение. Формат экспортного JSON расширить опциональным полем имени, старые файлы читаются как безымянные.
|
||||||
|
|
||||||
|
5. **Batch-обработка архивов выбранным chain** — оценка L.
|
||||||
|
Вход — zip-архив картинок (распаковка в браузере), прогон текущего или выбранного сохранённого chain по каждому файлу, сборка результата обратно в zip для скачивания. Зависит от п.4 (выбор цепочки) и от экспортного формата pipeline JSON; прогресс и ошибки — пофайлово.
|
||||||
|
|
||||||
|
6. **Избранные инструменты (fav tools)** — оценка S/M.
|
||||||
|
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке страницы инструмента; список id в localStorage. Избранное показывается отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к popularity при скоринге).
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# План: параметры между исходником и результатом
|
||||||
|
|
||||||
|
> Статус: план к выполнению.
|
||||||
|
|
||||||
|
## 1. Что получается
|
||||||
|
|
||||||
|
Альтернативная раскладка этапа инструмента: три колонки «Исходник | Параметры | Результат» на широких экранах, вертикальный стек на узких. Цель — отдать вариант дизайнеру как отправную точку. Откат к текущей раскладке — замена одной строки в `ToolPage`.
|
||||||
|
|
||||||
|
## 2. Решения
|
||||||
|
|
||||||
|
- Два взаимозаменяемых компонента этапа с одинаковым набором пропсов; `ToolPage` рендерит один тег. Попробовать новое — поменять компонент в этом теге; вернуть старое — поменять обратно. Никаких флагов и конфигов.
|
||||||
|
- Общая типизация пропсов в одном файле рядом с компонентами; сами карточки (`SourceCard`, `ResultCard`, `TextInputCard`, `ParamsCard`) переиспользуются как есть — вся логика исполнения, маски, пипетки и автозапуска остаётся в `ToolPage` и приходит сверху.
|
||||||
|
- Chain-звенья не затрагиваются: у них своя раскладка «Вход | Результат», эксперимент только про базовый этап.
|
||||||
|
- Один порог отзывчивости: три колонки от ~75rem, ниже — стек «исходник → параметры → результат». Промежуточные перестроения не придумываем до фидбека дизайнера.
|
||||||
|
|
||||||
|
## 3. Компоненты
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/lib/components/stage/
|
||||||
|
stage-props.ts # интерфейс StageProps + сборщик дефолтов
|
||||||
|
ToolStageClassic.svelte # текущая разметка .tool-stage, перенесена 1:1 из ToolPage
|
||||||
|
ToolStageInline.svelte # новая сетка [source | params | result]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ToolStageClassic` — вырезка существующего шаблона со стилями; после извлечения страница должна быть пиксель-в-пиксель прежней.
|
||||||
|
- `ToolStageInline` — grid-колонки `minmax(0, 1fr) | фикс-ширина параметров | minmax(0, 1fr)`; легенда «Параметры» над средней колонкой в том же стиле edge-legend.
|
||||||
|
- Модификаторы состояний через классы на корне компонента: `no-params` (инструменты без параметров → две колонки), `no-source` (генераторы → параметры и результат), текстовый вход до декодирования занимает левую ячейку как сейчас.
|
||||||
|
|
||||||
|
## 4. Этапы и проверки
|
||||||
|
|
||||||
|
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||||
|
|
||||||
|
- **A. Извлечение классики** — перенос разметки и стилей этапа из `ToolPage` в `ToolStageClassic` без изменений поведения; пропсы собираются в одном объекте. Критерий: страница неотличима, diff по логике `ToolPage` нулевой.
|
||||||
|
- **B. Инлайн-раскладка** — `ToolStageInline`, переключение в `ToolPage` на новый компонент, прогон смоука §5. Ревью в браузере; решение дизайнера — потом, код уже позволяет жить в обоих вариантах.
|
||||||
|
|
||||||
|
## 5. Смоук
|
||||||
|
|
||||||
|
1. Замена компонента этапа — одна строка; обе версии собираются без предупреждений.
|
||||||
|
2. Инструмент без параметров (например Автоконтраст): две колонки, пустой середины нет.
|
||||||
|
3. Генератор без исходника: параметры и результат в две колонки.
|
||||||
|
4. Текстовый инструмент до декодирования: вход слева, параметры в центре, пустое состояние результата справа.
|
||||||
|
5. Маска, пипетка, слайдеры в средней колонке; изменение параметра перезапускает инструмент как раньше.
|
||||||
|
6. Узкий экран: стек без горизонтального скролла, порядок исходник → параметры → результат.
|
||||||
|
7. Легенды присутствуют у всех трёх зон в обеих раскладках.
|
||||||
|
|
||||||
|
## 6. Что сознательно не делаем
|
||||||
|
|
||||||
|
- Редизайн визуального языка (цвета, типографика, формы) — материал для дизайнера, не для этого плана.
|
||||||
|
- Перестройка chain-звеньев под новую сетку.
|
||||||
|
- Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает про исполнение.
|
||||||
+22
-1
@@ -1,4 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
--bg: #f6f7f9;
|
--bg: #f6f7f9;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--text: #1b1e24;
|
--text: #1b1e24;
|
||||||
@@ -6,6 +8,7 @@
|
|||||||
--accent: #2563eb;
|
--accent: #2563eb;
|
||||||
--accent-hover: #1d4ed8;
|
--accent-hover: #1d4ed8;
|
||||||
--accent-contrast: #ffffff;
|
--accent-contrast: #ffffff;
|
||||||
|
--link: var(--accent);
|
||||||
--border: #dfe2e8;
|
--border: #dfe2e8;
|
||||||
--danger: #e5484d;
|
--danger: #e5484d;
|
||||||
--danger-strong: #b3261e;
|
--danger-strong: #b3261e;
|
||||||
@@ -40,6 +43,24 @@
|
|||||||
--font-mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
--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,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -79,7 +100,7 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -10,4 +10,9 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.css?raw' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+12
-10
@@ -7,14 +7,16 @@ export type CategoryId =
|
|||||||
| 'generate'
|
| 'generate'
|
||||||
| 'filters';
|
| 'filters';
|
||||||
|
|
||||||
export type Category = { id: CategoryId; label: string };
|
/**
|
||||||
|
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
||||||
export const CATEGORIES: Category[] = [
|
* i18n: секция categories, ключ = CategoryId.
|
||||||
{ id: 'convert', label: 'Конвертация' },
|
*/
|
||||||
{ id: 'alpha', label: 'Прозрачность' },
|
export const CATEGORIES: readonly CategoryId[] = [
|
||||||
{ id: 'color', label: 'Цвет' },
|
'convert',
|
||||||
{ id: 'geometry', label: 'Геометрия' },
|
'alpha',
|
||||||
{ id: 'filters', label: 'Фильтры' },
|
'color',
|
||||||
{ id: 'analyze', label: 'Анализ' },
|
'geometry',
|
||||||
{ id: 'generate', label: 'Генерация' }
|
'filters',
|
||||||
|
'analyze',
|
||||||
|
'generate'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
border: 2px dashed var(--accent);
|
border: 2px dashed var(--accent);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
.dropzone:hover,
|
.dropzone:hover,
|
||||||
.dropzone:focus-visible,
|
.dropzone:focus-visible,
|
||||||
.dragging {
|
.dragging {
|
||||||
border-color: var(--accent);
|
border-color: var(--link);
|
||||||
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
height: 1.4rem;
|
height: 1.4rem;
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove {
|
.remove {
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
padding: 2px var(--space-2);
|
padding: 2px var(--space-2);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--link);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
padding: 2px var(--space-2);
|
padding: 2px var(--space-2);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actions .secondary:hover {
|
.actions .secondary:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--link);
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -68,8 +68,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.secondary:hover:not(:disabled) {
|
button.secondary:hover:not(:disabled) {
|
||||||
border-color: var(--accent);
|
border-color: var(--link);
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
|
|||||||
@@ -69,13 +69,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pipette:hover {
|
.pipette:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--link);
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pipette[aria-pressed='true'] {
|
.pipette[aria-pressed='true'] {
|
||||||
border-color: var(--accent);
|
border-color: var(--link);
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--link);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +96,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step:hover:not(:disabled) {
|
.step:hover:not(:disabled) {
|
||||||
border-color: var(--accent);
|
border-color: var(--link);
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.step:disabled {
|
.step:disabled {
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export const en: Dict = {
|
|||||||
decrease: 'Decrease',
|
decrease: 'Decrease',
|
||||||
increase: 'Increase',
|
increase: 'Increase',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
pipette: 'Eyedropper'
|
pipette: 'Eyedropper',
|
||||||
|
themeLight: 'Light theme',
|
||||||
|
themeDark: 'Dark theme'
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
noImageRun: 'This tool does not process images',
|
noImageRun: 'This tool does not process images',
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export const ru: Dict = {
|
|||||||
decrease: 'Уменьшить',
|
decrease: 'Уменьшить',
|
||||||
increase: 'Увеличить',
|
increase: 'Увеличить',
|
||||||
reset: 'Сбросить',
|
reset: 'Сбросить',
|
||||||
pipette: 'Пипетка'
|
pipette: 'Пипетка',
|
||||||
|
themeLight: 'Светлая тема',
|
||||||
|
themeDark: 'Тёмная тема'
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
// @ts-expect-error node types are not wired into svelte-check; vitest resolves fine
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
declare const process: { cwd(): string };
|
||||||
|
|
||||||
|
const css = readFileSync('src/app.css', 'utf8');
|
||||||
|
|
||||||
|
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 cssText = css;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ describe('реестр инструментов', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: категория валидна', (_, tool) => {
|
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: категория валидна', (_, tool) => {
|
||||||
expect(CATEGORIES.map((c) => c.id)).toContain(tool.category);
|
expect(CATEGORIES).toContain(tool.category);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||||
|
|||||||
@@ -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 { onMount } from 'svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
|
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
|
||||||
|
import { getTheme, initTheme, setTheme } from '$lib/theme.svelte';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
import { LOCALES, type Locale } from '$lib/i18n/dict';
|
import { LOCALES, type Locale } from '$lib/i18n/dict';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
onMount(() => initLocale());
|
onMount(() => {
|
||||||
|
initLocale();
|
||||||
|
initTheme();
|
||||||
|
});
|
||||||
|
|
||||||
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
|
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
|
||||||
</script>
|
</script>
|
||||||
@@ -37,6 +41,30 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -75,7 +103,7 @@
|
|||||||
|
|
||||||
.brand:hover {
|
.brand:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
@@ -92,12 +120,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-switch {
|
.lang-switch,
|
||||||
|
.theme-switch {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-left: var(--space-2);
|
margin-left: var(--space-2);
|
||||||
@@ -119,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn:hover {
|
.lang-btn:hover {
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn.active {
|
.lang-btn.active {
|
||||||
@@ -129,7 +158,7 @@
|
|||||||
|
|
||||||
.workspace-link {
|
.workspace-link {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -102,6 +102,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back:hover {
|
.back:hover {
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
<h1>{t('catalog.heading')}</h1>
|
<h1>{t('catalog.heading')}</h1>
|
||||||
<p class="lead text-muted">{t('catalog.lead', { count: TOOLS.length })}</p>
|
<p class="lead text-muted">{t('catalog.lead', { count: TOOLS.length })}</p>
|
||||||
|
|
||||||
{#each CATEGORIES as category (category.id)}
|
{#each CATEGORIES as category (category)}
|
||||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
{@const categoryTools = TOOLS.filter((tool) => tool.category === category)}
|
||||||
{#if categoryTools.length > 0}
|
{#if categoryTools.length > 0}
|
||||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
<section id={category} class="category" aria-labelledby="{category}-heading">
|
||||||
<h2 id="{category.id}-heading" class="heading-section">{t(`categories.${category.id}`)}</h2>
|
<h2 id="{category}-heading" class="heading-section">{t(`categories.${category}`)}</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each categoryTools as tool (tool.id)}
|
{#each categoryTools as tool (tool.id)}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|||||||
Reference in New Issue
Block a user