Модуль 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:
tsentry: [ '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-hidden | Screen 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
- Расширьте
Themeвui-kit/src/theme/types.ts. - Добавьте значения в
defaultTheme.ts. - Добавьте маппинг в
ThemeProvider.tsx. npm run typecheck— должно быть зелёным.
Шаг 2. Реализовать Icon и Divider
- Создайте
ui-kit/src/components/Icon/иui-kit/src/components/Divider/по образцу выше. - Добавьте минимум 3 иконки (
Send,Chat,Settings). - Обновите barrel
index.tsиtsup.config.ts.
Шаг 3. Тесты
- Напишите
Text.test.tsxиDivider.test.tsxпо образцу. npm run test— все тесты (включая Button) должны проходить.
Шаг 4. Storybook
- Добавьте
Icon.stories.tsxиDivider.stories.tsx. 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
- В Playground переключите тему в dark → сделайте скриншот.
- Переключите в light → скриншот.
- Переключите в brand → скриншот.
- Приложите три скриншота к сдаче М2.
2.10 Артефакты сдачи М2
| Артефакт | Как получить | Критерий |
|---|---|---|
verify зелёный | npm run verify | lint + typecheck + все тесты + build |
| Скриншот Playground (dark) | :5173, тема dark | Видны Box/Text/Button/Icon/Divider + ChatCard |
| Скриншот Playground (light) | :5173, тема light | Все компоненты корректно в светлой теме |
| Скриншот Playground (brand) | :5173, тема brand | Primary-цвет изменён на brand |
| Скриншот Storybook | :6006, Primitives/Icon | axe-панель зелёная (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.
🧩 Проверь себя
🔗 Мостик к Модулю 3
Примитивы готовы: Box, Text, Button, Icon, Divider с тремя темами и зелёным axe. Но как управлять состоянием приложения — модалки, тосты, асинхронные запросы? Знакомьтесь с Effector.