npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zoloto585/facet

v2.5.1

Published

UI-кит для Nuxt/Vue админок Zoloto585. Vue 3 + TypeScript, **единственная рантайм-зависимость — `vue`** (peerDependency, `^3.5.0`). Никакого Tailwind/PrimeVue/Nuxt UI/Element Plus/Vuetify, никаких иконных наборов, никакого floating-vue/Popper/vuedraggable

Readme

@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>