# Interface Copy Standard — enforcement and sweep

Against `docs/specs/2026-08-18-interface-copy.md`. Written as the work proceeds so a replacement
agent resumes rather than restarts.

## Part 1 — the linter (done)

`node tools/lint-prose.mjs --ui`

- `server/lib/prose.mjs` gained `extractUiStrings(source, {kind})`, `checkUiString(item)` and
  `lintUiCopy(source, {path, kind})`. The lesson-prose functions are untouched.
- Extraction is **position-based**, not filter-based: a string is only collected if where it sits
  says it is copy — JSX text children, the five copy-bearing attributes (`title`, `placeholder`,
  `aria-label`, `alt`, `label`), string/template literals inside a JSX children expression, and
  literals assigned to a copy-named property (`name`, `note`, `label`, `hint`, `reason`, …).
  `className`, `style`, `key`, `id`, `data-*` and every other attribute are skipped because they
  are not on the whitelist. Template holes become `…`.
- `kind: 'server'` collects prose-shaped literals (two or more words, a real lowercase word, not a
  mime type / CSS / path / SQL) from the designated server modules plus `server/lib/routes/**` and
  `server/index.mjs`, which is where the HTTP error bodies are actually written.
- 39 unit tests in `server/test/prose-ui.test.mjs`, TDD, extraction first.

### Rules

| rule | severity | notes |
|---|---|---|
| `exclamation` | error | `!` anywhere, except `!=` |
| `emoji` | error | |
| `ui-banned` | error | cheerful assistant, gamification, empty states that say nothing, the interface talking about itself, first person plural, `Loading` |
| `internals` | error | endpoint paths, source file names, file paths, shell commands, HTTP status names, stack frames |
| `title-case` | error | label-shaped strings only |
| `label-full-stop` | error | text inside `<button>`, or a `label`/`aria-label` value |
| `tooltip-restates-label` | error | `title` equal to the element's own text |
| `ui-vague` | warning | `manage`, `explore`, `discover`, `view`, `handle`, `process`, `submit`, `continue`, and uncounted `items`/`content`/`data`/`results` |

Vagueness is a warning because the spec itself hands it to the sweep, not the linter.

### Rules that were wrong, not the strings

Three rules fired on copy that was correct. In every case the rule was narrowed and the string
kept — recorded here because the spec asks for it explicitly.

1. **`points`.** A bare `\bpoints\b` flagged six strings, all right: "covered 3 of 7 points in the
   decision log" (Phase 5), "a trend needs at least three data points" (ledger), "Variation points
   sit behind interfaces" (a STANDARD v1.0 anchor, verbatim and not editable), "These are the
   points a strong candidate makes". This app uses `points` almost exclusively to mean *items in an
   argument*. Narrowed to a scoring tally (`points: 340`) or a scoring verb (`earned 20 points`).
   A count alone is not enough — `7 points in the decision log` is a count and is correct.
2. **Title Case.** Flagged `Phase 1 · Extract requirements · 10 min` (`Extract` heads its own
   `·`-separated label), `Over budget. Level 1 as it stands.` (a sentence, not a label),
   ``use `public record Money(...)` `` (a markdown code span is code), `the Strategy seam` (a GoF
   pattern name), `Back to Learn` (a place in the sidebar). Now: code spans stripped, `·|—|/|:`
   split into separate labels, any string containing sentence punctuation skipped, and a
   proper-noun list covering products, pattern names, Java types and this app's own surfaces.
3. **Generic arrow functions.** Not a copy rule but a parser bug with the same shape: `const call =
   async <T>(path) => …` in `api.ts` was parsed as an element named `T` whose children were the
   rest of the file, so every `export interface Foo` was reported as interface copy — 6 findings,
   all phantom. `<T>(`, `<T,` and `<T extends …>` are now recognised as type parameters.

`server/lib/rubric.mjs` is scanned but never edited: its twenty anchors are transcribed verbatim
from the standard and are the audit surface.

