Vector — тренажёр Go

Урок → упражнение с настоящим запуском кода → экзамен, где ответ оценивает не самооценка, а разбор по рубрикам. Разбор инженерных решений: живого демо нет и не может быть.

Год

2026, июнь

Роль

Фронтенд целиком: архитектура SPA, дизайн-система, движок сессий, mock-слой, сборка и деплой статики

Стек

React 19, TypeScript 5.9 (strict), Vite 7, CodeMirror 6 с грамматикой Go, motion, nginx, Docker

Вне моей зоны

Go-бэкенд, песочница исполнения, планировщик повторений и промпты судьи. Фронт писался против согласованного контракта

01

Задача

Подготовка к собеседованию разваливается в трёх местах

Продукт делался под конкретного человека, готовящегося к собеседованию по Go. Его правки сохранились в рабочих заметках дословно — и почти каждая техническая деталь ниже выросла из одной такой фразы.

Первое: читать — не значит уметь. Человек читает статью про net/http, кивает, а на собеседовании не может написать двадцать строк с мультиплексором и тестовым сервером. Между «понятно» и «пишу» лежит разрыв, который закрывается только клавиатурой.

Второе: самооценка врёт. Курсы предлагают отметить галочкой «понял». Через две недели в голове пусто, а трекер зелёный. Любая система, где ученик сам ставит себе оценку, со временем превращается в генератор приятных цифр.

Третье: забывание идёт по расписанию, а повторение — по настроению. Материал, разобранный в начале подготовки, к концу исчезает, и никто не подсказывает, что именно пора освежить сегодня.

Отсюда три жёстких требования, которые определили всю архитектуру: код должен реально исполняться, оценку должен ставить не ученик, а повторение должно назначаться расписанием.

02

Как устроено

Две петли проверки: дешёвая детерминированная и дорогая языковая

Главное архитектурное решение — не смешивать их. Всё, что можно проверить сравнением строк, проверяется сравнением строк. Языковую модель зовут только туда, где сравнивать нечего: к устному объяснению.

Петля A · детерминированная

Запуск кода и сверка вывода

  1. 01 редактор CodeMirror в браузере
  2. 02 POST кода на Go-бэкенд
  3. 03 исполнение в песочнице
  4. 04 stdout / stderr обратно
  5. 05 diff с ожидаемым выводом
  6. 06 вердикт: совпало / не совпало

Стоит копейки, отвечает за секунды, ошибиться не может. Ответ приходит структурой predictCorrect, expectedOutput, diff — не текстом, который надо парсить глазами.

Петля B · языковая

Экзамен и ИИ-судья

  1. 01 вопрос без подсказок, засекается время
  2. 02 развёрнутый устный ответ
  3. 03 ответ уходит судье вместе с рубриками
  4. 04 счёт по рубрикам, до 4 баллов за вопрос
  5. 05 эталонный разбор: ядро / глубже / ловушка
  6. 06 карточка в память — по вердикту, не по самооценке

Медленно и небесплатно, поэтому зовётся редко и только на экзамене. Отсюда 120-секундный proxy_read_timeout в nginx: обычный дефолт в 60 с рвал соединение на длинных разборах.

2.1

Лестница упражнений: разные уровни — разные компоненты, а не разные тексты

Уровни от L0 до L5 — это не шкала сложности внутри одного шаблона, а разные режимы взаимодействия. Каждый живёт своим компонентом:

  • L0 — лаборатория. Редактируемый пример из урока, кнопка «Запустить», блок «Эксперименты» (список «а что если…», приходит вместе с упражнением полем experiments) и поле для объяснения своими словами. Зачёт — по кнопке «Засчитать и дальше».
  • L1 — предсказание вывода. Код только на чтение, ученик сначала записывает, что напечатается, и лишь потом запускает. Совпадение прогноза отмечается отдельным тостом: ценность упражнения именно в прогнозе, а не в факте запуска.
  • L3 — сборка из блоков. Собственный ParsonsBuilder: программа собирается из перемешанных строк. Синтаксис не мешает проверять, понятен ли порядок операций.
  • L4 / L5 — полные задачи. Отмечены отдельно в системе достижений: clean-l4 — сдал с первой попытки, boss-l5 — закрыл финальную задачу ступени.

Подсказки выдаются порционно и считаются: в интерфейсе упражнения счётчик Подсказка 1/3. Это не украшение — количество взятых подсказок отличает «решил» от «подсмотрел».

2.2

Зачёт ставится только явной кнопкой — это инвариант, зафиксированный в коде

