textmachine/frontend/docs/STACK_DECISIONS.md

228 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Решения по стеку — фронт и платформа
> Источник: многоагентное исследование 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` |
| Пакетный менеджер | 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, старый рецепт подключения удалён, а весь интернет
показывает именно его; при неверном порядке плагинов компилятор молча не запускается.
**Почему 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` (точный пин) |
**Почему 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` | |
| 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`/`PanelResizeHandle`
`Group`/`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/M``edit 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` требует точного
совпадения версии схемы. Это влияет на порядок деплоя платформы.