# Front-end surfaces — build + verification log

Started 2026-08-18. Written incrementally. **If this agent dies, read this before restarting** —
everything below is either done or explicitly still open.

Boundary: everything under `ui-src/`, plus this file. `server/**`, `corpus/**`, `lessons/**`,
`tools/**` are other agents' lanes and are never edited here.

## Ground truth gathered before writing any code

Read: design-system spec (zone rule, motion tables, budgets), lld-dojo-design (eight phases,
evidence ledger, companion principles), learning-design (worked → faded → independent,
predict-before-reveal, no MCQs), standard-and-syllabus (Part 1 anchors, Part 2 syllabus),
`docs/verify/fe-verification.md` (the standard to match).

Read the real server, not the docs, for every response shape: `server/index.mjs`,
`server/lib/routes/{_shared,meta,grade,curveball,round,lessons}.mjs`,
`server/lib/{rubric,grade-phase3,grade-phase4,grade-phase5,grade-phase6,grade-phase7,modes,diff,stress,junit,ledger,readiness,syllabus,lessons,corpus}.mjs`.
Then started the real server on 127.0.0.1:7777 and curled every GET endpoint to confirm.

### Gaps in the API that the surfaces have to be honest about

These are **backend gaps, not UI decisions**. Each surface consumes the field the moment it
appears (typed as optional), and until then says plainly what is missing rather than faking it.

1. `GET /api/problems/:id/drill` returns only
   `id, title, tier, sealed, standard_version, prompt_ambiguous, skeleton_spec, syllabus, curveballs, dir`
   (confirmed by curl). It does **not** serve `acceptance.json`, `contract/` or `scaffold/`.
   - Phase 3's visible acceptance checklist has no data source. `CoreBuild.tsx` renders
     `problem.acceptance.checks` when present and otherwise states that the checklist is not
     served yet.
   - Phase 3/4/6/7 cannot compile against the hidden suite without the GIVEN `contract/` types
     (`ParkingLotApi`, `Entry`, `Ticket`, `Receipt`, `Vehicle`, `VehicleType`), because
     `runSuite()` copies only `tests_base/`. `CoreBuild.tsx` uses `problem.contract` /
     `problem.scaffold` when present, otherwise seeds a `Main.java` starter and says so.
2. `POST /api/grade/core-build` returns `summary` (`{pass,total,green}`) but **not** the per-test
   `results[]` that `runSuite()` already produced. So a `@DisplayName` cannot tick green per test.
   `CoreBuild.tsx` reads `result.results` when present, else shows the pass/total figure.
3. There is no endpoint that returns Phase 5's four questions *before* grading
   (`questionsFor()` is server-side only). `Defence.tsx` uses `problem.review_questions` when the
   corpus authors an override, and otherwise a constant that mirrors
   `server/lib/grade-phase5.mjs` `ARCHETYPES` verbatim. Flagged as a duplication risk.
4. No sealed problems exist in the corpus yet, so `POST /api/round/start` with no `problemId`
   404s for every mode. `Round.tsx` offers an explicitly-labelled practice round on a drilled
   problem (allowed: `round/start` accepts `problemId` and uses `corpus.forRound`), stating that
   it cannot count toward readiness because readiness only counts `sealed === true` rounds.
5. `corpus/_syllabus.json` has 34 items and **zero** `track: 'bridge'` items, so `/api/syllabus`
   returns `bridge.total = 0`. `Learn.tsx` derives the bridge track from the catalog when it is
   loaded and from authored lessons (`group: 'Java Bridge'`) meanwhile, and says the catalog is
   not loaded rather than printing a fake `3 / 12`.
6. `lessonDetail()` serves `idea.md`, `when-not.md`, `from-cpp.md`, `predict.json`,
   `retrieval.json`, `worked/*.java`, `faded/src/*.java` + gaps. It does **not** serve
   `worked/NOTES.md` or a `contrast/` pair, and no authored lesson has `when-not.md` or
   `contrast/` yet. `Learn.tsx` renders each when present and names the gap when not.

## Plan

1. `zone` context first — everything depends on it.
2. Shared pieces: `api.ts` wrappers, `Markdown`, `Numeral`, `Workbench`, `diffcount.ts`.
3. Surfaces in dependency order: Standard, Ledger, Readiness (read-only, cheap) → CoreBuild →
   Extension → Concurrency → Defence → Learn → Round.
4. Snapshot after each surface lands (`node tools/snapshot.mjs "fe-<surface>"`, from PowerShell).
5. `npx tsc --noEmit` at zero errors throughout; `node tools/build-ui.mjs`; Puppeteer against the
   real server for the five required observations.

## Progress log

- (in progress) reading done, gaps recorded above, starting on the zone context.