## Before and after

`node tools/lint-prose.mjs --ui`

- before: 60 files, **10 errors, 8 warnings** — `docs/verify/ui-copy-before.txt`
- after: 60 files, **0 errors, 0 warnings** — `docs/verify/ui-copy-after.txt`

`node tools/lint-prose.mjs` (lessons) stays at 0/0. `node --test`: 442 tests, 0 failures.
`npx tsc --noEmit`: 0 errors. `node tools/build-ui.mjs`: succeeds.

### A fourth rule that was wrong

`internals` fired nine times on `logError(...)` arguments — that is, on the log half of the fix
itself. Extraction now skips literals inside `logError`, `console.*` and `new Error(...)`. The last
of those is safe *because* of `userError()`: since thrown messages are translated at the HTTP
boundary, no `Error` message written in these modules reaches a browser. Two unit tests cover it.

## Part 2 — the three named defects

| # | before | after (user) | log | code |
|---|---|---|---|---|
| 1 | `no curveball baseline recorded for this attempt — call /api/curveball/inject first` | *The requirement change has not been delivered yet, so there is nothing to measure the diff against. Start the extension from the problem page.* | `POST /api/grade/extension: no baseline for attempt 41 — /api/curveball/inject was not called first` | `curveball-not-injected` |
| 2 | `ui not built — run: node tools/build-ui.mjs` | *The interface has not been built yet, so there is nothing to serve here. Build it and reload.* | `GET /: no index.html under <uiDir> — run: node tools/build-ui.mjs` | `ui-not-built` |
| 3 | `unknown problem: nope` | *That problem is not in the corpus. The ladder lists the ones that are.* | `GET /api/problems/nope/drill: <stack>` | `unknown-problem` |

Defect 3 is fixed at the boundary, not at the throw site: `corpus.mjs` keeps its own message
(`corpus.test.mjs` matches `/unknown problem/i` on it, and it is the right log line), and
`server/lib/http.mjs` now owns one table that drives both `statusForError` and the new
`userError`. Because both read the same table in the same order, a status can never disagree with
the sentence beside it. `sealed` → 403 and `unknown problem` → 404 are unchanged, and the sealed
user message still contains the word `sealed`, which `api.test.mjs` asserts on.

`fail(res, status, error, { code })` in `routes/_shared.mjs` gained the optional `code`, so the UI
switches on a machine value and never on prose.

## Part 3 — the sweep

Every string in `ui-src/src/**` and the designated server modules was read in place against the
six-point checklist.

### Strings changed per surface

| surface | changed |
|---|---|
| `views/Round.tsx` | 5 |
| `views/Learn.tsx` | 6 (one is the naked-zero block) |
| `views/CoreBuild.tsx` | 3 |
| `views/Extension.tsx` | 3 |
| `views/ExtractDrill.tsx` | 3 |
| `components/RubricReview.tsx` | 3 |
| `views/SkeletonSprint.tsx` | 2 |
| `views/Defence.tsx` | 2 |
| `views/Concurrency.tsx` | 2 |
| `components/AcceptanceList.tsx` | 2 |
| `components/FadedExercise.tsx` | 2 |
| `api.ts`, `App.tsx`, `views/Ladder.tsx`, `views/Brief.tsx`, `views/VocabDrill.tsx`, `components/CurveballSheet.tsx` | 1 each |
| `views/Ledger.tsx`, `views/Readiness.tsx`, `views/Standard.tsx`, and 11 other components | 0 — read and left alone |
| `server/index.mjs` | 3 |
| `server/lib/http.mjs` | 5 new user sentences |
| `server/lib/routes/{grade,curveball,round,lessons,meta}.mjs` | 2 / 2 / 3 / 3 / 1 |
| `server/lib/grade-phase{1,2,3,4,5,6}.mjs` | 1 / 1 / 1 / 1 / 4 / 2 |
| `server/lib/{ledger,readiness}.mjs` | 2 / 1 |

