Модуль 2. Дизайн-токены, темизация и примитивы

Цель модуля: создать систему дизайн-токенов с тремя темами (light/dark/brand), добавить примитивы Icon и Divider к уже существующим Box/Text/Button, написать тесты и Storybook-истории, собрать карточку чата из примитивов.

К концу модуля у вас:

  • Три темы (dark/light/brand) с автоопределением prefers-color-scheme.
  • Четыре примитива: Box, Text, Icon, Divider — каждый с CSS Modules, тестами и stories.
  • Карточка чата, собранная только из примитивов, корректно отображаемая во всех темах.
  • Скриншоты карточки в трёх темах — артефакт сдачи М2.

📌 Перед началом: Модуль 1. 🛠 Инструменты: CSS Variables, Vitest, axe-core. ⏱ Время: ~6 ч.

🔥 Инцидент: find-and-replace по 247 файлам

Дизайнер поменял primary-цвет с #0066CC на #4F46E5. Вы открываете IDE и делаете find-and-replace по 247 файлам. Через неделю находите ещё 12 мест, где цвет не заменился. Token system решает это одной переменной: меняете --c-primary в defaultTheme.ts — и весь UI Kit переключается синхронно.

📖 Теория

2.1 Дизайн-токены: один источник правды

Токены — это именованные значения дизайн-системы: цвета, отступы, радиусы, шрифты. В boilerplate они уже определены в ui-kit/src/theme/defaultTheme.ts и экспортированы через ThemeProvider как CSS-переменные.

Иерархия токенов

TS-объект (defaultTheme.ts)
    ↓  ThemeProvider маппит в CSS-переменные
CSS-переменные на корневом div (--c-primary, --s-md, --radius-md)
    ↓  CSS Modules обращаются через var(--...)
Компоненты (Box.module.css, Text.module.css, ...)

Один источник правды — TS-объект. CSS-переменные — механизм доставки значений в стили. Компоненты никогда не хардкодят значения: только var(--...).

Структура токенов (уже в boilerplate)

ts
// ui-kit/src/theme/types.ts (уже существует)
export interface Theme {
  colors: ThemeColors       // primary, background, surface, text, danger, success...
  spacing: ThemeSpacing     // xs(4px), sm(8px), md(16px), lg(24px), xl(32px)
  radius: ThemeRadius       // sm(6px), md(8px), lg(12px), full(9999px)
  fontSize: { sm, base, lg, xl }
  fontFamily: string
  shadow: { sm, md }
}

Добавляем недостающие токены

В defaultTheme.ts добавим lineHeight и zIndex — они понадобятся в М6 (Overlay):

ts
// ui-kit/src/theme/types.ts — расширяем Theme
export interface Theme {
  colors: ThemeColors
  spacing: ThemeSpacing
  radius: ThemeRadius
  fontSize: { sm: string; base: string; lg: string; xl: string }
  fontFamily: string
  shadow: { sm: string; md: string }
  lineHeight: { tight: string; normal: string; relaxed: string }  // ← новое
  zIndex: { base: number; dropdown: number; sticky: number; overlay: number; toast: number }  // ← новое
}
ts
// ui-kit/src/theme/defaultTheme.ts — добавляем значения
export const defaultTheme: Theme = {
  // ... существующие поля ...
  lineHeight: { tight: '1.2', normal: '1.5', relaxed: '1.7' },
  zIndex: { base: 0, dropdown: 100, sticky: 200, overlay: 1000, toast: 1100 },
}

Не забудьте продублировать в lightTheme и brandTheme (они используют spread ...defaultTheme, поэтому новые поля наследуются автоматически).

Маппинг в CSS-переменные

В ThemeProvider.tsx уже есть функция themeToCssVars. Добавим туда новые переменные:

tsx
// ui-kit/src/theme/ThemeProvider.tsx — фрагмент themeToCssVars
const themeToCssVars = (t: Theme): CSSProperties =>
  ({
    // ... существующие ...
    '--lh-tight': t.lineHeight.tight,
    '--lh-normal': t.lineHeight.normal,
    '--lh-relaxed': t.lineHeight.relaxed,
    '--z-dropdown': t.zIndex.dropdown,
    '--z-overlay': t.zIndex.overlay,
    '--z-toast': t.zIndex.toast,
  }) as CSSProperties

Правило: каждое новое токен-значение должно пройти путь TS → CSS-var → использование в CSS Module. Если хардкода нет — tema переключается одной заменой объекта.


2.2 Темизация: dark / light / brand + auto-detect

Три готовые темы

В boilerplate уже есть:

  • defaultTheme (= darkTheme) — основная, тёмная.
  • lightTheme — светлая, переопределяет только цвета.
  • brandTheme — brand-цвета (фиолетовый primary), остальное от dark.

Автоопределение prefers-color-scheme

Студент может вручную передать тему, но полезно и автоопределение. Добавим хук usePreferredTheme:

tsx
// ui-kit/src/theme/usePreferredTheme.ts
import { useEffect, useState } from 'react'

type ThemeMode = 'dark' | 'light'

export function usePreferredTheme(): ThemeMode {
  const [mode, setMode] = useState<ThemeMode>('dark')

  useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)')
    setMode(mq.matches ? 'dark' : 'light')
    const handler = (e: MediaQueryListEvent) => setMode(e.matches ? 'dark' : 'light')
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [])

  return mode
}

В приложении (М5+):

tsx
'use client'
import { ThemeProvider, defaultTheme, lightTheme, usePreferredTheme } from '@myorg/ui-kit'

function AppLayout({ children }: { children: React.ReactNode }) {
  const mode = usePreferredTheme()
  const theme = mode === 'dark' ? defaultTheme : lightTheme
  return <ThemeProvider theme={theme} dataTheme={mode}>{children}</ThemeProvider>
}

Почему brand: мессенджер может быть white-label — разный primary-цвет для разных инсталляций. Это закладка на будущее, в курсе мы показываем, что kit поддерживает произвольные темы, а не только dark/light.


2.3 Примитивы: Icon и Divider

Box, Text, Button уже готовы в boilerplate. Добавляем Icon и Divider — последние примитивы М2.

Icon — SVG-иконки с tree-shaking

tsx
// ui-kit/src/components/Icon/Icon.tsx
import { type SVGProps } from 'react'
import styles from './Icon.module.css'

interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
  name: IconName
  size?: number | string
}

// Иконки хранятся как React-компоненты в отдельной папке.
// Tree-shaking: импортируется только то, что используется.
import { Chat, Send, Settings, Search, Close, Reaction } from './icons'

const icons = { Chat, Send, Settings, Search, Close, Reaction } as const
export type IconName = keyof typeof icons

export function Icon({ name, size = 20, className, ...rest }: IconProps) {
  const Svg = icons[name]
  if (!Svg) return null
  return (
    <Svg
      width={size}
      height={size}
      className={[styles.icon, className].filter(Boolean).join(' ')}
      aria-hidden={rest['aria-label'] ? undefined : true}
      {...rest}
    />
  )
}
css
/* ui-kit/src/components/Icon/Icon.module.css */
.icon {
  display: inline-block;
  flex-shrink: 0;
  color: currentColor;
}

Пример одной иконки:

tsx
// ui-kit/src/components/Icon/icons/Send.tsx
import { type SVGProps } from 'react'

export function Send(props: SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} {...props}>
      <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  )
}

a11y: если иконка несёт смысл (например, кнопка «отправить» без текста) — передавайте aria-label. Если декоративная — aria-hidden="true" (по умолчанию).

tsx
// смысловую
<Icon name="Send" aria-label="Отправить" />
// декоративную
<Icon name="Chat" />  // aria-hidden="true" автоматически

Divider — разделитель

