26 KiB
Решения по стеку — фронт и платформа
Источник: многоагентное исследование 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) даёт 8–12× скорости,
но не поставляет программный 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. Ловушки, на которые мы наступим
Отсортировано по вероятности укусить.
- Вся документация в сети описывает прошлые версии. Vite 8 сделал
build.commonjsOptionsмолчаливым no-op;react-resizable-panelsv4 сменил экспорты (PanelGroup/PanelResizeHandle→Group/Separator); рецепт React Compiler для Vite удалён. AI-сессия скопирует конфиг из статьи 2025 года и получит тихо неработающую опцию. Лечение: конфиги минимальные, каждая нестандартная опция с комментарием «зачем», версии проверять по npm перед использованием. typesв tsconfig теперь по умолчанию[], а не["*"]— забыть прописать"types": ["node", "vite/client"]значит внезапно потерять глобальные типы.- Дефолтные скроллбары Chromium на Windows толстые и светлые — ломают Fleet-вид сразу на трёх панелях и в читалке. Стилизовать с первого дня.
- Порядок каскада между стилями React Aria,
tokens.cssи CSS Modules — источник тихих расхождений. Задать@layerявно на этапе токенов. - Fleet закрыт (объявлено 08.12.2025, скачивание прекращено 22.12.2025). Референс невоспроизводим: всё, что не замерено (кегли, интерлиньяж, внутренние отступы), больше посмотреть негде. Скриншот снят на macOS при 2x, цель — Windows-Chromium с другим хинтингом. Растровое совпадение недостижимо и не должно быть критерием готовности.
- Node на стенде объявлен как 22, но react-router 8 требует ≥22.22, Vite 8 — ≥22.12. Проверить первым действием.
8. Что придётся достроить в движке
Через оркестратора, в зону backend/. Обосновано разбором кода.
Живое:
- Приём внешнего
TRACEPARENTвcmd/tmctl, чтобы прогон был одной трассой. Не построено: грепаtraceparentпо*.go— ноль хитов (положительный контроль: греп поtraceв тех же каталогах хиты даёт). - Машиночитаемая таблица ПОДПИСИ. Весь банк JSON-сайдкаром уже есть
(
internal/pipeline/bankexport.go, D39.122; единый бэклог, строка 169), а стоп-таблица подписи по-прежнему человеческий текст с капом (cmd/tmctl/render.go, константаbankStopStdoutCap). ⚠ Дверь правок банка при этом смонтирована —tmctl bank-apply(D39.162), см. спутник контракта §2.19.
Закрыто — не переоткрывать:
- Пофазный прогресс
draft N/M∥edit N/M— вердикт «НЕ строить» (D39.138; спутник контракта, К-10). Фаз на проводе нет: одна полоса до ближайшей остановки. - Персист манифеста глав и чанков (+
chunker_version, хеш источника) — построено (backend/internal/pipeline/manifest.go). Он же снял ре-ингест: прежде каждый read-вызов заново читал и резал исходник, замерено 1.42–1.51 с процессорного времени на книге 23 МБ.
Спаны для подсветки внутри текста — не делаем (владелец решил 02.08): у дешёвых гейтов нет ни одного байтового смещения, только счётчики, а подсветка на уровне блока признана достаточной.
Выравнивание колонок достраивать не нужно — tmctl export --pairs уже отдаёт колонку
исходника, выровненную по единицам редактора (export.go:189). Гранулярность грубая и владельцем
принята.
⚠ Подпись термина — это НЕ UPDATE строки. Банк пересобирается из файлов на каждом прогоне
(seedGlossary + ReplaceBank), поэтому прямая запись в таблицу glossary молча стирается
следующим прогоном. Контракт подписи обязан это учитывать.
⚠ После апгрейда бинарника движка все read-запросы по старым книгам падают
(«schema vN, expects vM»), пока по книге не пройдёт write-команда: OpenReadOnly требует точного
совпадения версии схемы. Это влияет на порядок деплоя платформы.