@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
1,825
Install
npm install @zoloto585/facet
Latest Version:2.5.0
Vue:3
Last Updated:Oct 23, 2026
Links: npm
Maintainer: zoloto585

@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-tooltip PrimeVue: сторона по умолчанию здесь 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: danger h≈27°, warning h≈47°, success h≈162° в light и ≈146° в dark, info h≈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" из сброса исключён.

Как пережить сброс своей значимой анимации

Две обязательные части, одной недостаточно:

  1. Атрибут data-motion="essential" на ТОМ ЖЕ элементе, на котором объявлена animation. Селектор исключает только сам элемент с атрибутом — его потомки под сброс по-прежнему попадают.
  2. Собственное замедление в своём @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-тарбол не входит (раздел «Референс токенов» выше).

Принципы

  1. Плотность важнее воздуха. Списки и таблицы — рабочая поверхность админки, а не витрина: строка контрола/таблицы — 28-36px (--control-h-*/--row-h-*), действия живут прямо в строке (UiTableActionButton), а не уводятся на отдельный экран. Цель — окинуть взглядом больше данных за один скролл, а не «подышать» пустым пространством.
  2. Цвет — это семантика, а не украшение. Зелёный и красный нельзя ставить «чтобы было заметнее»: красный зарезервирован за dead/destructive, зелёный — за подтверждением уже случившегося действия (тост, сохранено). Всё остальное — нейтральный --ink-*. Подробнее — подраздел «Severity» ниже.
  3. Волоски, а не коробки. Разделители — 1px solid var(--hairline), не рамка с тенью вокруг каждого блока. Тень (--shadow-*) зарезервирована за тем, что физически всплывает над плоскостью страницы — карточка под курсором, поповер, модалка; подраздел «Тени по слоям» ниже расписывает это по уровням.
  4. Числа — это данные, а не текст. Суммы, счётчики, идентификаторы набираются tabular-nums (утилита .tnum или свойство font-variant-numeric: tabular-nums напрямую — раздел «Утилиты» выше): колонка не должна визуально «прыгать» при обновлении значения.
  5. Публичный экран — другой регистр. Страницу, которую видит не администратор (вход, восстановление пароля, подтверждение по ссылке), собирайте крупнее и без навигации: UiContainer size="narrow", поля и кнопка size="lg", минимум хрома вокруг. Готовый рецепт — подраздел «Рецепты layout» ниже.
  6. Токены или ничего. Нет нужного цвета/отступа/радиуса среди токенов — сначала заводится переменная (в своём CSS, после tokens.css), а не подставляется #hex/padding: 14px с цифры. Внутри самого кита это проверяет автоматический скрипт в CI; у потребителя такого скрипта нет, поэтому здесь дисциплина держится только на вас.
  7. Движение — 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 карточки, которая целиком кликабельна как один блок (UiCard interactive), и плавающих элементов страницы (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, тот же приём, что у UiCard variant="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