Продуктовый дизайнер, Claude Code и месяц соло-разработки: что пришлось построить вокруг AI, чтобы проект не развалился

Я продуктовый дизайнер, больше шести лет делаю SaaS. Программировать умею, но фулстек-разработчиком себя не назову. Уже несколько месяцев я в одиночку делаю Котомку, планировщик жизни по методу PARA: сферы, проекты, задачи, календарь, привычки, финансы. Есть веб, мобильная версия, синхронизация между устройствами и с Google и Apple календарями. Почти весь код пишет Claude Code. Я ставлю задачи, проверяю результат и принимаю решения.
Первые недели были эйфорией: фичи появлялись быстрее, чем я успевал их придумывать. Потом начались две проблемы. Интерфейс стал расползаться, а у пользователей начали пропадать данные.
Эта статья не про то, что AI заменил программиста. Она про то, что пришлось построить вокруг AI, чтобы проект перестал разваливаться. Всё ниже взято из реального репозитория, с реальными граблями.
Стек: Next.js 16 (App Router, по факту SPA), весь стейт в одном React-контексте плюс LocalStorage, сервер для синка и интеграций, снимки в Postgres (Neon), Vitest + React Testing Library, e2e-тесты, husky, Conventional Commits.
Часть 1. AI не помнит вашу дизайн-систему
Симптом
Прошу поправить форму на мобилке, и Claude Code делает свой размер инпута, хотя в проекте есть готовый примитив. Прошу новый экран, и там заголовки на пиксель другие, чем на соседнем. Каждое отдельное решение выглядит разумно, а вместе получается лоскутное одеяло.
Причина оказалась банальной. Дизайн-системы в явном виде не было. CLAUDE.md разросся до 240 строк сплошной прозы, и в нём не было ни слова про UI. Модель на каждый запрос решала заново, как должна выглядеть кнопка. Жаловаться на это так же странно, как ругать нового разработчика, которому не показали гайдлайны.
Шаг 1. Измерить, а не спорить на глаз
Первым делом я попросил Claude Code написать скрипт аудита. scripts/ui-audit.ts прогоняет экраны приложения в браузере, собирает вычисленные стили элементов и группирует их по ролям: заголовки, инпуты, кнопки, строки списков. В отчёт попадает разброс, то есть сколько разных значений font-size, height, padding встречается у элементов, которые должны выглядеть одинаково.
Первый прогон охватил 3089 элементов на 36 экранах. Разброс был даже хуже, чем я ожидал.
Шаг 2. Токены и грабли с tailwind-merge
Дальше всё стандартно: шкала токенов в globals.css, три роли заголовков, ui-компоненты переведены на size/variant без изменения публичного API. 90 произвольных значений вроде h-[37px] и хардкодных hex-цветов заменены токенами, остались только единичные осознанные исключения вроде calc(). ESLint-правило на них стоит в режиме error. Мобильные значения живут внутри самого токена через @media, чтобы компонентам не приходилось об этом думать.
А потом табы начали «плавать», хотя токен был прописан правильно.


Корень нашёлся в cn(). Внутри него работает tailwind-merge, который не знает про кастомные классы text-*. Он считает их цветом, а значит, конфликтующими с настоящим цветом текста, и молча выкидывает один из двух.
Лечится расширением конфига:
// lib/utils.ts (упрощённо)
import { extendTailwindMerge } from 'tailwind-merge'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ text: ['body', 'caption', 'field' /* ...все токены */] }],
},
},
})С тех пор в правилах записано: новый токен размера текста обязан объявляться в cn(), иначе его срежет. На это правило есть отдельный тест.
У этой же истории было второе дно. На iPhone при вводе суммы страница зумилась. iOS зумит, если у инпута font-size меньше 16px, а в финансовых формах text-sm перебивал мобильный размер примитива. Решение — единый токен text-field: 14px на десктопе, 16px на мобилке, внутри токена. Его применили ко всем двенадцати финансовым инпутам, зум пропал.
Шаг 3. Правила для AI, но ленивые
Самое контринтуитивное открытие: чем больше правил загружено в контекст, тем хуже модель им следует и тем дольше думает. У меня в автозагрузке было 951 строка: CLAUDE.md на 241 строку и AGENTS.md на 710.
После реорганизации стало так:
117 строк грузятся всегда: CLAUDE.md на 110 строк и AGENTS.md на 7. Это ядро: архитектура, команды, ключевые запреты.
8 файлов в
.claude/rules/грузятся лениво, только когда Claude трогает файлы по маске из frontmatter.
---
paths:
- "src/components/**"
- "src/app/**/*.tsx"
---
# UI: правила работы с компонентами
...Проверять, что реально попало в контекст, нужно через /context. Отдельный скрипт check-rules-globs.ts следит, чтобы каждая маска совпадала хотя бы с одним файлом. Мёртвая маска — это правило, которое никогда не загрузится, и узнать об этом без проверки невозможно.
И честное дополнение. Когда я готовил эту статью и пересчитал строки на текущем main, автозагрузка уже снова выросла: 157 строк вместо 117. CLAUDE.md подрос до 135, а файл правил для режима аудита грузится всегда, потому что у него нет масок. Ленивых файлов стало 13. Правила разрастаются так же, как код, и их тоже надо периодически ревьюить.

