textmachine/frontend/docs/STACK_DECISIONS.md

26 KiB
Raw Blame History

Решения по стеку — фронт и платформа

Источник: многоагентное исследование 02.08.2026 (15 агентов: 7 направлений, у каждого независимый скептик-опровергатель, затем синтез). Все версии сверены с вебом на 2026-08-02. Где скептик дал поправку уровня blocker/major — записан ИСПРАВЛЕННЫЙ вариант, не исходный.

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


1. Фронт — ядро

Что Пин Заметка
React 19.2.8 React 20 не существует. Ветка 19.2 живая
TypeScript 6.0.3 НЕ 7.x — см. ниже
Сборщик vite 8.2.0 + @vitejs/plugin-react 6.0.5 Vite 8 (12.03.2026) заменил Rollup+esbuild на Rolldown
Маршрутизация react-router 8.3.0 библиотечный (data) режим, без framework mode и SSR
Серверное состояние @tanstack/react-query 5.101.4 мажор 5 актуален; «v6» — это svelte-адаптер
Состояние интерфейса zustand 5.0.14 никакого useStore() без селектора или useShallow
Строки интерфейса @internationalized/string 3.2.10 каталог сообщений и подстановка; уже в дереве под react-aria-components
Пакетный менеджер npm из поставки Node один package.json в frontend/, воркспейсы не нужны
Node ≥ 22.22 нижняя граница react-router 8; проверить ПЕРВЫМ делом

Почему TypeScript 6, а не 7. TS 7 (нативный компилятор на Go, GA 08.07.2026) даёт 812× скорости, но не поставляет программный API — он обещан в 7.1. Следствие: не работают typescript-eslint, плагины языкового сервера, typescript-plugin-css-modules. Официальный обход — держать обе версии через npm-алиасы, то есть ровно та связность, которую владелец запретил. Выигрыш в скорости измерялся на миллионных кодовых базах; у нас один пакет, где tsc и на TS 6 занимает секунды. Пересмотреть после выхода TS 7.1 (~октябрь 2026) — решение обратимо, тайпчек не участвует в сборке.

React Compiler — НЕ включать в MVP. Совместимость держим линтом (eslint-plugin-react-hooks 7.1.1 с compiler-powered правилами), сам компилятор — отдельным шагом после заморозки интерфейса и только с проверкой в CI, что вставки реально попали в бандл. Причина: в Vite 8 плагин React выбросил Babel, старый рецепт подключения удалён, а весь интернет показывает именно его; при неверном порядке плагинов компилятор молча не запускается.

Почему @internationalized/string, а не react-intl и не lingui (S3.7, 10.08). Требование — отраслевой инструмент, не самописка: компонент берёт строку по ключу, локаль это данные, новый язык добавляется файлом перевода. Кандидаты сравнены по трём осям — цена бандла, цена сборки, цена ошибки.

@internationalized/string react-intl (FormatJS) lingui
Уже в дереве да, транзитивом react-aria-components (npm ls: одна копия, deduped) нет нет
Цена бандла 0 новых байт, кроме самих слов ~40 КБ min ~10 КБ + рантайм ICU
Шаг сборки нет нет (без экстрактора) нужен плагин babel/swc
Формат ключ → строка с {переменной} полный ICU полный ICU + макросы
Локаль тот же провайдер, что у примитивов (I18nProvider) свой провайдер свой провайдер

Взят первый: это i18n-слой Adobe, на котором react-aria и React Spectrum отдают СВОИ строки, то есть он уже несёт локаль нашего интерфейса и уже проверен на нём. Полный ICU нам сегодня не нужен: единственная нетривиальная форма — склонение числительных, и его делает Intl.PluralRules в одном месте (showcase/format.ts, counted()), а не таблицей окончаний в коде. Пересмотреть — если понадобятся вложенные ICU-конструкции (select внутри plural) или экстрактор строк для переводчика.

Механизм и его границы: каталог src/i18n/ru.ts — единственный дом слов интерфейса, MessageKey выводится ИЗ каталога (опечатка в ключе роняет tsc), выбор языка — стор src/i18n/language.ts, и от него же берут локаль I18nProvider и Intl. Второй язык = второй файл того же вида плюс строка в language.ts; ни один компонент при этом не трогается. Гейт против нового хардкода — две половины с РАЗНЫМИ правилами, и это не педантизм: правило «текста нет в разметке» держит ESLint по МЕСТУ (буква в JSXText или в читаемом атрибуте), поэтому оно ловит и русский литерал, и английский — алфавит тут ни при чём; правило «код зоны английский» держит src/i18n/catalogue.test.ts, потому что комментарии, CSS и python в AST линтера не входят. Первая редакция держала оба правила алфавитом и в двух носителях сразу — переделано по ревью. Легальных квартир русского ровно две, и обе — данные: сам каталог и фикстуры src/mock/.