Самый простой способ незаметно испортить обучающий продукт — начислять прогресс за то, что человек долистал экран. Поэтому после аудита все вызовы markLectureRead и completeExercise были пересмотрены поштучно, и каждый оказался привязан к нажатию кнопки: «Прочитал — засчитать» в узле урока, «Засчитать и дальше» в L0, кнопка проверки в L1 и L3.

Ни одного вызова из onBack, из размонтирования компонента или из эффекта. Правило записано комментарием-инвариантом прямо в двух местах, где соблазн максимален, — чтобы следующая правка не сломала его молча.

Из той же логики выросли переименования кнопок: «Понятно, дальше» стало «Засчитать и дальше», «Прочитал — дальше» стало «Прочитал — засчитать». Формулировка должна называть последствие нажатия, а не эмоцию.

2.3

Память: карточку пишет вердикт судьи, а не желание ученика

Экзамен — источник данных для интервального повторения. Ответ, разобранный судьёй, превращается в карточку («записано в память»), промахи уходят в журнал по вердикту, а не по самооценке. Расписание — интервальный алгоритм FSRS на бэкенде; на фронте живут отметки сессии (lib/reviewmarks.ts) и локальный кэш прогресса (lib/progressstore.ts).

Готовность к цели ревью считается нарочито простой формулой: половина веса за долю зрелых карточек, половина — за долю сделанных практик. Формула тупая, зато её можно прочитать вслух и объяснить, почему кольцо показывает именно 17%. Красивая непрозрачная метрика в обучающем продукте вреднее грубой понятной.

Сводка экзамена считает четыре величины: покрытие рубрик, фактические ошибки, пропуски и превышение времени. Итоговый вердикт формулируется в терминах собеседования — «Не сдал бы», — а не в процентах, потому что процент легко себе простить.

2.4

Почему «Повторение» — отдельное приложение, а не пятая вкладка

У повторения другой сценарий использования. Основное приложение — это получасовая сессия за столом: урок, редактор, запуск, разбор. Повторение — это десять минут с телефона в произвольный момент дня. Смешивать их в одной оболочке значит заставлять телефон тащить весь курс ради очереди карточек.

Поэтому review.html — вторая точка входа со своей оболочкой ReviewApp, своим контейнером nginx и своим адресом в локальной сети. Важные следствия:

  • Тяжёлое переиспользуется. Движок сессии (TrainScreen, ReviewScreen, ExamScreen), дизайн-система и motion — общие чанки одной сборки Vite. Два приложения, одна кодовая база, один npm run build.
  • CORS не возникает вовсе. nginx проксирует /api/ на бэкенд по внутренней сети Docker, поэтому браузер телефона обращается ровно к одному адресу. Ни preflight-запросов, ни заголовков Access-Control-*, ни отладки того и другого.
  • Раздача разная. Основное приложение вкомпилировано в Go-бинарь через embed; приложение повторения раздаётся статикой из nginx с гзипом и тридцатидневным иммутабельным кэшем на хешированные ассеты Vite.

2.5

Редактор Go в браузере и песочница без оценок

Редактор — CodeMirror 6 с грамматикой Go (@codemirror/lang-go поверх Lezer) и собственной темой в components/codetheme.ts: подсветка построена на токенах приложения, а не на дефолтном one-dark, иначе код-блок выглядел бы вставкой из другого продукта.

Отдельная песочница появилась после прямой просьбы: дать место, где можно трогать код без оценок. Решения, которые её вытянули:

  • Вход отовсюду. В правом нижнем углу любого код-блока — компактная кнопка-пилюля «В песочницу», в том числе у кода предсказания в L1, где раньше его нельзя было потрогать принципиально.
  • Фрагменты оборачиваются автоматически. Кусок без package main прогоняется через wrapInMain и становится компилируемой программой. Иначе половина кнопок вела бы к ошибке компиляции.
  • Пустой файл — плохой старт. Вход без черновика отдаёт стартовый шаблон с тремя микро-шагами в комментариях («запусти», «поменяй текст», «добавь Println»). Черновик хранится в localStorage, кнопка «Начать заново» возвращает шаблон, а не пустоту.
  • Панель «Попробуй». Если у упражнения есть свои эксперименты — показываются они; если нет — пять универсальных челленджей (предскажи и запусти; сломай нарочно и прочитай ошибку вслух; вставь fmt.Println в середину и проследи порядок; оберни в цикл; вызови панику осознанно и прочитай stack trace).

2.6

Режим без бэкенда: демо, которое нельзя перепутать с живым сервером