Шаг 4. Инвентарь компонентов и урок про доверие
Чтобы модель переиспользовала, а не изобретала, ей нужно знать, что уже есть. Скрипт генерирует инвентарь компонентов: примитивы из ui/ плюс обёртки, которые импортируются хотя бы в двух местах. Колонку «когда использовать» ведут вручную, генератор её не затирает.
При выборочной проверке оказалось, что три описания из четырёх, написанных AI, были неточными. Модель перепутала границу между двумя шторками и насчитала пять вариантов маскота вместо девяти. Документация, сгенерированная AI и никем не проверенная, опаснее, чем её отсутствие: модель будет ей уверенно следовать.
Шаг 5. Запреты вместо просьб
Правила в тексте — это просьба. Надёжнее сделать нарушение невозможным:
PreToolUse-хук блокирует создание новых файлов в
src/components/ui/. Новый примитив — это решение, которое принимаю я, а не побочный эффект задачи.Гвард UI-аудита: скрипт сравнивает текущий аудит с эталоном и падает только на новом значении. Эталон обновляется вручную отдельной командой, то есть осознанно. Гвард запускается командой
npm run ui-audit:check, локально и в CI. Первый же прогон, кстати, показал, что закоммиченный отчёт давно протух.
Вот как это выглядит. Для статьи я сделал демо-ветку, где у табов поменялся font-weight на 600, и запустил гвард локально. Он нашёл новое значение, показал все места, где оно встречается, и упал:

По итогу разброс в аудите заметно сократился, а главное, перестал расти. Новые экраны собираются из тех же деталей.
Часть 2. Синхронизация, которая теряла данные
Архитектура
Всё состояние пользователя — один JSON-снимок с целочисленной версией. Клиент отправляет изменения с дебаунсом, а подтягивает их при фокусе вкладки и по таймеру. Конфликты разрешаются по принципу «последняя запись побеждает»: сервер принимает запись, только если базовая версия клиента совпадает с текущей, иначе отвечает 409. Схема простая и понятная, но у простых схем тоже бывают тонкие места.
Баг 1. Текст откатывается сразу после ввода
Пользователь печатает, а через секунду часть текста исчезает. Воспроизводилось нестабильно, что для гонок обычное дело.
Диагноз я попросил делать в режиме аудита: код не трогать, давать ссылки файл:строка, ставить метки уверенности, догадки выносить в отдельный раздел. Это отдельное правило в .claude/rules/audit.md, и оно сильно дисциплинирует модель. Без него она склонна «чинить» по первой гипотезе.
Нашлось вот что. Флаг «есть неотправленные изменения» один на весь стор. Push берёт снимок в момент старта, а при ответе 200 сбрасывает флаг. Всё, что пользователь напечатал, пока запрос летел, считалось отправленным. Следующий pull приносил серверную версию и затирал ввод.
Фикс занимает одну строку, но строку правильную:
const local = stateRef.current
await push(local)
// сбрасываем флаг, только если за время запроса ничего не изменилось
if (stateRef.current === local) dirtyRef.current = falseТот же дефект сидел в ветке обработки 409, причём там он был шире ожидаемого: проверка равенства состояний шла раньше проверки локальных правок. Починено тем же гардом.
Грабли с тестами
Тест на гонку написан на fake timers и моке fetch. Честность я проверял откатом фикса: тест обязан упасть. Он падал.
А потом выяснилось, что хуки из прошлых тестов не размонтировались. Автоочистка RTL без globals: true в Vitest не срабатывает, поэтому их слушатели focus будили чужие хуки и двигали общую версию. Добавили cleanup() в afterEach и перепроверили: каждый тест падает ровно при снятии своего гарда. Повезло, ложно зелёных не оказалось. Но урок я записал: тест, который ни разу не падал, ничего не доказывает.
Баг 2. Пинг-понг между вкладками
Следующая находка оказалась смешнее. После pull стор нормализует данные и получает новый объект. Защита «не отправлять то, что только что пришло» сравнивала ссылки, видела разные объекты и отправляла данные обратно на сервер. С двумя открытыми вкладками это превращалось в бесконечный пинг-понг: версия росла каждый цикл опроса при нуле действий пользователя.
Для статьи я воспроизвёл это в тестовом харнессе, потому что для живого прогона в двух браузерных вкладках нужна отдельная тестовая база. Две «вкладки» — это два экземпляра хука синхронизации, каждый со своим стором. Фейковый сервер ведёт себя так же, как настоящий: принимает запись только при совпадении версии. Время модельное, 60 секунд, действий пользователя ноль.
До фикса версия на сервере выросла с 5 до 9: плюс один на каждом цикле опроса. После фикса она осталась 5. Регрессионные тесты из фикса, запущенные против старого кода, падают ровно на этом сценарии.

