textmachine/frontend/docs/FRONTEND_PLAN.md

34 KiB
Raw Blame History

План фронта — как пишется интерфейс TextMachine

Этап S0 сессии фронта: план до первой строки кода (FRONTEND_SESSION_PROMPT.md §7.2). Ландится отдельным коммитом. Правят следующие фронт-сессии; при конфликте с STACK_DECISIONS.md побеждает STACK_DECISIONS (там ратифицированы пины), при конфликте с промтом сессии — промт (там ратифицирован продукт).

0. Границы

Эта сессия делает S0 (план) и S1 (инструменты, скриншот-цикл, tokens.css, витрина) и останавливается на витрине. Экраны — S2S7, по одной сессии на этап (BACKLOG.md Ф-1).

Зона записи — только frontend/. backend/, platform/, docs/, eval/ — read-only.


1. Пины

Все версии сверены с npm-регистри 02.08.2026 командой npm view <pkg> version. Ставятся точными версиями, без ^ и ~. Колонка «дата» — дата публикации именно этой версии.

Строка «= latest» означает: на 02.08.2026 эта версия и есть последняя опубликованная. Единственное намеренное отставание от latest — TypeScript (см. сноску).

Рантайм

Пакет Пин Дата Зачем именно нам
react 19.2.8 2026-07-21 ядро; = latest, React 20 не существует
react-dom 19.2.8 2026-07-21 обязан совпадать с react версия-в-версию
react-router 8.3.0 2026-07-22 библиотечный (data) режим: маршрут на экран — несущая правила «каждый экран открывается в изоляции» (§3.10). Требует Node ≥ 22.22
@tanstack/react-query 5.101.4 2026-07-21 всё серверное состояние: прогон, книги, банк. Мажор 5 актуален, «v6» на npm — svelte-адаптер
zustand 5.0.14 2026-05-28 состояние интерфейса (свёрнутость панелей, активные вкладки). Только с селектором или useShallow
react-aria-components 1.20.0 2026-07-31 ЕДИНСТВЕННАЯ библиотека примитивов: Table+Virtualizer (банк), Tree (главы), Tabs/Menu/Dialog/Popover/Tooltip (оболочка), I18nProvider (русские служебные строки). Стилей не навязывает. Импорт разрешён только внутри src/ui/
react-resizable-panels 4.12.2 2026-07-12 три панели Fleet. API v4: Group/Panel/Separator — старые имена PanelGroup/PanelResizeHandle мертвы
lucide-react 1.28.0 2026-07-30 тонкие линейные монохромные иконки как во Fleet: size 16, strokeWidth 1.5, без absoluteStrokeWidth
@fontsource-variable/inter 5.3.0 2026-07-19 интерфейсный гротеск; локальный woff2, а не CDN — вид не зависит от сети и одинаков в скриншот-цикле
@fontsource-variable/jetbrains-mono 5.3.0 2026-07-19 моноширинный для содержимого

CJK-шрифт не ставим: иероглифы отдаём системному стеку, lang на элементе приходит из данных языковой пары (STACK_DECISIONS.md §2).

Сборка и типы

Пакет Пин Дата Зачем именно нам
vite 8.2.0 2026-07-30 внутри Rolldown вместо Rollup+esbuild; = latest
@vitejs/plugin-react 6.0.5 2026-07-30 без Babel; старый рецепт подключения React Compiler в нём удалён
typescript 6.0.3 2026-04-16 намеренно не latest (latest = 7.0.2): у TS 7 нет программного API до 7.1, значит нет typescript-eslint и плагинов языкового сервера. Пересмотр — BACKLOG.md Ф-3
@types/react 19.2.18 2026-07-30
@types/react-dom 19.2.4 2026-07-30
@types/node 22.20.1 мажор типов держим на мажоре рантайма (Node 22), а не на latest 26 — иначе тайпчек разрешит API, которых на стенде нет
lightningcss 1.33.0 2026-07-20 минификация CSS в бою; в dev не участвует