---

# Session 2 — phases 3–7 verification, and the layout-projection trap

Picked up 2026-08-18 from a session that died mid-hunt. Its last recorded action was *"now let me
check whether the same hazard exists in the file-tab rails (Workbench and SkeletonSprint)"*, and the
progress log above had gone stale at "starting on the zone context" — so the hazard had to be
re-derived from the code. It is written down properly this time.

Server used for every observation below: `scratchpad/fe2-server.mjs` on `127.0.0.1:7779` — the
**real** `corpus/` and the **real** built `ui/`, but a throwaway db and workspace so nothing here
lands in the user's own ledger, and `tier: 'T3'` so no Claude usage is spent. Puppeteer drives a
real Chromium; every claim marked OBSERVED was read off the live DOM.

## 1 · The hazard, named properly: a stuck **layout projection**, and `Lozenge` was the live one

The hazard class is: **an element with a `layout` / `layoutId` prop that unmounts in the same commit
as a large layout change leaves a layout projection that never reports completion.** `Shell` wraps
every route in `AnimatePresence mode="wait"`, which refuses to mount the next surface until the
outgoing one has finished leaving — so one stuck projection means **the app can never be navigated
again**. Route state and the breadcrumbs change; the body does not.

The previous session had found and fixed two instances, both by replacing a sliding `layoutId` pill
with a per-item opacity fade, and both are still correctly fixed (verified by reading and by the
regression net below):

- `views/Learn.tsx` — the lesson stage rail (the Predict entry disappears the moment it is answered).
- `components/Workbench.tsx` + `views/SkeletonSprint.tsx` — the file-tab rails ("+ file" changes the
  tab list and moves the selection in one commit).

**So the answer to "does the hazard still exist in the file-tab rails" is: no.** OBSERVED — the
regression net's `leave phase 3 with two files open` case passes, and did so even before any change
of mine. That was the lead, and it was already closed.

**But the hazard itself was still live, in a worse place.** `components/Lozenge.tsx` carried a bare
`layout` prop, and `Lozenge` is used on nearly every surface. The reproduction is Phase 4: the
pre-injection curveball picker is a list of cards full of lozenges, and choosing a curveball replaces
that entire tree in one commit. After that, **no sidebar link worked again for the rest of the
session.**

How it was pinned down, because the answer was not the obvious one:

1. OBSERVED — the regression net reported `TRAP leave phase 4 with a curveball injected`, and every
   later case in the run failed as a cascade of being stuck (which is also why the previous
   session's own script crashed on `no button.card containing "equals, hashCode"` — the Learn index
   never mounted).
2. OBSERVED — the trap is independent of the curveball sheet (traps with it open, dismissed, and
   after editing) and independent of which curveball is chosen.
3. OBSERVED — removing `DiffMeter` did not fix it. Nor did removing `ClockRing`, `EditorPane` and
   `DiffMeter` all at once.
4. OBSERVED, and this is what cracked it — replacing the whole post-injection tree with
   `<div>minimal post-injection tree</div>` **still trapped**. So the blocker was not in the new
   tree at all; it was left behind by the *outgoing* pre-injection tree.
5. OBSERVED — deleting the single word `layout` from `Lozenge.tsx` fixed both cases immediately.

Fix: `layout` removed from `Lozenge`. Colour still animates, which is the part that carries meaning;
the `layout` prop only smoothed a pill resizing when its own text changed. The reasoning is written
into the component so it is not re-added.

`components/Expand.tsx` also has a bare `layout` prop. It was **tested rather than assumed** and is
fine: OBSERVED — leaving Phase 4 with the contract-delta `Expand` mounted *and open* (curveball
`03-nearest-spot`) works. Left alone deliberately. If a future trap appears, this is the first place
to look, and step 4 above is the technique that localises it in one build.

Regression net: `scratchpad/fe2-t4-navout.mjs` walks every surface and asserts the next one mounts.
OBSERVED, after the fix: **16/16 `ok`, "EVERY SURFACE IS LEAVEABLE", zero page errors.** Re-run it
after any motion change.

## 2 · Two more real defects found in the same sweep

**`views/Extension.tsx` — Phase 4's clock was switched off by a leftover debug line.** The source
read `const clock = useClock(LIMIT_MS, false); // BISECT` — the previous session had disabled it
while bisecting this trap and died before restoring it. The fifteen-minute clock therefore never
moved: the ring sat at 15:00 for the whole drill, and `elapsedMs` was always 0. Now
`useClock(LIMIT_MS, !!injection && !result)`, which matches the comment above it (the clock starts
when the curveball lands, not when the page opens, and stops when a result exists).
OBSERVED after the fix: no ring at all on the pre-injection picker; `14:58 → 14:55` across 3.2 s
once the curveball has landed; Phase 2 ticks identically as a control.