Почему React Router, а не TanStack Router. Оба выбора обоснованы: TanStack даёт типобезопасность маршрутов, React Router — то, что AI-сессии знают его кратно лучше и генерируют по его образцам корректный код. При требовании «поддерживаемость важнее» выигрывает узнаваемость. Типизация параметров запроса — тонкий хелпер на zod вокруг useSearchParams, а не типобезопасный роутер целиком.

2. Фронт — внешний вид и компоненты

Что Пин Заметка
Стилизация CSS Modules (нативно в Vite) Tailwind не берём
Токены единственный src/tokens/tokens.css CSS-переменные; минификация — Lightning CSS 1.33.0
Примитивы react-aria-components 1.20.0 ОДНА библиотека на всё, Apache-2.0
Панели react-resizable-panels 4.12.2 API v4: { Group, Panel, Separator }
Виртуализация RAC Virtualizer @tanstack/react-virtual 3.14.9 — в резерве, по замеру
Иконки lucide-react 1.28.0 size 16, strokeWidth 1.5, БЕЗ absoluteStrokeWidth
Шрифты @fontsource-variable/inter 5.3.0 + jetbrains-mono 5.3.0 CJK — системный стек + обязательный lang из данных пары

Одна библиотека примитивов, не две. React Aria Components закрывает всё сразу: Table + Virtualizer под банк памяти, Tree под дерево глав, Tabs/Menu/Dialog/Popover/Tooltip под оболочку, I18nProvider под русские служебные строки. Две библиотеки означали бы два focus-scope и два портальных менеджера в одном приложении — это ломает поддерживаемость. Импортируется только внутри src/ui/; экраны ходят в наши обёртки.

⚠ Читалка двух колонок — синхронизации прокруток НЕТ. Это исправление моей прежней рекомендации. Правильно: один скролл-контейнер, список строк-пар, у каждой строки внутри display:grid; grid-template-columns:1fr 1fr. Тогда колонки физически не могут разъехаться. Единица пары — edit-unit из экспорта движка (--pairs). Копирование текста ограничить колонкой.

Панели: персист раскладки — через хук useDefaultLayout({panelIds, storage}) плюс Group defaultLayout и onLayoutChanged, а не через проп storage. Докинг (dockview) в MVP нет.

3. Фронт — контроль качества

Что Пин
Линтер eslint 10.8.0 + typescript-eslint 8.65.0 + eslint-plugin-react-hooks 7.1.1
CSS-линтер stylelint 17.14.1 + stylelint-config-standard 40.0.0
Формат prettier 3.9.6 (точный пин)
Тесты vitest 4.1.10
Моки msw 2.15.0
Браузер @playwright/test 1.62.1 (точный пин)
Контракт API @stoplight/spectral-cli 6.16.3 + openapi-typescript 7.13.0

Контракт API — машинный артефакт, а не проза (04.08, дофикс ревью оркестратора). Нормативная поверхность — OpenAPI 3.1 в docs/api-contract/openapi.yaml: spectral линтует её шестым шагом npm run check, openapi-typescript генерит из неё src/api/schema.ts, тест дрифта не даёт спеке и типам разъехаться. Прозаический контракт расходится с кодом ровно тем способом, ради предотвращения которого заведена строка 95 единого бэклога. ⚠ openapi-typescript объявляет пир typescript@^5.x при нашем намеренном TS 6 — --legacy-peer-deps ОТВЕРГНУТ проверкой (после него ломается обычный npm install), взят точечный overrides на один пакет; npm ci с нуля проходит. Хвост — BACKLOG.md Ф-23.

Почему ESLint, а не oxlint. oxlint быстрее и связнее, но его type-aware режим требует TS 7, а мы на TS 6. Плюс конкретно для нас у ESLint есть no-restricted-syntax — правила, которого в oxlint нет вовсе, и на котором строится главный гейт (ниже).

Машинный гейт «одно место для цвета и размера»