tsx
// ui-kit/src/components/Divider/Divider.tsx
import styles from './Divider.module.css'

interface DividerProps {
  orientation?: 'horizontal' | 'vertical'
  className?: string
}

export function Divider({ orientation = 'horizontal', className }: DividerProps) {
  return (
    <hr
      className={[styles.divider, styles[orientation], className]
        .filter(Boolean)
        .join(' ')}
      aria-hidden="true"
    />
  )
}
css
/* ui-kit/src/components/Divider/Divider.module.css */
.divider { border: none; margin: 0; }
.horizontal { width: 100%; height: 1px; border-top: 1px solid var(--c-border); }
.vertical   { height: 100%; width: 1px; border-left: 1px solid var(--c-border); }

Barrel exports

Обновляем index.ts:

ts
// ui-kit/src/index.ts — после существующих
export { Icon, type IconName } from './components/Icon'
export { Divider } from './components/Divider'

И добавляем subpath entry в tsup.config.ts:

ts
entry: [
  'src/index.ts',
  'src/components/Box/index.ts',
  'src/components/Text/index.ts',
  'src/components/Button/index.ts',
  'src/components/Icon/index.ts',     // ← новое
  'src/components/Divider/index.ts',  // ← новое
  'src/theme/index.ts',
],

2.4 A11y: контраст и focus-ring

Контраст через axe-core

Storybook уже настроен с @storybook/addon-a11y. Каждая story автоматически проверяется на нарушения WCAG. Откройте любую story — внизу панель Accessibility с результатами axe.

Для ручной проверки контраста пар цвет/фон:

ts
// Контрастное соотношение должно быть ≥ 4.5:1 для обычного текста (WCAG AA)
// В dark теме: var(--c-text) #c9d1d9 на var(--c-bg) #0d1117 → ~12:1 ✓
// В light теме: var(--c-text) #333 на var(--c-bg) #f8f9fa → ~11:1 ✓

Focus-ring

Каждый интерактивный компонент должен иметь видимый focus-ring. В Button.module.css уже есть:

css
.btn:focus-visible {
  outline: 2px solid var(--c-primary);
  outline-offset: 2px;
}

Добавьте такое же правило для всех будущих интерактивов (Input в М4, Tab в М6 и т.д.).

:focus-visible, а не :focus — это показывает ring только при клавиатурной навигации, не при клике мыши. Лучший UX.


2.5 Тесты: Vitest + RTL на примитивы

В boilerplate уже есть Button.test.tsx (4 теста). Добавим тесты на Text и Divider:

tsx
// ui-kit/src/components/Text/Text.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Text } from './Text'

describe('Text', () => {
  it('renders body by default as <p>', () => {
    render(<Text>Hello</Text>)
    const el = screen.getByText('Hello')
    expect(el.tagName).toBe('P')
  })

  it('renders h1 variant as <h1>', () => {
    render(<Text variant="h1">Title</Text>)
    expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
  })

  it('applies muted color class', () => {
    const { container } = render(<Text color="muted">Subtle</Text>)
    expect(container.firstChild).toHaveClass(/muted/)
  })
})
tsx
// ui-kit/src/components/Divider/Divider.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Divider } from './Divider'

describe('Divider', () => {
  it('renders horizontal by default', () => {
    const { container } = render(<Divider />)
    expect(container.querySelector('hr')).toHaveClass(/horizontal/)
  })

  it('renders vertical when specified', () => {
    const { container } = render(<Divider orientation="vertical" />)
    expect(container.querySelector('hr')).toHaveClass(/vertical/)
  })

  it('is aria-hidden', () => {
    const { container } = render(<Divider />)
    expect(container.querySelector('hr')).toHaveAttribute('aria-hidden', 'true')
  })
})

Паттерн: тесты на CSS-классы используют regex (/muted/), потому что CSS Modules хеширует имена. Тесты на теги/ARIA — точные строки.


2.6 Storybook: stories для всех примитивов

