Урок → упражнение с настоящим запуском кода → экзамен, где ответ оценивает не самооценка, а разбор по рубрикам. Разбор инженерных решений: живого демо нет и не может быть.
Год
2026, июнь
Роль
Фронтенд целиком: архитектура SPA, дизайн-система, движок сессий, mock-слой, сборка и деплой статики
Стек
React 19, TypeScript 5.9 (strict), Vite 7, CodeMirror 6 с грамматикой Go, motion, nginx, Docker
Вне моей зоны
Go-бэкенд, песочница исполнения, планировщик повторений и промпты судьи. Фронт писался против согласованного контракта
01
Задача
Продукт делался под конкретного человека, готовящегося к собеседованию по Go. Его правки сохранились в рабочих заметках дословно — и почти каждая техническая деталь ниже выросла из одной такой фразы.
Первое: читать — не значит уметь. Человек читает статью про
net/http, кивает, а на собеседовании не может написать двадцать
строк с мультиплексором и тестовым сервером. Между «понятно» и «пишу» лежит
разрыв, который закрывается только клавиатурой.
Второе: самооценка врёт. Курсы предлагают отметить галочкой «понял». Через две недели в голове пусто, а трекер зелёный. Любая система, где ученик сам ставит себе оценку, со временем превращается в генератор приятных цифр.
Третье: забывание идёт по расписанию, а повторение — по настроению. Материал, разобранный в начале подготовки, к концу исчезает, и никто не подсказывает, что именно пора освежить сегодня.
Отсюда три жёстких требования, которые определили всю архитектуру: код должен реально исполняться, оценку должен ставить не ученик, а повторение должно назначаться расписанием.
02
Как устроено
Главное архитектурное решение — не смешивать их. Всё, что можно проверить сравнением строк, проверяется сравнением строк. Языковую модель зовут только туда, где сравнивать нечего: к устному объяснению.
Петля A · детерминированная
Стоит копейки, отвечает за секунды, ошибиться не может. Ответ приходит
структурой predictCorrect, expectedOutput,
diff — не текстом, который надо парсить глазами.
Петля B · языковая
Медленно и небесплатно, поэтому зовётся редко и только на экзамене.
Отсюда 120-секундный proxy_read_timeout в nginx: обычный
дефолт в 60 с рвал соединение на длинных разборах.
2.1
Уровни от L0 до L5 — это не шкала сложности внутри одного шаблона, а разные режимы взаимодействия. Каждый живёт своим компонентом:
experiments) и поле для объяснения своими
словами. Зачёт — по кнопке «Засчитать и дальше».
ParsonsBuilder:
программа собирается из перемешанных строк. Синтаксис не мешает проверять,
понятен ли порядок операций.
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.
/api/
на бэкенд по внутренней сети Docker, поэтому браузер телефона обращается
ровно к одному адресу. Ни preflight-запросов, ни заголовков
Access-Control-*, ни отладки того и другого.
embed; приложение повторения раздаётся
статикой из nginx с гзипом и тридцатидневным иммутабельным кэшем
на хешированные ассеты Vite.
2.5
Редактор — CodeMirror 6 с грамматикой Go (@codemirror/lang-go
поверх Lezer) и собственной темой в components/codetheme.ts:
подсветка построена на токенах приложения, а не на дефолтном
one-dark, иначе код-блок выглядел бы вставкой из другого продукта.
Отдельная песочница появилась после прямой просьбы: дать место, где можно трогать код без оценок. Решения, которые её вытянули:
package main прогоняется через wrapInMain и
становится компилируемой программой. Иначе половина кнопок вела бы
к ошибке компиляции.
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, а не в состоянии. Состояние обновится через рендер,
а второе событие приходит раньше.
key на экране
тренировки собирается из состава cardIds — сессия честно
начинается заново, а не продолжает предыдущую с чужим состоянием.
screen, а таб-бар
и баннер демо-режима замерли. Дефолтный кроссфейд корня давал двойную
экспозицию — ученик описал это как «неаккуратные переходы».
Tab, возврат фокуса при закрытии), зоны нажатия
от 44×44 px, content-visibility: auto на длинных списках,
prefers-reduced-motion — включая отключение конфетти.
03
Экраны
Слева — детерминированная проверка, справа — вердикт судьи с эталонным разбором. Оба экрана сняты в рабочей сборке против живого бэкенда.
04
Код
Не самые длинные файлы, а те, где решение видно целиком: сборка, навигация, переходы и раздача.
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' },
},
},
/** Глубокий вход на конкретный узел дорожки (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);
});
}, []);
/* Анимируется ТОЛЬКО группа 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; }
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 записал при последней успешной инкрементальной
сборке. Это фактический граф проекта, а не оценка на глаз.
модуля TypeScript в зоне web/src: 69 .tsx и 15 .ts
компонентов дизайн-системы — от Sheet и Toast до ProgressRing и Sparkline
модуля экранов в 11 папках: курс, справка, практика, ревью, «Сегодня», тренировка
модулей lib: контексты, звук, речь, прогресс, пружины анимации, View Transitions
модуля API-слоя: types, client, mock — контракт, транспорт и его двойник
точки входа собираются одной командой; общие чанки переиспользуются
модулей в графе Vite на проде, прод-сборка укладывается примерно в две секунды
ошибок tsc в строгом режиме с noUncheckedIndexedAccess и noUnused*
вкладок основного приложения: Сегодня, Повторение, Карта, Курс, Справка
этапов единой дорожки — от первой программы до готовности к собеседованию
достижений с описанием «как получить» у каждого; 5 открыто на старте демо
верхняя веха шкалы званий — от «Старта» до «Готов к собесу»
таймаут прокси на ответ судьи — под длинный разбор с рубриками
дебаунс смены фазы сессии: лечит конфликт быстрых горячих клавиш
минимальная сторона зоны нажатия у кнопок возврата и закрытия
иммутабельный кэш на хешированные ассеты Vite в nginx
06
Что осталось за кадром
Честный список важнее глянца: он показывает, что автор знает границы своей работы и не выдаёт заготовку за законченный продукт.
Серверная часть исполняет присланный пользователем код Go в песочнице и ходит к внешнему ИИ-судье. Ни то, ни другое не раздаётся статикой с GitHub Pages: нужен работающий процесс, изоляция и лимиты по CPU и времени. Класть такое в публичный статический хостинг нельзя, поэтому страница объясняет устройство, а не притворяется приложением.
В web/src сохранились только App.tsx,
main.tsx, review-main.tsx и стили; папки
api/, components/, lib/ и
screens/ отсутствуют. Проект в таком виде не собирается.
Состав модулей и их назначение восстановлены из
tsconfig.app.tsbuildinfo и рабочих отчётов — все цифры
на этой странице оттуда, ни одна не оценочная.
Тест-раннера в проекте нет вовсе. Критерием готовности были два гейта
сборки — tsc -b в строгом режиме и vite build —
плюс ручные прогоны ключевых сценариев в mock-режиме через Playwright.
Для однопользовательского тренажёра это работало, но чистая функция
вроде computeMilestone просится под юнит-тесты в первую очередь.
Песочница исполнения, планировщик FSRS, рубрики и промпты судьи, начисление достижений — всё это Go-бэкенд, который писала другая сторона. Фронт работал против согласованного контракта: типы полей, идентификаторы достижений и таблица вех фиксировались отдельно. Здесь я отвечаю за интерфейс и клиентскую архитектуру, а не за движок проверки.
В vite.config.ts комментарий обещает прокси на порт 8080,
а target указывает на 8090: порт бэкенда переезжал, комментарий
за ним не пошёл. Мелочь, но именно из таких мелочей потом складывается
час на выяснение, почему dev-прокси «не работает».
Спарклайн недели открывает модальный лист с разбивкой по дням, а не полноценный
экран аналитики. Это осознанный выбор в пользу честной навигации вместо
выдуманной вкладки, но при росте продукта лист станет тесен.
Там же осталась пара мёртвых экспортов вроде PLAYGROUND_DEFAULT.