39 in `ui-src/**`, 32 in `server/**`.

### Empty states

Every empty state now names a next action. New ones found beyond the two already fixed:

- **`Learn` → `TrackBlock`: a third naked zero.** `demonstrated` and `held` rendered as 34px
  numerals straight from the API, so a fresh install showed a `0` under "demonstrated of 34" — the
  exact figure the Ledger and Readiness had already been fixed for. Now `none of 34 yet` in prose
  until there is something to count, with `headline` and `nextStep` unchanged beside it. A zero in
  `held` next to a real `demonstrated` is kept: that one is information.
- `Extension`: "no curveballs authored" now says where to see which problems have one.
- `Learn`: the worked, retrieval and contrast empty states each name what to do instead.
- `Ladder`: the empty corpus panel keeps its action and loses the file path.
- `ExtractDrill`: "Nothing matched yet." → "Nothing matched — everything is under Missed."

### INVALID copy

Checked at all seven places it surfaces. `CoreBuild`, `Extension` and `RubricReview` said only
*Nothing was scored.*; they now carry the standard's own second clause, *This is not a result, and
nothing was recorded.* `grade-phase2` said only `did not compile`. `grade-phase4` and
`grade-phase6` said nothing was scored but not that nothing was recorded. `Defence` and
`Concurrency` were already right and were left alone.

### Controls naming their consequence

- `Start a sealed round` → `Start a sealed round · 50 min` (per mode; it starts that clock)
- `Practice round` → `Practice round · 50 min`
- `Run the stress suite` → `Run the stress suite · 10 repetitions`
- `Submit` ×4 → `Grade the skeleton` / `Grade the build` / `Grade the extension` / `Grade the round`
- `Submit for grading` → `Grade the extraction`; `Submit for review` → `Grade the answers`
- `Check` → `Check the shape`; `Check it` → `Check the gaps`
- `Requirement change` → `Read the requirement change`
- `Phase 6 · Concurrency` → `Phase 6 · Concurrency · no clock`. Deliberate: Phase 6 has no
  `ClockRing`, so every sibling label naming a duration made this one look like an omission.

### Module names in the UI

`RubricReview` rendered `measured by <code>{det.source}</code>`, and `det.source` is the grader
module's own name (`grade-phase3`). `grade-phase7.test.mjs` asserts on that value, and it is right
that the body carries provenance — so the server contract is unchanged and the *view* now maps it:
"Measured by the compiler and the hidden base suite". Same pattern as `code` on an error body.

### Left alone on purpose

- The twenty anchors in `rubric.mjs` and the `Standard` view that renders them. `Standard` prints
  `d.anchors[level]` and `d.note` verbatim from `GET /api/standard`; a view that paraphrases what
  the server handed it stops being an audit.
- `ParkingLot.java`, `Main.java`, `contract/`, `File.java:N`, `main(String[] args)`, `javac`
  diagnostics in the editor gutter. These name files and messages he is actually looking at — the
  companion spec calls recognising compiler messages half of what fluency means.
- "the API surface" in `Round` and `Brief`. That is the public API surface of his own Java design,
  not an endpoint.
- "classpath" in `Concurrency`. A Java concept he knows, not a path.
- `T1`/`T2`/`T3` in the header, and `D1`…`D5`. The app's own documented vocabulary.

### One judgement call worth flagging

`grade-phase5.test.mjs` asserts that `feedback` contains the judge's failure reason
(`/timed out/`, `/level/i`). That test is outside this lane's file boundary, and the underlying
requirement is defensible: *why* the judge was unavailable is the difference between running it
again and the quota being gone. So instead of dropping the cause, `causeText()` sanitizes it —
first line only, URLs and anything path-shaped removed, capped at 90 characters — and the whole
error still goes to the log. The same shape would be worth applying wherever a provider error is
surfaced.