Контроль качества

Пакет Пин Дата Зачем именно нам
eslint 10.8.0 2026-07-24 ради no-restricted-syntax — на нём стоит половина гейта «одно место для цвета»; в oxlint такого правила нет вовсе
@eslint/js 10.0.1 2026-02-06 базовый набор для flat-config
typescript-eslint 8.65.0 2026-07-20 единый пакет (парсер+плагин+config)
eslint-plugin-react-hooks 7.1.1 2026-04-17 держит совместимость с React Compiler, пока сам компилятор выключен (BACKLOG.md Ф-2)
eslint-plugin-react-refresh 0.5.3 ловит экспорты, ломающие HMR
eslint-config-prettier 10.1.8 2025-07-18 гасит форматные правила ESLint, чтобы формат был ровно один
stylelint 17.14.1 2026-07-20 вторая половина гейта: declaration-property-value-allowed-list
stylelint-config-standard 40.0.0 2026-01-15 база
prettier 3.9.6 2026-07-21 точный пин: разные патчи форматируют по-разному и создают шум в диффах
vitest 4.1.10 2026-07-06 тесты на том же конфиге Vite
happy-dom 20.11.1 2026-07-22 DOM для контракт-теста токенов: в отличие от jsdom считает каскад CSS-переменных
@testing-library/react 16.3.2 2026-01-19 рендер компонентов в тестах
@playwright/test 1.62.1 2026-07-30 скриншот-цикл и сверка вычисленных токенов в настоящем Chromium
msw 2.15.0 2026-07-08 моки сети; пин ставим сейчас, включает S3
globals 17.8.0 2026-07-26 наборы глобалов для flat-config

Стенд

node -v = 22.22.3 — проходит нижнюю границу react-router (engines.node: >=22.22.0), она самая жёсткая из всех. Проверять первым действием на любой новой машине. Пакетный менеджер — npm из поставки Node, один package.json в frontend/, воркспейсов нет.


2. Карта src/

Папка Что внутри Что НЕ внутри
tokens/ tokens.css — единственный источник цвета, размера, радиуса, шрифта. reset.css ничего кроме двух глобальных файлов
ui/ глупые примитивы на токенах: кнопка, поле, вкладки, строка дерева, таблица, выноска. Единственное место, где разрешён импорт react-aria-components запросы, знание о доменных сущностях
shell/ оболочка: три панели, верхняя полоса, статус-полоса, вкладки доменная логика экранов
features/ books, chapters, bank, reader, settings — по экрану на папку; запрос живёт здесь, на уровне экрана цвета и размеры мимо токенов
api/ единственный вход к данным: типы контракта + функции. Сейчас внутри моки, потом HTTP React-компоненты
mock/ фикстуры: книга, главы, пары оригинал/перевод, банк, состояния прогона логика; фикстуры — данные
showcase/ витрина примитивов: маршрут /showcase, живой каталог для сверки с references/fleet.png продуктовые экраны

Правило зависимостей — сверху вниз, без обратных рёбер: features/ui/ + api/; shell/ui/; ui/tokens/. api/ не знает про React.

2.1. Слои каскада

Ловушка STACK_DECISIONS.md §7.4 — тихие расхождения из-за порядка каскада между стилями сторонних примитивов, токенами и CSS Modules. Порядок объявлен один раз, в шапке reset.css (он импортируется первым, а заявление слоёв обязано стоять раньше любого слоя):

@layer reset, vendor;
  • сброс — самый низ, перебивается чем угодно;
  • vendor — стили сторонних примитивов; подключаются только так: @import 'пакет/styles.css' layer(vendor);
  • токены и CSS Modules — намеренно ВНЕ слоёв. Неслойное правило выигрывает у любого слоя, поэтому ни сброс, ни чужие стили наши перебить не могут, а модуль экрана при нужде может локально переопределить токен обычным каскадом.