Ждать бэкенд, чтобы посмотреть экран, — потерянные часы. Поэтому в клиенте есть параллельный mock-слой: api/client.ts при VITE_MOCK=1 (или при недоступном бэкенде) переключается на api/mock.ts с собственными сидами, чистыми функциями расчёта (computeMilestone, buildWeek) и эмуляцией запуска кода.

Два принципа, без которых mock быстро протухает. Первый: одни и те же массивы. Экран статистики и спарклайн недели читают один источник, поэтому «вчера» на двух экранах совпадает — расхождение в моке ловится как настоящий баг. Второй: честная маркировка. В mock-режиме поверх интерфейса висит липкий баннер «Демо-режим · данные сгенерированы локально, бэкенд не подключён». Демо не должно выглядеть убедительнее, чем оно есть.

Мелочь, показывающая цену реализма: эмулятор запуска сначала распознавал HTTP-сервер по тексту приветствия, и обычный Println в песочнице ложно печатал вывод сервера. Стало — по вызову ListenAndServe.

2.7

Дисциплина интерфейса: гонки, фокус, зоны нажатия

Тренажёр — приложение с горячими клавишами, которым пользуются быстро. Отсюда набор решений, которые не видно, пока они работают:

  • Двойной сабмит мышью и клавишей. Замок inFlight на useRef ставится синхронно — до await, а не в состоянии. Состояние обновится через рендер, а второе событие приходит раньше.
  • Конфликт хоткеев фаз. Дебаунс 260 мс на смену фазы сессии: быстрый двойной хват клавиш переставал перепрыгивать через шаг.
  • Повторный вход в чекпоинт. key на экране тренировки собирается из состава cardIds — сессия честно начинается заново, а не продолжает предыдущую с чужим состоянием.
  • Переходы между экранами. View Transitions с именованными группами: анимируется только группа screen, а таб-бар и баннер демо-режима замерли. Дефолтный кроссфейд корня давал двойную экспозицию — ученик описал это как «неаккуратные переходы».
  • Доступность. Focus-trap в модальных листах (запоминание триггера, цикл Tab, возврат фокуса при закрытии), зоны нажатия от 44×44 px, content-visibility: auto на длинных списках, prefers-reduced-motion — включая отключение конфетти.

03

Экраны

Как обе петли выглядят на месте

Слева — детерминированная проверка, справа — вердикт судьи с эталонным разбором. Оба экрана сняты в рабочей сборке против живого бэкенда.

Упражнение «HTTP без боли»: редактор Go с подсветкой, кнопки «Запустить и проверить», «Подсказка 1/3», «Сбросить», ниже зелёная отметка «Совпало! 42»
Петля A · упражнение Задание с инлайновыми фрагментами кода, редактор на 21 строку, счётчик подсказок и результат сверки вывода. Ниже — блок «Спроси себя»: вопросы, на которые запуск ответа не даёт.
Экран экзамена: вердикт «Не сдал бы», метрики покрытия рубрик, счёт судьи по вопросу и эталонный разбор с блоками «Ядро», «Глубже», «Ловушка»
Петля B · экзамен Сводка вердикта, счёт судьи по каждому вопросу, эталонный разбор в трёх слоях — ядро, глубже, ловушка — и отметка «записано в память» там, где карточка ушла в расписание повторений.

04

Код

Четыре фрагмента, в которых видно устройство

Не самые длинные файлы, а те, где решение видно целиком: сборка, навигация, переходы и раздача.

web/vite.config.ts Два приложения из одной кодовой базы. Плюс dev-прокси, чтобы фронт работал против живого Go-бэкенда без CORS.
server: {
  port: 5173,
  proxy: {
    // dev: /api и /healthz уходят на Go-бэкенд — без CORS.
    // Если бэкенда нет (или VITE_MOCK=1), client.ts сам
    // переключается на встроенный mock-слой.
    '/api':     { target: 'http://localhost:8090', changeOrigin: true },
    '/healthz': { target: 'http://localhost:8090', changeOrigin: true },
  },
},
build: {
  outDir: 'dist',
  target: 'es2022',
  // CodeMirror + motion весят прилично, а приложение
  // однопользовательское — поднимаем порог предупреждения.
  chunkSizeWarningLimit: 1200,
  // Две точки входа в одной сборке:
  //  index.html  → основное приложение (его embed-ит Go-бинарь);
  //  review.html → приложение «Повторение» (его раздаёт nginx).
  // Общие чанки (motion, дизайн-система, движок сессии)
  // переиспользуются между ними.
  rollupOptions: {
    input: { main: 'index.html', review: 'review.html' },
  },
},
web/src/App.tsx Навигация без роутера: одно намерение перехода вместо трёх независимых состояний. Так «Сегодня» может открыть конкретный узел дорожки или конкретную цель ревью, не изобретая URL-схему.
/** Глубокий вход на конкретный узел дорожки (deep-link из «Сегодня»). */
export interface PathFocus {
  pathSlug: string;
  stepKind: PathStepKind;
  ref: string;
}