**`views/SkeletonSprint.tsx` — adding a file whose name is already open corrupted the editor.** This
drill has its own copy of `addFile` rather than using `useWorkbench`, and its copy had no
duplicate-path guard (the shared one in `Workbench.tsx` does). Two tabs sharing a path collide on
`key={f.path}` in the rail *and* on `key={current.path}` on the editor, so React kept the first
file's CodeMirror instance mounted while `active` pointed at the new one — he would be shown one
file's text while typing into another's. This is the "key colliding between tabs so React reuses the
wrong CodeMirror instance" failure, and it was real. Now it switches to the open tab instead.
OBSERVED after the fix: answering the prompt with `Main.java` when `Main.java` is already open
leaves the tab list at `["Main.java"]` and the editor still showing its content.

## 3 · The zone rule — OBSERVED, PASS

Method: `scratchpad/fe2-t1-zone.mjs`, reading computed `opacity` and `animationPlayState` off the
live grain and ambient nodes.

```
LOBBY (ladder)          grain 0.025   ambient live    opacity 1          drift running
LOBBY (brief)           grain 0.025   ambient live    opacity 1          drift running
EXEC +250ms  (draining) grain 0.0083  ambient drained opacity 0.331491   drift paused
EXEC +1150ms (drained)  grain 0       ambient drained opacity 0          drift paused
LOBBY again (restored)  grain 0.025   ambient live    opacity 1          drift running
EXEC (vocab)            grain 0       ambient drained opacity 0          drift paused
```

The +250 ms sample is the important one: the intermediate values prove this is a real ~600 ms ramp
rather than a snap, and the drift is genuinely `paused` rather than merely invisible, so no
compositor work continues behind a sprint. Leaving restores all three. Zero page errors.

## 4 · Phase 4's live diff number — OBSERVED, PASS, and it agrees with the grader

Method: `scratchpad/fe2-t2-diff.mjs`. Real `parking-lot` curveball `02-tiered-pricing`, budget 3.
Typed into the live CodeMirror; **never pressed Compile or Submit.**

```
before any edit      0   "/ 3 budget"  gold glow present, "Zero lines changed … level 3"
after 2 typed lines  1   "Inside budget — level 2 as it stands. Zero would be level 3."
after 5 typed lines  4   "Over budget. Level 1 as it stands."
after undo to base   0   gold glow back, zero-lines sentence back
API calls fired during the whole editing window: []
```

Zero `/api/*` requests across the entire editing window — the number is genuinely client-side, with
no submit and no round trip. The zero-line reading was colour-sampled off the live DOM:
`rgb(240, 217, 168)`, which is exactly `--gold-300` (`#f0d9a8`). So zero lines reads in gold, as the
achievement it is.

Trustworthiness of the figure — `scratchpad/fe2-t2b-diff-agreement.mjs` checks the browser port in
`diffcount.ts` against the server's own `measureChange()` on 12 cases: identical, whitespace-only,
blank lines added, append, delete-first-line, reordered, new-file-only, file-deleted, everything
replaced, empty baseline, empty current, both empty. **All 12 agree exactly**, including the
load-bearing ones (whitespace-only is 0, a brand-new file is free, deleting a pre-existing file is
charged its whole length). The corpus curveballs measured at literally 0 lines will therefore read 0.

## 5 · Reduced motion — OBSERVED, PASS

Method: `scratchpad/fe2-t3-reduced.mjs`, `emulateMediaFeatures(['prefers-reduced-motion' = reduce])`
set **before** `goto`, so the preference is real at first paint rather than toggled after.

All 18 surfaces walked — ladder, learn index, a lesson at all seven stages, ledger, readiness,
standard, round picker, phases 3/4/5/6 — every one `ok`: real content, buttons present, no grain
node, no ambient node, no "Loading", **zero page errors**. The Phase 3 editor took keyboard input
and the Phase 5 counter-evidence field worked. Separately OBSERVED: Phase 4 *post-injection* under
reduced motion keeps its live meter working (read `1` after typing) with no grain and no ambient.

## 6 · The Standard surface — OBSERVED, PASS

Method: fetched `GET /api/standard` and asserted every string appears in the rendered `main`.
5 dimensions rendered as 5 `section.card`s; **all 20 anchors present** (4 levels × 5 dimensions),
plus every dimension title, every dimension `note`, and the `STANDARD v1.0` version string. Nothing
missing. Comparison was whitespace- and markdown-normalised because anchors render through
`Markdown`.

## 7 · No naked zeros — two real violations found and fixed

Against the empty history the server genuinely returns all-zero figures, and both record surfaces
rendered them as large numerals.