Токены сознательно не завёрнуты в слой: заворачивать нечего (это только объявления переменных на :root), а лишний слой делает их слабее собственных стилей приложения без всякой выгоды.


3. Правила поддерживаемости — в проверяемой форме

Из FRONTEND_SESSION_PROMPT.md §5.1. Слева — правило, справа — чем оно проверяется. Правило без проверки — лозунг, поэтому у каждого либо машинный гейт, либо ревью-вопрос с однозначным ответом.

# Правило Проверка
1 Цвет и размер только из tokens.css машинно: stylelint declaration-property-value-allowed-list (/^var\(--/ на color, background-color, border-color, fill, stroke, font-size, z-index) + ESLint no-restricted-syntax на #hex/rgb(/hsl(/oklch( в TSX. Исключение по пути — только tokens/
2 Отключить гейт комментарием нельзя машинно: reportDisables: true в конфиге stylelint — /* stylelint-disable */ сам становится ошибкой
3 Данные только через src/api/ ревью-вопрос: есть ли fetch/axios/импорт из mock/ вне src/api/? Должно быть «нет»
4 Файл = один компонент + свой .module.css, больше ~150 строк — делить ревью глазами при лендинге пакета
5 Состояние ровно в двух местах: TanStack Query (серверное), Zustand (интерфейсное) ревью-вопрос: есть ли useState с копией серверных данных? Должно быть «нет»
6 Глобальных стилей два файла машинно: любой .css вне src/tokens/, не являющийся *.module.css, — ошибка сборки правилом ESLint на импорт
7 Никаких UI-китов ревью-вопрос: в package.json кроме react-aria-components библиотек компонентов нет
8 Компоненты глупые: данные пропсами, запросы на уровне экрана ревью-вопрос: есть ли useQuery внутри src/ui/? Должно быть «нет»
9 Комментарии — одна-две строки «почему» ревью глазами; проектная норма
10 Каждый экран открывается в изоляции: свой маршрут, своя фикстура машинно косвенно: скриншот-скрипт снимает экран по URL. Не открывается по прямой ссылке — не снимется
11 styles.имяКласса ссылается на существующий класс машинно: src/cssModules.test.ts. Vite типизирует модуль как { [key: string]: string }, поэтому опечатка даёт className="undefined" тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась

Контрольный вопрос владельца (§5.1, применять к каждому пакету работ): добавление нового состояния главы или нового вида замечания правит один файл. Правит три — структура неверна, переделать до движения дальше.

Ответ этой структуры: состояние главы живёт как строка типа в src/api/types.ts и как одна запись в карте отображения src/features/chapters/. Цвет индикатора — токен. Новый вид замечания — запись в карте видов, компонент выноски не трогается.


4. Протокол скриншот-цикла

Цель — не «код валиден», а «вид совпал». Смотреть на построенное обязательно, а не предполагать.

Команда: npm run shot [маршрут ...] [--size ШxВ]. Без аргументов снимает /showcase при 1440×900.

Что делает:

  1. собирает и поднимает vite preview (сборка, а не dev — dev-оверлеи не должны попадать в кадр);
  2. запускает Chromium из Playwright, вьюпорт 1440×900 (базовая ширина из §4.5 промта), deviceScaleFactor: 2 — чтобы снимок был сравним с референсом, снятым на macOS при 2x; --size 1280x764 даёт вьюпорт самого референса, когда нужно прямое наложение;
  3. ждёт networkidle и загрузки шрифтов (document.fonts.ready) — иначе в кадр попадает фолбэк-шрифт;
  4. кладёт PNG в frontend/.shots/<маршрут>.png.

Что делаю я после команды — обязательная часть цикла, а не опция: открываю PNG инструментом чтения файлов, смотрю на него, кладу рядом references/fleet.png, называю расхождения словами, правлю, снимаю снова.

Стенд без root. .tooling/ собирается один раз и в репозиторий не едет (вместе с .shots/ он в .gitignore). Chromium не стартует, пока не найдёт libnss3, libnssutil3, libnspr4, libasound.so.2; в системе их нет, а sudo недоступен, поэтому пакеты выкачиваются и распаковываются локально. Тем же способом кладётся шрифт CJK: на стенде не установлено ни одного (fc-list :lang=zh пуст), и без него весь китайский текст в кадре — квадраты, а плотность колонок проверить нечем. scripts/shot.mjs сам подставляет LD_LIBRARY_PATH и XDG_DATA_HOME, когда видит .tooling/root — думать об этом больше не нужно.

mkdir -p .tooling && cd .tooling
apt-get download libnss3 libnspr4 libasound2t64 fonts-noto-cjk
for d in *.deb; do dpkg -x "$d" root; done
rm -f *.deb && cd ..

Шрифт нужен только стенду: продукт отдаёт CJK системному стеку (STACK_DECISIONS.md §2), на Windows у пользователя он есть.


5. Критерий сходимости с референсом

Растровое совпадение не является целью и недостижимо: Fleet закрыт (скачивание прекращено 22.12.2025), референс снят на macOS при 2x, цель — Chromium с другим хинтингом шрифтов. Эталонных скриншотов в тестах нет — вместо них контракт-тест токенов (STACK_DECISIONS.md §3).

Сходиться обязаны: цвета · промежутки 8px · радиусы 6px · плотность строк · общее впечатление. Подбираются на глаз: кегли, интерлиньяж, внутренние отступы — померить их больше негде.

5.1. Замеры, перепроверенные в этой сессии

Значения §1.1 промта перепроверены заново по references/fleet.png (PIL: гистограмма всего изображения, срезы строк и столбцов, детект края скругления по порогу яркости). Все числа совпали, одна роль разошлась: §1.1 отдаёт #353739 и «выбранной строке», и «активной вкладке», а в кадре это разные вещи — вкладка открытого документа залита #142f4c, вкладка панели #27292b, и #353739 под вкладками не встречается вовсе. В токенах роли разведены. Ниже — сводка с тем, что удалось доснять; это и есть входные числа tokens.css.

Масштаб снимка 2x подтверждён независимо: все четыре промежутка между панелями равны ровно 16 физическим пикселям, что даёт целые 8 CSS-пикселей.

Поверхности (доля площади — по гистограмме всего кадра 2560×1528):

Роль Значение Подтверждение
фон оболочки #090909 10.45% площади
заливка панели #17191a 80.48% площади
приподнятая поверхность, наведение, активная вкладка панели #27292b 18 196 px; вкладки Files, Terminal, AI Assistant
выбранная строка дерева, клавиша-чип #353739 37 374 px; ровно три пятна в кадре, и все три — строка дерева и чипы клавиш
активная вкладка открытого документа #142f4c поправка: заливка вкладки rpc-node.ts — 7 183 px синеватого, а не серого
подсветка текущей строки #152945 36 800 px
выделение #164e8d 476 px
текст основной #dfe1e3 0.40% площади
текст вторичный #8a8e91 доснято: статус-полоса
текст приглушённый #707479 доснято: колонка номеров строк
акцент #746deb 1 258 px, рамка поля ввода
красный индикатора #b82e45 829 px

Геометрия (физические пиксели / 2):

Величина CSS Как замерено
промежуток между панелями 8 четыре среза по 16 физ. без исключений
поле оболочки по краям 8 тот же срез: слева и справа те же 16 физ.
радиус панели 6 угол выходит на прямую за 12 физ.
верхняя полоса 28 верхняя полоса фона 72 физ. = 8 поля + 28 полосы
статус-полоса 20 нижняя полоса фона 56 физ. = 20 полосы + 8 поля
шапка панели (вкладки) 26 заливка вкладки Files 86..137 физ.
шаг строки дерева 26 шаг текстовых полос: 52 физ., 15 строк подряд
подложка выбранной строки 24 заливка #353739 — 48 физ. при шаге 52
межстрочный интервал содержимого 21 подсветка текущей строки 42 физ.
ширина боковой панели при 1280 320 (25%) границы панелей: 8‥328 · 336‥944 · 952‥1272
внутренний отступ панели 6 подложка строки 14‥321.5 при панели 8‥328

Модель раскладки, вытекающая из замеров и сходящаяся точно (764 = 8+28 + 700 + 20+8): поле оболочки 8 · промежуток 8 · верхняя полоса 28 · статус-полоса 20.

Типографика (оценка по высоте глифов, точнее не измеряется): интерфейс ~13px (высота выносных элементов 10px), статус-полоса ~12px, содержимое ~13px моноширинным при интерлиньяже 21px.

Из antigravity_chat.png — модель выноски замечания (§3.8 промта): левая граница 3px, синяя #2964ad (заметка) / зелёная #3c7d4f (подсказка), заливки текста нет. Палитра Antigravity (нейтральный серый, без холодного оттенка): #101010 фон · #161616 боковая панель · #1c1c1c приподнятая карточка · #252525 кнопка основного действия · #323232 разделитель · #cecece текст.

⚠ Две палитры в одном приложении не смешиваются. Базовая — Fleet; из Antigravity берётся форма пустого состояния и выноски, не цвета. Токенов Antigravity в tokens.css не заводим.

5.2. Витрина сведена с референсом — что сошлось и что нет

Витрина снята тем же способом, что и референс (--size 1280x764, 2x), и промерена тем же кодом.

Сошлось до пикселя: промежутки — четыре по 16 физ.; верхний край панелей — 72 физ.; радиус — угол выходит на прямую за 12 физ.; шаг строки дерева — 26 CSS; нижняя полоса — 56 физ.; доли площади поверхностей (панель 83.5% против 80.5%, фон 9.4% против 10.5% — разница от того, что у нас другое наполнение). Высота глифов статус-полосы совпала точно (23 физ.), вкладки — 19 против 20 физ.

Расхождения, названные вслух:

  1. Субпиксельное сглаживание. У Fleet текст сглажен в серую шкалу (macOS): в статус-полосе ровно 0% цветных пикселей. У нас в том же месте 1.29%, в дереве 1.35% против 0.37% — это цветная бахрома Chromium. -webkit-font-smoothing: antialiased в сбросе действует только на macOS. Расхождение принято: §8 промта прямо снимает совпадение по хинтингу, а на Windows субпиксельное сглаживание — норма платформы. Гасить его флагом в скриншот-цикле не стали: снимок должен показывать то, что рисует настоящий браузер.
  2. Колонка оригинала приглушена (--color-text-secondary) — это решение, а не замер: во Fleet на этом месте подсвеченный код. Пересмотреть на S6, когда читалка будет настоящей.
  3. + в конце рядов вкладок у Fleet есть, у нас нет — добавляется вместе с действием, которое он будет запускать (S2).
  4. Наведение и прочие интерактивные состояния витриной не проверены: снимок статичен. Токен --color-raised подтверждён только на активной вкладке.
  5. Боковые панели фиксированы 320px (замеренная абсолютная ширина при 1280). Тянущиеся панели на react-resizable-panels — S2; тогда же решится, тянуть их долей или пикселями.
  6. Скроллбар в кадр не попадает. Chromium на стенде отдаёт оверлейные полосы (offsetWidth - clientWidth = 0), поэтому в статичном снимке их нет. Что стилизация применяется — проверено вычисленными значениями: scrollbar-width: thin, scrollbar-color: rgb(53, 55, 57) rgba(0, 0, 0, 0). На Windows полосы займут место и получат эти цвета.

5.3. Что выяснилось про инструменты — не переоткрывать

  • eslint-plugin-react-hooks: flat-конфиг лежит в configs.flat['recommended-latest']. Одноимённый ключ верхнего уровня — старого формата, ESLint 10 на нём падает с ошибкой про «plugins as array».
  • Vitest по умолчанию подменяет CSS пустой заглушкой. Без test.css: true контракт-тест токенов сверял бы пустоту и был бы вечно зелёным. Проверено: до включения он падал на пустой строке, а не проходил.
  • happy-dom не понимает заявление @layer a, b; — проглатывает весь остаток файла, и getComputedStyle перестаёт видеть переменные. Поэтому заявление слоёв живёт в reset.css, а tokens.css остаётся чистым (§2.1). Блочную форму @layer x { } он тоже не поддерживает.
  • @types/node держим на мажоре 22, а не на latest 26: иначе тайпчек разрешает API, которых на стенде нет.
  • import.meta.glob по *.module.css брать с ?raw, а не ?inline: ?inline отдаёт уже скомпилированный CSS с хешированными именами (._shell_1abc_1), и сверять с ним имена из TSX бессмысленно.
  • Гейты и оба структурных теста проверены живым нарушением, а не заявлением: литерал цвета в модуле · литерал в сокращённой записи border · font-size числом · попытка отключить правило комментарием · hex в TSX · инлайновый стиль с обычным свойством · импорт глобального CSS мимо main.tsx · опечатка в имени класса. Каждый раз проверка падала, после отката — зелено. Разрешённое исключение style={{ '--dot': … }} проходит.

6. Что уже известно про движок — учтено в форме данных

Из FRONTEND_SESSION_PROMPT.md §9 и STACK_DECISIONS.md §8. Влияет на типы в src/api/ уже сейчас, поэтому записано в плане, а не откладывается на S3.

  1. Прогресс — пофазный, не «готово N из M». Юнит становится done только когда есть и черновые строки всех членов, и строка редактуры: во время черновой волны сквозной счётчик стоит на нуле почти всё время. Тип прогресса — две пары { done, total } (черновик ∥ редактура), а не одно число. Пользователю всё равно показывается одна полоса без названий фаз (§4.1).
  2. Подпись термина — не UPDATE строки. Банк пересобирается из файлов на каждом прогоне, прямая запись стирается следующим прогоном. Контракт подписи — отправка набора решений, а не правка записи; фикстуры S3 моделируют именно это.
  3. Байтовых смещений у замечаний нет. Замечание адресует блок или главу — и только. Тип замечания не имеет полей offset/length; подчёркиваний внутри текста не будет.
  4. Единица пары — edit-unit, ~1.9 на главу. Местами вся глава окажется одним блоком. Читалка обязана нормально выглядеть в этом случае; выравнивание грубое, по единицам экспорта.
  5. Одна вертикальная прокрутка на читалку. Список строк-пар, внутри строки grid-template-columns: 1fr 1fr. Синхронизации двух прокруток нет и не будет — колонки физически не могут разъехаться.

Денежных полей в интерфейсе нет нигде (§4.8): ни сумм, ни потолков, ни оценки, ни остатка.


7. Порядок S1 и что в него НЕ входит

Порядок работ S1: каркас → npm run check → скриншот-цикл и проверка, что он живой → tokens.css → витрина → контракт-тест токенов → сверка витрины с референсом.

npm run check = prettier --checkeslintstylelinttsc --noEmitvitest run. Шагов пять, а не четыре: STACK_DECISIONS.md §3 перечисляет четыре, но там же требует, чтобы обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint. npm run check:full = checkvite buildshot. Отдельного e2e-набора в S1 нет: единственная браузерная проверка — скриншот-цикл, он и стоит в check:full. Git-хуков нет.

Не входит в S1 (и не должно появиться раньше срока): экраны, оболочка трёх панелей, слой данных и MSW, React Compiler (Ф-2), токен-гейт на отступы (Ф-4 — включается, когда шкала отступов устоится, иначе мешает подбору), эталонные скриншоты в тестах (отложены решением STACK_DECISIONS.md §3), светлая тема, мобильная раскладка.