Files
ds-galo/openspec/changes/material-icons-ui/design.md
T
iu 997ec5e6c1 feat(frontend): replace emoji with inline Material Symbols icon system
Add a self-contained icon system: vendored Material Symbols registry, an
<Icon> component that references a single <symbol> sprite via <use>, plus
IconButton/IconSprite. Replace every emoji/Unicode glyph (logo, theme
switcher, cover placeholder, like/comment counts, lightbox controls, back
links) and add leading icons to key controls. Decorative icons are
aria-hidden; icon-only buttons get labels; theme toggles expose
aria-pressed; counters get accessible names.

Recorded in openspec change material-icons-ui (all tasks complete).
2026-10-10 13:19:29 +03:00

86 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design
## Context
Фронтенд — SvelteKit 2 + Svelte 5, `adapter-static` в режиме SPA (`ssr=false`),
собирается в статику и вшивается в Go-бинарник через `go:embed`. Стили — Tailwind v4,
семантические токены через CSS-переменные и `data-theme` на `<html>` (темы light/dark/neutral
+ system). Внешние CDN-запросы нежелательны: приложение self-hosted и должно работать
полностью автономно. Сейчас все «иконки» — эмодзи и юникод-глифы, рендерящиеся системным
шрифтом (мотивация — в proposal.md).
## Goals / Non-Goals
**Goals:**
- Единый набор векторных иконок, самодостаточный (без внешних запросов и без иконочных шрифтов).
- Иконки наследуют цвет текущей темы и не влияют на размеры/раскладку при переключении тем.
- Переиспользуемый компонентный API, спрайт для дедупликации повторяющихся иконок.
- Доступность: корректное поведение для декоративных и смысловых иконок и для тумблеров.
**Non-Goals:**
- Не менять доменную логику, API, БД и backend.
- Не вводить анимации/переходы иконок.
- Не добавлять внешние зависимости (иконочные пакеты, Vite-плагины).
- Не делать полный редизайн макета — только замена иконок и ведущие иконки контролов.
## Decisions
### 1. Inline SVG через спрайт `<symbol>` + `<use>`
В `+layout.svelte` монтируется один скрытый `<svg>` со всеми `<symbol id="i-...">`;
места использования рисуют `<svg><use href="#i-..."/></svg>`.
- **Почему:** нет внешних запросов, работает внутри статичного бандла, `currentColor`
наследуется через `<use>`, а данные пути хранятся один раз в DOM (важно для сеток, где
`favorite`/`mode_comment` повторяются на каждой плитке).
- **Альтернативы (отклонены):** иконочный шрифт через CDN — внешний запрос, FOUT, ломает
автономность; внешний файл-спрайт `<use href="/icons.svg#id">` — межфайловые `<use>` имеют
ограничения в ряде браузеров; «иконка = компонент с полным inline-путём» — дублирование
path-данных в DOM при массовом повторении.
### 2. Вендорим данные иконок в `registry.ts`, без зависимостей
Реестр — объект `имя -> { path }`, где `path` — сырая inner-разметка (`<path d="..." .../>`,
иногда с `fill-rule`/`clip-rule`). Источник — Google Material Symbols (через Iconify,
prefix `material-symbols`); базовые имена дают стиль **filled**, поэтому набор вендорится в
filled-стиле, а для состояний-пар берутся отдельные ключи (`favorite` = filled, лайкнут;
`favorite-outline` = контурный, не лайкнут). Примечание: контурного варианта для всех
иконок в источнике нет, поэтому единый filled-набор согласованнее, чем смесь стилей.
- **Почему:** ноль зависимостей и supply-chain, малый бандл, полный контроль, согласуется с
философией «один статичный бинарник».
- **Альтернативы (отклонены):** `@mdi/js` — готовые path-строки, но новая зависимость и другой
визуальный набор; Iconify + `unplugin-icons` — мощный DX, но тяжёлый тулчейн ради ~25 иконок.
- **Задел:** при росте набора переход на `@mdi/js` не меняет компонентный API — только источник
данных реестра.
### 3. Компонентный API
- `Icon.svelte`: пропсы `name`, необязательный `label`; размер и цвет задаются классами
(`size-4/5/6/16`, `text-*`). По умолчанию `aria-hidden="true" focusable="false"`; при
заданном `label` — `role="img"` + `aria-label` и `aria-hidden` снимается.
- `IconSprite.svelte`: рендерит все `<symbol>` из реестра один раз, монтируется в `+layout.svelte`.
- `IconButton.svelte`: иконочная кнопка с обязательным `label` (попадает в `aria-label`).
- **Неизвестное имя:** в dev — `console.warn` и пустой бокс (не падать); в prod — пусто.
### 4. Стиль и метрики
- `fill="currentColor"`, шкала размеров: `size-4` (в тексте/счётчиках), `size-5`
(кнопки/навигация), `size-6` (панель лайтбокса), `size-16` (плейсхолдеры).
- Пара состояний лайка: `favorite-outline` (не лайкнут) / `favorite` (залитая, лайкнут).
### 5. Маппинг иконок тем
`light_mode`, `dark_mode`, `palette` (нейтральная), `brightness_auto` (системная).
### 6. Доступность
- Декоративные иконки рядом с текстом/внутри кнопки с текстом — `aria-hidden`.
- Одиночные иконочные кнопки (тумблеры тем) — `aria-label` на `<button>` + `aria-pressed`.
- Счётчики лайков/комментариев: иконка декоративная, число несёт доступное имя
(`aria-label="12 лайков"`).
## Risks / Trade-offs
- **[Спрайт должен оставаться в DOM]** → `<use>` ссылается на `#id` в том же документе;
`IconSprite` живёт в `+layout.svelte` и не размонтируется.
- **[`{@html}` в `<symbol>`]** → данные статичны и вендорятся на этапе сборки, не приходят от
пользователя; риск XSS отсутствует.
- **[Рост DOM при больших сетках]** → митигируется спрайтом (одна копия пути).
- **[Визуальная регрессия/раскладка]** → размеры задаём классами, иконки в квадратном
`viewBox 0 0 24 24`; проверяем на трёх темах.
- **[Лицензия/атрибуция]** → Material Symbols под Apache 2.0; добавляем уведомление об
источнике рядом с реестром.