▸ избранное · лонгрид — № 015 · 13 июн '26 · 12 мин · серия: claude-driven монорепа · ч.1
Дизайн-система как набор инвариантов: собираем токен-пайплайн с нуля
часть 1 серии про claude-driven монорепу — дизайн-слой, чьи правила делают неправильное невозможным, а не «не рекомендованным».
это первая статья серии о принципах, на которых стоит claude-driven монорепа. репозиторий описывает себя как «spec-first, agent-friendly monorepo boilerplate» — стартер под продакшен для команд, которые строят продукт в связке Claude Design → Claude Code: рабочие backend / web / mobile, общий пайплайн дизайн-токенов, единый источник правды для каждого контракта и защита от дрейфа, которая делает агентную разработку безопасной «без человека над каждой строкой». сегодня — дизайн-слой, подробно и по шагам, так, что ты сможешь собрать такой же пайплайн у себя. стек репы (NestJS / Nuxt / Flutter) — лишь рабочий пример: подход переносится на любой.
зачем вообще инварианты
у greenfield-проектов с AI-ассистентом провалы предсказуемы: агент зашивает цвет хардкодом прямо в компонент, переименовывает поле в одном клиенте и забывает в другом, меняет что-то «по месту». к моменту ревью диф уже на десятки файлов, и человек ловит дрейф слишком поздно.
вывод, на котором стоит весь подход:
дизайн-система — это не библиотека компонентов, а набор машинно-проверяемых инвариантов. ты делаешь неправильное невозможным, а не «не рекомендованным». тогда неважно, кто пишет код — человек или агент: правила одинаково держат и тех, и других.
ниже — как собрать такую систему по шагам.
схема целиком
один нейтральный формат в центре, и к нему сходятся и входы, и выходы:
источники дизайна таргеты стека
───────────────── ────────────
Claude Design ┐ ┌─► CSS / SCSS-переменные
Figma ├─► [ ТОКЕНЫ: W3C DTCG ] ──► эмиттеры ─┼─► TypeScript-константы
Penpot ├─► единый источник (по таргету) ├─► Flutter/Dart-константы
вручную (JSON) ┘ └─► …любой другой
│
▼
библиотека примитивов на токенах ──► экраны продукта
│
▼
машинные правила (линтеры · аудит · CI-drift)
дальше — каждый блок по шагам.
шаг 1 · источник: токены в нейтральном формате
источник правды — семантические токены в формате W3C Design Tokens (DTCG). формат намеренно не знает ни одного фреймворка: лист — это пара $value / $type, плюс пары тем.
// specs/design/tokens/color.json
"accent": {
"light": { "$value": "#635bff", "$type": "color" },
"dark": { "$value": "#7b75ff", "$type": "color" }
}
два принципа закладываются прямо здесь:
- единый источник правды. все дизайн-решения (цвет, типографика, отступы, радиусы, тени, движение, z-index) живут в одном месте — в токен-файлах. всё остальное либо генерируется из них, либо ссылается.
- имя = намерение, не значение. токен называется
accent,surface.raised,radius.xl— по роли, а не#635bff/16px. и вот в чём сила: ребрендинг становится правкой одной строки. в этой репе так и было — акцент мигрировал#5c16c5 → #635bff, и ни один компонент не поменялся, потому что компоненты ссылаются на роль, а не на хекс.
откуда брать сам источник — выбираешь ты, и вариантов много (всё, что умеет отдавать DTCG):
- Claude Design / claude.ai — описываешь бренд брифом, Claude генерит токен-JSON и мокапы. в репе для этого лежит готовый copy-paste бриф. это «AI как источник дизайна».
- Figma — экспорт токен-плагина в JSON, раскладка по
*.json. - Penpot (опенсорсная альтернатива Figma) — тот же экспорт плагина.
- вручную — просто пишешь JSON.
→ исходники и формат:
specs/design(в еёREADME.mdописан импорт из Claude Design / Figma / Penpot / вручную) · иspecs/design/tokens.
шаг 2 · эмиттер: один источник → много таргетов
это сердце переносимости. берём одну токен-модель и компилируем под каждую платформу в её нативный формат. никаких ручных «продублируй цвет в трёх местах» — паритет структурный.
эмиттер для CSS проходит по токенам и раскладывает их по темам (имена camelCase → kebab-case):
// упрощённо из packages/design-tokens/src/emit-scss.ts
function themedBlock(theme, tokens) {
const selector = theme === 'light' ? `:root, [data-theme='light']` : `[data-theme='${theme}']`;
const lines = [`${selector} {`];
for (const [key, leaf] of Object.entries(tokens.color.brand)) {
const value = (leaf[theme] ?? leaf.light).$value; // fallback на light
lines.push(` --brand-${kebab(key)}: ${value};`);
}
lines.push('}');
return lines.join('\n');
}
результат — обычные CSS-переменные с каскадом тем (light — он же дефолт под :root, дальше переопределения):
:root,
[data-theme='light'] {
--brand-accent: #635bff;
}
[data-theme='dark'],
.dark {
--brand-accent: #7b75ff;
}
из одного источника эмиттер выдаёт все темы разом (тут — light / dark / sepia / forest) — то есть тёмная тема и ребрендинг достаются «бесплатно».
сборка веером пишет артефакты под каждую платформу одной командой:
// упрощённо из packages/design-tokens/src/build.ts
const targets = [
{ file: 'apps/web/app/assets/css/tokens.generated.css', contents: emitScss(tokens) },
{ file: 'packages/ui/src/tokens.generated.css', contents: emitScss(tokens) },
{ file: 'apps/web/app/design-tokens.generated.ts', contents: emitTypescript(tokens) },
{ file: 'packages/ui/src/design-tokens.generated.ts', contents: emitTypescript(tokens) },
{ file: 'packages/ui_flutter/lib/src/theme/tokens.g.dart', contents: emitDart(tokens) },
];
вот наглядный пруф переносимости — один и тот же токен на двух несвязанных рантаймах:
/* CSS / SCSS (браузер) */
--brand-accent: #635bff;
// Flutter / Dart (мобильный рантайм)
static const Color brandAccent = Color(0xFF635BFF);
переносим именно подход: хочешь новый стек — добавляешь эмиттер под новый таргет, источник не трогаешь. принцип целиком: генерация в нативный формат каждой платформы.
→ эмиттеры целиком:
packages/design-tokens· те же токены, доехавшие до мобильного рантайма:packages/ui_flutter.
шаг 3 · примитивы — на токенах, а не обёртки над чужим китом
соблазн — взять готовый UI-кит и обернуть его кнопку. но тогда стили живут внутри чужого кода, и правила на них не навесишь. поэтому базовые примитивы — свои, со стилями только на токенах.
каждый компонент — это папка с полным комплектом (об этом — на шаге 4):
packages/ui/src/components/AppButton/
AppButton.vue # разметка + стили на токенах
AppButton.stories.ts # витрина всех вариантов
AppButton.spec.ts # рендер + a11y + варианты
index.ts
стили ссылаются только на var(--*), классы — по BEM с префиксом, никаких сырых значений:
.app-button {
height: var(--space-10);
padding: 0 var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-md);
transition: background var(--dur-base) var(--ease);
&--primary {
// BEM-модификатор
background: var(--brand-primary);
color: var(--brand-primary-fg);
}
}
api компонента читается как намерение: color="primary", а не цвет. поэтому ребрендинг из шага 1 проходит сквозь весь UI, не задевая ни одной сигнатуры.
полностью убежать от чужого кита нельзя — сложные поведенческие виджеты (модалка, дропдаун, таблица, календарь) дешевле взять готовыми. их не запрещают, а изолируют: чужой символ импортируется ровно в одном месте — в своей обёртке-примитиве, и наружу торчит только твой компонент.
принцип: владей примитивами — правила можно проверять только на коде, которым владеешь.
→ библиотека примитивов:
packages/ui.
шаг 4 · зубы: машинные правила вместо гайдлайна
это шаг, ради которого всё затевалось. правила — не строчки в вики, а конфиги, которые валят сборку.
линтер запрещает хардкод. реальные правила:
// упрощённо из stylelint.config.mjs
rules: {
'declaration-no-important': true, // никаких !important
'color-no-hex': true, // никаких #635bff — только var(--brand-*)
'color-named': 'never', // никаких red / blue
// плюс: z-index только var(--z-*), длительности — var(--dur-*),
// отступы ≥3px — var(--space-*); классы — BEM с префиксом app-/health-/brand-
}
захардкодил #F26A1F вместо accent — линт это отвергает. не абстрактная договорённость, а отказ.
аудит требует полный комплект у компонента. скрипт обходит папки и падает, если у компонента нет истории или спеки:
// суть packages/ui/scripts/audit-components.ts
// для каждой папки компонента обязаны существовать:
// <Name>.vue, <Name>.stories.ts, <Name>.spec.ts, index.ts
// иначе process.exit(1) — «component audit failed».
этот аудит реально крутится в CI (job ui-quality), так что «компонент без витрины и теста» физически не доезжает до main.
CI ловит дрейф сгенерированного кода. артефакты закоммичены; CI перегенерирует их и сравнивает с деревом — расхождение валит PR. в этой репе drift-gate сейчас стоит на сгенерированных API-клиентах (spec:codegen → git diff --exit-code); для дизайн-токенов тот же приём — следующий очевидный шаг (см. «что дальше»). пока токены держит запрет ручной правки генерёнок и регенерация на месте.
принцип: правила машинные, а не в гайдлайне. гайдлайн — это надежда; падающий пайплайн — гарантия. и именно это превращает «агент пишет фичи» из риска в управляемый процесс.
→ реальный конфиг правил:
stylelint.config.mjs.
шаг 5 · поток изменений — всегда в одну сторону
связываем всё в цикл. любое дизайн-изменение идёт строго сверху вниз:
- правишь токен —
specs/design/tokens/*.json. - регенерируешь —
pnpm design:build. артефакты под web / Storybook / mobile пересобираются разом. - обновляешь или создаёшь компонент в библиотеке (с историей и спекой).
- потребляешь в приложениях — страницы остаются тонкими: данные, пропсы, роутинг.
правило: никогда не правишь «вниз по течению», чтобы починить значение «выше». если компонент рендерит старый цвет — он использует литерал вместо токена; чинишь компонент, а не подгоняешь токен. обратная правка — это баг, а не гибкость.
честные ограничения
подход не бесплатный — и это нормально:
- регенерация — реальный шаг, а артефакты коммитятся. забыл пересобрать — получаешь дрейф. сейчас токены держит запрет ручной правки генерёнок; CI-drift-gate (уже закрывающий сгенерированные клиенты) на токены пока не распространён — см. «что дальше».
- токен — это контракт. переименовать или удалить токен = breaking change для всех потребителей на всех платформах. только осознанно (в репе — через ADR).
- свои примитивы дороже в моменте, чем
installготового кита. - от чужого кита не убежать полностью — сложные виджеты всё равно на нём, просто за обёрткой.
- налог на строгость. «никаких magic numbers» замедляет «просто быстро накидать».
что дальше / что можно улучшить
направление одно: каждый следующий шаг сужает разрыв между намерением и проверкой или укорачивает петлю источник → продукт.
- замкнуть петлю дизайнер↔код — тянуть токены прямо из дизайн-инструмента через тот же стандарт (DTCG ровно для этого и создан), чтобы источник перестал быть рукописным JSON.
- сделать регенерацию токенов незабываемой — drift-gate в CI (как уже сделано для сгенерированных клиентов через
spec:codegen), который регенерит токены и падает на расхождении. закрывает ограничение №1. - алиасы токенов — семантические токены ссылаются на примитивы (
{group.token}по DTCG), убирая дублирование и ещё удешевляя ребрендинг. - codemod на переименование — превратить цену «токен = контракт» из ручной в механическую.
- шире машинная проверка — контракт-тест «каждая роль × каждая тема имеет значение»; визуальные baseline-снимки на каждый компонент.
итог
дизайн-слой здесь — не библиотека, а машина инвариантов: один нейтральный источник, генерация под любой стек и набор линтеров и аудитов, которые делают нарушение правил технически невозможным. много источников дизайна сходятся в один стандартный формат, из него — много таргетов; а правила держат систему целостной, кто бы ни писал код.
→ посмотреть всё вживую: репозиторий-витрина · стандарт формата токенов — designtokens.org.
дальше в серии — спек-first контур: как OpenAPI + кодоген не дают бэкенду и клиентам разъехаться по контракту.