Lesson → exercise with real code execution → an exam where the answer is graded against rubrics rather than by self-assessment. An engineering breakdown: there is no live demo, and there cannot be one.
Year
June 2026
Role
The entire frontend: SPA architecture, design system, session engine, mock layer, build and static deployment
Stack
React 19, TypeScript 5.9 (strict), Vite 7, CodeMirror 6 with the Go grammar, motion, nginx, Docker
Outside my scope
The Go backend, the execution sandbox, the repetition scheduler and the judge prompts. The frontend was written against an agreed contract
01
The problem
The product was built for one specific person preparing for Go interviews. His feedback survives verbatim in the working notes — and nearly every technical decision below grew out of one of those sentences.
First: reading is not the same as being able to write.
Someone reads an article about net/http, nods along, and then
cannot produce twenty lines with a multiplexer and a test server under
interview pressure. The gap between “that makes sense” and “I can type it”
closes only at the keyboard.
Second: self-assessment lies. Courses offer a checkbox that says “understood”. Two weeks later the head is empty and the tracker is green. Any system where the learner grades themselves eventually becomes a generator of pleasant numbers.
Third: forgetting runs on a schedule, revision runs on mood. Material covered at the start of preparation has evaporated by the end, and nothing tells the learner what specifically needs refreshing today.
Three hard requirements followed, and they shaped the whole architecture: code must actually execute, the grade must come from somebody other than the learner, and revision must be scheduled, not chosen.
02
How it works
The central architectural decision is to keep them apart. Anything that can be verified by comparing strings is verified by comparing strings. The language model is called only where there is nothing to compare: a spoken explanation.
Loop A · deterministic
Costs almost nothing, answers in seconds, cannot be wrong. The response
arrives as a structure — predictCorrect,
expectedOutput, diff — not as prose the client
has to parse by eye.
Loop B · language
Slow and not free, so it is invoked rarely and only during exams.
Hence the 120-second proxy_read_timeout in nginx: the
usual 60-second default cut long breakdowns off mid-response.
2.1
Levels L0 through L5 are not a difficulty dial inside one template — they are distinct modes of interaction, each with its own component:
experiments field), and a box for
explaining the code in your own words. Credit is granted by an explicit
“mark done and continue” button.
ParsonsBuilder: the program is reassembled from shuffled
lines, so syntax errors stop getting in the way of testing whether the
learner understands the order of operations.
clean-l4 for passing on the first
submission, boss-l5 for closing a stage’s final task.
Hints are handed out in portions and counted — the exercise UI shows
Hint 1/3. That counter is not decoration: the number of hints
taken is what separates “solved it” from “peeked”.
2.2
The easiest way to quietly ruin a learning product is to award progress for
scrolling past a screen. So after an audit, every call site of
markLectureRead and completeExercise was reviewed
one by one, and each turned out to hang off a button press: “read it — mark
done” on a lesson node, “mark done and continue” in L0, the check button in
L1 and L3.
Not a single call from onBack, from unmount, or from an effect.
The rule is written as an invariant comment in the two places where the
temptation is highest, so that the next edit cannot break it silently.
The same reasoning drove the button relabelling: “got it, next” became “mark done and continue”; “read — next” became “read it — mark done”. A label should name the consequence of the press, not the emotion.
2.3
The exam is the data source for spaced repetition. An answer the judge has
broken down becomes a card (“written to memory” in the UI), and misses are
logged from the verdict rather than from self-rating. Scheduling uses the
FSRS interval algorithm on the backend; the frontend holds session marks
(lib/reviewmarks.ts) and a local progress cache
(lib/progressstore.ts).
Readiness for a review goal is computed with a deliberately blunt formula: half the weight for the share of mature cards, half for the share of completed practice items. The formula is crude, but it can be read aloud, and it explains why a ring shows exactly 17%. In a learning product, an elegant opaque metric does more harm than a coarse legible one.
The exam summary reports four quantities: rubric coverage, factual errors, skipped questions and time overruns. The final verdict is phrased in interview terms — “would not have passed” — rather than as a percentage, because a percentage is easy to forgive yourself.
2.4
Revision has a different usage pattern. The main application is a half-hour session at a desk: lesson, editor, run, breakdown. Revision is ten minutes on a phone at an arbitrary point in the day. Merging them into one shell would force the phone to carry the whole course just to reach a queue of cards.
So review.html is a second entry point with its own
ReviewApp shell, its own nginx container and its own address on
the local network. The consequences that matter:
TrainScreen, ReviewScreen, ExamScreen),
the design system and motion land in shared chunks of a
single Vite build. Two applications, one codebase, one
npm run build.
/api/ to the
backend over the internal Docker network, so the phone’s browser only ever
talks to one origin. No preflight requests, no
Access-Control-* headers, and no time spent debugging either.
embed; the revision
application is served as static files from nginx with gzip and a
thirty-day immutable cache on Vite’s hashed assets.
2.5
The editor is CodeMirror 6 with the Go grammar
(@codemirror/lang-go on top of Lezer) and a custom theme in
components/codetheme.ts: highlighting is built from the
application’s own design tokens rather than the default one-dark,
otherwise every code block would look pasted in from another product.
The standalone playground appeared after a direct request: give me somewhere to poke at code without being graded. What made it work:
package main goes through wrapInMain and becomes
a compilable program. Without that, half the buttons would lead straight
to a compile error.
Println. The draft lives in
localStorage, and “start over” restores the template rather
than blanking the buffer.
fmt.Println in the middle and trace the ordering; wrap it in a
loop; trigger a panic on purpose and read the stack trace).
2.6
Waiting on the backend just to look at a screen costs hours. So the client
carries a parallel mock layer: with VITE_MOCK=1 (or when the
backend is unreachable) api/client.ts switches to
api/mock.ts, which has its own seeds, pure computation
functions (computeMilestone, buildWeek) and an
emulated code run.
Two principles keep a mock from rotting. First: one set of arrays. The stats sheet and the weekly sparkline read the same source, so yesterday’s figure agrees across both screens and a discrepancy in the mock surfaces as a real bug. Second: honest labelling. In mock mode a sticky banner sits above the interface, stating that the data is generated locally and no backend is attached. A demo should never look more convincing than it is.
One detail shows what realism costs: the run emulator originally detected an
HTTP server by matching the greeting text, so a plain Println in
the playground falsely printed server output. It now matches on the
ListenAndServe call instead.
2.7
A trainer is a keyboard-driven application used at speed. That produced a set of decisions nobody notices while they are working:
inFlight lock is a useRef set synchronously,
before the await, not in state. State updates a render later,
and the second event arrives sooner than that.
key is derived from the set of cardIds, so the
session genuinely restarts instead of resuming with state belonging to a
different card set.
screen group animates, while the tab bar and the demo
banner stay put. The default root cross-fade produced double exposure —
the learner described it as sloppy.
Tab, restore focus on close), hit targets
of at least 44×44 px, content-visibility: auto on long
lists, and prefers-reduced-motion respected — confetti included.
03
Screens
On the left, deterministic verification; on the right, the judge’s verdict with a reference breakdown. Both were captured from a working build against a live backend. The interface is in Russian — it was built for one Russian-speaking learner.
04
Code
Not the longest files, but the ones where a decision is legible end to end: the build, navigation, transitions and delivery. Comments are translated from the Russian originals.
server: {
port: 5173,
proxy: {
// dev: /api and /healthz go to the Go backend — no CORS.
// With no backend (or VITE_MOCK=1), client.ts switches
// itself to the built-in mock layer.
'/api': { target: 'http://localhost:8090', changeOrigin: true },
'/healthz': { target: 'http://localhost:8090', changeOrigin: true },
},
},
build: {
outDir: 'dist',
target: 'es2022',
// CodeMirror + motion are sizeable, and this is a
// single-user app — raise the warning threshold.
chunkSizeWarningLimit: 1200,
// Two entry points in one build:
// index.html → the main app (embedded into the Go binary);
// review.html → the revision app (served by nginx).
// Shared chunks (motion, design system, session engine)
// are reused across both.
rollupOptions: {
input: { main: 'index.html', review: 'review.html' },
},
},
/** Deep entry into a specific track node (deep link from Today). */
export interface PathFocus {
pathSlug: string;
stepKind: PathStepKind;
ref: string;
}
/** A tab transition intent with optional focus on an entity. */
export interface NavIntent {
tab: TabId;
exerciseId?: string;
pathFocus?: PathFocus; // open a track node on the Course tab
reviewSlug?: string; // end of a track → straight to a review goal
}
const navigate = useCallback((intent: NavIntent) => {
withViewTransition(() => {
setTab(intent.tab);
setFocusExercise(intent.exerciseId ?? null);
setPathFocus(intent.pathFocus ?? null);
setReviewFocus(intent.reviewSlug ?? null);
});
}, []);
/* ONLY the screen group animates. Interface layers
(background, tab bar, demo banner) stay static: the default
root cross-fade with plus-lighter caused double exposure,
and the bar should never flicker at all. */
::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;
}
/* A seatbelt for paint order. */
::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 → the Vector backend (the «app» service on the same network) ──
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; # a judge breakdown exceeds the default
}
# ── Vite's hashed assets: cache them hard ──
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# ── SPA: any unknown path → review.html ──
location / { try_files $uri $uri/ /review.html; }
05
Numbers
The module inventory was reconstructed from
tsconfig.app.tsbuildinfo — the file list TypeScript recorded
during the last successful incremental build. It is the project’s actual
graph, not an estimate.
TypeScript modules under web/src: 69 .tsx and 15 .ts
design-system components, from Sheet and Toast to ProgressRing and Sparkline
screen modules across 11 folders: course, reference, practice, review, Today, training
lib modules: contexts, sound, speech, progress, animation springs, View Transitions
API-layer modules — types, client, mock: the contract, the transport and its stand-in
entry points built by a single command, with shared chunks reused between them
modules in the production Vite graph; the build finishes in about two seconds
tsc errors in strict mode with noUncheckedIndexedAccess and noUnused*
tabs in the main application: Today, Revision, Map, Course, Reference
stages on a single track, from the first program to interview readiness
achievements, each carrying its own how-to-earn-it text; 5 unlocked at the demo’s start
the top milestone on the rank scale, running from the start to interview-ready
proxy timeout for a judge response, sized for a long rubric breakdown
debounce on session phase changes, curing the fast-hotkey collision
minimum hit-target edge on the back and close controls
immutable cache on Vite’s hashed assets in nginx
06
What is not here
An honest list beats a polished one: it shows that the author knows the boundaries of the work and is not passing a work in progress off as a finished product.
The server side executes user-submitted Go inside a sandbox and calls out to an external AI judge. Neither can be served as static files from GitHub Pages: both need a running process, isolation, and CPU and wall-clock limits. That does not belong on public static hosting, so this page explains the machine instead of pretending to be it.
Only App.tsx, main.tsx, review-main.tsx
and the stylesheets survive under web/src; the
api/, components/, lib/ and
screens/ folders are missing, so the project does not build in
this state. The module inventory and its structure were reconstructed from
tsconfig.app.tsbuildinfo and the working reports — every number
on this page comes from there, none of them is an estimate.
There is no test runner in the project at all. The definition of done was
two build gates — tsc -b in strict mode and
vite build — plus manual Playwright passes over the key flows
in mock mode. That held for a single-user trainer, but a pure function like
computeMilestone is the first thing that should be under unit test.
The execution sandbox, the FSRS scheduler, the rubrics and judge prompts, and the achievement logic all live in the Go backend, written by another party. The frontend worked against an agreed contract: field types, achievement identifiers and the milestone table were fixed separately. What I own here is the interface and the client architecture, not the verification engine.
In vite.config.ts the comment promises a proxy to port 8080
while target points at 8090: the backend port moved and the
comment did not follow. A small thing, but small things like this are what
later cost an hour of working out why the dev proxy is not connecting.
Tapping the weekly sparkline opens a modal sheet with a per-day breakdown
rather than a full analytics screen — a deliberate choice in favour of
honest navigation over an invented tab, though the sheet will get cramped as
the product grows. A couple of dead exports such as
PLAYGROUND_DEFAULT are still sitting in the same area.