В boilerplate уже есть Tokens.stories.tsx, Box.stories.tsx, Text.stories.tsx, Button.stories.tsx. Добавляем Icon и Divider:

tsx
// ui-kit/stories/Icon.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Icon } from '../src/components/Icon'

const meta: Meta<typeof Icon> = {
  title: 'Primitives/Icon',
  component: Icon,
  parameters: { layout: 'centered' },
  args: { name: 'Send', size: 24 },
  argTypes: {
    name: { control: 'select', options: ['Chat', 'Send', 'Settings', 'Search', 'Close', 'Reaction'] },
    size: { control: 'number' },
  },
}
export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {}
export const Small: Story = { args: { size: 16 } }
export const Large: Story = { args: { size: 32 } }
export const WithLabel: Story = { args: { name: 'Send', 'aria-label': 'Отправить сообщение' } }

export const All: Story = {
  render: () => (
    <div style={{ display: 'flex', gap: 'var(--s-md)', alignItems: 'center' }}>
      <Icon name="Chat" size={24} />
      <Icon name="Send" size={24} />
      <Icon name="Settings" size={24} />
      <Icon name="Search" size={24} />
      <Icon name="Close" size={24} />
    </div>
  ),
}
tsx
// ui-kit/stories/Divider.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Divider } from '../src/components/Divider'

const meta: Meta<typeof Divider> = {
  title: 'Primitives/Divider',
  component: Divider,
}
export default meta
type Story = StoryObj<typeof meta>

export const Horizontal: Story = {
  render: () => (
    <div style={{ width: 300 }}>
      <p>Над чертой</p>
      <Divider />
      <p>Под чертой</p>
    </div>
  ),
}

export const Vertical: Story = {
  render: () => (
    <div style={{ display: 'flex', height: 60, alignItems: 'center' }}>
      <span>Слева</span>
      <Divider orientation="vertical" />
      <span>Справа</span>
    </div>
  ),
}

2.7 Playground и UI Gallery: обновляем превью

В ui-kit/dev/main.tsx добавьте секции для Icon и Divider:

tsx
// фрагмент Playground (dev/main.tsx)
<Card title="Icon">
  <Box gap="md" align="center">
    <Icon name="Chat" size={24} />
    <Icon name="Send" size={24} />
    <Icon name="Settings" size={24} />
    <Icon name="Reaction" size={24} />
  </Box>
</Card>

<Card title="Divider">
  <Box direction="column" gap="sm">
    <Text>Первый пункт</Text>
    <Divider />
    <Text>Второй пункт</Text>
  </Box>
</Card>

2.8 Антипаттерны

АнтипаттернПочему плохоКак правильно
padding: 16px в CSS ModuleНе меняется при смене темы/масштабаpadding: var(--s-md)
color: #2563eb в CSS ModuleХардкод, ломает темизациюcolor: var(--c-primary)
<div className="custom-icon"> без aria-hiddenScreen reader читает мусор<Icon aria-hidden="true" /> или aria-label
import { Chat } from './icons/Chat' в компонентеНет tree-shaking по имени<Icon name="Chat" /> — Icon резолвит по ключу
Spreading неизвестных пропсов {...props} в UI KitНепредсказуемый APIЯвный interface с типизированными полями
box-shadow: 0 2px 8px rgba(0,0,0,0.08) в CSSХардкод тениbox-shadow: var(--shadow-sm)

Компромиссы (Trade-offs)

РешениеПлюсыМинусы
TS-объект → CSS-var (не чистый CSS)Типизация, темизация одним объектомДополнительный слой ThemeProvider
prefers-color-scheme auto-detectУважает системную настройкуМожет расходиться с выбором пользователя
CSS Modules (не styled-components)Нулевой runtime, изоляцияДинамические стили сложнее
:focus-visible (не :focus)Ring только при клавиатуреНе работает в старых браузерах

💻 2.9 Практика