/** Намерение перехода между вкладками с опциональным фокусом. */
export interface NavIntent {
  tab: TabId;
  exerciseId?: string;
  pathFocus?: PathFocus;   // открыть узел дорожки на вкладке «Курс»
  reviewSlug?: string;     // финал дорожки → сразу цель в «Повторении»
}

const navigate = useCallback((intent: NavIntent) => {
  withViewTransition(() => {
    setTab(intent.tab);
    setFocusExercise(intent.exerciseId ?? null);
    setPathFocus(intent.pathFocus ?? null);
    setReviewFocus(intent.reviewSlug ?? null);
  });
}, []);
web/src/App.module.css Переходы экранов. Дефолтный кроссфейд корня давал двойную экспозицию, а таб-бар мигал: анимируется ровно одна группа, остальные слои прибиты явно.
/* Анимируется ТОЛЬКО группа screen. Слои интерфейса
   (фон, таб-бар, баннер демо) статичны: дефолтный кроссфейд
   root с plus-lighter давал двойную экспозицию, а бар
   вообще не должен мигать. */
::view-transition-old(screen) { animation: vt-out .26s cubic-bezier(.32,.72,0,1) both; }
::view-transition-new(screen) { animation: vt-in  .26s cubic-bezier(.32,.72,0,1) both; }

::view-transition-old(root),        ::view-transition-new(root),
::view-transition-old(tabbar),      ::view-transition-new(tabbar),
::view-transition-old(demo-banner), ::view-transition-new(demo-banner) {
  animation: none;
}

/* Ремень безопасности к порядку пейнта. */
::view-transition-group(screen) { z-index: 1; }
::view-transition-group(tabbar),
::view-transition-group(demo-banner) { z-index: 10; }
web/nginx.review.conf Раздача приложения «Повторение». Прокси на тот же бэкенд по внутренней сети Docker — телефон видит один origin. Таймаут поднят под ответ судьи.
root /usr/share/nginx/html;
index review.html;

# ── API → бэкенд Vector (сервис app в той же docker-сети) ──
location /api/ {
  proxy_pass http://app:8080/api/;
  proxy_http_version 1.1;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_read_timeout 120s;   # разбор судьи не укладывается в дефолт
}

# ── хешированные ассеты Vite: кэш надолго ──
location /assets/ {
  expires 30d;
  add_header Cache-Control "public, immutable";
  try_files $uri =404;
}

# ── SPA: любой неизвестный путь → review.html ──
location / { try_files $uri $uri/ /review.html; }

05

Цифры

Объём фронтенда

Состав модулей восстановлен из tsconfig.app.tsbuildinfo — списка файлов, который TypeScript записал при последней успешной инкрементальной сборке. Это фактический граф проекта, а не оценка на глаз.

84

модуля TypeScript в зоне web/src: 69 .tsx и 15 .ts

29

компонентов дизайн-системы — от Sheet и Toast до ProgressRing и Sparkline

34

модуля экранов в 11 папках: курс, справка, практика, ревью, «Сегодня», тренировка

13

модулей lib: контексты, звук, речь, прогресс, пружины анимации, View Transitions

3

модуля API-слоя: types, client, mock — контракт, транспорт и его двойник

2

точки входа собираются одной командой; общие чанки переиспользуются

550

модулей в графе Vite на проде, прод-сборка укладывается примерно в две секунды

0

ошибок tsc в строгом режиме с noUncheckedIndexedAccess и noUnused*

5

вкладок основного приложения: Сегодня, Повторение, Карта, Курс, Справка

8

этапов единой дорожки — от первой программы до готовности к собеседованию

12

достижений с описанием «как получить» у каждого; 5 открыто на старте демо

4 500XP

верхняя веха шкалы званий — от «Старта» до «Готов к собесу»

120с

таймаут прокси на ответ судьи — под длинный разбор с рубриками

260мс

дебаунс смены фазы сессии: лечит конфликт быстрых горячих клавиш

44px

минимальная сторона зоны нажатия у кнопок возврата и закрытия

30дн

иммутабельный кэш на хешированные ассеты Vite в nginx

06

Что осталось за кадром

Ограничения, техдолг и то, чего здесь нет

Честный список важнее глянца: он показывает, что автор знает границы своей работы и не выдаёт заготовку за законченный продукт.