@zoloto585/facet
| Install | |
|---|---|
npm install @zoloto585/facet |
|
| Latest Version: | 2.5.0 |
| Vue: | 3 |
| Last Updated: | Oct 23, 2026 |
| Links: | npm |
@zoloto585/facet
UI-кит для Nuxt/Vue админок Zoloto585. Vue 3 + TypeScript, единственная рантайм-зависимость — vue
(peerDependency, ^3.5.0). Никакого Tailwind/PrimeVue/Nuxt UI/Element Plus/Vuetify, никаких иконных
наборов, никакого floating-vue/Popper/vuedraggable/chart-библиотек внутри — всё позиционирование
оверлеев, drag-and-drop и графики написаны на чистом Vue/CSS/SVG. Это осознанная политика нулевых
зависимостей: каждая внешняя библиотека — это версионный конфликт у потребителя рано или поздно и
лишний вес в тарболе, поэтому пакет несёт только то, что написано здесь.
Живые примеры всех компонентов, их таблицы props/slots/events и составленные из них шаблоны экранов
живут в витрине apps/showcase монорепозитория кита — она пока не выложена по публичному адресу,
поэтому смотреть её можно только локально (pnpm install && pnpm dev в клоне репозитория). Если
доступа к репозиторию нет, вся нужная информация всё равно приехала к вам вместе с пакетом: у
каждого компонента в начале файла лежит purpose-комментарий с назначением и ключевыми словами, а
точные типы пропсов, слотов и событий — в его же <script setup lang="ts">. Как искать по ним —
раздел «Как найти нужный компонент» ниже.
Если в проекте есть AI-агент (Claude Code и т.п.)
node_modules обычно не входит в область, которую агент сканирует автоматически — этот README и
searchable purpose-комментарии компонентов (раздел «Как найти нужный компонент» ниже) он увидит,
только если специально туда заглянет. Без этого агент нередко пишет код по API других UI-китов
(PrimeVue/Ant Design/shadcn) вместо реального API кита — а часть API здесь нарочно другая
(UiStat ≠ UiKpiCard, UiChip без привязки selected — статичная метка, а не кнопка, и т.п.).
Чтобы агент узнавал о ките сразу, добавьте в AGENTS.md/CLAUDE.md своего проекта (не кита) вот
такой блок целиком — он написан как инструкция для агента, а не как справка для человека:
## UI-кит @zoloto585/facet
Все Ui*-компоненты в этом проекте — из `@zoloto585/facet` (node_modules/@zoloto585/facet или
workspace-пакет `packages/facet`, смотря как подключено). Перед тем как использовать компонент:
1. Прочитать `node_modules/@zoloto585/facet/README.md` целиком — quickstart, темы/гаммы,
мотор-контракт `data-motion`, миграции в `CHANGELOG.md` того же пакета.
2. Если не знаете точное каноническое имя нужного компонента — искать по смыслу задачи, а не
угадывать по аналогии с другими UI-китами:
`grep -ril "ключевое слово задачи" node_modules/@zoloto585/facet/src/components`
3. Прочитать purpose-комментарий (`/** ... */`) в начале файла найденного компонента — пропсы/слоты
описаны там словами, точные типы — в самом `<script setup lang="ts">`.
4. Не дублировать разметку/CSS компонента кита вручную — если нужна кнопка/бейдж/поповер/т.п.,
это `UiButton`/`UiBadge`/`UiPopover`/…, а не рукописный аналог.
Остальные разделы этого README копировать в свой AGENTS.md/CLAUDE.md не нужно — агент дойдёт до
них сам по пункту 1 после того, как найдёт этот блок в своём файле инструкций.
Установка
pnpm add @zoloto585/facet
Требования: Vue >= 3.5 (Nuxt >= 3.13)
Минимальная версия Vue — 3.5.0 (объявлена как peerDependencies: { "vue": "^3.5.0" }).
На Vue 3.4 и ниже кит не работает: 15 компонентов (UiField, UiSelect, UiMultiSelect,
UiRadioGroup, UiFieldForm, UiTabs, UiModal, UiFormModal, UiConfirmDialog, UiDrawer,
UiFullscreenViewer, UiCommandPalette, UiPopover, UiTooltip, UiHeatmap) генерируют
DOM-идентификаторы для связок label[for] /
aria-labelledby / aria-controls через useId(), появившийся именно в Vue 3.5. На более старой
версии импорт разрешится в undefined, и компонент упадёт в рантайме с useId is not a function —
сразу при первом рендере, ещё до того как что-то отрисуется.
Почему не «свой счётчик» или Math.random(), ради совместимости с 3.4: кит нацелен на Nuxt-админки,
то есть на SSR с гидратацией. Идентификатор обязан совпасть байт в байт между серверным и клиентским
рендером, иначе Vue сообщает hydration mismatch и, что хуже, label[for] перестаёт указывать на
существующий инпут — доступность ломается молча. useId() — единственный способ получить такой
стабильный id из коробки: Vue выдаёт его детерминированно по порядку создания компонентов и
синхронизирует между сервером и клиентом; модульный счётчик в Node общий на все параллельные запросы,
а Math.random() не совпадёт по определению.
Пакет публикуется без сборки: потребитель получает .vue/.ts-исходники как есть (vue-tsc/Vite
потребителя компилируют их сами), поэтому дополнительных dist/шагов сборки не требуется.
Включите strictTemplates — иначе половина ошибок будет молчаливой
В tsconfig.json вашего проекта:
{ "vueCompilerOptions": { "strictTemplates": true } }
Это не вкусовая настройка, а условие, при котором vue-tsc вообще способен проверять использование
кита. Без неё несуществующий или переименованный проп не ошибка: Vue молча складывает его в
$attrs и вешает HTML-атрибутом на корневой элемент, сборка остаётся зелёной, компонент просто
ведёт себя как будто пропа не передали. Проверено на самом ките, а не выведено из документации: при
переименовании v-model:open → v-model у UiPopover сломались четыре собственных вызова внутри
пакета, и vue-tsc не сказал ни слова. С включённым strictTemplates те же четыре места
подсвечиваются сразу.
Практическое следствие для обновлений: раздел «Миграция» в CHANGELOG.md делит пункты на те, что
ловит typecheck, и те, что не ловит никто — вторых обычно большинство. С strictTemplates
первая категория становится заметно шире, и по грепам руками приходится ходить меньше.
Шрифты
Кит рассчитан на Geist (интерфейсный шрифт, --font-ui) и JetBrains Mono (моноширинный,
--font-mono), но сами шрифты в пакет не входят — это осознанное решение: пакет source-only
(files: ["src", …]), и добавление бинарников шрифтов раздуло бы тарбол и потянуло бы за собой
рантайм-зависимость, которой в ките, кроме vue, быть не должно. Подключить шрифты — ответственность
потребителя; ниже — два готовых способа.
- Без шрифта кит не «выглядит чуть иначе» — часть настроек типографики буквально не к чему
применить.
font-feature-settings: 'cv11' 1, 'ss01' 1(стилистические альтернаты Geist) не имеет эффекта ни на одном другом шрифте.--tracking-tight: -0.014emпосчитан под метрики конкретно Geist и наsystem-uiдаёт другую, не ту задуманную, плотность заголовков — где-то теснее, чем нужно, где-то свободнее.
Nuxt (@nuxt/fonts, шрифты подтягиваются и самохостятся автоматически):
export default defineNuxtConfig({
modules: ['@nuxt/fonts'],
fonts: {
families: [
{ name: 'Geist', provider: 'google' },
{ name: 'JetBrains Mono', provider: 'google' },
],
},
})
Чистый Vite (так же подключает шрифты витрина кита) — пакеты Fontsource несут готовые
self-hosted woff2 и @font-face, без обращения к CDN в рантайме:
pnpm add @fontsource-variable/geist @fontsource-variable/jetbrains-mono
// main.ts — до импорта токенов кита
import '@fontsource-variable/geist'
import '@fontsource-variable/jetbrains-mono'
import '@zoloto585/facet/tokens.css'
Оговорка: стек --font-ui принимает оба написания — 'Geist' (например, если шрифт пришёл через
@nuxt/fonts) и 'Geist Variable' (имя семейства, под которым его регистрирует Fontsource); то же
для --font-mono и 'JetBrains Mono' / 'JetBrains Mono Variable'. Поэтому оба способа выше работают
без переопределения токенов. Если нужен свой шрифт — переопределите --font-ui/--font-mono в своём
CSS после tokens.css, но тогда cv11/ss01/--tracking-tight стоит перепроверить под его
метрики: они посчитаны под Geist и на другом шрифте могут быть не к месту.
Утилиты
Кит несёт необязательный файл готовых CSS-классов — src/styles/utilities.css. Он не подключается
ни tokens.css, ни каким-либо компонентом кита: токены обязаны работать всегда, а глобальные классы
навязывать потребителю нельзя. Подключение — на ваше усмотрение:
import '@zoloto585/facet/styles/utilities.css'
- Числа:
.tnum(моноширинное выравнивание цифр,font-variant-numeric: tabular-nums— не затираетfont-feature-settingsшрифта, в отличие от альтернативного диалекта),.font-mono. - Типографика:
.tracking-tight,.sr-only, а также классы ролей (.text-page-title,.text-section-title,.text-eyebrow,.text-caption,.text-table-header,.text-kpi-value,.text-kpi-label,.text-mono) — полное описание каждой роли и когда её применять см. раздел «Язык системы».
Осторожно с именами. Классы намеренно короткие (.tnum, .font-mono, .sr-only) и могут
пересечься с утилитами Tailwind/UnoCSS или вашим собственным CSS — если проект уже на утилитарном
фреймворке, этот файл ему, скорее всего, не нужен.
Quickstart
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import '@zoloto585/facet/tokens.css'
import '@zoloto585/facet/themes/amber.css' // дефолтная гамма — обязательна
createApp(App).mount('#app')
<script setup lang="ts">
import { UiButton, UiCard } from '@zoloto585/facet'
</script>
<template>
<UiCard>
<UiButton variant="primary" @click="() => {}">Сохранить</UiButton>
</UiCard>
</template>
Импортировать можно как из барреля (import { UiButton } from '@zoloto585/facet'), так и точечно —
это может уменьшить объём файлов, которые видит инструмент сборки потребителя, за счёт прямого пути:
import UiButton from '@zoloto585/facet/components/UiButton/UiButton.vue'
Директива v-tooltip
Подсказка не только компонентом, но и атрибутом на уже существующем элементе — для мест, где
обёртка UiTooltip физически мешает (ячейка таблицы со своей раскладкой, элемент внутри
inline-flex, сгенерированная разметка). Поведение общее с UiTooltip: та же задержка,
hover + focus, Escape, aria-describedby, пустой текст = подсказки нет вовсе.
<script setup lang="ts">
import { vTooltip } from '@zoloto585/facet'
</script>
<template>
<td v-tooltip="row.comment">{{ row.title }}</td>
<button v-tooltip.right="'Обновить'">↻</button>
<span v-tooltip="{ value: text, disabled: !text, showDelay: 400 }">…</span>
</template>
Глобальная регистрация, если удобнее так: app.directive('tooltip', vTooltip).
- Значение — строка или объект
{ value, text, placement, offset, openDelay, showDelay, disabled, focusOnly }.value/showDelay— имена ключей из PrimeVue,text/openDelay— те же настройки в терминах кита. - Модификаторы:
.top,.bottom,.left,.right,.auto— сторона;.focus— показ только по фокусу, без наведения мышью. - Мигрируете с
v-tooltipPrimeVue: сторона по умолчанию здесьtop, а у PrimeVue былаright— вызывающему месту, которому нужна прежняя сторона, допишите.right. Ключи оформления (class,pt,fitContent,escape) не поддерживаются: внешний вид подсказки задаёт кит, а не вызывающее место.
Типы
Все публичные типы (объектные пропы, опции, элементы списков, варианты/размеры) реэкспортированы из того же барреля рядом со своим компонентом — глубокий импорт ради типа не нужен:
import {
UiDataTable,
type UiDataTableColumn,
type UiSelectOption,
type UiButtonVariant,
} from '@zoloto585/facet'
const columns: UiDataTableColumn[] = [{ key: 'name', label: 'Название' }]
Имя типа всегда начинается с имени своего компонента (UiTreeNode, UiStepItem,
UiKpiCardDelta, …) — исключения остались только у самых ранних компонентов кита
(ModalSize, BadgeTone/BadgeSize, AlertTone, ProgressSize, SpinnerSize/SpinnerTone,
SkeletonVariant, ConfirmDialogTone, StatusPillTone/StatusPillSize,
TableActionButtonTone, DrawerSide/DrawerSize, RewardBadgeSize, SaveStatusValue,
FullscreenViewerVariant, ColorScheme, Toast*).
Диапазоны дат: from и to — всегда начало дня
UiDateRangeField (и UiDatePicker) отдают в модель обычный Date, у которого время всегда
обнулено до локальной полуночи (00:00:00.000) — каким бы из трёх способов дату ни выбрали:
клик по календарю, ручной ввод, пресет. Диапазон поэтому полуоткрытый — [from, to), и видимый
в поле «08.08.2026» означает ровно одно и то же время независимо от способа выбора.
Это зафиксированное решение кита, а не недосмотр: настраиваемого inclusive/toEndOfDay у поля
нет и не планируется. Раньше пресеты отдавали конец дня (23:59:59.999), а клик — начало, и
фильтр родителя молча давал разные выборки на одинаковом с виду диапазоне; инвариант «оба конца —
начало дня» ровно этот класс бага и закрывает. Хелпера «привести к концу дня» кит тоже не держит:
как трактовать верхнюю границу, знает бэкенд потребителя, а не UI-кит.
Что это значит для вас. Если ваш бэкенд сравнивает верхнюю границу включительно (<=,
lte в Elasticsearch-range, BETWEEN в SQL), то передавать to как есть нельзя — из выборки
молча выпадет весь последний выбранный день. Приведите границу у себя, в одном месте — там, где
значение поля превращается в параметр запроса:
/** Верхняя граница «по эту дату включительно» для бэкенда, который сравнивает через <=. */
function toInclusiveEnd(to: Date): Date {
const end = new Date(to)
end.setHours(23, 59, 59, 999)
return end
}
// либо, если бэкенду удобнее полуоткрытый интервал — сдвиньте границу на день и сравнивайте через `<`:
function toExclusiveEnd(to: Date): Date {
const end = new Date(to)
end.setDate(end.getDate() + 1)
return end
}
Поломка этого класса молчаливая: ни typecheck, ни рантайм её не увидят — запрос уходит валидным, просто короче на день. Поэтому границу стоит прикрыть тестом на своей стороне: «заказ, созданный в последний день диапазона в 23:30, попадает в выборку».
Тёмная тема и цветовые гаммы
Кит поддерживает 2 независимые оси кастомизации, которые свободно комбинируются:
- Светлая/тёмная тема — атрибут
data-theme="dark"на<html>(по умолчанию светлая, если атрибута нет; без атрибута вообще кит следует системномуprefers-color-scheme). Токены сами меняют значения под[data-theme="dark"]— от потребителя ничего, кроме простановки атрибута, не требуется. В тёмной теме инвертируется по светлоте не только нейтральная шкала, но и акцентная:--accent-50/100становятся тёмными тонированными подложками, а--accent-700/800— светлым текстом поверх них (роли шагов те же, что в светлой теме, просто зеркально). - Цветовая гамма — атрибут
data-color-scheme="amber|malachite|garnet|sapphire"на<html>. Подключите файл нужной гаммы (@zoloto585/facet/themes/<scheme>.css) и переключайте атрибут — либо вручную, либо через готовый composable:
import { useColorScheme } from '@zoloto585/facet'
const { colorScheme, setColorScheme, schemes } = useColorScheme()
// schemes === ['amber', 'malachite', 'garnet', 'sapphire']
setColorScheme('sapphire') // меняет атрибут + сохраняет выбор в localStorage
Статусные цвета (success/danger/warning/info) не зависят от гаммы — тематизируется только
акцентный/брендовый цвет. Если подключена не вся палитра тем, а только одна — достаточно только её
CSS-файла, useColorScheme не обязателен.
Своя (5-я) гамма
Компоненты кита никогда не ссылаются на --amber-*/--malachite-*/--garnet-*/--sapphire-*
напрямую — только на семантический алиас --accent-50…800 (+ --accent-500-rgb для градиентов).
Чтобы добавить свою гамму, не входящую в стандартные 4 — переопределите эти 9 переменных (+ rgb-версию
--accent-500) в любом CSS, подключаемом после файлов гамм (themes/*.css, не только после
tokens.css):
:root[data-color-scheme='my-brand'] {
--accent-50: #...;
/* ...--accent-100 … --accent-800 */
--accent-500-rgb: 202 88 117; /* ЧЕРЕЗ ПРОБЕЛ, не запятые: значение подставляется
в современный синтаксис rgb(... / a) — см. ниже */
}
Три вещи, на которых легко молча промахнуться:
- Селектор —
:root[data-color-scheme='my-brand'], не голый[data-color-scheme='my-brand']. Дефолтная гамма Amber объявлена под:root, :root[data-color-scheme="amber"], и ветка с голым:rootдействует безусловно. У неё та же специфичность (0-1-0), что у голого атрибутного селектора, поэтому при подключении вашего файла раньшеamber.cssпобедит Amber — гамма просто не применится, без единой ошибки в консоли. Вариант с:root(0-2-0) выигрывает при любом порядке. --accent-500-rgb— три числа через пробел (202 88 117), как во всех 4 штатных файлах гамм. Кит подставляет их вrgb(var(--accent-500-rgb) / 0.28); с запятыми получитсяrgb(202, 88, 117 / 0.28)— смесь legacy-запятых и слэш-нотации, невалидная в обеих грамматиках. Отвалится тихо и сразу в двух местах:--focus-ring(индикация фокуса с клавиатуры в 60+ местах кита) и--gradient-hero-glow(подсветка hero-вариантовUiCard/UiPageHeader/UiEntityHeader).- Ваш
--accent-500должен быть отличим от статусных шкал. Статусы (success/danger/warning/info) от гаммы не зависят и подвинуться под вас не смогут, а стоят рядом с акцентом в одних и тех же местах — точкиUiTimeline, полосыUiToast, чипыUiGanttGrid, серииUiBarChart/UiLineChart. Их тон в OKLCh:dangerh≈27°,warningh≈47°,successh≈162° в light и ≈146° в dark,infoh≈262° в light и ≈276° в dark (обе шкалы в dark сдвинуты специально — см. комментарии вtokens.css). Если ваш accent-500 попадает ближе ~20° к одному из них при близкой светлоте — статус и бренд сольются в одно пятно (ровно этот дефект и правился в 0.3.0:warningбыл байт-в-байт равен accent гаммы Amber). Сдвиньте accent по тону или по светлоте; и проверьте обе темы — в dark accent-шкала инвертируется и соседи у неё другие.
Если приложение поддерживает тёмную тему, своей гамме нужна ещё и dark-ветка: иначе светлые
--accent-50/100, используемые как фон, останутся светлыми под почти белым --ink-1. Обе формы
оси темы (системная и явная) — как у штатных гамм:
@media (prefers-color-scheme: dark) {
:root[data-color-scheme='my-brand']:not([data-theme='light']) {
/* тёмные значения: все 9 шагов + --accent-500-rgb */
}
}
:root[data-color-scheme='my-brand'][data-theme='dark'] {
/* те же тёмные значения */
}
Ориентир по светлоте (OKLab L): 50 ≈ 0.27, 100 ≈ 0.33, 200 ≈ 0.43, 300 ≈ 0.55, 400 ≈ 0.65,
500 ≈ 0.74, 600 ≈ 0.81, 700 ≈ 0.87, 800 ≈ 0.93 — то есть шкала зеркалится, роли шагов
сохраняются. Готовый пример с числами и проверками контраста — src/tokens/themes/amber.css.
Движение и prefers-reduced-motion
tokens.css содержит глобальное правило, которое действует на всю страницу — включая разметку
и анимации самого приложения, а не только компоненты кита:
@media (prefers-reduced-motion: reduce) {
*:not([data-motion='essential']),
*:not([data-motion='essential'])::before,
*:not([data-motion='essential'])::after {
animation: none !important;
transition: none !important;
}
}
Почему сброс глобальный, а не «каждый компонент гасит себя сам»: движение под этой настройкой
должно исчезать целиком со страницы, а не выборочно там, где автор вспомнил. Но у безусловного
сброса есть цена — он останавливает и индикаторы хода операции: замерший спиннер неотличим от
декоративного кружка, и пользователь теряет единственный признак того, что запрос ещё выполняется.
Смысл рекомендации WCAG — снизить интенсивность движения, а не удалить информацию, поэтому в ките
принят opt-in контракт: элемент с атрибутом data-motion="essential" из сброса исключён.
Как пережить сброс своей значимой анимации
Две обязательные части, одной недостаточно:
- Атрибут
data-motion="essential"на ТОМ ЖЕ элементе, на котором объявленаanimation. Селектор исключает только сам элемент с атрибутом — его потомки под сброс по-прежнему попадают. - Собственное замедление в своём
@media-блоке. Исключение из сброса ничего не замедляет: без этого шага анимация под «уменьшить движение» пойдёт на полной скорости, то есть настройка будет проигнорирована.
<template>
<span class="sync-indicator" data-motion="essential" />
</template>
<style scoped>
.sync-indicator {
animation: sync-pulse var(--duration-pulse) var(--ease-in-out) infinite;
}
@media (prefers-reduced-motion: reduce) {
.sync-indicator {
animation-duration: calc(var(--duration-pulse) * 3);
}
}
</style>
Атрибут — только для индикаторов состояния (спиннеры, indeterminate-прогресс, «идёт синхронизация»). Не для декора, не для появления/уезжания панелей и не для shimmer-скелетона: там форма плейсхолдера сама сообщает «грузится», а движение — украшение.
Точечное @media-переопределение без атрибута не работает в принципе: шорткат animation: none
сбрасывает и animation-name, и animation-duration, а !important бьёт любую специфичность —
поднять её со стороны компонента невозможно. Это единственная причина, по которой контракт вообще
существует.
Внутри кита помечены: спиннер UiButton, спиннер UiSplitButton, вращающаяся иконка
UiHeaderActionButton (только при spinning), корень UiSpinner, indeterminate-полоса
UiProgress. Всё остальное движение кита под reduce останавливается полностью.
Референс токенов
Компактная выжимка — сколько ступеней в каждой шкале и в каком диапазоне: ориентир, чтобы прикинуть,
каким токеном пользоваться, не открывая браузер. Полные значения с комментариями «почему именно
столько» — в src/tokens/tokens.css пакета, он приезжает к вам вместе с исходниками. Интерактивный
референс (живые свотчи, обе темы, все 4 гаммы) — в разделе «Токены» витрины apps/showcase
монорепозитория, локально.
| Группа | Диапазон | Ступени |
|---|---|---|
Отступы --space-* |
--space-1 … --space-24 |
14 ступеней, шаг 4px: 4, 8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64, 80, 96px |
Типографика --text-* |
--text-2xs … --text-4xl |
10 ступеней: 11, 12, 13, 14 (база), 15, 18, 22, 28, 36, 48px |
Радиусы --radius-* |
--radius-2xs … --radius-pill |
7 ступеней: 2, 4, 6, 8, 12, 16, 999px |
Тени --shadow-* |
--shadow-xs … --shadow-xl + --shadow-inset |
6 уровней, нейтральные (не завязаны на accent) — xs/sm для контролов и KPI-карточек, md для hover карточки, lg для поповера/дропдауна, xl для модалки |
Высота контролов --control-h-* |
sm / md / lg |
28 / 34 / 40px — кнопка, поле, сегмент, страничная кнопка пагинатора |
Иконки --icon-* |
xs / sm / md / lg |
12 / 14 / 16 / 20px — таблица контекстов в разделе «Иконки» ниже |
Слои --z-* |
--z-sticky … --z-toast |
sticky: 1, header: 20, floating: 30, backdrop: 40, modal: 50, popover: 60, toast: 70 — оверлей всегда поверх того, из чего он вызван |
| Каркас страницы | --header-h, --layout-gutter, --container-max, --container-narrow, --container-pad-x/y, --bp-compact |
72px — базовая высота липкой шапки (UiAppHeader); в двухстрочной раскладке до 1200px высота растёт по содержимому. Sticky-смещения должны учитывать фактическую высоту шапки. --layout-gutter — задаваемые приложением боковые поля шапки (адаптивное значение по умолчанию описано ниже). 1280px — колонка приложения, 480px — публичный экран (оба через UiContainer). --bp-compact (640px) — точка перелома оболочки: ниже неё двухколоночная раскладка не помещается и сайдбар пора уводить в UiDrawer (см. рецепты layout). ⚠️ @media не принимает var() в условии, поэтому --bp-compact документирует число и отдаёт его в JS (getComputedStyle → matchMedia), но в самих @media его придётся написать литералом |
Плюс три семантических слоя поверх этих шкал: статусные цвета --success/danger/warning/info-50|500|600|700
(фиксированы во всех 4 гаммах, не зависят от data-color-scheme), health-алиасы
--health-healthy|at-risk|dead|unknown (+ -bg) с опорными точками --heatmap-empty|min|max —
healthy намеренно нейтральный (--ink-2), не зелёный: зелёный зарезервирован за подтверждением
операции — и палитра серий графиков --chart-1…6 (UiBarChart/UiLineChart). У последней
--chart-1 = --accent-500, то есть первая серия следует за выбранной гаммой, а --chart-2…6
фиксированы во всех гаммах и имеют свои значения для light и dark. Свою палитру серий задавайте
переопределением этих шести переменных в одном месте, а не пропом color у каждой серии:
:root { --chart-2: #0F766E; --chart-3: #7C3AED; }
:root[data-theme="dark"] { --chart-2: #2DD4BF; --chart-3: #C4B5FD; }
Если переопределяете — проверьте попарную различимость цветов численно (критерий: ΔE OKLab ≥ ~0.09
между всеми шестью сериями, во всех гаммах, которыми пользуетесь, и в обеих темах); статусные цвета
в серии графика лучше не ставить — у них зарезервированная семантика. Полные значения и комментарии «почему так» — в src/tokens/tokens.css пакета, он доезжает
до потребителя вместе с исходниками (files пакета — src, README.md, CHANGELOG.md).
Язык системы
Кит даёт вам компоненты и токены, но композицию — что из чего собрать и как это должно выглядеть
рядом — не решает: это осознанная гибкость, а не недосмотр. У неё есть цена: двое разработчиков,
взяв один и тот же набор Ui*, соберут два экрана разного духа, и формально оба будут правы — ни
vue-tsc, ни рантайм этого не поймают. Раздел ниже — не справочник по API (он в
purpose-комментариях исходников и в разделе «Как найти нужный компонент» ниже), а свод решений:
что выбрать, чего не делать и каким токеном красить конкретный смысл, а не какой понравится. Это
единственное место, которое физически едет к вам вместе с пакетом — витрина в npm-тарбол не входит
(раздел «Референс токенов» выше).
Принципы
- Плотность важнее воздуха. Списки и таблицы — рабочая поверхность админки, а не витрина:
строка контрола/таблицы — 28-36px (
--control-h-*/--row-h-*), действия живут прямо в строке (UiTableActionButton), а не уводятся на отдельный экран. Цель — окинуть взглядом больше данных за один скролл, а не «подышать» пустым пространством. - Цвет — это семантика, а не украшение. Зелёный и красный нельзя ставить «чтобы было
заметнее»: красный зарезервирован за
dead/destructive, зелёный — за подтверждением уже случившегося действия (тост, сохранено). Всё остальное — нейтральный--ink-*. Подробнее — подраздел «Severity» ниже. - Волоски, а не коробки. Разделители —
1px solid var(--hairline), не рамка с тенью вокруг каждого блока. Тень (--shadow-*) зарезервирована за тем, что физически всплывает над плоскостью страницы — карточка под курсором, поповер, модалка; подраздел «Тени по слоям» ниже расписывает это по уровням. - Числа — это данные, а не текст. Суммы, счётчики, идентификаторы набираются
tabular-nums(утилита.tnumили свойствоfont-variant-numeric: tabular-numsнапрямую — раздел «Утилиты» выше): колонка не должна визуально «прыгать» при обновлении значения. - Публичный экран — другой регистр. Страницу, которую видит не администратор (вход,
восстановление пароля, подтверждение по ссылке), собирайте крупнее и без навигации:
UiContainer size="narrow", поля и кнопкаsize="lg", минимум хрома вокруг. Готовый рецепт — подраздел «Рецепты layout» ниже. - Токены или ничего. Нет нужного цвета/отступа/радиуса среди токенов — сначала заводится
переменная (в своём CSS, после
tokens.css), а не подставляется#hex/padding: 14pxс цифры. Внутри самого кита это проверяет автоматический скрипт в CI; у потребителя такого скрипта нет, поэтому здесь дисциплина держится только на вас. - Движение — opt-in.
prefers-reduced-motionгасит все анимации, кроме элементов сdata-motion="essential"— полный контракт в разделе «Движение иprefers-reduced-motion» выше. Если добавляете своё движение поверх компонентов кита, тот же контракт распространяется и на него.
Чего не делать
| Не надо | Почему |
|---|---|
Красить строку таблицы целиком по статусу (фон --danger-50 на весь <tr>) |
Цвет на всю ширину экрана читается как «всё сломалось», а не «эта строка проблемная». Предел — узкая цветная полоса слева у критичной строки: в UiDataTable это проп rowTone, см. «Severity» ниже. Фон выбранной строки (rowSelected, --accent-50) — выбор пользователя, а не статус, и правилу не противоречит. |
Вешать декоративный градиент (--gradient-accent-*) на обычную поверхность — карточку списка, панель фильтров |
Градиенты кита зарезервированы за акцентными элементами (UiKpiCard, hero-варианты UiCard/UiPageHeader/UiEntityHeader, UiBrandMark). Если градиент есть у каждой третьей карточки, он перестаёт читаться как акцент. |
Скруглять кнопки и поля больше --radius-lg (например --radius-xl/--radius-pill) |
--radius-pill в ките зарезервирован за пилюлями (UiBadge, UiStatusPill, UiChip) — кнопка с тем же скруглением визуально путается с меткой состояния. |
Накладывать несколько уровней теней на один элемент (--shadow-md вместе с --shadow-lg) |
Elevation в ките одноступенчатый: одна тень — один слой (см. «Тени по слоям» ниже). Наложение уровней не читается как «выше», а читается как грязный край. |
Ставить разный gap/padding у соседних карточек одного грида «на глаз» |
Ритм страницы разъезжается ещё до того, как заметна причина. Один уровень вложенности — одна ступень --space-* на весь грид, не подбор по месту. |
Подключать свой шрифт «потому что красивее», не трогая --tracking-tight/cv11/ss01 |
Трекинг и стилистические альтернаты в ките посчитаны под метрики Geist (раздел «Шрифты» выше); чужой шрифт с той же настройкой плотности сжимается или растягивается непредсказуемо. |
| Держать зелёную плашку «Сохранено» под формой на постоянной основе | Зелёный — это подтверждение, оно обязано исчезнуть само (тост, UiSaveStatus), а не стоять статичным блоком — иначе экран выглядит так, будто на нём вечно что-то только что произошло. |
| Выкладывать 3+ инлайн-действия в строке таблицы иконками в ряд | После третьей иконки строка превращается в панель инструментов. Оставляйте в строке 1-2 самых частых действия и кнопку «ещё», остальное — в меню. |
Роли типографики
Восемь из одиннадцати ролей ниже уже оформлены готовым CSS-классом в src/styles/utilities.css
(раздел «Утилиты» выше) — таблица и код специально построены значение-в-значение, чтобы не
разойтись. У Subtitle, Body и Table cell отдельного класса нет (это либо базовое поведение body,
либо унаследованные от родителя значения) — расставляйте эти три вручную теми же токенами.
| Роль | Размер | Вес | Цвет | Трекинг / особенности | Класс-утилита |
|---|---|---|---|---|---|
| Page H1 | --text-2xl (28px) |
--weight-bold |
--ink-1 |
--tracking-tight, --leading-tight |
.text-page-title |
| Section H2 | --text-lg (18px) |
--weight-semibold |
--ink-1 |
--tracking-tight |
.text-section-title |
| Subtitle | --text-sm (13px) |
--weight-regular |
--ink-3 |
--leading-normal |
— (см. UiPageHeader description) |
| Eyebrow | --text-2xs (11px) |
--weight-semibold |
--ink-3 |
--tracking-wide, uppercase |
.text-eyebrow |
| Body | --text-base (14px) |
--weight-regular |
--ink-1 |
--leading-normal (значения body по умолчанию) |
— |
| Caption | --text-sm (13px) |
--weight-regular |
--ink-3 |
— | .text-caption |
| Table header | --text-2xs (11px) |
--weight-semibold |
--ink-3 |
--tracking-wide, uppercase |
.text-table-header |
| Table cell | --text-sm (13px) |
--weight-regular |
--ink-1 |
— | — (см. UiDataTable/UiJobQueueTable) |
| KPI value | --text-3xl (36px) |
--weight-semibold |
--ink-1 |
--tracking-tight, tabular-nums |
.text-kpi-value |
| KPI label | --text-2xs (11px) |
--weight-semibold |
--ink-3 |
--tracking-wide, uppercase |
.text-kpi-label |
| Mono | --text-xs (12px) |
--weight-regular |
--ink-2 |
font-family: var(--font-mono) |
.text-mono |
Тени по слоям
Пять уровней, один слой на элемент — не смешивайте несколько сразу (см. «Чего не делать» выше):
--shadow-xs— состояние покоя карточек и контролов:UiCard,UiEntityCard,UiResourceCard,UiKpiCard, обведённые состоянияUiButton.--shadow-sm— небольшой hover-подъём у карточек с лёгким взаимодействием (UiEntityCard,UiResourceCard) и у мелких тактильных элементов (UiSlider,UiRewardBadge).--shadow-md— hover карточки, которая целиком кликабельна как один блок (UiCardinteractive), и плавающих элементов страницы (UiScrollToTopButton): сигнал «эта поверхность интерактивна», заметнее, чем--shadow-sm.--shadow-lg— всплывающие панели у триггера:UiPopover,UiTooltip, листбоксыUiSelect/UiMultiSelect,UiToast.--shadow-xl— модальные слои:UiModal,UiDrawer,UiCommandPalette,UiFullscreenViewer.
Слои (z-index)
Числа — в разделе «Референс токенов» выше и в tokens.css; здесь — принцип, который важнее самих
чисел. Шкала растёт в одном порядке: --z-sticky → --z-header → --z-floating → --z-backdrop → --z-modal → --z-popover → --z-toast, и правило одно — оверлей всегда рисуется поверх того, из
чего он был вызван. На практике это значит: UiSelect, открытый внутри UiModal, обязан
оказаться над модалкой, а не под ней (--z-popover выше --z-modal) — иначе список опций
телепортируется в body и рисуется под фоном диалога, невидимо и некликабельно. Тост об ошибке,
всплывший поверх открытой модалки, обязан перекрыть и её (--z-toast — самый верхний слой) —
иначе крестик закрытия тоста существует в DOM, но недоступен под диалогом. Свой оверлей поверх
компонентов кита размещайте относительно этой шкалы, а не произвольным числом «на глаз побольше».
Severity: чем красить состояние
Правила поверх health-алиасов (--health-healthy|at-risk|dead|unknown, реализованы в UiStatusPill):
- healthy — нейтральный
--ink-2(--health-healthy), не зелёный: зелёный зарезервирован за подтверждением уже случившейся операции (принцип 2 выше), а не за фактом «сущность жива». - at-risk —
--warning-*(--health-at-risk) — в ките это оранжевый, разведённый с брендовым акцентом каждой гаммы; не путайте предупреждение с обычной интерактивной подсветкой. - dead — единственное законное место для
--danger-*(--health-dead) в контексте здоровья сущности. Не занимайте danger под менее критичные состояния — иначе для настоящей проблемы не останется цвета. - unknown —
--ink-3(--health-unknown), не пустая ячейка: отсутствие данных — тоже состояние, и его стоит показать явно, а не молчанием. - Не красьте строку целиком. Максимум — узкая полоса 3px слева у критичной строки, сама строка
остаётся на обычном
--surface. Полоса рисуетсяbox-shadow: inset 3px 0 0на первой ячейке, а не рамкой: приborder-collapseрамка ячейки спорит с рамками соседей и сдвигает содержимое, тень — нет. ВUiDataTableеё ставит пропrowTone('danger'— dead,'warning'— at-risk). Статус всё равно дублируется текстом в ячейке (UiStatusPill): цвет не единственный носитель смысла. - Сортировка по умолчанию, если она есть —
dead → at-risk → healthy → unknown: самое критичное наверху, неизвестное внизу. - Подтверждение «Сохранено» — не постоянная зелёная плашка под формой, а текст
--ink-2с галочкой либо тост (useToast().success), который исчезает сам.
Лимиты композиции страницы
- Ровно один
UiPageHeaderна страницу.eyebrowдержит категорию («Заказы»),title— конкретику («Заказ №4821»). Нужно два заголовка — это два экрана, не один. - Не больше 4
UiKpiCardв ряд, и не больше одной карточки сaccent.accent— верхняя градиентная полоса (--gradient-accent-strong, тот же приём, что уUiCardvariant="hero"), предназначена ровно для одной карточки — главной метрики ряда/страницы. Если акцентных карточек несколько, полоса перестаёт читаться как выделение и лимит теряет смысл. Не путать с нейтральным тоном спарклайна: у карточки без чёткого направления (delta.direction: 'flat'либо отсутствующийdelta) линия красится в--accent-500(см. purpose-комментарийUiKpiCard) — это цвет линии тренда, а не декоративная полоса, и лимита «одна на ряд» для него нет. СUiKpiRow columns="auto"лимит держит ширина контейнера: 8 плиток в контейнере ≈960px ложатся в 2 ряда по 4, на более широком контейнере колонок станет больше. - 3+ инлайн-действия строки — в меню, не в ряд иконок —
UiActionMenu(см. «Чего не делать» выше); destructive- действие («Удалить») — отдельной секцией внизу формы/детальной страницы, не среди повседневных действий шапки. - Отступ между секциями страницы —
--space-8(32px); внутри секции —--space-4/--space-5. Не смешивайте разные ступени на одном уровне вложенности. - Контентная колонка —
UiContainer. Обычная страница — без пропа (size="page",--container-max, 1280px); публичный экран —size="narrow"(--container-narrow, 480px). Третьего размера нет намеренно: всё, что не публичный экран, — этоpage, возможно с более узким содержимым внутри, а не свойство самого контейнера. - Счётчик в заголовке карточки и у вкладки по умолчанию нейтральный. Акцентный
(
UiCard countTone="accent") — только у того, что ждёт действия пользователя («На ручную сверку»), иначе акцент перестаёт выделять. УUiTabsакцент получает счётчик активной вкладки сам, пропа нет. - Статус сущности рядом с заголовком —
UiPageHeader #titleMeta(UiStatusPill, короткий статус вроде «Поиск»), не в#actionsи не вtitle: слот стоит внеh1, имя заголовка остаётся чистым. УUiAlert #actions— не больше двух кнопок и одна primary.
Рецепты layout
Кит не даёт монолитный UiAppShell — сознательно: Nuxt-потребители собирают layouts/ сами (через
definePageMeta/файловую структуру layouts/*.vue), и монолитная оболочка конфликтовала бы с их
роутингом. Вместо этого — четыре переиспользуемых части (UiAppHeader, UiAppNav, UiBrandMark,
UiContainer) и два готовых рецепта их сборки. import '@zoloto585/facet/tokens.css' (и файл нужной
гаммы) подключается один раз глобально — в корневом app.vue/entry-файле, а не внутри layout'а.
<!-- layouts/default.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { UiAppHeader, UiAppNav, UiBrandMark, UiContainer, type UiAppNavItem } from '@zoloto585/facet'
// useRoute — автоимпорт Nuxt; на чистом Vite это `import { useRoute } from 'vue-router'`
const route = useRoute()
const NAV_ITEMS: UiAppNavItem[] = [
{ id: 'dashboard', label: 'Дашборд', href: '/dashboard' },
{ id: 'orders', label: 'Заказы', href: '/orders', badge: 12 },
{ id: 'users', label: 'Пользователи', href: '/users' },
]
const activeId = computed(
() => NAV_ITEMS.find((i) => i.href && route.path.startsWith(i.href))?.id ?? null,
)
</script>
<template>
<div class="app-shell">
<UiAppHeader>
<template #brand>
<UiBrandMark label="Zoloto585 Admin">Z</UiBrandMark>
<!-- Обёртка #brand ограничивает ширину и обрезает содержимое. Многоточие
потребитель задаёт на своём текстовом элементе: кит не знает, какой
из элементов слота текстовый. Без этих трёх свойств длинное
название будет срезано по краю, а не сокращено. По той же причине абсолютно
спозиционированные оверлеи внутри `#brand` (меню на логотипе, значок-«уголок»)
надо выносить в `body` через `Teleport` — их тоже обрежет. -->
<strong class="app-shell__brand-name">Zoloto585 Admin</strong>
</template>
<UiAppNav :items="NAV_ITEMS" :model-value="activeId" />
<template #actions>
<!-- переключатель темы/гаммы (useColorScheme), меню аккаунта — компоненты потребителя -->
</template>
</UiAppHeader>
<UiContainer>
<slot />
</UiContainer>
</div>
</template>
<style scoped>
.app-shell__brand-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
Шапка использует фон --bg и базовую высоту --header-h (72px). На desktop бренд занимает
не более 30% ряда; навигация занимает место, оставшееся после бренда, действий и промежутков. До 1200px бренд и действия стоят в первой
строке, навигация — во второй; высота растёт по содержимому, оставаясь не меньше --header-h.
Границы 1200px и 640px включительные и относятся к ширине окна, а не контейнера примера.
Длинное горизонтальное меню прокручивается внутри UiAppNav, в том числе при фокусе на пункте.
Цвет текста активного горизонтального пункта — --accent-700, фон — --surface-2.
В вертикальном меню цвет текста — --ink-1, фон — --surface-3.
Боковые поля шапки — var(--layout-gutter, clamp(var(--space-4), 5.5vw, 160px));
до 640px значение по умолчанию — var(--space-4) (16px). Приложение может задать
--layout-gutter на родительском элементе и переключать его классом — переопределение работает
на любой ширине, отдельного свойства для этого нет. contained по-прежнему ограничивает ряд
через --container-max, слоты brand, default, actions и свойство sticky сохраняются.
Промежутки между элементами слота actions — --space-2 (по умолчанию 8px),
до 640px — --space-1 (4px). Промежутками внутри вложенных обёрток управляет приложение.
Слот #actions шапки не сжимается: контролы обрезать нельзя. На desktop бренд также
не сжимается ради меню, но ограничен 30% ряда; до 1200px он занимает остаток первой строки.
Что прятать на узком экране — решает приложение. Рабочий приём (так сделана витрина кита): ниже
--bp-compact убрать подписи у кнопок, оставив иконки, а навигацию из сайдбара увести в UiDrawer
за кнопку-гамбургер в #brand. Один и тот же список разделов при этом стоит держать в одном
компоненте и рендерить его и в сайдбаре, и в дровере — иначе список и иконки быстро разъезжаются
между двумя копиями.
Сайдбар с группами. Пункту UiAppNav можно задать group — над пунктами появится подпись-eyebrow.
Группа — непрерывный отрезок пунктов с одинаковым group; в горизонтали group игнорируется. Меню
остаётся одним <nav> (группы — role="group" с подписью), а скрытая по ролям группа пропадает вместе
с подписью, если потребитель отфильтровал её пункты. Тон счётчиков — badgeTone у меню или у пункта.
<UiAppNav
v-model="activeId"
orientation="vertical"
badge-tone="neutral"
:items="[
{ id: 'requests', label: 'Запросы', group: 'Подбор', href: '/requests' },
{ id: 'shortlists', label: 'Шорт-листы', group: 'Подбор', href: '/shortlists', badge: 7 },
{ id: 'users', label: 'Пользователи', group: 'Админ', href: '/users' },
]"
>
<template #icon="{ item }"><component :is="NAV_ICONS[item.id]" /></template>
</UiAppNav>
Колокол уведомлений в #actions. Icon-only UiButton variant="ghost" size="sm" с пропом indicator —
точка «есть новое» в правом верхнем углу кнопки. Точка декоративная (aria-hidden), поэтому наличие и
число новых — в доступном имени (ariaLabel). Нужно само число на экране — UiBadge рядом с кнопкой.
<UiButton
variant="ghost"
size="sm"
:indicator="unread > 0"
:ariaLabel="unread > 0 ? `Уведомления, ${unread} новых` : 'Уведомления'"
>
<template #icon><IconBell /></template>
</UiButton>
<!-- layouts/public.vue — вход, восстановление пароля: без шапки и навигации -->
<script setup lang="ts">
import { UiContainer } from '@zoloto585/facet'
</script>
<template>
<div class="public-shell">
<UiContainer size="narrow">
<slot />
</UiContainer>
</div>
</template>
<style scoped>
.public-shell {
min-height: 100vh;
display: grid;
place-items: center;
}
</style>
Что выбрать
Сводная таблица «ситуация → компонент → с чем не путать» — when-to-use рассыпан по purpose-комментариям исходников, здесь он собран в одном месте:
| Ситуация | Берите | Не путайте с |
|---|---|---|
| Число + подпись внутри другого блока (карточка, таблица, KPI-ряд), без тренда | UiStat |
UiKpiCard — самостоятельный виджет дашборда с дельтой и спарклайном, не вариант UiStat |
| Число + подпись как отдельная карточка-виджет дашборда, с трендом за период | UiKpiCard |
— композиция UiStat + UiSparkline + UiBadge, не собирайте вручную |
| Пары «ключ — значение» в карточке или панели (настройки, свойства записи) | UiDescriptionList |
UiEntityHeader descriptions (ряд пар в шапке страницы сущности) и UiStat (один показатель с подписью) |
| Доля от лимита или квоты рядом с числом (ячейка «потрачено 6 из 20», плитка «214 из 400») | UiInlineBar (layout="stacked" — подпись над полосой, tone="warning" — исчерпано); в KPI-плитке — UiKpiCard progress (progress.tone красит только полосу; цвет числа — tone карточки) |
UiProgress — тот про ход выполнения задачи (role="progressbar"), не про долю |
| Текстовая подсказка по hover/focus, некликабельная | UiTooltip |
UiPopover — держит интерактивный контент и открывается кликом, pointer-events не заблокированы |
| Та же подсказка, но элемент нельзя обернуть (ячейка таблицы, чужая разметка) | директива v-tooltip |
UiTooltip — добавляет вокруг триггера <span> с display: inline-flex, а он ломает раскладку ячейки |
| Всплывающая панель, где нужно что-то нажать/выбрать/пролистать | UiPopover |
UiTooltip — только текст, курсор её «не видит» (pointer-events: none) |
Переключатель 2-5 опций, который сам ничего не переключает в контенте (период, режим отображения), или компактный фильтр-вкладки над соседней областью (тогда role="tablist" + controls) |
UiSegmented |
UiTabs — держит полноценные панели контента внутри себя и переключает их |
| Переключение видимых панелей контента страницы | UiTabs |
UiSegmented — визуально похож, но панели сам не держит: даже в режиме tablist панель рисует вызывающий код |
| Выбор значения поля формы из нескольких вариантов, в т.ч. с пояснением у каждого (роль пользователя, тариф) | UiRadioGroup (orientation="vertical" + description) |
UiSegmented — смена режима отображения или фильтр без пояснений у опций, не значение формы; UiSelect — когда вариантов много |
| Правка одного набора полей сразу у N выбранных записей | UiBulkEditModal |
UiFormModal — тот сабмитит все поля формы; UiBulkEditModal — только явно выбранные, плюс фазы подтверждения/прогресса/итога |
| Массовая правка «одно поле за раз» с поиском по большому каталогу полей | UiBulkEditModal mode="single-field-search" |
тот же компонент в дефолтном mode="multi-field" — там чек-лист сразу нескольких полей без поиска |
| Форма создания/редактирования одной записи | UiFormModal |
UiBulkEditModal — только для массовой правки нескольких записей разом |
| Одна карточка-форма из нескольких разделов с подзаголовками и общим подвалом | UiCard с sections |
несколько UiCard подряд — когда разделы независимы и сохраняются отдельно |
| Статус записи в списке/таблице | UiStatusPill |
UiBadge — без обязательной точки-индикатора и шире по смыслу (не только статус) |
| Тег/счётчик/произвольная компактная метка | UiBadge |
UiStatusPill — тот именно про статус записи, с точкой |
| Фильтр-чип, который выбирают/снимают кликом, или удаляемый тег | UiChip |
UiBadge/UiStatusPill — те кликом сами по себе не переключаются |
| Короткое центральное прерывание потока (подтверждение, форма из нескольких полей) | UiModal |
UiDrawer — выезжает сбоку и оставляет контекст списка видимым по краям экрана |
| Форма/детали, когда важно не терять из виду список позади | UiDrawer |
UiModal — перекрывает экран целиком, контекст пропадает |
| Таблица только для чтения (сортировка, скелетон, пустое состояние) | UiDataTable |
UiRecordList — тот редактируемый, с drag-and-drop; UiJobQueueTable — тот про очередь задач |
| Редактируемый список записей со схемой колонок и переупорядочиванием | UiRecordList |
UiDataTable — read-only, ячейки не редактируются |
| Короткий нумерованный список строк с закреплённой нередактируемой строкой (вопросы анкеты) | UiListEditor с numbered и fixedTail |
UiRecordList — тот с колонками и перестановкой строк |
| Таблица асинхронных задач (экспорт/импорт) со статусом и прогресс-баром | UiJobQueueTable |
UiDataTable — без встроенной семантики статуса/прогресса задачи |
| Журнал событий системы в одну строку: время · точка · текст | UiTimeline variant="log" |
UiTimeline по умолчанию — история с описаниями и diff; UiJobQueueTable — очередь задач со статусом и прогрессом |
| Сообщение, которое стоит там, где случилось событие, и не исчезает само | UiAlert |
UiToast — тот всплывает поверх интерфейса и улетает по таймеру |
| Уведомление, которое должно появиться и само закрыться | UiToast (через useToast()) |
UiAlert — статичная плашка внутри страницы/формы |
| Данных нет — уже известно | UiEmptyState |
UiSkeleton — тот для «ещё грузится», а не для «пусто» |
| Данные ещё грузятся, форма результата известна заранее | UiSkeleton |
UiEmptyState — тот для уже подтверждённого отсутствия данных |
| Навигация по секциям одной длинной страницы со scrollspy | UiSectionNav |
UiAppNav — навигация верхнего уровня приложения; UiContents — оглавление без автоподсветки |
| Навигация верхнего уровня приложения (шапка/сайдбар, роутер) | UiAppNav |
UiSectionNav — тот только про секции одной страницы |
| Сгруппированное оглавление с ручным выбором раздела, без scrollspy | UiContents |
UiSectionNav — тот сам подсвечивает активную секцию при скролле |
| Список записей по группам (по дням/разделам) с выбранной текущей записью — левая колонка master-detail | UiGroupedList |
UiContents — оглавление: одна линия + заметка, без статуса; UiRecentItemsCard — виджет «последние N» с заголовком, без групп и без выбранной строки |
| Короткий список сообщений/вопросов со счётчиком в шапке, текст нельзя обрезать | UiRecentItemsCard с count и wrap-text |
UiGroupedList — выбор текущей записи, группы |
| Одно поле внутри панели фильтров (лейбл + контрол + очистка) | UiFilterField |
UiFilterBuilderPanel — целая сворачиваемая панель с динамическими условиями |
| Конструктор произвольных условий фильтра (добавить/удалить строку) | UiFilterBuilderPanel |
UiFilterField — один строительный блок панели, не панель целиком |
| Обычная кнопка (primary/secondary/ghost/danger) | UiButton |
UiTableActionButton — фиксированный квадрат 28×28 под строку таблицы; UiHeaderActionButton — умеет рендериться и как <a> |
| Компактная icon-кнопка действия в строке таблицы/списка | UiTableActionButton |
UiButton — не заточен под плотный ряд, нет фиксированного квадрата |
| Текстовое действие в строке таблицы без иконки («Изменить», «Деактивировать») | UiTableActionButton только с default-слотом |
UiButton variant="ghost" size="sm" — работает, но выпадает из тонов и геометрии соседних действий строки |
| 3+ действия строки или карточки — одна кнопка «⋯» с меню команд | UiActionMenu |
UiSplitButton (всегда есть главное действие) и UiPopover (панель-диалог с произвольным контентом, без role="menu" и навигации стрелками) |
| Кнопка действия в шапке страницы/секции, которая может быть ссылкой | UiHeaderActionButton |
UiButton — не умеет дуально рендериться <a>/<button> по наличию href |
| Короткий экспорт: один запрос, файл приезжает сразу | UiExportButton |
сценарий с фоновым заданием — не он, см. «Рецепт: асинхронный экспорт (фоновый job)» |
| Длинный экспорт фоновым заданием (статус, поллинг, автоскачивание) | сборка UiSplitButton + UiModal + UiProgress (рецепт ниже) |
UiExportButton — он не знает ни форматов, ни статусов job'а |
Тон текста
- Глагол + объект, без многоточий и восклицаний. «Кампания закрыта», а не «Готово!» или «Кампания успешно закрыта...» — сообщение об уже случившемся факте не нуждается в эмоции или паузе.
- Заголовок модалки — что произойдёт, а не что сделал пользователь. «Удалить 3 записи?», а не «Вы уверены?» — заголовок обязан быть понятен без чтения тела диалога.
- Кнопка называет действие, а не реакцию. «Удалить», «Сохранить изменения», «Отправить приглашение» — не «ОК»/«Да»/«Готово». Исключение — единственная кнопка закрытия информационного, не решающего диалога («Понятно»).
- Ошибка говорит, что делать дальше, а не только что сломалось. «Не удалось сохранить: проверьте соединение и повторите» — не «Ошибка 500».
- Пустое состояние объясняет причину и даёт действие. У
UiEmptyState— не «Данных нет», а «Заказов за этот период не найдено» с кнопкой «Сбросить фильтры», если пустота — следствие фильтра, а не факт о мире.
Пример: плохо — «Упс! Что-то пошло не так...»; хорошо — «Не удалось загрузить заказы. Проверьте соединение и повторите.»
Рецепт: асинхронный экспорт (фоновый job)
Кит не даёт одного компонента «экспорт с фоновым заданием» — сознательно, решение
зафиксировано в docs/superpowers/specs/2026-08-11-export-button-async-job-design.md (вариант B).
Два соображения:
- кит не делает сетевых вызовов и не заводит собственных таймеров (та же граница уже проведена
у
UiJobQueueTable: «нет polling/websocket» — состояние приходит пропами). Компонент сonPoll/pollIntervalMsвнутри стал бы первым в ките, кто крутитsetTimeoutсам; - детали у всех разные: частота опроса, что считать «большой выборкой», как именно скачивается
результат (прямой URL,
Blob, presigned-ссылка с истечением). Один API кита эти различия унифицировал бы насильно.
Поэтому сценарий собирается из блоков, которые в ките уже есть, а цикл опроса живёт в композабле приложения. Что чем закрывается:
| Часть сценария | Блок кита |
|---|---|
| Кнопка действия + выбор формата (xlsx/csv) | UiSplitButton (items + событие select) |
| Короткий экспорт без задания, одной кнопкой | UiExportButton (loading + progress) |
| Окно статуса задания | UiModal (persistent, пока задание не завершено) |
| Ход выполнения, в т.ч. пока объём неизвестен | UiProgress (indeterminate → value) |
Состояние задания (queued/running/done/failed) |
UiStatusPill (pulse для идущего) |
| Ошибка задания | UiAlert (tone="danger") |
| История прошлых выгрузок рядом | UiJobQueueTable |
| Уведомление об ошибке вне окна | useToast() |
Живой пример со всеми состояниями — карточка «Рецепт: асинхронный экспорт» в витрине
(apps/showcase, секция «Кнопки»).
Композабл опроса — на стороне приложения
// composables/useExportJob.ts — код приложения, не кита
import { onScopeDispose, ref } from 'vue'
export type ExportJobStatus = 'idle' | 'queued' | 'running' | 'done' | 'failed'
export function useExportJob() {
const status = ref<ExportJobStatus>('idle')
/** null — объём ещё неизвестен: показываем indeterminate-полосу, а не 0%. */
const progress = ref<number | null>(null)
const error = ref<string | null>(null)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer) clearTimeout(timer)
timer = null
}
async function poll(jobId: string) {
const job = await $fetch(`/api/exports/${jobId}`)
status.value = job.status
progress.value = typeof job.progress === 'number' ? job.progress : null
error.value = job.error ?? null
if (job.status === 'done') {
// Скачивание — забота приложения: бэкенд может отдать URL, Blob или presigned-ссылку.
const a = document.createElement('a')
a.href = job.fileUrl
a.download = job.fileName
a.click()
return
}
if (job.status === 'failed') return
// setTimeout, а не setInterval: следующий тик планируется только после ответа предыдущего —
// иначе на медленном бэкенде запросы наложатся друг на друга.
timer = setTimeout(() => void poll(jobId), 2000)
}
async function start(format: 'xlsx' | 'csv') {
stop()
status.value = 'queued'
progress.value = null
error.value = null
const { jobId } = await $fetch('/api/exports', { method: 'POST', body: { format } })
await poll(jobId)
}
onScopeDispose(stop) // задание переживает уход со страницы, таймер — нет
return { status, progress, error, start, stop }
}
Разметка — блоки кита
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
UiAlert,
UiButton,
UiModal,
UiProgress,
UiSplitButton,
UiStatusPill,
type UiSplitButtonItem,
} from '@zoloto585/facet'
import { useExportJob } from '~/composables/useExportJob'
const FORMATS: UiSplitButtonItem[] = [
{ key: 'xlsx', label: 'Excel (.xlsx)' },
{ key: 'csv', label: 'CSV (.csv)' },
]
// Словари приложения, а не кита: статусы вашего бэкенда → тон и подпись на экране
const STATUS_TONE = {
idle: 'neutral',
queued: 'neutral',
running: 'info',
done: 'success',
failed: 'danger',
} as const
const STATUS_LABEL = {
idle: 'Не запущен',
queued: 'В очереди',
running: 'Готовим файл',
done: 'Файл скачан',
failed: 'Ошибка',
} as const
const { status, progress, error, start, stop } = useExportJob()
const open = ref(false)
const running = computed(() => status.value === 'queued' || status.value === 'running')
function run(format: string) {
open.value = true
void start(format as 'xlsx' | 'csv')
}
function close() {
open.value = false
stop() // закрыли окно — перестали опрашивать; само задание на бэке продолжается
}
</script>
<template>
<!-- Главное действие — формат по умолчанию, меню — остальные форматы -->
<UiSplitButton :items="FORMATS" menu-label="Другой формат" @click="run('xlsx')" @select="run($event.key)">
Экспортировать
</UiSplitButton>
<!-- persistent, пока задание идёт: случайный клик по фону не должен обрывать опрос -->
<UiModal v-model="open" title="Экспорт заказов" size="sm" :persistent="running" @close="close">
<div class="export-job">
<UiStatusPill :tone="STATUS_TONE[status]" :pulse="running">{{ STATUS_LABEL[status] }}</UiStatusPill>
<!-- indeterminate, пока бэкенд не знает объём; дальше — проценты -->
<UiProgress
v-if="running"
:indeterminate="progress === null"
:value="progress ?? 0"
label="Ход экспорта"
/>
<UiAlert v-if="status === 'failed'" tone="danger">
<template #title>Экспорт не выполнен</template>
{{ error ?? 'Повторите попытку или выберите меньший период.' }}
</UiAlert>
</div>
<template #footer>
<UiButton v-if="status === 'failed'" variant="secondary" @click="run('xlsx')">Повторить</UiButton>
<UiButton variant="ghost" :disabled="running" @click="close">Закрыть</UiButton>
</template>
</UiModal>
</template>
Словарь статусов у кита не единый намеренно: у UiJobQueueTable
свой (pending/running/done/error), и если вы показываете историю выгрузок этой таблицей —
маппинг из статусов вашего бэкенда делается на месте, одной функцией.
Когда рецепт не нужен: экспорт отвечает одним HTTP-запросом за секунды — берите
UiExportButton (loading + опциональный progress), окно статуса и поллинг здесь лишние.
Как найти нужный компонент
Кит — это ~84 компонента и несколько composable-ов, сгруппированных по категориям, но физически лежащих
плоско (без вложенных папок по категориям) в src/components/<CanonicalName>/. Если вы (или агент,
который автоматизирует внедрение кита в проект) не знаете точное каноническое имя нужного компонента —
не обязательно листать все папки: у каждого компонента в самом начале файла есть searchable
purpose-комментарий, специально для такого поиска.
Формат (первая строка блока /** ... */ сразу после <script setup lang="ts">):
<CanonicalName> — <однострочное назначение>. Категория: <категория>. Поиск: <ключевые слова>.
Ключевые слова написаны так, как задачу опишут своими словами — не только точным именем компонента. Поэтому вместо того чтобы гадать имя, можно просто grep'нуть исходники пакета по сути задачи:
# ищем "что-то для выбора диапазона дат"
grep -ril "диапазон дат\|date range" node_modules/@zoloto585/facet/src/components
# ищем "что-то для отображения прав доступа/ролей"
grep -ril "access\|роли\|permission" node_modules/@zoloto585/facet/src/components
(При монорепо-подключении через workspace — то же самое, но по packages/facet/src/components
вместо node_modules/@zoloto585/facet/src/components.)
Этот способ работает надёжнее, чем угадывание имени по аналогии с другими UI-китами: часть компонентов
кита не имеет прямого 1:1 аналога в Ant Design/PrimeVue/shadcn, а у части — другое название (например
UiDrawer называется Sheet в shadcn и Slideover в Nuxt UI — все три слова попадут в его
purpose-комментарий как синонимы).
Каталог по категориям
Полный список — карта имён для быстрой ориентации. Точные пропсы, слоты и события каждого компонента
смотрите в его исходнике (src/components/<Имя>/<Имя>.vue): purpose-комментарий в начале файла
описывает назначение словами, defineProps/defineEmits/defineSlots ниже — точные типы.
- Токены и инфраструктура:
useColorScheme,useDialogA11y,useOverlayLayer,useListNavigation,usePopoverPosition - Кнопки:
UiButton,UiTableActionButton,UiHeaderActionButton,UiCopyButton,UiSplitButton,UiActionMenu,UiScrollToTopButton,UiExportButton,UiDisclosure - Формы и поля ввода:
UiInput,UiTextarea,UiSelect,UiMultiSelect,UiCheckbox,UiRadioGroup,UiCheckboxChipGroup,UiSwitch,UiField,UiFieldForm,UiFilterField,UiDatePicker,UiDateRangeField,UiChip,UiFileUpload,UiRecordList,UiNumberRangeField,UiSlider,UiListEditor,UiPermissionScopePicker - Обратная связь и состояния:
UiBadge,UiEmptyState,UiSkeleton,UiSpinner,UiProgress,UiAlert,UiToast,UiConfirmDialog,UiStatusPill,UiSaveStatus,UiSecretRevealBanner,UiRewardBadge - Навигация:
UiTabs,UiSegmented,UiBreadcrumb,UiSteps,UiCommandPalette,UiDrawer,UiTooltip,UiPopover,UiTree,UiFacetedSidebar,UiSectionNav,UiContents,UiGroupedList - Таблицы и данные:
UiDataTable,UiStat,UiKpiCard,UiPageHeader,UiPaginator,UiColumnSetting,UiEntityCard,UiEntityHeader,UiDescriptionList,UiAvatar,UiJobQueueTable,UiTimeline,UiRecentItemsCard,UiProportionalBarList,UiInlineBar - Модалки и оверлеи:
UiModal,UiFormModal,UiBulkEditModal,UiFullscreenViewer - Оболочка приложения:
UiAppHeader,UiAppNav,UiBrandMark,UiContainer,UiKpiRow,UiFilterBar - Layout и карточки:
UiCard,UiResourceCard,UiGalleryGrid,UiMediaFrame - Графики (все — inline SVG, без сторонних chart-библиотек):
UiBarChart,UiLineChart,UiSparkline,UiHBarList,UiHeatmap,UiDiffViewer - Admin-паттерны:
UiAccess,UiFilterBuilderPanel,UiSavedPresetManager,UiGanttGrid,UiMarkdownEditor,UiImportWizard
UiStat и UiKpiCard — разные компоненты, не варианты одного: UiStat — простой label+value+hint для
встраивания внутрь других карточек, UiKpiCard — самостоятельная карточка-виджет (композиция UiStat +
UiSparkline + UiBadge, с градиент-акцентом).
Иконки
Кит icon-agnostic: ни один компонент не бандлит иконный набор. Иконки передаются через слоты
(#icon/#leadingIcon/#trailingIcon — конкретное имя смотрите в purpose-комментарии/пропсах
нужного компонента) или через проп-компонент — подставляйте свой набор (lucide, heroicons, свои SVG).
Кит не диктует набор, но диктует габарит — иначе иконки разных наборов встанут в разном оптическом
размере рядом друг с другом. Для этого есть токены --icon-xs|sm|md|lg:
| Контекст | Токен | Размер |
|---|---|---|
| Индикатор внутри бейджа/чипа | --icon-xs |
12px |
| Иконка внутри контрола: кнопка, поле, пункт меню | --icon-sm |
14px |
| Самостоятельная иконка: пункт навигации, шапка секции | --icon-md |
16px |
| Акцентная иконка: пустое состояние, крупная карточка | --icon-lg |
20px |
Подставляйте нужный токен в свою иконку явно — размер самого элемента <svg>/компонента кит не
навязывает, только рекомендует габарит:
<UiButton size="md">
<template #icon><IconTrash :style="{ width: 'var(--icon-sm)', height: 'var(--icon-sm)' }" /></template>
Удалить
</UiButton>
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 2.5.0 | ^3.5.0 | ||
| 2.4.0 | ^3.5.0 | ||
| 2.3.0 | ^3.5.0 | ||
| 2.2.0 | ^3.5.0 | ||
| 2.1.0 | ^3.5.0 | ||
| 2.0.0 | ^3.5.0 | ||
| 1.2.0 | ^3.5.0 | ||
| 1.1.0 | ^3.5.0 | ||
| 0.4.1 | ^3.5.0 | ||
| 0.4.0 | ^3.5.0 | ||
| 0.3.1 | ^3.5.0 | ||
| 0.3.0 | ^3.5.0 | ||
| 0.2.1 | ^3.5.0 | ||
| 0.2.0 | ^3.5.0 | ||
| 0.1.0 | ^3.4.0 |