Storybook

Своего Storybook у библиотеки нет и не будет. Как подключить компоненты в ваш — конфиг, тема, оверлеи и главная ловушка.

Своего Storybook у Granularity нет, и он не планируется. За вопросом «а Storybook у вас есть?» почти всегда стоят два других: есть ли у компании единая витрина компонентов и встраивается ли библиотека в её процессы визуального тестирования. Ответ на оба — да, и эта страница про то, как.

Почему не свой

Портал уже делает то, ради чего Storybook заводят: живые демо на странице каждого компонента, таблицу API прямо из кода, playground с изменяемыми пропами, проверку доступности и визуальную регрессию. Второй такой инструмент рядом означал бы второй источник правды и дублирование сотен демо — при одном мейнтейнере это прямая дорога к расхождению, а расходятся такие пары молча.

То же относится к Chromatic и Histoire: совместимость документируется, своё не строится.

Свой Storybook у вас при этом остаётся осмысленным — но по другой причине. В нём живут ваши компоненты, собранные из наших, и витрина нужна именно им.

Что подключить

Granularity ничем не отличается от любой другой Vue-библиотеки, кроме одного: её CSS не импортируется файлом, а собирается UnoCSS из granular-провайдера (установка). Поэтому вся настройка — про то, чтобы UnoCSS отработал внутри Storybook.

Сборка

Storybook на Vite (@storybook/vue3-vite) читает ваш vite.config.ts. Если плагин UnoCSS уже стоит там, добавлять в .storybook/main.ts нечего:

vite.config.ts
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'

export default defineConfig({ plugins: [Vue(), UnoCSS()] })

Если конфиг у Storybook свой, плагин добавляется через viteFinal:

.storybook/main.ts
import UnoCSS from 'unocss/vite'

export default {
  framework: '@storybook/vue3-vite',
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  viteFinal: async config => ({
    ...config,
    plugins: [...(config.plugins ?? []), UnoCSS()],
  }),
}

Стили

Те же три импорта, что в точке входа приложения, — в preview.ts:

.storybook/preview.ts
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno:granular.css'
import 'virtual:uno.css'

Второй импорт есть только при layer: 'granular' в конфиге. Без слоя всё приезжает одним virtual:uno.css, и лишний импорт сломает сборку. Забыть же virtual:uno.css — получить компоненты с токенами, но без раскладки: цвета есть, вёрстки нет.

Главная ловушка: extractor не видит dist

Это первое, что ломается, и ломается одинаково у всех: истории отрисовались, а компоненты приехали без стилей.

Причина не в Storybook. Утилитарные классы, которыми нарисованы шаблоны компонентов, лежат в собранном dist пакета, а extractor UnoCSS по умолчанию туда не заглядывает — он сканирует исходники вашего проекта. Ему нужно сказать:

uno.config.ts
import { defineConfig, presetMini } from 'unocss'
import { granularContent, presetGranularNode } from '@feugene/unocss-preset-granular/node'
import granularityProvider from '@feugene/granularity/granular-provider/node'

// Один объект на оба вызова: разойдутся — extractor сканирует не то,
// что генерирует пресет.
const granular = {
  providers: [granularityProvider],
  themes: { names: ['light', 'dark'] },
  layer: 'granular' as const,
}

export default defineConfig({
  content: granularContent(granular),
  presets: [presetMini(), presetGranularNode(granular)],
})

content читается только из верхнего уровня конфига, не из пресета. Это то же требование, что и в приложении, — Storybook просто делает его заметнее, потому что в нём чаще собирают отдельным конфигом.

Перечислять components в конфиге Storybook обычно не нужно: витрина по смыслу показывает всё, а вес CSS в ней никого не волнует. Сужение списка — приём боевого приложения, и там оно остаётся.

Обёртка историй

Дефолты поддерева и цель для оверлеев задаёт GrConfigProvider. В Storybook он живёт в декораторе:

.storybook/preview.ts
import { GrConfigProvider } from '@feugene/granularity/components/GrConfigProvider'
import { h } from 'vue'

export const decorators = [
  (story: () => unknown) => ({
    setup: () => () => h(GrConfigProvider, { size: 'md' }, () => h(story() as never)),
  }),
]

Провайдер рендерится прозрачно (display: contents) и раскладку истории не меняет, поэтому его можно ставить на все истории разом.

Оверлеи

Модалки, дропдауны и тултипы телепортируются в общий портал — #gr-portal в body, который пакет создаёт сам при первом открытии. В Storybook это работает без настройки: body у превью свой, и портал появится в нём.

Настройка нужна, только если вы указываете portalTarget своим контейнером. Тогда требование к нему то же, что в приложении, и оно жёсткое: никаких transform, filter, contain, perspective и will-change — они создают containing block для position: fixed, и панели начнут считать позицию от контейнера, а не от вьюпорта. Декораторы Storybook такие обёртки создают охотно.

Переключение темы

Тема — атрибут на корне документа, и переключается она так же, как в приложении:

.storybook/preview.ts
export const globalTypes = {
  theme: {
    toolbar: {
      items: [
        { value: 'light', title: 'Light' },
        { value: 'dark', title: 'Dark' },
      ],
    },
  },
}

export const decorators = [
  (story: () => unknown, context: { globals: { theme?: string } }) => {
    document.documentElement.dataset.theme = context.globals.theme ?? 'light'
    return story()
  },
]

Обе темы обязаны попасть в CSS — за это отвечает themes.names в конфиге выше. Указав там только light, вы получите переключатель, который меняет атрибут и не меняет ничего больше.

Импорты в историях

Резолвер авто-импорта (@feugene/unplugin-granularity) работает в шаблонах SFC. История на TypeScript — не шаблон, и компонент в ней импортируется подпутём:

src/stories/Button.stories.ts
import { GrButton } from '@feugene/granularity/components/GrButton'

export default { component: GrButton }

export const Primary = { args: { variant: 'primary', tone: 'primary' } }

Подпуть, а не корневой импорт: он и есть весь смысл гранулярности — в бандл истории попадает один компонент, а не пакет.

Что взять с портала, а не переписывать

  • Аргументы историй — таблица API на странице компонента порождается из кода, включая типы и значения по умолчанию. Копировать её в argTypes руками незачем: Storybook выведет большую часть сам из типов SFC.
  • Проверка доступности — подпуть @feugene/granularity/testing даёт окружение для монтирования и уборку между тестами, а axe и Playwright подключаются вашими. Подробности — на странице тестирования. @feugene/granularity-test-kit для этого не нужен: он про контрактные тесты пакетов семейства, а не приложения.
  • Клавиатурные контракты — описаны в репозитории библиотеки по компонентам, и история их не заменяет: они про поведение, а не про вид.

Последняя ревизия: 2026-09-02