Прямой ответ на требование владельца о поддерживаемости. Двухсторонний, в одной команде:

  • в CSS — stylelint declaration-property-value-allowed-list со значением /^var\(--/ на color, background-color, border-color, fill, stroke, z-index, font-size; исключение по пути только для tokens.css и сброса;
  • в TSX — ESLint no-restricted-syntax на литералы #hex / rgb( / hsl( / oklch( плюс запрет атрибута style (уровень warn);
  • в CI — stylelint с reportDisables: true, чтобы отключение правила комментарием тоже падало.

Отступы (padding/margin/gap/border-radius) подключить вторым шагом, когда шкала токенов зафиксирована — иначе гейт будет мешать на этапе подбора.

Одна команда проверки

npm run check       # prettier --check → eslint → tsc --noEmit → vitest run
npm run check:full  # + vite build + e2e

CI вызывает именно их, а не дублирует список инструментов. Path-фильтры на уровне job'ов (правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой contains(needs.*.result,'failure')||contains(needs.*.result,'cancelled'). Git-хуков в MVP нет — пересмотрено 02.08 запросом владельца: CI ещё не поднят, и до него pre-commit — единственный машинный рубеж. Хук зовёт те же npm run check-команды, не дубль списка (scripts/githooks/, детали — FRONTEND_PLAN.md §7).

Визуальный гейт с эталонными скриншотами — ОТЛОЖЕН. Он флейкует между платформами, а наш референс снят на macOS при 2x, целевая платформа — Windows. Вместо него контракт-тест токенов: рендерим корень, сверяем вычисленные значения CSS-переменных с замеренными числами.

4. Форма продукта и SEO

Два независимых деплоя.

  • Приложение — чистая SPA на app.<домен>, X-Robots-Tag: noindex на КАЖДОМ HTML-ответе. robots.txt обход HTML не запрещает (иначе Google не увидит noindex), Disallow только для /api/*.
  • Публичный контур — лендинг, цены, справка, политика куков, страницы входа и регистрации — статический HTML на апексе, отдаётся CDN или существующим Go-сервисом. Astro в MVP не берём. Каждая публичная страница достижима обычным <a href> не дальше трёх кликов от главной (это и есть лечение «страниц-сирот», о которых писал владелец). sitemap.xml — дубль, не основной канал.

Продуктовый запрет (жёсткий): ни один символ пользовательского перевода никогда не попадает на публично индексируемый URL. В MVP нет ни витрины примеров, ни «поделиться главой», ни публичных ссылок на прочтение. Причины две: авторские права на исходники и политика поисковиков в отношении машинно-сгенерированного контента.

5. Платформа

Что Пин Заметка
Go 1.26.4 в go.mod, ⚠ тулчейн сборки платформы ≥1.26.5 (platform/Makefile GO_MIN_VERSION — security-фиксы crypto/tls и os в сетевом модуле); пин живёт в Makefile, здесь только указатель
HTTP стандартный net/http + ServeMux роутер-библиотеку не тянуть
Postgres pgx v5.10.0 нижняя допустимая граница 5.9.2, не 5.9.0
Миграции goose v3.27.3 как библиотека, embed.FS, WithLocker
Очередь river v0.42.0 на том же Postgres
Безопасность govulncheck обязательный гейт CI

Redis не заводим нигде — зафиксировано как архитектурное «нет», иначе он приползёт по частям.

Конкурентность по книге. Одна книга = один процесс-воркер; сериализация в очереди по book_id плюс пиннинг книги к одному хосту на MVP. Лизы book_leases с heartbeat — поверх, ради вежливого ожидания вместо аварии. Глобальный брокер исходящих LLM-вызовов — отдельный компонент, включаемый по гейту «до второго параллельного пользователя».

Прогресс — SSE, и события ПУШИТ воркер, а не фронт опрашивает read-model (см. §7, риск про ре-ингест). Браузер — нативный EventSource на cookie; десктоп и CLI — обычный GET с Authorization: Bearer и построчным парсером. WebSocket не нужен. Обязательно: HTTP/2 на границе, Cache-Control: no-store, X-Accel-Buffering: no, heartbeat ~20 с, монотонный id + Last-Event-ID для докачки.

Аутентификация — одна серверная сессия, два способа предъявления. Запись сессии в Postgres, непрозрачный токен 256 бит (в БД только SHA-256), и предъявляется он либо __Host-cookie (HttpOnly, Secure, SameSite=Lax) для браузера, либо Authorization: Bearer для десктопа и CLI. Principal создаётся только в middleware; ни один эндпоинт не имеет права предполагать cookie — именно это сохраняет портируемость на десктоп, ради которой владелец и требовал Bearer. CSRF только на cookie-пути: Sec-Fetch-Site → фолбэк на Origin по allowlist → обязательный кастомный заголовок; та же проверка Origin на любом стрим-handshake. JWT отвергнут.

6. Десктоп

Первый шаг — устанавливаемое PWA на том же origin, что API: ноль второго режима аутентификации, ноль подписи кода, ноль сборочного конвейера.

Tauri 2.11.5 — вторым шагом, по явным триггерам: нужен трей или глобальные горячие клавиши, нужен распространяемый .exe, появился офлайн-режим, нужен доступ к папке без диалога выбора, нужно хранилище учётных данных ОС. Electron 43.2.0 и Wails v3 (всё ещё alpha) отвергнуты.

До этого решения в приложении — только браузерные API, вся связь с движком через HTTP-контракт платформы.

7. Ловушки, на которые мы наступим

Отсортировано по вероятности укусить.

  1. Вся документация в сети описывает прошлые версии. Vite 8 сделал build.commonjsOptions молчаливым no-op; react-resizable-panels v4 сменил экспорты (PanelGroup/PanelResizeHandleGroup/Separator); рецепт React Compiler для Vite удалён. AI-сессия скопирует конфиг из статьи 2025 года и получит тихо неработающую опцию. Лечение: конфиги минимальные, каждая нестандартная опция с комментарием «зачем», версии проверять по npm перед использованием.
  2. types в tsconfig теперь по умолчанию [], а не ["*"] — забыть прописать "types": ["node", "vite/client"] значит внезапно потерять глобальные типы.
  3. Дефолтные скроллбары Chromium на Windows толстые и светлые — ломают Fleet-вид сразу на трёх панелях и в читалке. Стилизовать с первого дня.
  4. Порядок каскада между стилями React Aria, tokens.css и CSS Modules — источник тихих расхождений. Задать @layer явно на этапе токенов.
  5. Fleet закрыт (объявлено 08.12.2025, скачивание прекращено 22.12.2025). Референс невоспроизводим: всё, что не замерено (кегли, интерлиньяж, внутренние отступы), больше посмотреть негде. Скриншот снят на macOS при 2x, цель — Windows-Chromium с другим хинтингом. Растровое совпадение недостижимо и не должно быть критерием готовности.
  6. Node на стенде объявлен как 22, но react-router 8 требует ≥22.22, Vite 8 — ≥22.12. Проверить первым действием.

8. Что придётся достроить в движке

Через оркестратора, в зону backend/, в порядке критичности. Обосновано разбором кода.

  1. Пофазный прогресс draft N/Medit N/M поверх chunk_status. Сейчас unit объявляется done только когда есть и все draft-строки его членов, и edit-строка — то есть во время черновой волны индикатор показывал бы 0% почти всё время.
  2. Персист манифеста глав и чанков (+ chunker_version и хеш источника). Нужен под экран разбора и дерево глав, и снимает ре-ингест: сейчас каждый read-вызов заново читает и режет исходник — замерено 1.421.51 с процессорного времени на книге 23 МБ, и это умножается на число книг в библиотеке.
  3. JSON-выход таблицы подписи банка — сейчас только человеческий текст с ограничением 20 строк.
  4. Приём внешнего TRACEPARENT в cmd/tmctl, чтобы прогон был одной трассой.

Спаны для подсветки внутри текста — не делаем (владелец решил 02.08): у дешёвых гейтов нет ни одного байтового смещения, только счётчики, а подсветка на уровне блока признана достаточной.

Выравнивание колонок достраивать не нужноtmctl export --pairs уже отдаёт колонку исходника, выровненную по единицам редактора (export.go:189). Гранулярность грубая и владельцем принята.

Подпись термина — это НЕ UPDATE строки. Банк пересобирается из файлов на каждом прогоне (seedGlossary + ReplaceBank), поэтому прямая запись в таблицу glossary молча стирается следующим прогоном. Контракт подписи обязан это учитывать.

После апгрейда бинарника движка все read-запросы по старым книгам падают («schema vN, expects vM»), пока по книге не пройдёт write-команда: OpenReadOnly требует точного совпадения версии схемы. Это влияет на порядок деплоя платформы.