Обсуждали четыре варианта. Выбрали такой: replaceState возвращает уже нормализованный объект, и именно он кладётся в lastSyncedRef. Вариант «нормализовать на стороне хука» отклонили, потому что он ломает инвариант «стор — единственный нормализатор». Вариант «сравнивать по содержимому» отклонили из-за стоимости сериализации на горячем пути.
Баг 3. Потеря при переоткрытии
Данные в LocalStorage были целы, но при старте приложение видело, что версия на сервере новее, и выбирало серверную. Флаг неотправленных изменений жил только в памяти, поэтому после закрытия вкладки о них никто не знал. Решение: маркер «есть неотправленное» в LocalStorage плюс push при уходе приложения в фон.
Скорость
Исходный интервал опроса 15 секунд, дебаунс отправки полторы секунды. Для сценария «добавил задачу с телефона, смотрю на ноутбук» это вечность. Я хотел 1–3 секунды без уведомлений «данные обновились» и без откатов.
WebSocket и SSE не понадобились. Хватило лёгкого GET /api/state/version, который возвращает только число, опроса раз в 2 секунды и дебаунса по переднему фронту с защитой от параллельных push. Полный снимок тянется только тогда, когда версия изменилась. Проверили на проде, синхронизация ощущается мгновенной.
Что я вынес из этого
AI пишет код быстро, но за архитектурные решения отвечает человек. Во всех историях выше модель отлично находила причины и предлагала варианты. Выбирать между вариантами и видеть, какой инвариант ломается, приходилось мне.
Сначала диагноз, потом правка. Режим аудита с метками уверенности сэкономил больше времени, чем любая другая настройка.
Меньше контекста — лучше результат, но это нужно поддерживать. 117 строк в автозагрузке работали лучше, чем 951. Через месяц их снова стало 157, так что ревью правил теперь в моём регулярном списке.
Запреты надёжнее просьб. Хуки и гварды продолжают работать, когда модель забывает правила.
Проверяйте то, что AI написал о собственном коде. Инвентарь и тесты выглядели убедительно, но и там, и там были ошибки.
Быстрый pre-commit важен. Когда e2e-тесты гонялись на каждый коммит, разработка заметно замедлилась. Сейчас e2e живут в CI, а pre-commit (lint, tsc, юнит-тесты) занимает около 13 секунд.
Вместо заключения
Если интересно посмотреть, что получилось в итоге kotomka.app
Это планировщик, где задачи разложены по сферам жизни, рядом лежат цели, календарь с рутиной, привычки, дневник и финансы. Сейчас проект проходит акселератор А:СТАРТ Академпарка, а первые пользователи могут пользоваться им бесплатно.
Но больше всего мне интересно обсудить подход. Как вы держите AI в рамках дизайн-системы? Какие правила для Claude Code или Cursor у вас реально работают? Буду рад вопросам и критике в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.