- `views/Ledger.tsx` — the syllabus claim returns `{total: 34, demonstrated: 0, held: 0}`, which
  rendered as a 34px `0` labelled **"held of 34"** — literally the `0 / 34` the principle forbids.
  Fixed: the figure block is suppressed when every figure in a claim is zero. Nothing is lost, since
  the server already supplies the better sentences and they are rendered — `"Nothing demonstrated
  yet — the list is a closed 34 items…"` and `next: "Start with A1 — Enum carrying state and
  behaviour."` A zero *among* real figures is still shown; it is information once anything has been
  measured.
- `views/Ledger.tsx` — Invalid runs rendered a 40px `0`. Fixed: reads "None yet" until there is
  something to count.
- `views/Readiness.tsx` — the three conditions rendered `0 / 4`, `0 / 3` and `0 / 34` as 32px
  numerals. Fixed: reads "none of 4" / "none of 34" while the value is zero. The condition still
  reads completely (title, the server's `detail` sentence, and an "outstanding" lozenge).

OBSERVED after the fixes, sweeping every element ≥28px on all three record surfaces:

```
ledger     bigFigures []  nakedZeros []  "0 / n" false  streak false
readiness  bigFigures []  nakedZeros []  "0 / n" false  streak TRUE
standard   bigFigures []  nakedZeros []  "0 / n" false  streak false
```

**Open, and outside this session's file boundary:** Readiness still renders the word *"streak"*. It
is not UI text — it is the server's own condition detail from `server/lib/readiness.mjs`:

> `"0 of 3 distinct company modes represented in that streak."`

The companion principle is "no streaks anywhere", and `Readiness.tsx`'s own header comment says "It
is a technical gate, not a streak" — so the backend string contradicts the design. It was left
alone rather than paraphrased in the view, because a surface that rewrites the wording it is handed
stops being an audit of what the server said. **Needs a one-word change in `server/lib/readiness.mjs`
by whoever owns that lane** — e.g. "…represented in that run of hires."

## 8 · Standing checks, all re-run after the last edit

- `cd ui-src && npx tsc --noEmit` — **zero errors.**
- `node tools/build-ui.mjs` — succeeds.
- The word "Loading" in `ui-src/src` — **zero matches.**
- External hosts in built `ui/index.html` — **none**; only `/assets/…` and local `ui/fonts/*.woff2`.
  The app runs with the network off.
- `--gold-` — swept again: lit seam, satisfied rung, passed drill/score, zero-line curveball,
  met readiness condition, `.panel.earned`, `Lozenge` "earned" tone. No chrome, link or border use.

## 9 · Files changed this session, and why

| File | Why |
| --- | --- |
| `ui-src/src/components/Lozenge.tsx` | Removed the bare `layout` prop — the stuck layout projection that made every surface after a Phase 4 injection unreachable. Reasoning documented in the file. |
| `ui-src/src/views/Extension.tsx` | Restored Phase 4's clock from a leftover `// BISECT` line that had disabled it. |
| `ui-src/src/views/SkeletonSprint.tsx` | Added the duplicate-path guard to `addFile`, so a repeated filename switches tabs instead of colliding two tabs onto one CodeMirror instance. |
| `ui-src/src/views/Ledger.tsx` | No naked zeros: suppress an all-zero claim figure block, and the invalid-runs zero. |
| `ui-src/src/views/Readiness.tsx` | No naked zeros: the three conditions read "none of N" until there is something to count. |
| `docs/verify/fe-surfaces.md` | This log. |

## 10 · Not verified, stated plainly

- **No drill was graded end to end.** Every `POST /api/grade/*` path is unexercised here: the result
  panels, `RubricReview`, and `useZoneOverride(result ? 'lobby' : null)` (results are Lobby) were
  read but never OBSERVED. Grading `parking-lot` needs the GIVEN `contract/` types that
  `GET /api/problems/:id/drill` still does not serve (gap 1 above), and Phase 5's rubric needs a
  Claude call. **This is the largest remaining gap on phases 3–7.**
- **Phase 7 rounds were only seen as the mode picker.** No sealed problem exists, so
  `POST /api/round/start` still 404s without a `problemId` (gap 4 above); the practice-round path and
  the round result surface are unobserved.
- `Concurrency` (Phase 6) and `Defence` (Phase 5) were observed to render, accept input and be
  leaveable, but their stress/deadlock and citation-checking result surfaces were not reached.
- The `Learn` faded exercise was observed to render its 4 gap inputs under reduced motion; grading a
  faded exercise was not exercised.
- The 600 ms drain figure was confirmed as a real ramp by its intermediate values, not measured
  precisely against a stopwatch.
