textmachine/frontend/docs/STACK_DECISIONS.md

26 KiB
Raw Permalink 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 — машинный артефакт, а не проза. Спека OpenAPI 3.1 в docs/api-contract/openapi.yaml: spectral линтует её шагом npm run check (внутри npm run contract), openapi-typescript генерит из неё src/api/schema.ts, тест дрифта не даёт спеке и типам разъехаться. ⚠ На время фриза зоны нормативен ТОЛЬКО канон docs/architecture/14-api-contract/ (D39.142 п.5): зонная копия — отстающее зеркало, байт-равенства сегодня НЕТ. Прозаический контракт расходится с кодом ровно тем способом, ради предотвращения которого контракт и заводили. ⚠ 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 нет вовсе, и на котором строится главный гейт (ниже).

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

Прямой ответ на требование владельца о поддерживаемости: двухсторонний (stylelint в CSS, no-restricted-syntax в TSX), в одной команде, и отключить его комментарием нельзя. ПОСТРОЕН и с тех пор ушёл дальше этого абзаца — действующий состав правил, каждое с тем, чем оно проверяется, живёт таблицей в FRONTEND_PLAN.md §3, а границы («что гейт НЕ видит») названы там же в §5.4. Здесь их копии намеренно нет: она отставала — этот абзац ещё описывал запрет атрибута style уровнем warn и отступы «вторым шагом, когда шкала зафиксируется», тогда как оба закрыты (Ф-4, S2).

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

npm run check       # prettier → eslint → stylelint → spectral (контракт) → tsc → vitest
npm run check:full  # + vite build + npm run shot (маршруты и axe) + npm run scenes (сценарии)

⚠ Состав сверен по package.json (node -e по scripts.check): шесть шагов у check и три добавочных у check:full. Число шагов проверять этой командой, а не по прозе доков: здесь и в промтах оно уже отставало.

CI вызывает именно их, а не дублирует список инструментов. Path-фильтры на уровне job'ов (правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой contains(needs.*.result,'failure')||contains(needs.*.result,'cancelled'). Pre-commit хук есть (запрос владельца 02.08): CI ещё не поднят, и до него хук — единственный машинный рубеж. Он зовёт те же 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. Платформа — только то, что видно фронту

Пины и внутреннее устройство платформы здесь БОЛЬШЕ НЕ ДУБЛИРУЮТСЯ — их дом platform/docs/STACK_DECISIONS.md (Go/тулчейн, pgx, goose, river, govulncheck, «Redis нет», конкурентность по книге и лизы), и зонная копия отставала: она называла тулчейн старой версии против ратифицированной D39.130. Ниже остаются два шва, на которых стоит КОД ФРОНТА, — стрим и предъявление сессии.

Одна оговорка о границе, которой по тому адресу НЕТ: нижняя допустимая версия pgx — 5.9.2, не 5.9.0. Сегодняшний пин (v5.10.0) выше её, но при откате знание уже не восстановить, а platform/docs/STACK_DECISIONS.md несёт только сам пин. Дом строки — там; перенос — пингом оркестратору (чужая зона).

Прогресс — 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. Приём внешнего TRACEPARENT в cmd/tmctl, чтобы прогон был одной трассой. Не построено: грепа traceparent по *.go — ноль хитов (положительный контроль: греп по trace в тех же каталогах хиты даёт).
  2. Машиночитаемая таблица ПОДПИСИ. Весь банк JSON-сайдкаром уже есть (internal/pipeline/bankexport.go, D39.122; единый бэклог, строка 169), а стоп-таблица подписи по-прежнему человеческий текст с капом (cmd/tmctl/render.go, константа bankStopStdoutCap). ⚠ Дверь правок банка при этом смонтирована — tmctl bank-apply (D39.162), см. спутник контракта §2.19.

Закрыто — не переоткрывать:

  • Пофазный прогресс draft N/Medit N/M — вердикт «НЕ строить» (D39.138; спутник контракта, К-10). Фаз на проводе нет: одна полоса до ближайшей остановки.
  • Персист манифеста глав и чанков (+ chunker_version, хеш источника) — построено (backend/internal/pipeline/manifest.go). Он же снял ре-ингест: прежде каждый read-вызов заново читал и резал исходник, замерено 1.421.51 с процессорного времени на книге 23 МБ.

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

Выравнивание колонок достраивать не нужноtmctl export --pairs уже отдаёт колонку исходника, выровненную по единицам редактора (backend/internal/pipeline/export.go:176=pend.Source = u.sourceText()). Гранулярность грубая и владельцем принята.

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

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