Шаг 1. Добавить токены lineHeight и zIndex

  1. Расширьте Theme в ui-kit/src/theme/types.ts.
  2. Добавьте значения в defaultTheme.ts.
  3. Добавьте маппинг в ThemeProvider.tsx.
  4. npm run typecheck — должно быть зелёным.

Шаг 2. Реализовать Icon и Divider

  1. Создайте ui-kit/src/components/Icon/ и ui-kit/src/components/Divider/ по образцу выше.
  2. Добавьте минимум 3 иконки (Send, Chat, Settings).
  3. Обновите barrel index.ts и tsup.config.ts.

Шаг 3. Тесты

  1. Напишите Text.test.tsx и Divider.test.tsx по образцу.
  2. npm run test — все тесты (включая Button) должны проходить.

Шаг 4. Storybook

  1. Добавьте Icon.stories.tsx и Divider.stories.tsx.
  2. npm run storybook — проверьте, что axe-a11y зелёный для всех stories.

Шаг 5. Собрать карточку чата из примитивов

В ui-kit/dev/main.tsx добавьте карточку:

tsx
<Card title="ChatCard из примитивов">
  <Box gap="md" align="center">
    {/* Аватар-плейсхолдер из Box */}
    <Box padding="md" style={{
      width: 40, height: 40, borderRadius: 'var(--radius-full)',
      background: 'var(--c-primary)', flexShrink: 0,
    }} />
    <Box direction="column" gap="xs" style={{ flex: 1, minWidth: 0 }}>
      <Box justify="between" align="center">
        <Text variant="body" weight="bold">Alice</Text>
        <Text variant="caption">5 мин назад</Text>
      </Box>
      <Text color="muted">Привет! Как дела с UI Kit?</Text>
    </Box>
  </Box>
  <Divider />
  <Box justify="between" align="center">
    <Text variant="caption" color="muted">3 сообщения</Text>
    <Icon name="Chat" size={16} />
  </Box>
</Card>

Шаг 6. Зафиксировать артефакт М2

  1. В Playground переключите тему в dark → сделайте скриншот.
  2. Переключите в light → скриншот.
  3. Переключите в brand → скриншот.
  4. Приложите три скриншота к сдаче М2.

2.10 Артефакты сдачи М2

АртефактКак получитьКритерий
verify зелёныйnpm run verifylint + typecheck + все тесты + build
Скриншот Playground (dark):5173, тема darkВидны Box/Text/Button/Icon/Divider + ChatCard
Скриншот Playground (light):5173, тема lightВсе компоненты корректно в светлой теме
Скриншот Playground (brand):5173, тема brandPrimary-цвет изменён на brand
Скриншот Storybook:6006, Primitives/Iconaxe-панель зелёная (0 violations)

2.11 Чек-лист перед Модулем 3

  • Токены lineHeight и zIndex добавлены в типы, дефолтную тему и ThemeProvider.
  • Icon и Divider реализованы с CSS Modules и тестами.
  • Barrel index.ts обновлён, tsup.config.ts — тоже.
  • npm run test покрывает Box, Text, Button, Icon, Divider.
  • npm run storybook показывает все примитивы, axe зелёный.
  • ChatCard собрана только из примитивов (Box, Text, Icon, Divider).
  • Три скриншота (dark/light/brand) приложены к сдаче.

Вперёд → Модуль 3: основы Effector.


🧩 Проверь себя

1. Design tokens (CSS custom properties) — зачем?
2. Tokens в JSON/YAML (Style Dictionary) vs CSS custom properties — разница?
3. Темизация (light/dark) через tokens — как?
4. Примитивы (Button, Input) содержат...
5. Spacing scale (4px base, multiples) — зачем?

🔗 Мостик к Модулю 3

Примитивы готовы: Box, Text, Button, Icon, Divider с тремя темами и зелёным axe. Но как управлять состоянием приложения — модалки, тосты, асинхронные запросы? Знакомьтесь с Effector.