Империя рекламы — дизайн-система
Сайт, кабинет менеджера и бот «Империи рекламы» — один продукт, который я веду без команды. Систему я собрал в обратном порядке: не макет как источник правды для разработки, а слепок с кода, которым этот код можно проверять.
Сверка нашла то, чего не было видно ни в макете, ни в коде по отдельности: классы тёмной темы в кабинете, которые никогда не срабатывали, три высоты контролов вместо двух, пять «компонентов», оказавшихся сборками из примитивов, и 402 иконки с неправильной толщиной контура. Тёмная тема кабинета и выравнивание действий в строках таблиц появились в продукте именно отсюда.
Всё ниже — живая разметка на тех же переменных, что в проде: 199 переменных, 49 компонентов, 82 иконки, 171 экран: сайт — 82, CRM — 77, бот — 12.
Цветовые токены
Компонент не знает своего цвета. Он знает роль — «фон карточки», «вторичный текст», «требует внимания» — а конкретное значение подставляет тема. Поэтому тёмная тема не переопределяет ни одного компонента: меняются только значения переменных.
Значения — из web/app/globals.css, в oklch. Образец слева — тот же цвет, что в проде, а не приближение из палитры Figma.
Semantic tokens — светлая тема
Пятнадцать переменных задают весь светлый интерфейс — сайт, кабинет менеджера и попапы.
| Образец | CSS-переменная | Значение | Где используется |
|---|---|---|---|
--background | oklch(0.985 0 0) | Фон страницы сайта и кабинета | |
--foreground | oklch(0.145 0 0) | Основной текст и иконки | |
--card | oklch(1 0 0) | Карточки, таблицы, поверхность попапа | |
--popover | oklch(1 0 0) | Выпадающие меню, поповеры, календарь | |
--primary | oklch(0.546 0.245 262.881) | Акцент: кнопки действия, ссылки, выбранный день | |
--primary-foreground | oklch(1 0 0) | Текст и иконки на акцентной заливке | |
--secondary | oklch(0.97 0 0) | Вторичная кнопка, бейдж «Вы» | |
--muted | oklch(0.97 0 0) | Шапка таблицы, боковое меню, наведение у ghost | |
--muted-foreground | oklch(0.556 0 0) | Подписи, второстепенный текст, плейсхолдеры | |
--accent | oklch(0.97 0 0) | Подсветка пункта меню под курсором и с клавиатуры | |
--border | oklch(0.922 0 0) | Рамки карточек, разделители строк | |
--input | oklch(0.922 0 0) | Рамка полей ввода и чекбоксов | |
--ring | oklch(0.708 0 0) | Кольцо фокуса с клавиатуры (через ring/50) | |
--sidebar | oklch(0.985 0 0) | Подложка бокового меню кабинета | |
--blob-mid · --blob-dark · --blob-light | производные от --primary | Градиентное пятно в хиро главной — считается от акцента, а не задаётся отдельно |
Semantic tokens — тёмная тема
Тот же список ролей, другие значения. Акцент не меняется: синий — цвет марки, и менять его по темам значило бы, что у продукта две разные марки. У решения есть цена: на белом синий даёт контраст 5,3:1, на тёмном фоне — 3,8:1. Кнопкам и крупным элементам этого хватает, мелкому тексту ссылок в тёмной теме — нет.
| Образец | CSS-переменная | Значение | Отличие от светлой |
|---|---|---|---|
--background | oklch(0.145 0 0) | Почти чёрный вместо почти белого | |
--foreground | oklch(0.985 0 0) | Инверсия пары фон/текст | |
--card | oklch(0.205 0 0) | Карточка светлее фона, а не темнее | |
--muted | oklch(0.269 0 0) | Шапки и подложки поднимаются ещё на ступень | |
--muted-foreground | oklch(0.708 0 0) | Светлее, чтобы держать контраст на тёмном | |
--border | oklch(1 0 0 / 10%) | Не серый, а полупрозрачный белый — рамка работает на любой подложке | |
--input | oklch(1 0 0 / 15%) | То же для полей ввода | |
--primary | оклch без изменений | Единственный токен, который в обеих темах одинаковый |
Статусы
Три роли, которые не сводятся к акценту: получилось, требует внимания, опасное действие. До системы это были литералы green на сайте и emerald в кабинете — один и тот же смысл двумя разными цветами.
| Образец | Переменная | Светлая / тёмная | Где используется |
|---|---|---|---|
--success | green-600 / green-400 | «Открыто сейчас» на сайте, «Есть», «Оплачена» в кабинете | |
--warning | amber-800 / amber-300 | Текст бейджа «не оплачено» | |
--warning-muted | amber-100 / amber-950 40% | Строка неразобранного клиента при наведении | |
--warning-subtle | amber-50 / amber-950 20% | Строка неразобранного клиента в покое | |
--destructive | red-600 / red-400 | Удаление, ошибки валидации, кнопка в подтверждении |
Как это выглядит в интерфейсе
Типографика
Один шрифт — Inter — и шкала Tailwind без собственных размеров. Своя шкала здесь была бы лишней сущностью: каждый новый кегль пришлось бы заводить и в коде, и в макете.
Ниже — те ступени, которые реально встречаются в продукте, и роль каждой. Всё, чего в списке нет, в интерфейсе не используется.
| Ступень | Начертание | Где используется |
|---|---|---|
text-5xl / text-4xl | Bold, трекинг −0.02em | Заголовок главной и страницы услуги; на 390 падает до text-3xl |
text-3xl / text-2xl | Semi Bold | Заголовки секций сайта, заголовок статьи |
text-xl | Medium | Заголовок карточки направления и карточки работы |
text-base | Regular | Текст страниц сайта и статьи блога |
text-sm | Regular / Medium | Весь кабинет менеджера: таблицы, формы, кнопки |
text-xs | Regular | Подписи под значениями, источник клиента, счётчики |
Сетка, отступы, радиусы
Сайт и кабинет живут по разным законам: у сайта — контейнер с полями, которые растут вместе с экраном, у кабинета — фиксированная колонка меню и таблица во всю оставшуюся ширину.
Контейнер сайта вынесен в одну утилиту site-container: до неё те же четыре класса были повторены в 21 секции, и правка полей требовала обхода всех страниц.
| Параметр | Значение | Комментарий |
|---|---|---|
site-container | max-w-6xl, от 2xl — 96rem | Единственный контейнер публичных страниц |
| Поля контейнера | 16 → 24 (sm) → 32 (lg) px | Растут ступенями, а не процентом ширины |
| Брейкпоинты в макете | 390 и 1440 | Две ширины вместо трёх: 768 не заводили сознательно |
| Боковое меню кабинета | 210 px, подложка muted | На 390 уезжает в шторку |
--radius | 0.625rem = 10 px | Базовый радиус; остальные считаются от него |
| Шкала радиусов | sm 6 · md 8 · lg 10 · xl 14 · 2xl 18 | Кнопка — lg, карточка — xl, бейдж — полный круг |
Отступы и радиусы вживую
Плотность и тач-таргеты
Один и тот же компонент обязан быть компактным под мышью и крупным под пальцем. Держать для этого две версии компонента — значит однажды поправить одну и забыть вторую.
В коде это вариант touch-target: на сенсорном экране высота поднимается до 44 px, с мышью остаётся исходной. Кабинет менеджера из правила исключён атрибутом data-compact-ui — это внутренний инструмент, за компьютером, и там крупные кнопки только съедают строки таблицы.
| Зона | Высота | Почему так |
|---|---|---|
| Публичный сайт, мышь | 32 px | Компактно, кнопка не доминирует над текстом |
| Публичный сайт, сенсор | 44 px | Минимум тач-таргета iOS HIG; включается атрибутом на <html> |
| Кабинет менеджера | 36 px | Единая высота шапки, тулбаров и форм |
| Действия внутри строки | 28 px | Единственное исключение: строка должна оставаться узкой |
Компоненты
Компонентом становится то, что в коде лежит отдельным файлом. Секции страниц, формы и попапы собираются из примитивов прямо на экране — иначе библиотека начинает повторять вёрстку, а не описывать её.
Демонстрации ниже — не картинки из Figma, а разметка с теми же токенами, что в продукте. Каждая пара показывает светлую и тёмную тему: одни и те же классы, разные значения переменных.
Badge, Button и текстовые стили — отдельные примитивы, которые живут и вне неё.Примитивы
Button components/ui/button.tsx
Единственная кнопка на три продукта. Десять вариантов и восемь размеров — не роскошь: это ровно те роли, которые встречаются в интерфейсе, от акцентного действия до пункта бокового меню.
Размеры
Варианты
| Вариант | Заливка и рамка | Где используется |
|---|---|---|
default | --primary, рамка прозрачная | Главное действие: «Написать в Telegram», «Добавить клиента» |
outline | --background + рамка --border | Второстепенные действия, фильтры, экспорт |
secondary | --secondary | Отмена в попапах |
ghost | прозрачная, при наведении --muted | Иконки действий в строке таблицы |
destructive | --destructive 10%, текст --destructive | Удаление; сплошную заливку красным приберегли для подтверждения |
link | без фона, текст --primary | Ссылки внутри текста; наведение — сменой цвета, не подчёркиванием |
nav | наведение красит в --background | Боковое меню кабинета: сам сайдбар уже muted, ghost на нём не читался |
toggle | aria-pressed заливает акцентом целиком | Лайк и дизлайк: включённое состояние раньше читалось блёкло |
chip | --card + тень, без рамки | Выбранный фильтр на серой панели |
search | --foreground 3% → 8% | Триггер поиска в шапке: фон от foreground темнит в светлой теме и светлит в тёмной |
Состояния
| Состояние | Что меняется | Почему так |
|---|---|---|
hover | заливка до 80% | Одинаково работает на всех вариантах |
active | translate-y 1px | Физическое «нажатие»; у кнопок с выпадающим меню отключено |
focus-visible | ring 3px, --ring 50% | Кольцо — тень, а не рамка: не входит в размер и не двигает соседей |
aria-expanded | как hover | На сенсорном экране hover не существует — без этого открытый дропдаун не давал обратной связи |
disabled | opacity 50% | Клики не проходят |
aria-invalid | рамка и кольцо --destructive | Ошибка валидации в формах кабинета |
Badge components/ui/badge.tsx
Метка состояния или тега. Никогда не кликабельна — для кликов есть chip-вариант кнопки.
Input, Label, Textarea components/ui/input.tsx · label.tsx · textarea.tsx
Поле формы высотой 36 px — та же высота, что у кнопки сохранения рядом. Ошибка живёт не в рамке, а в подписи под полем: рамка только помечает, текст объясняет.
Checkbox components/ui/checkbox.tsx
Используется в настройке колонок таблицы и в согласиях. Размер 16 px, но область нажатия — вся строка с подписью.
Tabs components/ui/tabs.tsx
Переключение подмножества одного списка: «Все» и «Мои» у клиентов, статусы у сделок. Не навигация — раздел при этом не меняется.
Table components/ui/table.tsx
Строка, ячейка и заголовок — три отдельных примитива. Строкой целиком компонент не стал: свойства вложенных экземпляров в Figma нельзя пробросить наружу, и переключение колонок ломалось бы.
| Имя | Организация | Менеджер | ПДн | Действия |
|---|---|---|---|---|
| Иван Петров | Кофейня «Пар» | Мария Вы | Есть | |
| Анна Смирнова | ООО «Север» | Не назначен | Нет | |
| Сергей Волков | Автосервис «Гараж» | Пётр | Есть |
| Элемент | Значение | Решение |
|---|---|---|
| Шапка | --muted, текст --muted-foreground | Шапка не должна конкурировать с данными |
| Ячейка | padding 12px, 13/1.4 | Одна высота строки во всех таблицах кабинета |
| Свободный клиент | --warning-subtle → --warning-muted | Подсветка строки целиком: неразобранного видно периферийным зрением |
| Колонка действий | выравнивание вправо | Правка в код: раньше действия прижимались к центру пустой колонки |
DropdownMenu components/ui/dropdown-menu.tsx
Класс строки меню вынесен в menuItemClassName и переиспользуется в подборщиках клиента, подрядчика и тегов — до этого одна и та же строка была сверстана в пяти местах по-своему.
Notice components/ui/notice.tsx
Плашка-сообщение внутри страницы: объясняет состояние, а не требует действия. Всплывающих тостов в продукте нет — сообщение остаётся на экране, пока причина не исчезла.
Dialog components/ui/dialog.tsx
Одна форма на один заход: заполнил, сохранил, вернулся. Попапы не заводятся отдельными компонентами — каждый собирается из поля, кнопки и этой поверхности.
Skeleton components/ui/skeleton.tsx
Заглушка загрузки повторяет геометрию того, что появится: не серый прямоугольник вообще, а конкретные строки таблицы.
Доменные компоненты
Их немного и это осознанно: компонентом становится то, что повторяется в разных местах продукта, а не каждая секция страницы.
ContentCard components/content-card.tsx
Одна карточка на работу из портфолио и на статью блога. Разница между ними — вариант, а не второй компонент: у статьи дата прижата к низу.
DirectionCard components/direction-card.tsx
Девять направлений на главной. Карточка держит ряд и не подстраивается под длину заголовка — иначе каждое новое направление ломало бы сетку.
ContactCta components/contact-cta.tsx
Единственная точка заявки на сайте: формы захвата нет вообще. Кнопка уводит в бота диплинком, который несёт услугу и место перехода; запасной выход в браузере — кликабельный телефон.
ManagerSidebar components/manager/manager-sidebar.tsx
Боковое меню кабинета с группами: CRM, контент, администрирование. Группы не украшение — у дизайнера половина пунктов просто не появляется.
WebAppBottomNav components/web-app-bottom-nav.tsx
Внутри Telegram и Max шапка занята мессенджером, поэтому разделы уезжают вниз — в зону большого пальца. В браузере этой навигации нет: там её роль играет попап поиска.
Иконки
Один набор — lucide, 82 иконки. Толщина контура не подбирается на глаз, а считается: 2 × размер / 24. У 16 px это 1,33, у 14 — 1,17, у 12 — 1.
Проверка скриптом нашла 402 иконки с неправильной толщиной. И сразу же — обратный случай: у карточек направлений в коде стоит strokeWidth={1.5}, и 289 «починенных» иконок пришлось вернуть. Правило без исключения сломало бы то, что сделано осознанно.
Figma ↔ код
В Figma та же система разложена на восемь коллекций переменных. Тема и плотность — режимы коллекции, а не оси вариантов: иначе матрица кнопки удвоилась бы дважды.
199 переменных, 49 компонентов и 82 иконки; 21 набор с вариантами даёт 483 варианта — из них 361 у одной кнопки, потому что вариант × размер × состояние перемножаются.
| Коллекция | Режимы | Переменных | Что в ней |
|---|---|---|---|
Primitives | — | 22 | Сырые значения, на которые ссылается семантика |
Theme | Light / Dark | 31 | Роли: фон, текст, акцент, статусы |
Component | Light / Dark | 46 | Точечные исключения, включая цвета с запечённой прозрачностью |
Radius | — | 10 | Шкала скруглений от --radius |
Spacing | — | 29 | Отступы и зазоры |
Density | Fine / Touch | 12 | Высоты контролов под мышь и под палец |
Typography | — | 27 | Кегли, межстрочные, начертания |
Breakpoint | Mobile / Desktop | 22 | Размеры и видимость, зависящие от ширины |
Что в Figma сделано иначе, чем в коде
| Место | Как в коде | Как в макете и почему |
|---|---|---|
| Прозрачные слои | bg-foreground/5 | Отдельный токен с запечённой прозрачностью: привязанная к переменной заливка игнорирует прозрачность слоя |
| Кольцо фокуса | ring-3 ring-ring/50 | Тень с разбросом 3: не входит в размер и не двигает соседей |
| Обрезка содержимого | overflow-hidden | Включена только там, где класс есть в коде: иначе срезает тени |
| Строка таблицы | компонент TableRow | В макете компонентом не стала: свойства вложенных экземпляров нельзя пробросить наружу |
| Маркеры тем форума | эмодзи ❗ и 🔵 | В макете — глифы Inter: Figma не рендерит цветные эмодзи ни в экспорте, ни в превью |