CH.05Дизайн-система как набор инвариантов: собираем токен-пайплайн с нуля— часть 1 серии про claude-driven монорепу — дизайн-слой, чьи правила делают неправильное невозможным, а не «не рекомендованным».
← все записи
#0152026-06-1312 минлонгрид

▸ избранное · лонгрид — № 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" }
}

два принципа закладываются прямо здесь:

  1. единый источник правды. все дизайн-решения (цвет, типографика, отступы, радиусы, тени, движение, z-index) живут в одном месте — в токен-файлах. всё остальное либо генерируется из них, либо ссылается.
  2. имя = намерение, не значение. токен называется 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:codegengit diff --exit-code); для дизайн-токенов тот же приём — следующий очевидный шаг (см. «что дальше»). пока токены держит запрет ручной правки генерёнок и регенерация на месте.

принцип: правила машинные, а не в гайдлайне. гайдлайн — это надежда; падающий пайплайн — гарантия. и именно это превращает «агент пишет фичи» из риска в управляемый процесс.

→ реальный конфиг правил: stylelint.config.mjs.

шаг 5 · поток изменений — всегда в одну сторону

связываем всё в цикл. любое дизайн-изменение идёт строго сверху вниз:

  1. правишь токенspecs/design/tokens/*.json.
  2. регенерируешьpnpm design:build. артефакты под web / Storybook / mobile пересобираются разом.
  3. обновляешь или создаёшь компонент в библиотеке (с историей и спекой).
  4. потребляешь в приложениях — страницы остаются тонкими: данные, пропсы, роутинг.

правило: никогда не правишь «вниз по течению», чтобы починить значение «выше». если компонент рендерит старый цвет — он использует литерал вместо токена; чинишь компонент, а не подгоняешь токен. обратная правка — это баг, а не гибкость.

честные ограничения

подход не бесплатный — и это нормально:

  • регенерация — реальный шаг, а артефакты коммитятся. забыл пересобрать — получаешь дрейф. сейчас токены держит запрет ручной правки генерёнок; 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 + кодоген не дают бэкенду и клиентам разъехаться по контракту.