# LLD Dojo — M1 Implementation Plan (Climbable Slice)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship a locally-runnable LLD Dojo that Pratyush can climb Phases 0–2 on within a day or two — vocabulary reps, requirement extraction, and 12-minute skeleton sprints against six problems, with real Java compilation.

**Architecture:** A zero-dependency Node server (`node:http` + `node:sqlite`) serves a prebuilt React UI and a small JSON API. Java compilation runs against a portable Temurin JDK 21 fetched on first run into `.toolchain/`. Grading is deterministic (compile + structural checks) except Phase 1's free-text scoring, which calls `claude -p` headlessly with an Ollama fallback and a keyword-overlap last resort. No git: versioning is timestamped snapshots.

**Tech Stack:** Node 25 (`node:http`, `node:sqlite`, `node:test`, `node:child_process`), React 19 + Vite (built ahead of time, shipped as static files), `motion` for spring physics and shared-element transitions, CodeMirror 6, bundled woff2 fonts, Temurin JDK 21, `junit-platform-console-standalone.jar` (fetched in M1, used from M2).

**Specs:** `docs/specs/2026-08-18-lld-dojo-design.md` and `docs/specs/2026-08-18-design-system.md`

**Front-end bar:** the user's explicit instruction is that this must feel like *"a very expensive,
well thought out product, not something coded in a basement on a wednesday."* Task 12 implements the
design system's foundations and Task 14 Step 0 implements its flagship trick. Neither is optional
polish — the animation exists to serve the anti-fear mandate, which is why the tool exists at all.

---

## Conventions for this plan

**No git.** Every task ends with a snapshot instead of a commit:

```bash
node tools/snapshot.mjs "task-N-short-name"
```

**Tests** use Node's built-in runner — zero dependencies, consistent with the architecture:

```bash
node --test
```

**Use bare `node --test` (default discovery). Do NOT pass the directory.** Verified on this machine's
Node v25.8.2: `node --test server/test/` *and* `node --test server/test` both fail with
`Cannot find module '...\server\test'` — the path is resolved as a module rather than recursed into.
Reproduces in both Git Bash and PowerShell. Bare `node --test` discovers every `*.test.mjs` correctly.
A single file may still be named explicitly: `node --test server/test/paths.test.mjs`.

**Working directory** is always `C:\Users\PratyushKumar\PycharmProjects\lld-trainer` (Windows) or the
unzipped `lld-trainer/` (macOS). All paths below are relative to it.

**ESM everywhere.** `.mjs` for server and tools. No `package.json` is required for the server, and
none is added — that is the point of zero dependencies. `ui-src/` has its own `package.json` because
Vite needs one, but it is a **build-time-only** concern and never shipped.

---

## File structure

| File | Responsibility |
|---|---|
| `run.ps1` | Windows launcher: bootstrap, then start server, then open browser |
| `run.sh` | macOS launcher: same |
| `server/index.mjs` | HTTP server, routing, static file serving. No business logic |
| `server/lib/paths.mjs` | Platform detection, toolchain paths and download URLs. Pure functions |
| `server/lib/db.mjs` | Schema creation and all SQL. The only file that touches `node:sqlite` |
| `server/lib/corpus.mjs` | Load and validate problems; **enforce the sealed set** |
| `server/lib/java.mjs` | Invoke `javac`; parse diagnostics into structured objects |
| `server/lib/vocab.mjs` | Phase 0 constructs and their structural checks |
| `server/lib/grader-llm.mjs` | `claude -p` / `ollama` invocation. Injectable runner for tests |
| `server/lib/grade-phase1.mjs` | Requirement-extraction scoring: LLM path + deterministic fallback |
| `server/lib/routes/*.mjs` | One file per resource: `problems`, `attempts`, `compile`, `grade` |
| `tools/bootstrap.mjs` | Fetch JDK, JUnit jar, portable Node if needed |
| `tools/snapshot.mjs` | Timestamped copy of `corpus/` + `data/` — the git stand-in |
| `tools/build-ui.mjs` | Runs Vite build from `ui-src/` into `ui/` |
| `ui-src/src/tokens.css` | Palette, type, depth, shared primitives. Single source of colour truth |
| `ui-src/src/motion.ts` | Spring/ease/duration/stagger tables. **No ad-hoc durations anywhere else** |
| `ui-src/src/MotionProvider.tsx` | Reduced-motion gate + `AmbientDepth` |
| `ui-src/src/grain.ts` | The noise data URI, generated once |
| `ui-src/src/App.tsx` | Routing and sidebar only |
| `ui-src/src/components/Shell.tsx` | Header, sidebar, breadcrumbs, route crossfade |
| `ui-src/src/components/Expand.tsx` | The spoiler gate — height spring + chevron |
| `ui-src/src/components/Lozenge.tsx` | Status pill that animates its own tone changes |
| `ui-src/src/components/Shimmer.tsx` | Loading skeletons. The word "Loading" appears nowhere |
| `ui-src/src/components/ClockRing.tsx` | Trick 2 — draining ring that recedes while you type |
| `ui-src/src/components/SeamLantern.tsx` | **Trick 1, the flagship** — seams light live as you type |
| `ui-src/src/components/Editor.tsx` | CodeMirror 6, multi-file, dropping diagnostics, success sheen |
| `ui-src/src/views/Ladder.tsx` | Card grid; each card morphs into its brief |
| `ui-src/src/views/Brief.tsx` | Problem brief; receives the shared-element morph |
| `ui-src/src/views/VocabDrill.tsx` | Phase 0 |
| `ui-src/src/views/ExtractDrill.tsx` | Phase 1 — carries Trick 4, chip flight |
| `ui-src/src/views/SkeletonSprint.tsx` | Phase 2 — editor + Seam Lantern + clock |
| `ui-src/public/fonts/*.woff2` | Bundled faces. No CDN, because there is no network at runtime |
| `corpus/<slug>/problem.json` | One problem's Phase 0–2 content |

**Deliberately deferred to M2+:** `tests_base/`, curveballs, the five-dimension code reviewer,
inline-comment review UI, reflection-based Phase 0 probes, console panel, keystroke autosave,
external-IDE `fs.watch` path. From the design system: the command palette, the sound pack, and
`Numeral` count-ups all land in M5–M6, because they are the least load-bearing and M1 must stay
climbable within a day or two.

---

### Task 1: Project skeleton and the snapshot tool

Snapshots replace git, so this comes first — nothing else is safe to build without it.

**Files:**
- Create: `tools/snapshot.mjs`
- Create: `server/test/snapshot.test.mjs`
- Create: `.gitignore` — **not** for git, but so PyCharm stops indexing `.toolchain/`

- [ ] **Step 1: Create the directory skeleton**

```bash
mkdir -p server/lib/routes server/test tools ui-src/src/components ui-src/src/views corpus data workspace snapshots .toolchain
```

- [ ] **Step 2: Write the failing test**

Create `server/test/snapshot.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { makeSnapshot } from '../../tools/snapshot.mjs';

test('makeSnapshot copies sources into a timestamped label directory', () => {
  const root = mkdtempSync(join(tmpdir(), 'snap-'));
  mkdirSync(join(root, 'corpus', 'parking-lot'), { recursive: true });
  writeFileSync(join(root, 'corpus', 'parking-lot', 'problem.json'), '{"id":"parking-lot"}');

  const dest = makeSnapshot({ root, label: 'task-1', now: new Date('2026-08-18T10:30:00Z') });

  assert.match(dest, /20260818-1030-task-1$/);
  const copied = join(dest, 'corpus', 'parking-lot', 'problem.json');
  assert.equal(readFileSync(copied, 'utf8'), '{"id":"parking-lot"}');
});

test('makeSnapshot tolerates a missing source directory', () => {
  const root = mkdtempSync(join(tmpdir(), 'snap-'));
  const dest = makeSnapshot({ root, label: 'empty', now: new Date('2026-08-18T10:30:00Z') });
  assert.equal(readdirSync(dest).length, 0);
});
```

- [ ] **Step 3: Run the test to verify it fails**

Run: `node --test server/test/snapshot.test.mjs`
Expected: FAIL — `Cannot find module '.../tools/snapshot.mjs'`

- [ ] **Step 4: Implement `tools/snapshot.mjs`**

```javascript
import { cpSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const SOURCES = ['corpus', 'data'];

function stamp(now) {
  const p = (n) => String(n).padStart(2, '0');
  return `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` +
         `-${p(now.getUTCHours())}${p(now.getUTCMinutes())}`;
}

export function makeSnapshot({ root, label, now = new Date() }) {
  const safeLabel = label.replace(/[^a-zA-Z0-9._-]/g, '-');
  const dest = join(root, 'snapshots', `${stamp(now)}-${safeLabel}`);
  mkdirSync(dest, { recursive: true });
  for (const src of SOURCES) {
    const from = join(root, src);
    if (existsSync(from)) cpSync(from, join(dest, src), { recursive: true });
  }
  return dest;
}

const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
  const label = process.argv[2] ?? 'manual';
  console.log(`snapshot -> ${makeSnapshot({ root, label })}`);
}
```

- [ ] **Step 5: Run the test to verify it passes**

Run: `node --test server/test/snapshot.test.mjs`
Expected: PASS — 2 tests

- [ ] **Step 6: Write `.gitignore` so PyCharm skips heavy directories**

Create `.gitignore` (used as an IDE exclusion hint only; this project has no git):

```
.toolchain/
snapshots/
workspace/
data/
ui/
ui-src/node_modules/
```

- [ ] **Step 7: Snapshot**

```bash
node tools/snapshot.mjs "task-1-skeleton"
```

---

### Task 2: Platform detection and toolchain URLs

Pure functions, so this is the cheapest place to get the cross-platform decision right — and getting
it wrong is how the Mac zip silently breaks.

**Files:**
- Create: `server/lib/paths.mjs`
- Create: `server/test/paths.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/paths.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { platformKey, jdkUrl, javacPath, JUNIT_URL } from '../lib/paths.mjs';

test('platformKey maps Node platform+arch to Temurin identifiers', () => {
  assert.equal(platformKey('win32', 'x64'), 'windows-x64');
  assert.equal(platformKey('darwin', 'arm64'), 'mac-aarch64');
  assert.equal(platformKey('darwin', 'x64'), 'mac-x64');
  assert.equal(platformKey('linux', 'x64'), 'linux-x64');
});

test('platformKey rejects combinations we do not support', () => {
  assert.throws(() => platformKey('win32', 'arm64'), /unsupported/i);
});

test('jdkUrl targets JDK 21 for the given platform', () => {
  const url = jdkUrl('mac-aarch64');
  assert.match(url, /^https:\/\/api\.adoptium\.net\//);
  assert.match(url, /\/21\//);
  assert.match(url, /os=mac/);
  assert.match(url, /architecture=aarch64/);
});

test('javacPath knows the per-platform executable name', () => {
  assert.equal(javacPath('/tc/jdk', 'win32'), '/tc/jdk/bin/javac.exe');
  assert.equal(javacPath('/tc/jdk', 'darwin'), '/tc/jdk/bin/javac');
});

test('JUNIT_URL points at the standalone console launcher', () => {
  assert.match(JUNIT_URL, /junit-platform-console-standalone.*\.jar$/);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/paths.test.mjs`
Expected: FAIL — `Cannot find module '../lib/paths.mjs'`

- [ ] **Step 3: Implement `server/lib/paths.mjs`**

```javascript
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const TOOLCHAIN = join(ROOT, '.toolchain');
export const JDK_DIR = join(TOOLCHAIN, 'jdk-21');
export const JUNIT_JAR = join(TOOLCHAIN, 'junit-platform-console-standalone.jar');
export const WORKSPACE = join(ROOT, 'workspace');
export const CORPUS = join(ROOT, 'corpus');
export const DB_FILE = join(ROOT, 'data', 'dojo.db');
export const UI_DIR = join(ROOT, 'ui');

export const JUNIT_URL =
  'https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.11.4/junit-platform-console-standalone-1.11.4.jar';

const SUPPORTED = {
  'win32:x64': 'windows-x64',
  'darwin:arm64': 'mac-aarch64',
  'darwin:x64': 'mac-x64',
  'linux:x64': 'linux-x64',
};

export function platformKey(platform = process.platform, arch = process.arch) {
  const key = SUPPORTED[`${platform}:${arch}`];
  if (!key) throw new Error(`unsupported platform: ${platform}/${arch}`);
  return key;
}

export function jdkUrl(key) {
  const [os, rawArch] = key.split('-');
  const architecture = rawArch === 'x64' ? 'x64' : rawArch;
  return `https://api.adoptium.net/v3/binary/latest/21/ga/${os}/${architecture}` +
         `/jdk/hotspot/normal/eclipse?project=jdk&os=${os}&architecture=${architecture}`;
}

export function javacPath(jdkDir, platform = process.platform) {
  return join(jdkDir, 'bin', platform === 'win32' ? 'javac.exe' : 'javac').replace(/\\/g, '/');
}

export function javaPath(jdkDir, platform = process.platform) {
  return join(jdkDir, 'bin', platform === 'win32' ? 'java.exe' : 'java').replace(/\\/g, '/');
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `node --test server/test/paths.test.mjs`
Expected: PASS — 5 tests

- [ ] **Step 5: Snapshot**

```bash
node tools/snapshot.mjs "task-2-paths"
```

---

### Task 3: Toolchain bootstrap

Downloads and unpacks the JDK. Not unit-tested against the network — instead it is **idempotent and
verifiable**, and Step 4 runs it for real, which is the honest test.

**Files:**
- Create: `tools/bootstrap.mjs`

- [ ] **Step 1: Implement `tools/bootstrap.mjs`**

```javascript
import { existsSync, mkdirSync, createWriteStream, readdirSync, renameSync, rmSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { platformKey, jdkUrl, javacPath, JUNIT_URL, JDK_DIR, JUNIT_JAR, TOOLCHAIN } from '../server/lib/paths.mjs';

async function download(url, dest) {
  const res = await fetch(url, { redirect: 'follow' });
  if (!res.ok) throw new Error(`download failed ${res.status}: ${url}`);
  await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
}

function systemJavac() {
  try {
    execFileSync(process.platform === 'win32' ? 'javac.exe' : 'javac', ['-version'], { stdio: 'ignore' });
    return process.platform === 'win32' ? 'javac.exe' : 'javac';
  } catch {
    return null;
  }
}

// Temurin archives unpack to a single versioned directory; flatten it to JDK_DIR.
function flatten(tmpDir, target) {
  const entries = readdirSync(tmpDir).filter((e) => !e.startsWith('.'));
  const inner = entries.length === 1 ? join(tmpDir, entries[0]) : tmpDir;
  // macOS bundles nest the JDK under Contents/Home.
  const home = existsSync(join(inner, 'Contents', 'Home')) ? join(inner, 'Contents', 'Home') : inner;
  renameSync(home, target);
}

async function ensureJdk() {
  if (existsSync(javacPath(JDK_DIR))) return { source: 'toolchain', javac: javacPath(JDK_DIR) };

  const sys = systemJavac();
  if (sys) return { source: 'system', javac: sys };

  const key = platformKey();
  const isZip = key.startsWith('windows');
  const archive = join(TOOLCHAIN, isZip ? 'jdk.zip' : 'jdk.tar.gz');
  const tmp = join(TOOLCHAIN, 'jdk-tmp');

  console.log(`Fetching Temurin JDK 21 for ${key} (~180 MB, one time)...`);
  await download(jdkUrl(key), archive);

  mkdirSync(tmp, { recursive: true });
  if (isZip) {
    execFileSync('powershell.exe', ['-NoProfile', '-Command',
      `Expand-Archive -Path '${archive}' -DestinationPath '${tmp}' -Force`], { stdio: 'inherit' });
  } else {
    execFileSync('tar', ['-xzf', archive, '-C', tmp], { stdio: 'inherit' });
  }
  flatten(tmp, JDK_DIR);
  rmSync(tmp, { recursive: true, force: true });
  rmSync(archive, { force: true });

  if (process.platform !== 'win32') {
    for (const bin of ['javac', 'java']) chmodSync(join(JDK_DIR, 'bin', bin), 0o755);
  }
  return { source: 'downloaded', javac: javacPath(JDK_DIR) };
}

async function ensureJunit() {
  if (existsSync(JUNIT_JAR)) return JUNIT_JAR;
  console.log('Fetching junit-platform-console-standalone (~2.5 MB, one time)...');
  await download(JUNIT_URL, JUNIT_JAR);
  return JUNIT_JAR;
}

export async function bootstrap() {
  mkdirSync(TOOLCHAIN, { recursive: true });
  mkdirSync(join(TOOLCHAIN, '..', 'data'), { recursive: true });
  const jdk = await ensureJdk();
  await ensureJunit();
  console.log(`javac: ${jdk.javac} (${jdk.source})`);
  return jdk;
}

if (process.argv[1]?.endsWith('bootstrap.mjs')) {
  bootstrap().catch((e) => { console.error(e.message); process.exit(1); });
}
```

- [ ] **Step 2: Run it for real**

Run: `node tools/bootstrap.mjs`
Expected: downloads the JDK and JUnit jar, then prints `javac: .../jdk-21/bin/javac.exe (downloaded)`

- [ ] **Step 3: Verify idempotence**

Run: `node tools/bootstrap.mjs`
Expected: no downloads, prints `(toolchain)` immediately

- [ ] **Step 4: Verify the JDK actually compiles**

```bash
.toolchain/jdk-21/bin/javac.exe -version
```

Expected: `javac 21.x.x`

- [ ] **Step 5: Snapshot**

```bash
node tools/snapshot.mjs "task-3-bootstrap"
```

---

### Task 4: Database schema

The only file touching `node:sqlite`. M1 uses a subset of the spec's model; unused tables are still
created so later milestones add no migration.

**Files:**
- Create: `server/lib/db.mjs`
- Create: `server/test/db.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/db.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../lib/db.mjs';

test('openDb creates the schema and round-trips an attempt', () => {
  const db = openDb(':memory:');
  const id = db.startAttempt({ problemId: 'parking-lot', phase: 2, mode: 'practice' });
  assert.ok(id > 0);

  db.finishAttempt({ id, verdict: 'pass', firstCompileAt: 41_000 });
  const a = db.getAttempt(id);
  assert.equal(a.problem_id, 'parking-lot');
  assert.equal(a.phase, 2);
  assert.equal(a.verdict, 'pass');
  assert.equal(a.first_compile_at, 41_000);
});

test('saveFiles replaces the stored submission for an attempt', () => {
  const db = openDb(':memory:');
  const id = db.startAttempt({ problemId: 'elevator', phase: 2, mode: 'practice' });

  db.saveFiles(id, [{ path: 'Main.java', content: 'class Main {}' }]);
  db.saveFiles(id, [{ path: 'Main.java', content: 'class Main { }' }, { path: 'Lift.java', content: 'class Lift {}' }]);

  const files = db.getFiles(id);
  assert.equal(files.length, 2);
  assert.equal(files.find((f) => f.path === 'Main.java').content, 'class Main { }');
});

test('rung progress records satisfaction per phase and problem', () => {
  const db = openDb(':memory:');
  assert.equal(db.rungCount(2), 0);
  db.satisfyRung(2, 'parking-lot');
  db.satisfyRung(2, 'parking-lot');   // idempotent
  db.satisfyRung(2, 'elevator');
  assert.equal(db.rungCount(2), 2);
});

test('failure tags accumulate per attempt', () => {
  const db = openDb(':memory:');
  const id = db.startAttempt({ problemId: 'elevator', phase: 2, mode: 'practice' });
  db.addFailureTags(id, ['god-class', 'ran-out-of-clock']);
  assert.deepEqual(db.getFailureTags(id).sort(), ['god-class', 'ran-out-of-clock']);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/db.test.mjs`
Expected: FAIL — `Cannot find module '../lib/db.mjs'`

- [ ] **Step 3: Implement `server/lib/db.mjs`**

```javascript
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';

const SCHEMA = `
CREATE TABLE IF NOT EXISTS attempts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  problem_id TEXT NOT NULL,
  phase INTEGER NOT NULL,
  mode TEXT NOT NULL,
  started_at INTEGER NOT NULL,
  ended_at INTEGER,
  verdict TEXT,
  first_compile_at INTEGER,
  first_green_at INTEGER,
  tier_used TEXT
);
CREATE TABLE IF NOT EXISTS files (
  attempt_id INTEGER NOT NULL, path TEXT NOT NULL, content TEXT NOT NULL,
  PRIMARY KEY (attempt_id, path)
);
CREATE TABLE IF NOT EXISTS grades (
  attempt_id INTEGER NOT NULL, dimension TEXT NOT NULL,
  score REAL NOT NULL, rationale TEXT, graded_by TEXT,
  PRIMARY KEY (attempt_id, dimension)
);
CREATE TABLE IF NOT EXISTS determinism (
  attempt_id INTEGER PRIMARY KEY, compiled INTEGER, base_pass INTEGER, base_total INTEGER,
  curveball_pass INTEGER, diff_lines INTEGER, diff_budget INTEGER, stress_survived INTEGER
);
CREATE TABLE IF NOT EXISTS failure_tags (
  attempt_id INTEGER NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (attempt_id, tag)
);
CREATE TABLE IF NOT EXISTS unlocks (
  problem_id TEXT NOT NULL, tier INTEGER NOT NULL, unlocked_at INTEGER NOT NULL,
  PRIMARY KEY (problem_id, tier)
);
CREATE TABLE IF NOT EXISTS rungs (
  phase INTEGER NOT NULL, problem_id TEXT NOT NULL, satisfied_at INTEGER NOT NULL,
  PRIMARY KEY (phase, problem_id)
);
CREATE TABLE IF NOT EXISTS reviews_used (day TEXT PRIMARY KEY, count INTEGER NOT NULL);
`;

export function openDb(file) {
  if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
  const db = new DatabaseSync(file);
  db.exec('PRAGMA journal_mode = WAL;');
  db.exec(SCHEMA);

  const now = () => Date.now();

  return {
    raw: db,

    startAttempt({ problemId, phase, mode }) {
      db.prepare(`INSERT INTO attempts (problem_id, phase, mode, started_at)
                  VALUES (?, ?, ?, ?)`).run(problemId, phase, mode, now());
      return db.prepare('SELECT last_insert_rowid() AS id').get().id;
    },

    finishAttempt({ id, verdict, firstCompileAt = null, firstGreenAt = null, tierUsed = null }) {
      db.prepare(`UPDATE attempts SET ended_at = ?, verdict = ?, first_compile_at = ?,
                  first_green_at = ?, tier_used = ? WHERE id = ?`)
        .run(now(), verdict, firstCompileAt, firstGreenAt, tierUsed, id);
    },

    getAttempt(id) { return db.prepare('SELECT * FROM attempts WHERE id = ?').get(id); },

    saveFiles(attemptId, files) {
      db.prepare('DELETE FROM files WHERE attempt_id = ?').run(attemptId);
      const ins = db.prepare('INSERT INTO files (attempt_id, path, content) VALUES (?, ?, ?)');
      for (const f of files) ins.run(attemptId, f.path, f.content);
    },

    getFiles(attemptId) {
      return db.prepare('SELECT path, content FROM files WHERE attempt_id = ? ORDER BY path').all(attemptId);
    },

    satisfyRung(phase, problemId) {
      db.prepare(`INSERT INTO rungs (phase, problem_id, satisfied_at) VALUES (?, ?, ?)
                  ON CONFLICT (phase, problem_id) DO NOTHING`).run(phase, problemId, now());
    },

    rungCount(phase) {
      return db.prepare('SELECT COUNT(*) AS n FROM rungs WHERE phase = ?').get(phase).n;
    },

    addFailureTags(attemptId, tags) {
      const ins = db.prepare(`INSERT INTO failure_tags (attempt_id, tag) VALUES (?, ?)
                              ON CONFLICT DO NOTHING`);
      for (const t of tags) ins.run(attemptId, t);
    },

    getFailureTags(attemptId) {
      return db.prepare('SELECT tag FROM failure_tags WHERE attempt_id = ?').all(attemptId).map((r) => r.tag);
    },

    recentAttempts(limit = 50) {
      return db.prepare('SELECT * FROM attempts ORDER BY id DESC LIMIT ?').all(limit);
    },

    bumpReviewsUsed(day) {
      db.prepare(`INSERT INTO reviews_used (day, count) VALUES (?, 1)
                  ON CONFLICT (day) DO UPDATE SET count = count + 1`).run(day);
      return db.prepare('SELECT count FROM reviews_used WHERE day = ?').get(day).count;
    },
  };
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `node --test server/test/db.test.mjs`
Expected: PASS — 4 tests

- [ ] **Step 5: Snapshot**

```bash
node tools/snapshot.mjs "task-4-db"
```

---

### Task 5: Corpus loader with sealed enforcement

The spec requires sealed problems to be unreachable **by construction**, not by discipline. That is
this task's real job.

**Files:**
- Create: `server/lib/corpus.mjs`
- Create: `server/test/corpus.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/corpus.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadCorpus, validateProblem } from '../lib/corpus.mjs';

function writeProblem(root, id, extra = {}) {
  const dir = join(root, id);
  mkdirSync(dir, { recursive: true });
  writeFileSync(join(dir, 'problem.json'), JSON.stringify({
    id, title: id, tier: 1, companies: ['uber'], sealed: false,
    prompt_ambiguous: 'Design a thing.',
    must_haves: { actors: ['user'], entities: ['Thing'], requirements: ['do it'], out_of_scope: ['billing'] },
    good_questions: ['how many things?'],
    skeleton_spec: { required_types: ['Thing'], required_seams: ['ThingPolicy'] },
    ...extra,
  }));
}

test('loadCorpus reads every problem directory', () => {
  const root = mkdtempSync(join(tmpdir(), 'corpus-'));
  writeProblem(root, 'parking-lot');
  writeProblem(root, 'elevator');
  const c = loadCorpus(root);
  assert.deepEqual(c.all().map((p) => p.id).sort(), ['elevator', 'parking-lot']);
});

test('drillable() excludes sealed problems', () => {
  const root = mkdtempSync(join(tmpdir(), 'corpus-'));
  writeProblem(root, 'parking-lot');
  writeProblem(root, 'trip-state-machine', { sealed: true });

  const c = loadCorpus(root);
  assert.deepEqual(c.drillable().map((p) => p.id), ['parking-lot']);
  assert.deepEqual(c.sealed().map((p) => p.id), ['trip-state-machine']);
});

test('forDrill throws when asked for a sealed problem', () => {
  const root = mkdtempSync(join(tmpdir(), 'corpus-'));
  writeProblem(root, 'trip-state-machine', { sealed: true });
  const c = loadCorpus(root);
  assert.throws(() => c.forDrill('trip-state-machine'), /sealed/i);
});

test('forDrill throws on an unknown id', () => {
  const root = mkdtempSync(join(tmpdir(), 'corpus-'));
  const c = loadCorpus(root);
  assert.throws(() => c.forDrill('nope'), /unknown problem/i);
});

test('validateProblem rejects a problem missing must_haves', () => {
  assert.throws(() => validateProblem({ id: 'x', title: 'x', tier: 1, prompt_ambiguous: 'p' }),
    /must_haves/);
});

test('validateProblem rejects an out-of-range tier', () => {
  assert.throws(() => validateProblem({
    id: 'x', title: 'x', tier: 9, prompt_ambiguous: 'p',
    must_haves: { actors: [], entities: [], requirements: [], out_of_scope: [] },
    good_questions: [], skeleton_spec: { required_types: [], required_seams: [] },
  }), /tier/);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/corpus.test.mjs`
Expected: FAIL — `Cannot find module '../lib/corpus.mjs'`

- [ ] **Step 3: Implement `server/lib/corpus.mjs`**

```javascript
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';

const REQUIRED = ['id', 'title', 'tier', 'prompt_ambiguous', 'must_haves', 'good_questions', 'skeleton_spec'];
const MUST_HAVE_KEYS = ['actors', 'entities', 'requirements', 'out_of_scope'];

export function validateProblem(p) {
  for (const key of REQUIRED) {
    if (p[key] === undefined) throw new Error(`problem ${p.id ?? '?'} missing ${key}`);
  }
  if (![1, 2, 3].includes(p.tier)) throw new Error(`problem ${p.id} has invalid tier ${p.tier}`);
  for (const key of MUST_HAVE_KEYS) {
    if (!Array.isArray(p.must_haves[key])) {
      throw new Error(`problem ${p.id} must_haves.${key} must be an array`);
    }
  }
  if (!Array.isArray(p.skeleton_spec.required_types)) {
    throw new Error(`problem ${p.id} skeleton_spec.required_types must be an array`);
  }
  return p;
}

// Fields never sent to the browser during a drill — they are the answers.
const DRILL_HIDDEN = ['must_haves', 'good_questions', 'prompt_clarified', 'reference'];

export function publicView(p, { reveal = false } = {}) {
  const out = { ...p };
  if (!reveal) for (const k of DRILL_HIDDEN) delete out[k];
  return out;
}

export function loadCorpus(root) {
  const problems = new Map();
  if (existsSync(root)) {
    for (const entry of readdirSync(root)) {
      const file = join(root, entry, 'problem.json');
      if (!existsSync(file) || !statSync(join(root, entry)).isDirectory()) continue;
      const p = validateProblem(JSON.parse(readFileSync(file, 'utf8')));
      p.dir = join(root, entry);
      p.sealed = Boolean(p.sealed);
      problems.set(p.id, p);
    }
  }

  const all = () => [...problems.values()];

  return {
    all,
    drillable: () => all().filter((p) => !p.sealed),
    sealed: () => all().filter((p) => p.sealed),

    // The single gate. Every drill endpoint goes through this and nothing else.
    forDrill(id) {
      const p = problems.get(id);
      if (!p) throw new Error(`unknown problem: ${id}`);
      if (p.sealed) throw new Error(`problem ${id} is sealed and reserved for Phase 7 rounds`);
      return p;
    },

    forRound(id) {
      const p = problems.get(id);
      if (!p) throw new Error(`unknown problem: ${id}`);
      return p;
    },
  };
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `node --test server/test/corpus.test.mjs`
Expected: PASS — 6 tests

- [ ] **Step 5: Snapshot**

```bash
node tools/snapshot.mjs "task-5-corpus"
```

---

### Task 6: Java compilation and diagnostic parsing

The spec names inline diagnostics as the feature separating a real editor from a toy. The parser is
the load-bearing part, so it gets thorough tests against real `javac` output shapes.

**Files:**
- Create: `server/lib/java.mjs`
- Create: `server/test/java.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/java.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseDiagnostics } from '../lib/java.mjs';

test('parses a single error with its column from the caret line', () => {
  const out = [
    'C:\\ws\\attempt-3\\Main.java:4: error: \';\' expected',
    '        int x = 5',
    '                 ^',
    '1 error',
  ].join('\n');

  const d = parseDiagnostics(out, 'C:\\ws\\attempt-3');
  assert.equal(d.length, 1);
  assert.deepEqual(d[0], {
    path: 'Main.java', line: 4, column: 18, severity: 'error', message: "';' expected",
  });
});

test('parses multiple diagnostics across files and keeps warnings', () => {
  const out = [
    '/ws/a/Lift.java:12: error: cannot find symbol',
    '        Doors d = new Doors();',
    '        ^',
    '  symbol:   class Doors',
    '/ws/a/Main.java:7: warning: [deprecation] foo() is deprecated',
    '        foo();',
    '        ^',
    '1 error',
    '1 warning',
  ].join('\n');

  const d = parseDiagnostics(out, '/ws/a');
  assert.equal(d.length, 2);
  assert.equal(d[0].path, 'Lift.java');
  assert.equal(d[0].severity, 'error');
  assert.equal(d[1].path, 'Main.java');
  assert.equal(d[1].severity, 'warning');
  assert.equal(d[1].line, 7);
});

test('handles nested package paths relative to the attempt root', () => {
  const out = [
    '/ws/a/src/com/dojo/Spot.java:9: error: incompatible types: int cannot be converted to String',
    '        String s = 1;',
    '                   ^',
    '1 error',
  ].join('\n');

  const d = parseDiagnostics(out, '/ws/a');
  assert.equal(d[0].path, 'src/com/dojo/Spot.java');
  assert.equal(d[0].line, 9);
});

test('returns an empty array for clean output', () => {
  assert.deepEqual(parseDiagnostics('', '/ws/a'), []);
});

test('column defaults to 1 when no caret line follows', () => {
  const out = '/ws/a/Main.java:3: error: class, interface, enum, or record expected';
  assert.equal(parseDiagnostics(out, '/ws/a')[0].column, 1);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/java.test.mjs`
Expected: FAIL — `Cannot find module '../lib/java.mjs'`

- [ ] **Step 3: Implement `server/lib/java.mjs`**

```javascript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { readdirSync, statSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { javacPath, javaPath, JDK_DIR } from './paths.mjs';

const exec = promisify(execFile);
const DIAG = /^(.*\.java):(\d+):\s+(error|warning):\s+(.*)$/;

function normalise(p) { return p.replace(/\\/g, '/'); }

export function parseDiagnostics(output, attemptRoot) {
  const lines = output.split(/\r?\n/);
  const root = normalise(attemptRoot).replace(/\/$/, '');
  const diags = [];

  for (let i = 0; i < lines.length; i++) {
    const m = DIAG.exec(lines[i]);
    if (!m) continue;
    const [, rawPath, line, severity, message] = m;

    // The caret line is the next line whose only non-space character is '^'.
    let column = 1;
    for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
      if (/^\s*\^\s*$/.test(lines[j])) { column = lines[j].indexOf('^') + 1; break; }
    }

    const abs = normalise(rawPath);
    const path = abs.startsWith(root + '/') ? abs.slice(root.length + 1) : abs.split('/').pop();
    diags.push({ path, line: Number(line), column, severity, message });
  }
  return diags;
}

export function listJavaFiles(dir) {
  const out = [];
  const walk = (d) => {
    for (const e of readdirSync(d)) {
      const full = join(d, e);
      if (statSync(full).isDirectory()) walk(full);
      else if (e.endsWith('.java')) out.push(full);
    }
  };
  if (existsSync(dir)) walk(dir);
  return out;
}

export function writeFiles(attemptRoot, files) {
  rmSync(join(attemptRoot, 'out'), { recursive: true, force: true });
  for (const f of files) {
    const dest = join(attemptRoot, f.path);
    mkdirSync(dirname(dest), { recursive: true });
    writeFileSync(dest, f.content, 'utf8');
  }
}

export async function compile(attemptRoot, { javac = javacPath(JDK_DIR) } = {}) {
  const sources = listJavaFiles(attemptRoot).filter((f) => !normalise(f).includes('/out/'));
  if (sources.length === 0) {
    return { compiled: false, diagnostics: [], raw: 'no .java files found' };
  }
  const outDir = join(attemptRoot, 'out');
  mkdirSync(outDir, { recursive: true });

  try {
    const { stderr } = await exec(javac, ['-d', outDir, '-Xlint:all', ...sources], {
      cwd: attemptRoot, maxBuffer: 8 * 1024 * 1024,
    });
    return { compiled: true, diagnostics: parseDiagnostics(stderr ?? '', attemptRoot), raw: stderr ?? '' };
  } catch (e) {
    const raw = `${e.stderr ?? ''}${e.stdout ?? ''}`;
    return { compiled: false, diagnostics: parseDiagnostics(raw, attemptRoot), raw };
  }
}

export async function runMain(attemptRoot, mainClass = 'Main', { java = javaPath(JDK_DIR) } = {}) {
  try {
    const { stdout, stderr } = await exec(java, ['-cp', join(attemptRoot, 'out'), mainClass],
      { cwd: attemptRoot, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
    return { ok: true, stdout, stderr };
  } catch (e) {
    return { ok: false, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message) };
  }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `node --test server/test/java.test.mjs`
Expected: PASS — 5 tests

- [ ] **Step 5: Verify against the real compiler**

Create `workspace/smoke/Main.java` with a deliberate error:

```java
public class Main {
    public static void main(String[] args) {
        int x = 5
        System.out.println(x);
    }
}
```

Run:

```bash
node -e "import('./server/lib/java.mjs').then(async m => console.log(JSON.stringify((await m.compile('workspace/smoke')).diagnostics, null, 2)))"
```

Expected: one diagnostic, `path: "Main.java"`, `line: 3`, `severity: "error"`, message about `';' expected`.
**If `line` is not 3, the parser is wrong — fix it before continuing.** Everything in the editor depends on this.

- [ ] **Step 6: Snapshot**

```bash
node tools/snapshot.mjs "task-6-java"
```

---

### Task 7: LLM grader with tier detection

Injectable runner so tiers are testable without ever calling a model.

> **AMENDED 2026-08-18 after real-world failure.** The code below is superseded — see
> `server/lib/grader-llm.mjs` as built. Three findings, all verified on this machine:
>
> 1. **`execFile('claude', ...)` fails with `spawn claude ENOENT` on Windows.** `claude` on PATH is an
>    npm shim (`claude`, `claude.cmd`, `claude.ps1`, no `.exe`) and `CreateProcess` cannot launch those.
>    Fix: `resolveClaudeBin()` reads the real exe path *out of* `claude.cmd` rather than guessing a
>    version-stamped path, falling back to `%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe`.
> 2. **`shell: true` is not the fix — it is worse.** It triggers Node's DEP0190 (arguments concatenated
>    unescaped) *and* silently swallows the empty-string argument to `--allowedTools`, which would leave
>    tools enabled during grading. Use `shell: false` with the resolved exe.
> 3. **The prompt travels over stdin, never in argv.** Verified working. Two independent reasons:
>    quoting arbitrary Java source on a command line is hazardous, and Windows caps a command line near
>    32 KB — which a code-review prompt carrying 300 lines of Java would breach intermittently.
>    `buildClaudeArgs()` therefore takes no prompt and a test asserts the prompt never appears in argv.
>
> Detection was also made shell-independent: `where`/`which` results differ between Git Bash and
> PowerShell, so `hasBinary('claude')` prefers a filesystem check on the resolved executable.

**Files:**
- Create: `server/lib/grader-llm.mjs`
- Create: `server/test/grader-llm.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/grader-llm.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { detectTier, askJson, buildClaudeArgs } from '../lib/grader-llm.mjs';

test('detectTier prefers claude, then ollama, then deterministic', async () => {
  assert.equal(await detectTier({ has: async (b) => b === 'claude' }), 'T1');
  assert.equal(await detectTier({ has: async (b) => b === 'ollama' }), 'T2');
  assert.equal(await detectTier({ has: async () => false }), 'T3');
});

test('buildClaudeArgs disables tools and requests json', () => {
  const args = buildClaudeArgs('score this');
  assert.ok(args.includes('-p'));
  assert.ok(args.includes('--output-format'));
  assert.ok(args.includes('json'));
  assert.ok(args.includes('--allowedTools'));
  assert.equal(args[args.indexOf('--allowedTools') + 1], '');
});

test('askJson unwraps the result field from Claude Code json output', async () => {
  const run = async () => ({ stdout: JSON.stringify({ result: '{"score":0.8}', is_error: false }) });
  assert.deepEqual(await askJson('T1', 'prompt', { run }), { score: 0.8 });
});

test('askJson tolerates a fenced code block inside result', async () => {
  const run = async () => ({ stdout: JSON.stringify({ result: '```json\n{"score":0.5}\n```', is_error: false }) });
  assert.deepEqual(await askJson('T1', 'prompt', { run }), { score: 0.5 });
});

test('askJson surfaces a Claude Code error as a thrown error', async () => {
  const run = async () => ({ stdout: JSON.stringify({ is_error: true, result: 'rate limited' }) });
  await assert.rejects(() => askJson('T1', 'prompt', { run }), /rate limited/);
});

test('askJson parses ollama plain-text json output', async () => {
  const run = async () => ({ stdout: 'here you go:\n{"score":0.3}\n' });
  assert.deepEqual(await askJson('T2', 'prompt', { run }), { score: 0.3 });
});

test('askJson refuses to run on tier T3', async () => {
  await assert.rejects(() => askJson('T3', 'prompt', { run: async () => ({ stdout: '{}' }) }), /no llm/i);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/grader-llm.test.mjs`
Expected: FAIL — `Cannot find module '../lib/grader-llm.mjs'`

- [ ] **Step 3: Implement `server/lib/grader-llm.mjs`**

```javascript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const exec = promisify(execFile);

const SYSTEM = 'You are grading a Java LLD interview submission. Reply with JSON only. No prose.';

async function hasBinary(bin) {
  try {
    await exec(process.platform === 'win32' ? 'where' : 'which', [bin]);
    return true;
  } catch { return false; }
}

export async function detectTier({ has = hasBinary } = {}) {
  if (await has('claude')) return 'T1';
  if (await has('ollama')) return 'T2';
  return 'T3';
}

// Run in an empty temp cwd so no CLAUDE.md, skills, or project files enter context.
export function buildClaudeArgs(prompt) {
  return ['-p', prompt, '--output-format', 'json', '--allowedTools', '',
          '--append-system-prompt', SYSTEM];
}

function extractJson(text) {
  const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
  const body = fenced ? fenced[1] : text;
  const start = body.search(/[{[]/);
  if (start === -1) throw new Error(`no JSON in model output: ${text.slice(0, 200)}`);
  const end = Math.max(body.lastIndexOf('}'), body.lastIndexOf(']'));
  return JSON.parse(body.slice(start, end + 1));
}

async function defaultRun(tier, prompt) {
  if (tier === 'T1') {
    const cwd = mkdtempSync(join(tmpdir(), 'dojo-grade-'));
    return exec('claude', buildClaudeArgs(prompt), { cwd, maxBuffer: 8 * 1024 * 1024, timeout: 180_000 });
  }
  const model = process.platform === 'darwin' ? 'qwen3:4b' : 'qwen3:8b';
  return exec('ollama', ['run', model, prompt], { maxBuffer: 8 * 1024 * 1024, timeout: 600_000 });
}

export async function askJson(tier, prompt, { run = defaultRun } = {}) {
  if (tier === 'T3') throw new Error('no LLM available (tier T3)');
  const { stdout } = await run(tier, prompt);

  if (tier === 'T1') {
    const envelope = JSON.parse(stdout);
    if (envelope.is_error) throw new Error(envelope.result ?? 'claude code reported an error');
    return extractJson(envelope.result);
  }
  return extractJson(stdout);
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `node --test server/test/grader-llm.test.mjs`
Expected: PASS — 7 tests

- [ ] **Step 5: Verify against the real Claude Code binary**

```bash
node -e "import('./server/lib/grader-llm.mjs').then(async m => console.log(await m.askJson('T1', 'Reply with only this JSON: {\"ok\":true}')))"
```

Expected: `{ ok: true }`

- [ ] **Step 6: Snapshot**

```bash
node tools/snapshot.mjs "task-7-grader-llm"
```

---

### Task 8: Phase 1 requirement-extraction scoring

Has an LLM path and a deterministic fallback, because the spec requires Phase 1 to work even at
tier T3, with reduced confidence stated.

**Files:**
- Create: `server/lib/grade-phase1.mjs`
- Create: `server/test/grade-phase1.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/grade-phase1.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scoreDeterministic, buildPrompt, gradePhase1 } from '../lib/grade-phase1.mjs';

const MUST = {
  actors: ['driver', 'attendant'],
  entities: ['ParkingSpot', 'Ticket'],
  requirements: ['park a vehicle', 'compute the fee'],
  out_of_scope: ['payment gateway'],
};

test('scoreDeterministic matches on stems, case-insensitively', () => {
  const r = scoreDeterministic(MUST, {
    actors: ['Driver', 'security guard'],
    entities: ['parking spot', 'Ticket'],
    requirements: ['park a vehicle'],
    out_of_scope: [],
  });
  assert.deepEqual(r.found.actors, ['driver']);
  assert.deepEqual(r.missed.actors, ['attendant']);
  assert.deepEqual(r.found.entities.sort(), ['ParkingSpot', 'Ticket']);
  assert.deepEqual(r.missed.out_of_scope, ['payment gateway']);
  assert.equal(r.total, 7);
  assert.equal(r.foundCount, 4);
});

test('scoreDeterministic reports a fraction and the 80% exit gate', () => {
  const perfect = scoreDeterministic(MUST, {
    actors: ['driver', 'attendant'], entities: ['ParkingSpot', 'Ticket'],
    requirements: ['park a vehicle', 'compute the fee'], out_of_scope: ['payment gateway'],
  });
  assert.equal(perfect.fraction, 1);
  assert.equal(perfect.passed, true);

  const weak = scoreDeterministic(MUST, { actors: ['driver'], entities: [], requirements: [], out_of_scope: [] });
  assert.ok(weak.fraction < 0.8);
  assert.equal(weak.passed, false);
});

test('buildPrompt includes the must-haves and the candidate answer but not the reference solution', () => {
  const p = buildPrompt(
    { prompt_ambiguous: 'Design a parking lot.', must_haves: MUST, good_questions: ['how many floors?'], reference: 'SECRET' },
    { actors: ['driver'], entities: [], requirements: [], out_of_scope: [], questions: ['how many floors?'] },
  );
  assert.match(p, /Design a parking lot\./);
  assert.match(p, /attendant/);
  assert.match(p, /how many floors\?/);
  assert.doesNotMatch(p, /SECRET/);
});

test('gradePhase1 uses the LLM result when a tier is available', async () => {
  const ask = async () => ({
    found: { actors: ['driver', 'attendant'], entities: ['ParkingSpot', 'Ticket'],
             requirements: ['park a vehicle', 'compute the fee'], out_of_scope: ['payment gateway'] },
    missed: { actors: [], entities: [], requirements: [], out_of_scope: [] },
    question_quality: 0.9,
    feedback: 'Strong scoping.',
  });
  const r = await gradePhase1({ problem: { prompt_ambiguous: 'p', must_haves: MUST, good_questions: [] },
                                answer: { actors: [], entities: [], requirements: [], out_of_scope: [], questions: [] },
                                tier: 'T1', ask });
  assert.equal(r.gradedBy, 'T1');
  assert.equal(r.passed, true);
  assert.equal(r.confidence, 'high');
});

test('gradePhase1 falls back to deterministic scoring when the LLM throws', async () => {
  const ask = async () => { throw new Error('rate limited'); };
  const r = await gradePhase1({
    problem: { prompt_ambiguous: 'p', must_haves: MUST, good_questions: [] },
    answer: { actors: ['driver'], entities: ['ParkingSpot'], requirements: [], out_of_scope: [], questions: [] },
    tier: 'T1', ask,
  });
  assert.equal(r.gradedBy, 'T3-fallback');
  assert.equal(r.confidence, 'reduced');
  assert.equal(r.foundCount, 2);
});

test('gradePhase1 goes straight to deterministic on tier T3', async () => {
  const r = await gradePhase1({
    problem: { prompt_ambiguous: 'p', must_haves: MUST, good_questions: [] },
    answer: { actors: ['driver'], entities: [], requirements: [], out_of_scope: [], questions: [] },
    tier: 'T3', ask: async () => { throw new Error('should not be called'); },
  });
  assert.equal(r.gradedBy, 'T3');
  assert.equal(r.confidence, 'reduced');
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/grade-phase1.test.mjs`
Expected: FAIL — `Cannot find module '../lib/grade-phase1.mjs'`

- [ ] **Step 3: Implement `server/lib/grade-phase1.mjs`**

```javascript
import { askJson } from './grader-llm.mjs';

const KEYS = ['actors', 'entities', 'requirements', 'out_of_scope'];
const PASS_THRESHOLD = 0.8;

function stem(s) {
  return String(s).toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
}

// A must-have counts as found if any candidate line shares a significant token run with it.
function matches(expected, candidates) {
  const e = stem(expected);
  const eTokens = e.split(' ').filter((t) => t.length > 3);
  return candidates.some((c) => {
    const s = stem(c);
    if (!s) return false;
    if (s.includes(e) || e.includes(s)) return true;
    if (eTokens.length === 0) return false;
    return eTokens.filter((t) => s.includes(t)).length / eTokens.length >= 0.6;
  });
}

export function scoreDeterministic(mustHaves, answer) {
  const found = {}, missed = {};
  let total = 0, foundCount = 0;

  for (const key of KEYS) {
    const expected = mustHaves[key] ?? [];
    const given = answer[key] ?? [];
    found[key] = expected.filter((x) => matches(x, given));
    missed[key] = expected.filter((x) => !matches(x, given));
    total += expected.length;
    foundCount += found[key].length;
  }

  const fraction = total === 0 ? 0 : foundCount / total;
  return { found, missed, total, foundCount, fraction, passed: fraction >= PASS_THRESHOLD };
}

export function buildPrompt(problem, answer) {
  return [
    'Grade a candidate\'s requirement-extraction answer for an LLD interview.',
    '',
    `PROMPT GIVEN TO CANDIDATE:\n${problem.prompt_ambiguous}`,
    '',
    `REFERENCE MUST-HAVES:\n${JSON.stringify(problem.must_haves, null, 2)}`,
    '',
    `REFERENCE GOOD QUESTIONS:\n${JSON.stringify(problem.good_questions ?? [], null, 2)}`,
    '',
    `CANDIDATE ANSWER:\n${JSON.stringify(answer, null, 2)}`,
    '',
    'A must-have counts as found if the candidate expressed the same idea in different words.',
    'Reply with exactly this JSON shape and nothing else:',
    '{"found":{"actors":[],"entities":[],"requirements":[],"out_of_scope":[]},',
    ' "missed":{"actors":[],"entities":[],"requirements":[],"out_of_scope":[]},',
    ' "question_quality":0.0,"feedback":"one or two sentences, plain and warm, no praise inflation"}',
  ].join('\n');
}

function tally(found) {
  return KEYS.reduce((n, k) => n + (found[k]?.length ?? 0), 0);
}

export async function gradePhase1({ problem, answer, tier, ask = askJson }) {
  const deterministic = scoreDeterministic(problem.must_haves, answer);

  if (tier === 'T3') {
    return { ...deterministic, gradedBy: 'T3', confidence: 'reduced',
             feedback: 'Scored by keyword overlap only — no model was available.' };
  }

  try {
    const r = await ask(tier, buildPrompt(problem, answer));
    const total = KEYS.reduce((n, k) => n + (problem.must_haves[k]?.length ?? 0), 0);
    const foundCount = tally(r.found ?? {});
    const fraction = total === 0 ? 0 : foundCount / total;
    return {
      found: r.found, missed: r.missed, total, foundCount, fraction,
      passed: fraction >= PASS_THRESHOLD,
      questionQuality: r.question_quality ?? null,
      feedback: r.feedback ?? '',
      gradedBy: tier, confidence: tier === 'T1' ? 'high' : 'medium',
    };
  } catch (e) {
    return { ...deterministic, gradedBy: 'T3-fallback', confidence: 'reduced',
             feedback: `Model unavailable (${e.message}); scored by keyword overlap only.` };
  }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `node --test server/test/grade-phase1.test.mjs`
Expected: PASS — 6 tests

- [ ] **Step 5: Snapshot**

```bash
node tools/snapshot.mjs "task-8-grade-phase1"
```

---

### Task 9: Phase 0 vocabulary constructs and checks

M1 grades Phase 0 by **compile plus structural source checks**. Reflection-based probes are M2 — an
explicit, documented limitation, not an oversight.

**Files:**
- Create: `server/lib/vocab.mjs`
- Create: `server/test/vocab.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/vocab.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CONSTRUCTS, checkConstruct } from '../lib/vocab.mjs';

test('there are constructs covering the spec\'s Phase 0 list', () => {
  const ids = CONSTRUCTS.map((c) => c.id);
  for (const required of ['enum-with-behaviour', 'record-value-object', 'sealed-interface',
                          'policy-interface', 'builder', 'equals-hashcode', 'exception-hierarchy']) {
    assert.ok(ids.includes(required), `missing construct ${required}`);
  }
});

test('every construct has a prompt, a target time, and at least two checks', () => {
  for (const c of CONSTRUCTS) {
    assert.ok(c.prompt.length > 20, `${c.id} prompt too short`);
    assert.equal(typeof c.targetSeconds, 'number');
    assert.ok(c.checks.length >= 2, `${c.id} needs at least two checks`);
  }
});

test('checkConstruct passes a correct enum-with-behaviour', () => {
  const src = `
public enum VehicleType {
    MOTORBIKE(1), CAR(2), TRUCK(4);
    private final int spotsNeeded;
    VehicleType(int spotsNeeded) { this.spotsNeeded = spotsNeeded; }
    public int spotsNeeded() { return spotsNeeded; }
}`;
  const r = checkConstruct('enum-with-behaviour', src);
  assert.equal(r.passed, true);
  assert.deepEqual(r.failures, []);
});

test('checkConstruct fails an enum with no constructor or field', () => {
  const r = checkConstruct('enum-with-behaviour', 'public enum VehicleType { CAR, TRUCK }');
  assert.equal(r.passed, false);
  assert.ok(r.failures.length > 0);
  assert.match(r.failures.join(' '), /field/i);
});

test('checkConstruct fails a record that is not a record', () => {
  const r = checkConstruct('record-value-object', 'public class Money { int amount; }');
  assert.equal(r.passed, false);
});

test('checkConstruct throws on an unknown construct id', () => {
  assert.throws(() => checkConstruct('nope', 'x'), /unknown construct/i);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/vocab.test.mjs`
Expected: FAIL — `Cannot find module '../lib/vocab.mjs'`

- [ ] **Step 3: Implement `server/lib/vocab.mjs`**

```javascript
// Phase 0: typed from blank, compiling, under the target time, twice.
// M1 checks structure in source. M2 replaces these with reflection probes.
export const CONSTRUCTS = [
  {
    id: 'enum-with-behaviour',
    title: 'Enum with state and behaviour',
    targetSeconds: 90,
    prompt: 'Write a public enum VehicleType with constants MOTORBIKE, CAR and TRUCK, each carrying '
          + 'how many standard spots it needs, a private final field, a constructor, and an accessor.',
    file: 'VehicleType.java',
    checks: [
      { name: 'is an enum', re: /\benum\s+VehicleType\b/, hint: 'declare `public enum VehicleType`' },
      { name: 'has a private final field', re: /private\s+final\s+\w+\s+\w+\s*;/,
        hint: 'enum state belongs in a private final field' },
      { name: 'has a constructor', re: /VehicleType\s*\([^)]*\)\s*\{/,
        hint: 'enum constants with data need a constructor' },
      { name: 'has an accessor method', re: /public\s+\w+\s+\w+\s*\(\s*\)\s*\{/,
        hint: 'expose the state through a method, not a public field' },
    ],
  },
  {
    id: 'record-value-object',
    title: 'Record as an immutable value object',
    targetSeconds: 60,
    prompt: 'Write a public record Money(long amountMinor, String currency) that rejects a null '
          + 'currency and a negative amount in a compact constructor.',
    file: 'Money.java',
    checks: [
      { name: 'is a record', re: /\brecord\s+Money\s*\(/, hint: 'use `public record Money(...)`' },
      { name: 'has a compact constructor', re: /Money\s*\{/,
        hint: 'a compact constructor is `public Money { ... }` with no parameter list' },
      { name: 'validates input', re: /throw\s+new\s+\w*(IllegalArgument|NullPointer)\w*Exception/,
        hint: 'reject bad input by throwing' },
    ],
  },
  {
    id: 'sealed-interface',
    title: 'Sealed interface over a closed hierarchy',
    targetSeconds: 90,
    prompt: 'Write a public sealed interface ParkingEvent permitting VehicleEntered and VehicleExited, '
          + 'both records implementing it.',
    file: 'ParkingEvent.java',
    checks: [
      { name: 'is sealed', re: /sealed\s+interface\s+ParkingEvent\b/, hint: 'use `sealed interface`' },
      { name: 'names its permits', re: /permits\s+\w+/, hint: 'list the subtypes with `permits`' },
      { name: 'declares both subtypes', re: /VehicleEntered[\s\S]*VehicleExited|VehicleExited[\s\S]*VehicleEntered/,
        hint: 'both permitted types must exist in the file' },
    ],
  },
  {
    id: 'policy-interface',
    title: 'Policy interface — the Strategy seam',
    targetSeconds: 75,
    prompt: 'Write a public interface PricingPolicy with a single method computing a fee from a '
          + 'Duration and a VehicleType, plus one implementation named FlatRatePricing.',
    file: 'PricingPolicy.java',
    checks: [
      { name: 'declares the interface', re: /interface\s+PricingPolicy\b/, hint: 'the seam is an interface' },
      { name: 'has exactly one abstract method', re: /interface\s+PricingPolicy\s*\{[^}]*\([^)]*\)\s*;/,
        hint: 'a policy should be a single-method interface' },
      { name: 'has an implementation', re: /class\s+FlatRatePricing\s+implements\s+PricingPolicy/,
        hint: 'provide `class FlatRatePricing implements PricingPolicy`' },
    ],
  },
  {
    id: 'builder',
    title: 'Builder past four parameters',
    targetSeconds: 120,
    prompt: 'Write a public class Reservation with a static nested Builder exposing fluent setters '
          + 'and a build() method, and a private constructor taking the builder.',
    file: 'Reservation.java',
    checks: [
      { name: 'has a nested Builder', re: /static\s+(final\s+)?class\s+Builder\b/,
        hint: 'the builder is a static nested class' },
      { name: 'has a build method', re: /\bbuild\s*\(\s*\)/, hint: 'the builder needs `build()`' },
      { name: 'has a private constructor', re: /private\s+Reservation\s*\(/,
        hint: 'construction goes through the builder only' },
      { name: 'setters return the builder', re: /Builder\s+\w+\s*\([^)]*\)\s*\{/,
        hint: 'fluent setters return `Builder`' },
    ],
  },
  {
    id: 'equals-hashcode',
    title: 'The equals/hashCode contract',
    targetSeconds: 120,
    prompt: 'Write a public class SpotId with a String value, overriding equals and hashCode '
          + 'correctly, including the null and wrong-type cases.',
    file: 'SpotId.java',
    checks: [
      { name: 'overrides equals', re: /@Override[\s\S]{0,40}boolean\s+equals\s*\(\s*Object\b/,
        hint: 'equals takes Object, not SpotId' },
      { name: 'overrides hashCode', re: /@Override[\s\S]{0,40}int\s+hashCode\s*\(\s*\)/,
        hint: 'equals without hashCode breaks every hash collection' },
      { name: 'handles the wrong type', re: /instanceof|getClass\s*\(\s*\)/,
        hint: 'guard the type before casting' },
    ],
  },
  {
    id: 'exception-hierarchy',
    title: 'A small exception hierarchy',
    targetSeconds: 90,
    prompt: 'Write a public class ParkingException extending RuntimeException, with subclasses '
          + 'LotFullException and TicketNotFoundException, each with a message constructor.',
    file: 'ParkingException.java',
    checks: [
      { name: 'extends RuntimeException', re: /class\s+ParkingException\s+extends\s+RuntimeException/,
        hint: 'domain errors are usually unchecked' },
      { name: 'has both subclasses', re: /LotFullException[\s\S]*TicketNotFoundException|TicketNotFoundException[\s\S]*LotFullException/,
        hint: 'both subclasses must exist' },
      { name: 'passes the message up', re: /super\s*\(\s*\w+\s*\)/, hint: 'call `super(message)`' },
    ],
  },
];

const BY_ID = new Map(CONSTRUCTS.map((c) => [c.id, c]));

// Strip comments and string literals so a check can't be satisfied by a comment.
function stripNoise(src) {
  return src
    .replace(/\/\*[\s\S]*?\*\//g, ' ')
    .replace(/\/\/[^\n]*/g, ' ')
    .replace(/"(?:\\.|[^"\\])*"/g, '""');
}

export function checkConstruct(id, source) {
  const c = BY_ID.get(id);
  if (!c) throw new Error(`unknown construct: ${id}`);
  const src = stripNoise(source);
  const failures = c.checks.filter((chk) => !chk.re.test(src)).map((chk) => `${chk.name} — ${chk.hint}`);
  return { id, passed: failures.length === 0, failures };
}

export function publicConstructs() {
  return CONSTRUCTS.map(({ id, title, targetSeconds, prompt, file }) =>
    ({ id, title, targetSeconds, prompt, file }));
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `node --test server/test/vocab.test.mjs`
Expected: PASS — 6 tests

- [ ] **Step 5: Snapshot**

```bash
node tools/snapshot.mjs "task-9-vocab"
```

---

### Task 10: HTTP server and API

**Files:**
- Create: `server/index.mjs`
- Create: `server/lib/http.mjs`
- Create: `server/test/api.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/api.test.mjs`:

```javascript
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createApp } from '../index.mjs';

let server, base, corpusRoot;

before(async () => {
  corpusRoot = mkdtempSync(join(tmpdir(), 'api-corpus-'));
  for (const [id, sealed] of [['parking-lot', false], ['trip-state-machine', true]]) {
    mkdirSync(join(corpusRoot, id), { recursive: true });
    writeFileSync(join(corpusRoot, id, 'problem.json'), JSON.stringify({
      id, title: id, tier: 1, companies: ['uber'], sealed,
      prompt_ambiguous: 'Design a thing.',
      must_haves: { actors: ['driver'], entities: ['Thing'], requirements: ['park'], out_of_scope: ['billing'] },
      good_questions: ['how many?'],
      skeleton_spec: { required_types: ['Thing'], required_seams: ['ThingPolicy'] },
    }));
  }
  const app = createApp({ corpusRoot, dbFile: ':memory:', tier: 'T3', workspace: mkdtempSync(join(tmpdir(), 'api-ws-')) });
  server = app.listen(0);
  await new Promise((r) => server.once('listening', r));
  base = `http://127.0.0.1:${server.address().port}`;
});

after(() => server.close());

test('GET /api/problems lists only drillable problems', async () => {
  const r = await fetch(`${base}/api/problems`);
  assert.equal(r.status, 200);
  const body = await r.json();
  assert.deepEqual(body.problems.map((p) => p.id), ['parking-lot']);
});

test('GET /api/problems does not leak must_haves', async () => {
  const body = await (await fetch(`${base}/api/problems`)).json();
  assert.equal(body.problems[0].must_haves, undefined);
  assert.equal(body.problems[0].good_questions, undefined);
});

test('GET /api/problems/:id/drill refuses a sealed problem with 403', async () => {
  const r = await fetch(`${base}/api/problems/trip-state-machine/drill`);
  assert.equal(r.status, 403);
  assert.match((await r.json()).error, /sealed/i);
});

test('GET an unknown problem returns 404', async () => {
  assert.equal((await fetch(`${base}/api/problems/nope/drill`)).status, 404);
});

test('GET /api/status reports the grading tier and phase progress', async () => {
  const body = await (await fetch(`${base}/api/status`)).json();
  assert.equal(body.tier, 'T3');
  assert.equal(typeof body.rungs['2'], 'number');
});

test('POST /api/attempts starts an attempt and returns its id', async () => {
  const r = await fetch(`${base}/api/attempts`, {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ problemId: 'parking-lot', phase: 2, mode: 'practice' }),
  });
  assert.equal(r.status, 201);
  assert.ok((await r.json()).attemptId > 0);
});

test('POST /api/attempts rejects a sealed problem', async () => {
  const r = await fetch(`${base}/api/attempts`, {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ problemId: 'trip-state-machine', phase: 2, mode: 'practice' }),
  });
  assert.equal(r.status, 403);
});

test('POST /api/grade/phase1 scores an answer and reveals what was missed', async () => {
  const r = await fetch(`${base}/api/grade/phase1`, {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      problemId: 'parking-lot',
      answer: { actors: ['driver'], entities: ['Thing'], requirements: ['park'], out_of_scope: ['billing'], questions: [] },
    }),
  });
  assert.equal(r.status, 200);
  const body = await r.json();
  assert.equal(body.passed, true);
  assert.equal(body.gradedBy, 'T3');
});

test('POST /api/grade/vocab checks structure', async () => {
  const r = await fetch(`${base}/api/grade/vocab`, {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ constructId: 'enum-with-behaviour', source: 'public enum VehicleType { CAR }' }),
  });
  const body = await r.json();
  assert.equal(body.passed, false);
  assert.ok(body.failures.length > 0);
});

test('unknown API routes return 404 json', async () => {
  const r = await fetch(`${base}/api/does-not-exist`);
  assert.equal(r.status, 404);
  assert.match(r.headers.get('content-type'), /json/);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/api.test.mjs`
Expected: FAIL — `Cannot find module '../index.mjs'`

- [ ] **Step 3: Implement `server/lib/http.mjs`**

```javascript
export function json(res, status, body) {
  const payload = JSON.stringify(body);
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8',
                          'content-length': Buffer.byteLength(payload) });
  res.end(payload);
}

export async function readJson(req, limit = 4 * 1024 * 1024) {
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > limit) throw new Error('request body too large');
    chunks.push(chunk);
  }
  if (chunks.length === 0) return {};
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

// Errors thrown by the corpus gate carry their meaning in the message.
export function statusForError(message) {
  if (/sealed/i.test(message)) return 403;
  if (/unknown problem/i.test(message)) return 404;
  return 500;
}
```

- [ ] **Step 4: Implement `server/index.mjs`**

```javascript
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { join, normalize, extname } from 'node:path';
import { openDb } from './lib/db.mjs';
import { loadCorpus, publicView } from './lib/corpus.mjs';
import { detectTier, askJson } from './lib/grader-llm.mjs';
import { gradePhase1 } from './lib/grade-phase1.mjs';
import { publicConstructs, checkConstruct } from './lib/vocab.mjs';
import { compile, writeFiles, runMain } from './lib/java.mjs';
import { json, readJson, statusForError } from './lib/http.mjs';
import { CORPUS, DB_FILE, UI_DIR, WORKSPACE } from './lib/paths.mjs';

const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
               '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };

export function createApp({ corpusRoot = CORPUS, dbFile = DB_FILE, uiDir = UI_DIR,
                            workspace = WORKSPACE, tier = 'T3' } = {}) {
  const db = openDb(dbFile);
  const corpus = loadCorpus(corpusRoot);

  async function serveStatic(req, res, pathname) {
    const rel = normalize(pathname === '/' ? 'index.html' : pathname.slice(1)).replace(/^(\.\.[/\\])+/, '');
    const file = join(uiDir, rel);
    try {
      if ((await stat(file)).isDirectory()) throw new Error('dir');
      const body = await readFile(file);
      res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' });
      res.end(body);
    } catch {
      // SPA fallback so client-side routes reload cleanly.
      try {
        res.writeHead(200, { 'content-type': 'text/html' });
        res.end(await readFile(join(uiDir, 'index.html')));
      } catch {
        json(res, 404, { error: 'ui not built — run: node tools/build-ui.mjs' });
      }
    }
  }

  async function handleApi(req, res, pathname) {
    if (req.method === 'GET' && pathname === '/api/status') {
      const rungs = {};
      for (let phase = 0; phase <= 7; phase++) rungs[phase] = db.rungCount(phase);
      return json(res, 200, { tier, rungs, problems: corpus.drillable().length, sealed: corpus.sealed().length });
    }

    if (req.method === 'GET' && pathname === '/api/problems') {
      return json(res, 200, { problems: corpus.drillable().map((p) => publicView(p)) });
    }

    if (req.method === 'GET' && pathname === '/api/vocab') {
      return json(res, 200, { constructs: publicConstructs() });
    }

    const drill = /^\/api\/problems\/([\w-]+)\/drill$/.exec(pathname);
    if (req.method === 'GET' && drill) {
      const p = corpus.forDrill(drill[1]);
      return json(res, 200, { problem: publicView(p) });
    }

    if (req.method === 'POST' && pathname === '/api/attempts') {
      const { problemId, phase, mode = 'practice' } = await readJson(req);
      corpus.forDrill(problemId);
      return json(res, 201, { attemptId: db.startAttempt({ problemId, phase, mode }) });
    }

    if (req.method === 'POST' && pathname === '/api/grade/vocab') {
      const { constructId, source } = await readJson(req);
      return json(res, 200, checkConstruct(constructId, source));
    }

    if (req.method === 'POST' && pathname === '/api/grade/phase1') {
      const { problemId, answer, attemptId } = await readJson(req);
      const problem = corpus.forDrill(problemId);
      const result = await gradePhase1({ problem, answer, tier, ask: askJson });
      if (result.passed) db.satisfyRung(1, problemId);
      if (attemptId) db.finishAttempt({ id: attemptId, verdict: result.passed ? 'pass' : 'fail', tierUsed: result.gradedBy });
      return json(res, 200, { ...result, prompt_clarified: problem.prompt_clarified ?? null,
                              good_questions: problem.good_questions });
    }

    if (req.method === 'POST' && pathname === '/api/compile') {
      const { attemptId, files } = await readJson(req);
      const root = join(workspace, `attempt-${attemptId}`);
      writeFiles(root, files);
      db.saveFiles(attemptId, files);
      const result = await compile(root);
      return json(res, 200, result);
    }

    if (req.method === 'POST' && pathname === '/api/run') {
      const { attemptId, mainClass = 'Main' } = await readJson(req);
      return json(res, 200, await runMain(join(workspace, `attempt-${attemptId}`), mainClass));
    }

    if (req.method === 'POST' && pathname === '/api/grade/skeleton') {
      const { attemptId, problemId, files, elapsedMs } = await readJson(req);
      const problem = corpus.forDrill(problemId);
      const root = join(workspace, `attempt-${attemptId}`);
      writeFiles(root, files);
      db.saveFiles(attemptId, files);

      const built = await compile(root);
      const source = files.map((f) => f.content).join('\n');
      const spec = problem.skeleton_spec;
      const missingTypes = (spec.required_types ?? []).filter(
        (t) => !new RegExp(`\\b(class|interface|enum|record)\\s+${t}\\b`).test(source));
      const missingSeams = (spec.required_seams ?? []).filter(
        (s) => !new RegExp(`\\b(interface|class)\\s+${s}\\b`).test(source));

      const withinClock = elapsedMs <= 12 * 60 * 1000;
      const passed = built.compiled && missingTypes.length === 0 && missingSeams.length === 0 && withinClock;
      if (passed) db.satisfyRung(2, problemId);

      const tags = [];
      if (!built.compiled) tags.push('did-not-compile');
      if (!withinClock) tags.push('ran-out-of-clock');
      if (missingSeams.length) tags.push('missing-seam');
      if (tags.length) db.addFailureTags(attemptId, tags);
      db.finishAttempt({ id: attemptId, verdict: passed ? 'pass' : 'fail',
                         firstCompileAt: built.compiled ? elapsedMs : null, tierUsed: 'T3' });

      return json(res, 200, { passed, compiled: built.compiled, diagnostics: built.diagnostics,
                              missingTypes, missingSeams, withinClock, elapsedMs });
    }

    return json(res, 404, { error: `no route: ${req.method} ${pathname}` });
  }

  const server = createServer(async (req, res) => {
    const { pathname } = new URL(req.url, 'http://127.0.0.1');
    try {
      if (pathname.startsWith('/api/')) return await handleApi(req, res, pathname);
      return await serveStatic(req, res, pathname);
    } catch (e) {
      json(res, statusForError(e.message), { error: e.message });
    }
  });

  server.db = db;
  server.corpus = corpus;
  return server;
}

export function createAppWithTier(opts) { return createApp(opts); }

if (process.argv[1]?.endsWith(`index.mjs`)) {
  const tier = await detectTier();
  const port = Number(process.env.PORT ?? 7777);
  createApp({ tier }).listen(port, '127.0.0.1', () => {
    console.log(`LLD Dojo on http://127.0.0.1:${port}  (grading tier ${tier})`);
  });
}
```

Note: `createApp` returns an `http.Server`, so the test's `app.listen(0)` works directly.

- [ ] **Step 5: Run the test to verify it passes**

Run: `node --test server/test/api.test.mjs`
Expected: PASS — 10 tests

- [ ] **Step 6: Run the whole suite**

Run: `node --test`
Expected: PASS — all tests from Tasks 1–10

- [ ] **Step 7: Snapshot**

```bash
node tools/snapshot.mjs "task-10-server"
```

---

### Task 11: Six problems of corpus content

Content, not code — but it is what makes M1 climbable. Six problems: five drillable, one sealed, so
the sealed machinery is exercised from day one.

**Files:**
- Create: `corpus/parking-lot/problem.json`
- Create: `corpus/vending-machine/problem.json`
- Create: `corpus/elevator/problem.json`
- Create: `corpus/rate-limiter/problem.json`
- Create: `corpus/splitwise/problem.json`
- Create: `corpus/trip-state-machine/problem.json` *(sealed)*
- Create: `server/test/corpus-content.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/corpus-content.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadCorpus } from '../lib/corpus.mjs';
import { CORPUS } from '../lib/paths.mjs';

test('the real corpus loads and validates', () => {
  const c = loadCorpus(CORPUS);
  assert.ok(c.all().length >= 6, `expected at least 6 problems, got ${c.all().length}`);
});

test('at least one problem is sealed, and it is not drillable', () => {
  const c = loadCorpus(CORPUS);
  assert.ok(c.sealed().length >= 1);
  const sealedIds = new Set(c.sealed().map((p) => p.id));
  assert.ok(c.drillable().every((p) => !sealedIds.has(p.id)));
});

test('every problem has a prompt short enough to be genuinely ambiguous', () => {
  for (const p of loadCorpus(CORPUS).all()) {
    assert.ok(p.prompt_ambiguous.length < 400, `${p.id} prompt is too detailed to be ambiguous`);
    assert.ok(p.must_haves.out_of_scope.length >= 1, `${p.id} needs an out_of_scope list`);
    assert.ok(p.good_questions.length >= 2, `${p.id} needs at least two good questions`);
  }
});

test('every problem declares at least one required seam', () => {
  for (const p of loadCorpus(CORPUS).all()) {
    assert.ok(p.skeleton_spec.required_seams.length >= 1, `${p.id} needs a required seam`);
  }
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `node --test server/test/corpus-content.test.mjs`
Expected: FAIL — expected at least 6 problems, got 0

- [ ] **Step 3: Write `corpus/parking-lot/problem.json`**

```json
{
  "id": "parking-lot",
  "title": "Parking Lot",
  "tier": 1,
  "companies": ["uber", "flipkart", "swiggy", "atlassian"],
  "sealed": false,
  "prompt_ambiguous": "Design a parking lot system. It has multiple floors and different kinds of vehicles. Drivers get a ticket on the way in and pay on the way out.",
  "prompt_clarified": "A lot has floors; each floor has spots typed by size. Vehicle types are motorbike, car and truck, needing 1, 2 and 4 standard spots respectively. On entry, allocate the nearest suitable spot and issue a ticket recording spot and entry time. On exit, compute a fee from elapsed time and vehicle type, free the spot, and mark the ticket paid. Reject entry when no suitable spot is free.",
  "must_haves": {
    "actors": ["driver", "parking attendant or gate system"],
    "entities": ["ParkingLot", "Floor", "ParkingSpot", "Vehicle", "Ticket"],
    "requirements": [
      "allocate a suitable spot on entry",
      "issue a ticket recording spot and entry time",
      "reject entry when the lot is full for that vehicle type",
      "compute a fee from elapsed time on exit",
      "free the spot on exit"
    ],
    "out_of_scope": ["payment gateway integration", "user accounts and authentication", "reservations in advance"]
  },
  "good_questions": [
    "Do different vehicle types need different spot sizes, or is one spot one vehicle?",
    "Is pricing flat per hour, or does it vary by vehicle type and duration?",
    "Should allocation prefer the nearest spot, or is any free spot acceptable?"
  ],
  "api_surface": [
    "Ticket park(Vehicle vehicle)",
    "Receipt unpark(TicketId ticketId, Instant exitTime)",
    "int availableSpots(VehicleType type)"
  ],
  "skeleton_spec": {
    "required_types": ["ParkingLot", "ParkingSpot", "Ticket", "VehicleType"],
    "required_seams": ["PricingPolicy", "SpotAllocationStrategy"]
  }
}
```

- [ ] **Step 4: Write `corpus/vending-machine/problem.json`**

```json
{
  "id": "vending-machine",
  "title": "Vending Machine",
  "tier": 1,
  "companies": ["flipkart", "swiggy", "uber"],
  "sealed": false,
  "prompt_ambiguous": "Design a vending machine. People put in coins, pick a product, and get the product plus change. It should handle running out of stock.",
  "prompt_clarified": "The machine holds slots, each with a product, a price and a quantity. It accepts a fixed set of coin denominations. It moves through states: idle, accepting money, dispensing. Selecting a product with enough inserted money dispenses it and returns change using available coins; insufficient money, an empty slot, or unavailable change each fail distinctly. Refund returns everything inserted.",
  "must_haves": {
    "actors": ["customer", "operator restocking the machine"],
    "entities": ["VendingMachine", "Slot", "Product", "Coin", "MachineState"],
    "requirements": [
      "accept coins of known denominations",
      "select a product and validate sufficient funds",
      "dispense the product and decrement stock",
      "return correct change from available coins",
      "reject selection when the slot is empty",
      "refund all inserted money on cancel"
    ],
    "out_of_scope": ["card payments", "remote telemetry or restock alerts", "a physical hardware interface"]
  },
  "good_questions": [
    "Which coin denominations must it accept, and can it ever refuse a sale for lack of change?",
    "Is the machine single-user at a time, or can selections interleave?",
    "Should a failed dispense refund automatically or hold the credit?"
  ],
  "api_surface": [
    "void insertCoin(Coin coin)",
    "DispenseResult select(String slotCode)",
    "List<Coin> refund()"
  ],
  "skeleton_spec": {
    "required_types": ["VendingMachine", "Slot", "Coin", "MachineState"],
    "required_seams": ["ChangeStrategy", "MachineStateHandler"]
  }
}
```

- [ ] **Step 5: Write `corpus/elevator/problem.json`**

```json
{
  "id": "elevator",
  "title": "Elevator System",
  "tier": 2,
  "companies": ["uber", "atlassian", "salesforce"],
  "sealed": false,
  "prompt_ambiguous": "Design an elevator system for a building with several lifts. People press buttons on floors and inside the lifts. Decide which lift goes where.",
  "prompt_clarified": "A building has N floors and M lifts. Two request kinds: a hall call from a floor with a direction, and a car call from inside a lift for a destination. A dispatcher assigns hall calls to lifts. Each lift tracks current floor, direction and a set of pending stops, and serves stops in a sensible order for its direction rather than strict arrival order. Lifts have a capacity and can be taken out of service.",
  "must_haves": {
    "actors": ["passenger on a floor", "passenger inside a lift", "maintenance operator"],
    "entities": ["Building", "Elevator", "Request", "Direction", "Dispatcher"],
    "requirements": [
      "accept a hall call with a floor and direction",
      "accept a car call with a destination floor",
      "assign a hall call to one lift",
      "serve stops in direction order, not arrival order",
      "track each lift's floor and direction",
      "take a lift out of service and reassign its calls"
    ],
    "out_of_scope": ["door motor control timings", "emergency and fire-service modes", "building access control"]
  },
  "good_questions": [
    "Should dispatch minimise passenger wait time, total travel, or just pick the nearest idle lift?",
    "Do lifts serve all floors, or are some restricted?",
    "What happens to assigned calls when a lift goes out of service?"
  ],
  "api_surface": [
    "void requestHallCall(int floor, Direction direction)",
    "void requestCarCall(int elevatorId, int destinationFloor)",
    "void step()",
    "ElevatorStatus status(int elevatorId)"
  ],
  "skeleton_spec": {
    "required_types": ["Elevator", "Request", "Direction", "Building"],
    "required_seams": ["DispatchStrategy", "StopSelectionStrategy"]
  }
}
```

- [ ] **Step 6: Write `corpus/rate-limiter/problem.json`**

```json
{
  "id": "rate-limiter",
  "title": "Rate Limiter",
  "tier": 3,
  "companies": ["uber", "atlassian", "salesforce"],
  "sealed": false,
  "prompt_ambiguous": "Design a rate limiter for an API. Different clients get different limits. It should support more than one limiting algorithm.",
  "prompt_clarified": "Given a client key and a timestamp, decide allow or deny. Rules are configured per client, and a client may have several rules that must all pass. At least two algorithms are required — fixed window and token bucket — selectable per rule, with sliding window as a likely extension. State is in-memory and per-key. Denials report when the caller may retry.",
  "must_haves": {
    "actors": ["API caller identified by a key", "operator configuring limits"],
    "entities": ["RateLimiter", "Rule", "ClientKey", "Decision", "Clock"],
    "requirements": [
      "decide allow or deny for a key at a given time",
      "support per-client rules",
      "support at least two algorithms behind one interface",
      "apply all of a client's rules, denying if any denies",
      "report retry-after on denial",
      "take time from an injectable clock, never from the wall clock directly"
    ],
    "out_of_scope": ["distributed state across nodes", "persistence across restarts", "HTTP middleware wiring"]
  },
  "good_questions": [
    "Is this single-node in-memory, or must limits be shared across instances?",
    "If a client has several rules, does one denial deny the request?",
    "Should the limiter be thread-safe from the outset, or is single-threaded acceptable first?"
  ],
  "api_surface": [
    "Decision tryAcquire(ClientKey key, Instant now)",
    "void configure(ClientKey key, List<Rule> rules)"
  ],
  "skeleton_spec": {
    "required_types": ["Rule", "Decision", "ClientKey"],
    "required_seams": ["RateLimitAlgorithm", "Clock"]
  }
}
```

- [ ] **Step 7: Write `corpus/splitwise/problem.json`**

```json
{
  "id": "splitwise",
  "title": "Expense Splitter",
  "tier": 2,
  "companies": ["flipkart", "swiggy", "cred", "razorpay"],
  "sealed": false,
  "prompt_ambiguous": "Design an expense sharing app. People in a group add expenses and the app tracks who owes whom. Splits are not always equal.",
  "prompt_clarified": "Users belong to groups. An expense records a payer, an amount, participants, and a split type: equal, exact amounts, or percentages. The system maintains pairwise balances and can show what one user owes or is owed. Settling records a payment that reduces a balance. Splits must validate — exact amounts must sum to the total, percentages to 100 — and money must never lose precision.",
  "must_haves": {
    "actors": ["group member who pays", "group member who owes", "group creator"],
    "entities": ["User", "Group", "Expense", "Split", "Balance"],
    "requirements": [
      "add an expense with a payer and participants",
      "support equal, exact and percentage splits behind one abstraction",
      "validate that exact splits sum to the total and percentages to 100",
      "maintain pairwise balances",
      "show a user's net position",
      "record a settlement that reduces a balance"
    ],
    "out_of_scope": ["real payment transfer", "currency conversion", "notifications and reminders"]
  },
  "good_questions": [
    "Should balances be simplified across the group, or kept strictly pairwise?",
    "Is money integer minor units, or can it be a floating amount?",
    "Can an expense be edited or deleted after balances have moved?"
  ],
  "api_surface": [
    "void addExpense(Expense expense)",
    "Money balanceBetween(UserId a, UserId b)",
    "List<Balance> balancesFor(UserId user)",
    "void settle(UserId from, UserId to, Money amount)"
  ],
  "skeleton_spec": {
    "required_types": ["Expense", "User", "Group", "Money"],
    "required_seams": ["SplitStrategy", "BalanceSheet"]
  }
}
```

- [ ] **Step 8: Write `corpus/trip-state-machine/problem.json` — sealed**

```json
{
  "id": "trip-state-machine",
  "title": "Trip State Machine",
  "tier": 3,
  "companies": ["uber"],
  "sealed": true,
  "prompt_ambiguous": "Design the state machine behind a ride. A trip moves through stages from request to completion, and not every transition is legal. Some stages can be cancelled and some cannot.",
  "prompt_clarified": "A trip moves through REQUESTED, MATCHED, DRIVER_ARRIVED, IN_PROGRESS, COMPLETED, with CANCELLED reachable only before IN_PROGRESS. Each transition is triggered by an event, may run side effects such as notifying a rider, and must reject illegal transitions explicitly rather than silently. Transition rules must be declared as data rather than nested conditionals, and the trip's history must be reconstructable.",
  "must_haves": {
    "actors": ["rider", "driver", "dispatch system"],
    "entities": ["Trip", "TripState", "TripEvent", "Transition", "TransitionLog"],
    "requirements": [
      "declare legal transitions as data, not nested conditionals",
      "reject an illegal transition explicitly",
      "run side effects on transition",
      "allow cancellation only before the trip is in progress",
      "record a replayable history of transitions"
    ],
    "out_of_scope": ["driver matching and dispatch algorithms", "pricing and surge", "map routing or ETAs"]
  },
  "good_questions": [
    "Which transitions are legal, and is cancellation allowed after the driver arrives?",
    "Must the transition history be replayable, or is the current state enough?",
    "Are side effects allowed to fail, and does a failed side effect roll back the transition?"
  ],
  "api_surface": [
    "TripState handle(TripEvent event)",
    "List<Transition> history()"
  ],
  "skeleton_spec": {
    "required_types": ["Trip", "TripState", "TripEvent"],
    "required_seams": ["TransitionTable", "TransitionListener"]
  }
}
```

- [ ] **Step 9: Run the test to verify it passes**

Run: `node --test server/test/corpus-content.test.mjs`
Expected: PASS — 4 tests

- [ ] **Step 10: Snapshot — the corpus is the irreplaceable part**

```bash
node tools/snapshot.mjs "task-11-corpus-content"
```

---

### Task 12: Design foundation — tokens, fonts, grain, motion

Implements `docs/specs/2026-08-18-design-system.md`. Everything visual descends from this task, so it
comes before any component.

**Files:**
- Create: `ui-src/package.json`, `ui-src/vite.config.ts`, `ui-src/index.html`
- Create: `ui-src/src/tokens.css`
- Create: `ui-src/src/motion.ts`, `ui-src/src/MotionProvider.tsx`, `ui-src/src/grain.ts`
- Create: `ui-src/src/components/Shimmer.tsx`, `Lozenge.tsx`, `Expand.tsx`, `Shell.tsx`
- Create: `ui-src/src/api.ts`
- Create: `tools/build-ui.mjs`, `tools/fetch-fonts.mjs`

- [ ] **Step 1: Create `ui-src/package.json`**

```json
{
  "name": "lld-dojo-ui",
  "private": true,
  "type": "module",
  "scripts": { "build": "vite build", "dev": "vite" },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "motion": "^11.15.0",
    "@codemirror/state": "^6.5.0",
    "@codemirror/view": "^6.36.0",
    "@codemirror/language": "^6.10.0",
    "@codemirror/commands": "^6.7.0",
    "@codemirror/search": "^6.5.0",
    "@codemirror/lint": "^6.8.0",
    "@codemirror/legacy-modes": "^6.4.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.0",
    "typescript": "^5.7.0",
    "vite": "^6.0.0"
  }
}
```

`@codemirror/legacy-modes` carries a Java mode, avoiding a Lezer grammar dependency. Tailwind is
**not** used — `theme.css` holds the ADS tokens directly, which is less machinery for a single-theme app.

- [ ] **Step 2: Create `ui-src/vite.config.ts`**

```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: { outDir: '../ui', emptyOutDir: true },
  server: { proxy: { '/api': 'http://127.0.0.1:7777' } },
});
```

- [ ] **Step 3: Create `ui-src/index.html`**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>LLD Dojo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

- [ ] **Step 4: Fetch and bundle the three fonts**

Create `tools/fetch-fonts.mjs`. Fonts must be **local** — the app has no network at runtime.

```javascript
import { mkdirSync, existsSync, createWriteStream } from 'node:fs';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { ROOT } from '../server/lib/paths.mjs';

// Static woff2 from the jsDelivr fontsource mirror — OFL-licensed faces, fetched once at build time.
const FONTS = {
  'inter-400.woff2': 'https://cdn.jsdelivr.net/npm/@fontsource/inter@5/files/inter-latin-400-normal.woff2',
  'inter-500.woff2': 'https://cdn.jsdelivr.net/npm/@fontsource/inter@5/files/inter-latin-500-normal.woff2',
  'inter-600.woff2': 'https://cdn.jsdelivr.net/npm/@fontsource/inter@5/files/inter-latin-600-normal.woff2',
  'instrument-serif-400.woff2': 'https://cdn.jsdelivr.net/npm/@fontsource/instrument-serif@5/files/instrument-serif-latin-400-normal.woff2',
  'jetbrains-mono-400.woff2': 'https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2',
};

const dir = join(ROOT, 'ui-src', 'public', 'fonts');
mkdirSync(dir, { recursive: true });

for (const [name, url] of Object.entries(FONTS)) {
  const dest = join(dir, name);
  if (existsSync(dest)) { console.log(`have ${name}`); continue; }
  const res = await fetch(url);
  if (!res.ok) throw new Error(`font fetch failed ${res.status}: ${name}`);
  await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
  console.log(`fetched ${name}`);
}
```

Run: `node tools/fetch-fonts.mjs`
Expected: five `.woff2` files in `ui-src/public/fonts/`

- [ ] **Step 5: Create `ui-src/src/tokens.css`**

Palette, type, depth. No component styles beyond the shared primitives.

```css
@font-face { font-family: Inter; src: url('/fonts/inter-400.woff2') format('woff2'); font-weight: 400; font-display: block; }
@font-face { font-family: Inter; src: url('/fonts/inter-500.woff2') format('woff2'); font-weight: 500; font-display: block; }
@font-face { font-family: Inter; src: url('/fonts/inter-600.woff2') format('woff2'); font-weight: 600; font-display: block; }
@font-face { font-family: 'Instrument Serif'; src: url('/fonts/instrument-serif-400.woff2') format('woff2'); font-weight: 400; font-display: block; }
@font-face { font-family: 'JetBrains Mono'; src: url('/fonts/jetbrains-mono-400.woff2') format('woff2'); font-weight: 400; font-display: block; }

:root {
  --ink-900: #0c0e12;
  --ink-800: #12151b;
  --ink-700: #171b22;
  --ink-600: #1e2430;
  --ink-500: #262e3c;
  --line: #232a36;
  --line-hi: #38424f;

  --text: #c3cdd9;
  --text-dim: #8593a4;
  --text-hi: #eef2f7;

  --iris-400: #7aa7ff;
  --iris-500: #4d84f5;
  --iris-600: #2f63d9;
  --iris-glow: #7aa7ff33;

  --gold-300: #f0d9a8;
  --gold-400: #d9b779;
  --gold-glow: #d9b77933;

  --mint-400: #6fdaae;
  --coral-400: #ff9d8d;
  --amber-400: #f2c85b;

  --serif: 'Instrument Serif', Georgia, serif;
  --sans: Inter, -apple-system, 'Segoe UI', system-ui, sans-serif;
  --mono: 'JetBrains Mono', ui-monospace, Consolas, monospace;

  --shadow-sm: 0 1px 2px #05070a66, 0 0 0 1px #ffffff08 inset;
  --shadow-md: 0 4px 16px -4px #05070a99, 0 0 0 1px #ffffff0a inset;
  --shadow-lg: 0 16px 48px -12px #05070acc, 0 0 0 1px #ffffff0d inset;

  --radius: 10px;
  --radius-lg: 16px;
  --radius-pill: 999px;
}

* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }

body {
  background: var(--ink-900);
  color: var(--text);
  font: 400 14px/1.6 var(--sans);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow: hidden;
}

/* The grain overlay. Fixed, inert, above everything, interactive with nothing. */
#grain {
  position: fixed; inset: 0; z-index: 9999;
  pointer-events: none; opacity: .025; mix-blend-mode: overlay;
}

a { color: var(--iris-400); text-decoration: none; }
a:hover { color: var(--text-hi); }
code, pre { font-family: var(--mono); font-size: .92em; }

h1 {
  font: 400 34px/1.2 var(--serif);
  color: var(--text-hi);
  margin: 0 0 6px;
  letter-spacing: -.01em;
}
h2 {
  font: 500 11px/1 var(--sans);
  text-transform: uppercase; letter-spacing: .09em;
  color: var(--text-dim);
  margin: 32px 0 12px;
}

.label { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); }
.numeral { font-family: var(--serif); font-size: 48px; line-height: 1; color: var(--text-hi); }

button {
  font: 500 13px/1 var(--sans);
  background: var(--ink-600);
  color: var(--text);
  border: 1px solid var(--line-hi);
  border-radius: var(--radius);
  padding: 9px 15px;
  cursor: pointer;
  box-shadow: var(--shadow-sm);
}
button:hover:not(:disabled) { background: var(--ink-500); color: var(--text-hi); }
button.primary {
  background: linear-gradient(180deg, var(--iris-500), var(--iris-600));
  border-color: #ffffff1f; color: #fff;
}
button.primary:hover:not(:disabled) { filter: brightness(1.1); }
button.ghost { background: transparent; border-color: transparent; box-shadow: none; }
button:disabled { opacity: .42; cursor: not-allowed; }

:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--ink-900), 0 0 0 4px var(--iris-500);
}

.card {
  background: linear-gradient(180deg, #ffffff07, transparent 40%), var(--ink-700);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
  /* Shadow lift lives here, not in the motion engine — see cardHover in motion.ts */
  transition: box-shadow .2s cubic-bezier(.22,1,.36,1), border-color .2s cubic-bezier(.22,1,.36,1);
}
.card:hover { box-shadow: var(--shadow-lg); border-color: var(--line-hi); }

.panel {
  background: linear-gradient(180deg, #ffffff06, transparent), var(--ink-700);
  border: 1px solid var(--line);
  border-left: 2px solid var(--iris-400);
  padding: 14px 18px;
  border-radius: var(--radius);
  margin: 16px 0;
  box-shadow: var(--shadow-sm);
}
.panel.success { border-left-color: var(--mint-400); }
.panel.danger  { border-left-color: var(--coral-400); }
.panel.warning { border-left-color: var(--amber-400); }
.panel.earned  { border-left-color: var(--gold-400); box-shadow: var(--shadow-md), 0 0 32px -12px var(--gold-glow); }

textarea {
  width: 100%; font: 400 13px/1.6 var(--sans);
  background: var(--ink-800); color: var(--text);
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: 11px 13px; resize: vertical;
  transition: border-color .16s cubic-bezier(.22,1,.36,1), box-shadow .16s cubic-bezier(.22,1,.36,1);
}
textarea:focus { border-color: var(--iris-500); box-shadow: 0 0 0 3px var(--iris-glow); outline: none; }
textarea::placeholder { color: var(--text-dim); }

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--ink-500); border-radius: var(--radius-pill); border: 2px solid var(--ink-900); }
::-webkit-scrollbar-track { background: transparent; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
```

- [ ] **Step 6: Create `ui-src/src/motion.ts` — the single source of motion truth**

```typescript
export const spring = {
  snappy: { type: 'spring', stiffness: 520, damping: 32, mass: 0.7 },
  soft:   { type: 'spring', stiffness: 260, damping: 26, mass: 1 },
  bouncy: { type: 'spring', stiffness: 420, damping: 18, mass: 0.9 },
  layout: { type: 'spring', stiffness: 340, damping: 30 },
} as const;

export const ease = {
  out:  [0.22, 1, 0.36, 1],
  in:   [0.64, 0, 0.78, 0],
  soft: [0.4, 0, 0.2, 1],
} as const;

export const duration = { instant: 0.09, fast: 0.16, base: 0.24, slow: 0.36, max: 0.4 } as const;
export const stagger = { tight: 0.02, base: 0.035, loose: 0.06 } as const;

/** Entrance: never fade alone — fade plus lift plus a hair of scale. */
export const rise = {
  initial: { opacity: 0, y: 8, scale: 0.985 },
  animate: { opacity: 1, y: 0, scale: 1 },
  exit: { opacity: 0, y: -4, scale: 0.99, transition: { duration: duration.fast } },
};

/** Parent of a staggered list. */
export const listParent = (delay = 0) => ({
  initial: 'hidden',
  animate: 'shown',
  variants: {
    hidden: {},
    shown: { transition: { staggerChildren: stagger.base, delayChildren: delay } },
  },
});

export const listChild = {
  variants: {
    hidden: { opacity: 0, y: 10 },
    shown: { opacity: 1, y: 0, transition: spring.soft },
  },
};

export const press = { whileHover: { y: -1 }, whileTap: { scale: 0.97 } };

/** Transform-only, per the design system's budget: box-shadow is never animated by the
 *  motion engine. The shadow lift is a plain CSS transition on `.card:hover` instead. */
export const cardHover = { whileHover: { y: -3 }, whileTap: { scale: 0.995 } };
```

- [ ] **Step 7: Create `ui-src/src/MotionProvider.tsx` and `ui-src/src/grain.ts`**

`grain.ts` — generated once, no network request:

```typescript
const SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.82" numOctaves="3" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/></filter>
<rect width="200" height="200" filter="url(#n)"/></svg>`;

export const grainUrl = `url("data:image/svg+xml;base64,${btoa(SVG)}")`;

export function mountGrain() {
  if (document.getElementById('grain')) return;
  const el = document.createElement('div');
  el.id = 'grain';
  el.style.backgroundImage = grainUrl;
  document.body.appendChild(el);
}
```

`MotionProvider.tsx` — reduced motion is honoured here, once, for the whole app:

```tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { MotionConfig } from 'motion/react';
import { mountGrain } from './grain';

const ReducedCtx = createContext(false);
export const useReducedMotion = () => useContext(ReducedCtx);

export function MotionProvider({ children }: { children: React.ReactNode }) {
  const [reduced, setReduced] = useState(
    () => window.matchMedia('(prefers-reduced-motion: reduce)').matches);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener('change', onChange);
    return () => mq.removeEventListener('change', onChange);
  }, []);

  useEffect(() => { if (!reduced) mountGrain(); }, [reduced]);

  return (
    <ReducedCtx.Provider value={reduced}>
      <MotionConfig reducedMotion={reduced ? 'always' : 'never'} transition={reduced ? { duration: 0.001 } : undefined}>
        {children}
      </MotionConfig>
    </ReducedCtx.Provider>
  );
}

/** Ambient drifting glow. Subliminal; its absence reads as dead. */
export function AmbientDepth() {
  if (useReducedMotion()) return null;
  return (
    <div aria-hidden style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 0, overflow: 'hidden' }}>
      <div style={{
        position: 'absolute', width: '90vw', height: '90vw', left: '5vw', top: '-30vw',
        background: 'radial-gradient(circle, var(--iris-glow) 0%, transparent 62%)',
        opacity: 0.06, animation: 'drift 40s ease-in-out infinite alternate',
      }} />
      <style>{`@keyframes drift {
        from { transform: translate3d(-6%, -3%, 0) scale(1); }
        to   { transform: translate3d(8%, 6%, 0) scale(1.15); }
      }`}</style>
    </div>
  );
}
```

- [ ] **Step 8a: Create `ui-src/src/components/Shimmer.tsx` — the word "Loading" appears nowhere**

```tsx
export function Shimmer({ h = 16, w = '100%', style }: { h?: number; w?: number | string; style?: React.CSSProperties }) {
  return (
    <div style={{
      height: h, width: w, borderRadius: 'var(--radius)',
      background: `linear-gradient(90deg, var(--ink-700) 0%, var(--ink-600) 42%, var(--ink-700) 84%)`,
      backgroundSize: '220% 100%',
      animation: 'shimmer 1.2s cubic-bezier(.4,0,.2,1) infinite',
      ...style,
    }}>
      <style>{`@keyframes shimmer {
        from { background-position: 140% 0; } to { background-position: -40% 0; }
      }`}</style>
    </div>
  );
}

export function ShimmerBlock({ lines = 3 }: { lines?: number }) {
  return (
    <div style={{ display: 'grid', gap: 10, maxWidth: 620 }}>
      <Shimmer h={30} w="46%" />
      {Array.from({ length: lines }, (_, i) => (
        <Shimmer key={i} h={13} w={`${94 - i * 13}%`} />
      ))}
    </div>
  );
}
```

- [ ] **Step 8b: Create `ui-src/src/components/ClockRing.tsx` — Trick 2**

A draining ring, not digits. The critical behaviour: **while `active` is true it dims and slows its
breathing**, so the clock recedes during flow and reasserts itself when you stall. Digits on hover only.

```tsx
import { motion } from 'motion/react';
import { useState } from 'react';
import { useReducedMotion } from '../MotionProvider';
import { duration, ease } from '../motion';

const R = 15;
const C = 2 * Math.PI * R;

export function ClockRing({ remaining, limit, typing = false, size = 38 }: {
  remaining: number; limit: number; typing?: boolean; size?: number;
}) {
  const [hover, setHover] = useState(false);
  const reduced = useReducedMotion();

  const frac = Math.max(0, Math.min(1, remaining / limit));
  const colour = frac > 0.34 ? 'var(--mint-400)' : frac > 0.15 ? 'var(--amber-400)' : 'var(--coral-400)';
  const mins = Math.floor(remaining / 60000);
  const secs = Math.floor((remaining % 60000) / 1000);

  return (
    <motion.div
      onHoverStart={() => setHover(true)}
      onHoverEnd={() => setHover(false)}
      animate={reduced ? {} : {
        scale: typing ? [1, 1.004, 1] : [1, 1.012, 1],
        opacity: typing ? 0.5 : 1,
      }}
      transition={reduced ? {} : {
        scale: { duration: typing ? 7 : 4, repeat: Infinity, ease: ease.soft },
        opacity: { duration: duration.slow, ease: ease.out },
      }}
      style={{ width: size, height: size, position: 'relative', flex: `0 0 ${size}px` }}
      title={`${mins}:${String(secs).padStart(2, '0')} remaining`}
    >
      <svg width={size} height={size} viewBox="0 0 34 34" style={{ transform: 'rotate(-90deg)' }}>
        <circle cx="17" cy="17" r={R} fill="none" stroke="var(--line-hi)" strokeWidth="2" opacity=".45" />
        <motion.circle
          cx="17" cy="17" r={R} fill="none" strokeWidth="2.4" strokeLinecap="round"
          stroke={colour}
          strokeDasharray={C}
          animate={{ strokeDashoffset: C * (1 - frac) }}
          transition={{ duration: duration.slow, ease: ease.out }}
        />
      </svg>
      <motion.div
        animate={{ opacity: hover ? 1 : 0 }}
        transition={{ duration: duration.fast }}
        style={{
          position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
          fontFamily: 'var(--mono)', fontSize: 9.5, color: colour, fontVariantNumeric: 'tabular-nums',
        }}
      >
        {mins}:{String(secs).padStart(2, '0')}
      </motion.div>
    </motion.div>
  );
}
```

- [ ] **Step 9: Create `ui-src/src/api.ts`**

```typescript
async function call<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(path, {
    ...init,
    headers: init?.body ? { 'content-type': 'application/json' } : undefined,
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error ?? `request failed: ${res.status}`);
  return body as T;
}

export const api = {
  status: () => call<{ tier: string; rungs: Record<string, number>; problems: number; sealed: number }>('/api/status'),
  problems: () => call<{ problems: Problem[] }>('/api/problems'),
  drill: (id: string) => call<{ problem: Problem }>(`/api/problems/${id}/drill`),
  vocab: () => call<{ constructs: Construct[] }>('/api/vocab'),
  startAttempt: (body: { problemId: string; phase: number; mode?: string }) =>
    call<{ attemptId: number }>('/api/attempts', { method: 'POST', body: JSON.stringify(body) }),
  gradeVocab: (body: { constructId: string; source: string }) =>
    call<{ passed: boolean; failures: string[] }>('/api/grade/vocab', { method: 'POST', body: JSON.stringify(body) }),
  gradePhase1: (body: { problemId: string; attemptId?: number; answer: Phase1Answer }) =>
    call<Phase1Result>('/api/grade/phase1', { method: 'POST', body: JSON.stringify(body) }),
  compile: (body: { attemptId: number; files: FileEntry[] }) =>
    call<CompileResult>('/api/compile', { method: 'POST', body: JSON.stringify(body) }),
  gradeSkeleton: (body: { attemptId: number; problemId: string; files: FileEntry[]; elapsedMs: number }) =>
    call<SkeletonResult>('/api/grade/skeleton', { method: 'POST', body: JSON.stringify(body) }),
};

export interface Problem {
  id: string; title: string; tier: number; companies: string[];
  prompt_ambiguous: string;
  api_surface?: string[];
  skeleton_spec: { required_types: string[]; required_seams: string[] };
}
export interface Construct {
  id: string; title: string; targetSeconds: number; prompt: string; file: string;
}
export interface Phase1Answer {
  actors: string[]; entities: string[]; requirements: string[]; out_of_scope: string[]; questions: string[];
}
export interface Phase1Result {
  passed: boolean; fraction: number; foundCount: number; total: number;
  found: Record<string, string[]>; missed: Record<string, string[]>;
  feedback: string; gradedBy: string; confidence: string;
  prompt_clarified: string | null; good_questions: string[];
}
export interface FileEntry { path: string; content: string; }
export interface Diagnostic { path: string; line: number; column: number; severity: string; message: string; }
export interface CompileResult { compiled: boolean; diagnostics: Diagnostic[]; raw: string; }
export interface SkeletonResult extends CompileResult {
  passed: boolean; missingTypes: string[]; missingSeams: string[]; withinClock: boolean; elapsedMs: number;
}
```

- [ ] **Step 10: Create `ui-src/src/components/Lozenge.tsx`**

Animates its own tone changes, so a status shift is felt rather than merely displayed.

```tsx
import { motion } from 'motion/react';
import { spring } from '../motion';

const TONES = {
  default:    { fg: 'var(--text-dim)',   bd: 'var(--line-hi)',    bg: '#ffffff08' },
  inprogress: { fg: 'var(--iris-400)',   bd: 'var(--iris-glow)',  bg: '#7aa7ff14' },
  success:    { fg: 'var(--mint-400)',   bd: '#6fdaae33',         bg: '#6fdaae14' },
  removed:    { fg: 'var(--coral-400)',  bd: '#ff9d8d33',         bg: '#ff9d8d14' },
  moved:      { fg: 'var(--amber-400)',  bd: '#f2c85b33',         bg: '#f2c85b14' },
  earned:     { fg: 'var(--gold-300)',   bd: 'var(--gold-glow)',  bg: '#d9b77918' },
} as const;

export function Lozenge({ children, tone = 'default' }: {
  children: React.ReactNode; tone?: keyof typeof TONES;
}) {
  const t = TONES[tone] ?? TONES.default;
  return (
    <motion.span
      layout
      animate={{ color: t.fg, borderColor: t.bd, backgroundColor: t.bg }}
      transition={spring.snappy}
      style={{
        border: '1px solid', borderRadius: 'var(--radius-pill)',
        padding: '3px 9px', fontSize: 10.5, fontWeight: 600,
        letterSpacing: '.07em', textTransform: 'uppercase', whiteSpace: 'nowrap',
        fontVariantNumeric: 'tabular-nums',
      }}
    >{children}</motion.span>
  );
}
```

- [ ] **Step 11: Create `ui-src/src/components/Expand.tsx` — the spoiler gate, with a height spring**

The chevron rotates, the body springs open from height 0, and the content itself rises — three
coordinated motions, which is what makes a disclosure feel considered.

```tsx
import { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { spring, duration, ease } from '../motion';

export function Expand({ title, locked = false, confirm, onOpen, children }: {
  title: string;
  locked?: boolean;
  confirm?: string;
  onOpen?: () => void;
  children: React.ReactNode;
}) {
  const [open, setOpen] = useState(false);

  function toggle() {
    if (open) { setOpen(false); return; }
    if (confirm && !window.confirm(confirm)) return;
    setOpen(true);
    onOpen?.();
  }

  return (
    <motion.div
      layout
      transition={spring.layout}
      style={{
        border: '1px solid var(--line)', borderRadius: 'var(--radius)',
        margin: '10px 0', overflow: 'hidden', background: '#ffffff04',
      }}
    >
      <motion.button
        onClick={toggle}
        disabled={locked}
        whileHover={locked ? undefined : { backgroundColor: '#ffffff08' }}
        transition={{ duration: duration.fast, ease: ease.out }}
        style={{
          width: '100%', textAlign: 'left', background: 'transparent',
          border: 'none', borderRadius: 0, boxShadow: 'none',
          padding: '12px 14px', color: 'var(--text)',
          display: 'flex', gap: 10, alignItems: 'center',
        }}
      >
        <motion.span
          animate={{ rotate: open ? 90 : 0 }}
          transition={spring.snappy}
          style={{ color: 'var(--text-dim)', display: 'inline-block', width: 10 }}
        >▸</motion.span>
        <span style={{ flex: 1, fontWeight: 500 }}>{title}</span>
        {locked && <span className="label">locked</span>}
      </motion.button>

      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            key="body"
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: 'auto', opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={spring.soft}
            style={{ overflow: 'hidden' }}
          >
            <motion.div
              initial={{ y: -6, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              transition={{ ...spring.soft, delay: 0.04 }}
              style={{ padding: '0 16px 16px 34px' }}
            >
              {children}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}
```

- [ ] **Step 12: Create `ui-src/src/components/Shell.tsx`**

The header wordmark uses the serif. The breadcrumb trail animates each crumb in as it appears. The
main column crossfades on route change while the sidebar stays put — which is what makes navigation
feel like moving *within* a product rather than between pages.

```tsx
import { motion, AnimatePresence } from 'motion/react';
import { Lozenge } from './Lozenge';
import { AmbientDepth } from '../MotionProvider';
import { spring, rise, duration, ease } from '../motion';

export interface Crumb { label: string; onClick?: () => void; }

const TIER_TONE = { T1: 'success', T2: 'moved', T3: 'default' } as const;

export function Shell({ tier, crumbs, sidebar, routeKey, children }: {
  tier: string;
  crumbs: Crumb[];
  sidebar: React.ReactNode;
  routeKey: string;
  children: React.ReactNode;
}) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
      <AmbientDepth />

      <motion.header
        initial={{ y: -48, opacity: 0 }}
        animate={{ y: 0, opacity: 1 }}
        transition={spring.soft}
        style={{
          height: 54, flex: '0 0 54px', zIndex: 2,
          background: 'linear-gradient(180deg, #ffffff07, transparent), var(--ink-800)',
          borderBottom: '1px solid var(--line)',
          display: 'flex', alignItems: 'center', gap: 14, padding: '0 20px',
          boxShadow: 'var(--shadow-sm)',
        }}
      >
        <span style={{ font: '400 19px/1 var(--serif)', color: 'var(--text-hi)', letterSpacing: '.01em' }}>
          LLD&nbsp;Dojo
        </span>
        <span style={{ flex: 1 }} />
        <span className="label">grading</span>
        <Lozenge tone={TIER_TONE[tier as keyof typeof TIER_TONE] ?? 'default'}>{tier}</Lozenge>
      </motion.header>

      <div style={{ display: 'flex', flex: 1, minHeight: 0, zIndex: 1 }}>
        <motion.nav
          initial={{ x: -20, opacity: 0 }}
          animate={{ x: 0, opacity: 1 }}
          transition={{ ...spring.soft, delay: 0.05 }}
          style={{
            width: 244, flex: '0 0 244px', background: 'var(--ink-800)',
            borderRight: '1px solid var(--line)', padding: '18px 12px', overflowY: 'auto',
          }}
        >{sidebar}</motion.nav>

        <main style={{ flex: 1, minWidth: 0, overflowY: 'auto', padding: '22px 40px 80px' }}>
          <motion.div
            {...{ initial: 'hidden', animate: 'shown' }}
            variants={{ hidden: {}, shown: { transition: { staggerChildren: 0.03 } } }}
            style={{ fontSize: 12, color: 'var(--text-dim)', marginBottom: 20, display: 'flex', gap: 7 }}
          >
            {crumbs.map((c, i) => (
              <motion.span
                key={`${i}-${c.label}`}
                variants={{ hidden: { opacity: 0, x: -5 }, shown: { opacity: 1, x: 0 } }}
                transition={{ duration: duration.base, ease: ease.out }}
              >
                {i > 0 && <span style={{ marginRight: 7, opacity: .45 }}>/</span>}
                {c.onClick
                  ? <a href="#" onClick={(e) => { e.preventDefault(); c.onClick!(); }}>{c.label}</a>
                  : <span style={{ color: 'var(--text)' }}>{c.label}</span>}
              </motion.span>
            ))}
          </motion.div>

          <AnimatePresence mode="wait">
            <motion.div key={routeKey} {...rise} transition={spring.soft}>
              {children}
            </motion.div>
          </AnimatePresence>
        </main>
      </div>
    </div>
  );
}
```

- [ ] **Step 13: Create `tools/build-ui.mjs`**

```javascript
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { ROOT } from '../server/lib/paths.mjs';

const uiSrc = join(ROOT, 'ui-src');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';

// Node's Windows child_process hardening (post batch-file argument-injection CVE) refuses to
// spawn .cmd files without an explicit shell — execFileSync('npm.cmd', ...) throws EINVAL on
// Node 25. Safe to enable here: every argument is a fixed literal, no user input reaches it.
const opts = { cwd: uiSrc, stdio: 'inherit', shell: process.platform === 'win32' };

if (!existsSync(join(uiSrc, 'node_modules'))) {
  console.log('Installing UI build dependencies (build-time only, never shipped)...');
  execFileSync(npm, ['install'], opts);
}
execFileSync(npm, ['run', 'build'], opts);
console.log(`UI built into ${join(ROOT, 'ui')}`);
```

**Verified on this machine:** without `shell: true` this throws `EINVAL` immediately on Node v25.8.2.
Any future task that spawns an `.exe`-less Windows launcher (`npm`, `npx`, `tsc`) needs the same
treatment. It is a no-op on macOS, where `shell` stays `false`.

- [ ] **Step 14: Fetch fonts, then install and verify the build runs**

```bash
node tools/fetch-fonts.mjs
node tools/build-ui.mjs
```

Expected: Vite reports built assets into `../ui`, and `ui/index.html` exists.
*(`main.tsx` and `App.tsx` land in Task 13; if the build fails for a missing entry, complete Task 13 then re-run.)*

- [ ] **Step 15: Snapshot**

```bash
node tools/snapshot.mjs "task-12-design-foundation"
```

---

### Task 13: Ladder view, problem brief, and Phase 1 drill

**Files:**
- Create: `ui-src/src/main.tsx`, `ui-src/src/App.tsx`
- Create: `ui-src/src/views/Ladder.tsx`, `Brief.tsx`, `ExtractDrill.tsx`, `VocabDrill.tsx`
- Create: `ui-src/src/useClock.ts`

- [ ] **Step 1: Create `ui-src/src/useClock.ts`**

```typescript
import { useEffect, useRef, useState } from 'react';

export function useClock(limitMs: number, running: boolean) {
  const [elapsed, setElapsed] = useState(0);
  const started = useRef<number | null>(null);

  useEffect(() => {
    if (!running) { started.current = null; return; }
    started.current = performance.now();
    const id = setInterval(() => {
      if (started.current !== null) setElapsed(performance.now() - started.current);
    }, 250);
    return () => clearInterval(id);
  }, [running]);

  const remaining = Math.max(0, limitMs - elapsed);
  return {
    elapsed,
    remaining,
    expired: running && remaining === 0,
    label: `${String(Math.floor(remaining / 60000)).padStart(2, '0')}:${String(Math.floor((remaining % 60000) / 1000)).padStart(2, '0')}`,
  };
}
```

- [ ] **Step 2: Create `ui-src/src/main.tsx`**

```tsx
import { createRoot } from 'react-dom/client';
import './tokens.css';
import { MotionProvider } from './MotionProvider';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <MotionProvider>
    <App />
  </MotionProvider>,
);
```

- [ ] **Step 3: Create `ui-src/src/App.tsx`**

```tsx
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { api, type Problem } from './api';
import { Shell } from './components/Shell';
import { Lozenge } from './components/Lozenge';
import { listParent, listChild, spring } from './motion';
import { Ladder } from './views/Ladder';
import { Brief } from './views/Brief';
import { ExtractDrill } from './views/ExtractDrill';
import { SkeletonSprint } from './views/SkeletonSprint';
import { VocabDrill } from './views/VocabDrill';

type Route =
  | { view: 'ladder' }
  | { view: 'vocab' }
  | { view: 'brief'; problemId: string }
  | { view: 'extract'; problemId: string }
  | { view: 'skeleton'; problemId: string };

const PHASES = [
  { phase: 0, name: 'Vocabulary', route: { view: 'vocab' } as Route },
  { phase: 1, name: 'Extraction' },
  { phase: 2, name: 'Skeleton sprint' },
  { phase: 3, name: 'Core build', locked: true },
  { phase: 4, name: 'Extension under fire', locked: true },
  { phase: 5, name: 'Defence', locked: true },
  { phase: 6, name: 'Concurrency', locked: true },
  { phase: 7, name: 'Full rounds', locked: true },
];

export function App() {
  const [route, setRoute] = useState<Route>({ view: 'ladder' });
  const [tier, setTier] = useState('…');
  const [rungs, setRungs] = useState<Record<string, number>>({});
  const [problems, setProblems] = useState<Problem[]>([]);
  const [error, setError] = useState<string | null>(null);

  const refresh = () => {
    api.status().then((s) => { setTier(s.tier); setRungs(s.rungs); }).catch((e) => setError(e.message));
    api.problems().then((r) => setProblems(r.problems)).catch((e) => setError(e.message));
  };
  useEffect(refresh, []);

The sidebar carries a **shared-element active indicator** — one `layoutId`, so the highlight slides
between phases instead of blinking. It is three lines of code and it is most of what makes navigation
feel expensive.

```tsx
  const activePhase =
    route.view === 'vocab' ? 0 :
    route.view === 'extract' ? 1 :
    route.view === 'skeleton' ? 2 : -1;

  const sidebar = (
    <div>
      <div className="label" style={{ padding: '0 10px 10px' }}>The ladder</div>

      <motion.div {...listParent(0.08)}>
        {PHASES.map((p) => {
          const active = p.phase === activePhase;
          return (
            <motion.div key={p.phase} {...listChild} style={{ position: 'relative' }}>
              {active && (
                <motion.div
                  layoutId="nav-active"
                  transition={spring.snappy}
                  style={{
                    position: 'absolute', inset: 0, borderRadius: 'var(--radius)',
                    background: '#7aa7ff14', border: '1px solid var(--iris-glow)',
                  }}
                />
              )}
              <motion.button
                disabled={p.locked}
                whileHover={p.locked ? undefined : { x: 3 }}
                whileTap={p.locked ? undefined : { scale: 0.985 }}
                transition={spring.snappy}
                onClick={() => p.route && setRoute(p.route)}
                style={{
                  position: 'relative', display: 'flex', width: '100%', gap: 8,
                  alignItems: 'center', textAlign: 'left', background: 'transparent',
                  border: 'none', boxShadow: 'none', padding: '9px 10px',
                  color: p.locked ? 'var(--text-dim)' : active ? 'var(--text-hi)' : 'var(--text)',
                  opacity: p.locked ? 0.55 : 1,
                }}
              >
                <span style={{ opacity: .5, fontFamily: 'var(--mono)', fontSize: 11 }}>
                  {String(p.phase)}
                </span>
                <span style={{ flex: 1, fontWeight: active ? 600 : 400 }}>{p.name}</span>
                {(rungs[String(p.phase)] ?? 0) > 0 && (
                  <Lozenge tone="earned">{rungs[String(p.phase)]}</Lozenge>
                )}
              </motion.button>
            </motion.div>
          );
        })}
      </motion.div>

      <div style={{ marginTop: 26, padding: '0 10px', fontSize: 12, color: 'var(--text-dim)', lineHeight: 1.5 }}>
        Phases 3–7 arrive in later milestones.
      </div>
    </div>
  );

  const crumbs = [{ label: 'Dojo', onClick: () => setRoute({ view: 'ladder' }) }];
  if (route.view !== 'ladder') {
    const title = 'problemId' in route
      ? problems.find((p) => p.id === route.problemId)?.title ?? route.problemId
      : 'Vocabulary';
    crumbs.push({ label: title });
  }

  const routeKey = 'problemId' in route ? `${route.view}:${route.problemId}` : route.view;

  return (
    <Shell tier={tier} crumbs={crumbs} sidebar={sidebar} routeKey={routeKey}>
      {error && <div className="panel danger">{error}</div>}

      {route.view === 'ladder' && (
        <Ladder problems={problems} rungs={rungs}
                onOpen={(id) => setRoute({ view: 'brief', problemId: id })}
                onVocab={() => setRoute({ view: 'vocab' })} />
      )}
      {route.view === 'vocab' && <VocabDrill onDone={refresh} />}
      {route.view === 'brief' && (
        <Brief problemId={route.problemId}
               onExtract={() => setRoute({ view: 'extract', problemId: route.problemId })}
               onSkeleton={() => setRoute({ view: 'skeleton', problemId: route.problemId })} />
      )}
      {route.view === 'extract' && (
        <ExtractDrill problemId={route.problemId}
                      onDone={() => { refresh(); setRoute({ view: 'brief', problemId: route.problemId }); }} />
      )}
      {route.view === 'skeleton' && (
        <SkeletonSprint problemId={route.problemId}
                        onDone={() => { refresh(); setRoute({ view: 'brief', problemId: route.problemId }); }} />
      )}
    </Shell>
  );
}
```

- [ ] **Step 4: Create `ui-src/src/views/Ladder.tsx`**

Cards, not a table — the design system discards dense enterprise tables. Each card carries
`layoutId={`card-${p.id}`}` so it **morphs into** the brief's header in Task 13 Step 5, and the grid
staggers in. Shimmer covers the loading state.

```tsx
import { motion } from 'motion/react';
import { Lozenge } from '../components/Lozenge';
import { Shimmer } from '../components/Shimmer';
import { listParent, listChild, spring, cardHover } from '../motion';
import type { Problem } from '../api';

const TIER_LABEL = { 1: 'Foundational', 2: 'Intermediate', 3: 'Advanced' } as const;

export function Ladder({ problems, rungs, onOpen, onVocab }: {
  problems: Problem[];
  rungs: Record<string, number>;
  onOpen: (id: string) => void;
  onVocab: () => void;
}) {
  const done = (rungs['0'] ?? 0) + (rungs['1'] ?? 0) + (rungs['2'] ?? 0);

  return (
    <div>
      <h1>The ladder</h1>
      <p style={{ color: 'var(--text-dim)', maxWidth: 560, marginTop: 0 }}>
        Ordered, not scheduled. There are no dates here and nothing to fall behind on.
      </p>

      <motion.div
        className="panel"
        initial={{ opacity: 0, y: 10 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ ...spring.soft, delay: 0.06 }}
      >
        {done === 0
          ? <>Start at{' '}
              <a href="#" onClick={(e) => { e.preventDefault(); onVocab(); }}>Phase 0 · Vocabulary</a>{' '}
              — seven constructs, typed from blank. It is the cheapest rung and it makes every later
              one faster.</>
          : <><strong style={{ color: 'var(--text-hi)' }}>{done} rungs satisfied.</strong>{' '}
              Phase 1 exits at five problems, Phase 2 at five.</>}
      </motion.div>

      <h2>Problems</h2>

      {problems.length === 0 ? (
        <div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(268px, 1fr))' }}>
          {Array.from({ length: 5 }, (_, i) => <Shimmer key={i} h={116} />)}
        </div>
      ) : (
        <motion.div
          {...listParent(0.1)}
          style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(268px, 1fr))' }}
        >
          {problems.map((p) => (
            <motion.button
              key={p.id}
              {...listChild}
              {...cardHover}
              layoutId={`card-${p.id}`}
              transition={spring.soft}
              onClick={() => onOpen(p.id)}
              className="card"
              style={{
                padding: 18, textAlign: 'left', cursor: 'pointer',
                display: 'flex', flexDirection: 'column', gap: 10, minHeight: 116,
              }}
            >
              <motion.span
                layoutId={`title-${p.id}`}
                style={{ font: '400 21px/1.25 var(--serif)', color: 'var(--text-hi)' }}
              >
                {p.title}
              </motion.span>
              <span style={{ flex: 1 }} />
              <div style={{ display: 'flex', gap: 7, alignItems: 'center', flexWrap: 'wrap' }}>
                <Lozenge tone={p.tier === 3 ? 'moved' : p.tier === 2 ? 'inprogress' : 'default'}>
                  {TIER_LABEL[p.tier as 1 | 2 | 3]}
                </Lozenge>
                <span style={{ fontSize: 11, color: 'var(--text-dim)' }}>
                  {p.companies.slice(0, 3).join(' · ')}
                </span>
              </div>
            </motion.button>
          ))}
        </motion.div>
      )}
    </div>
  );
}
```

- [ ] **Step 5: Create `ui-src/src/views/Brief.tsx`**

The `layoutId` pair matches the Ladder card exactly, so clicking a card makes it **become** this page
rather than being replaced by it. That single detail carries more perceived quality than any other
line in the front end.

```tsx
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { api, type Problem } from '../api';
import { ShimmerBlock } from '../components/Shimmer';
import { spring, press, listParent, listChild } from '../motion';

export function Brief({ problemId, onExtract, onSkeleton }: {
  problemId: string; onExtract: () => void; onSkeleton: () => void;
}) {
  const [problem, setProblem] = useState<Problem | null>(null);

  useEffect(() => { api.drill(problemId).then((r) => setProblem(r.problem)); }, [problemId]);
  if (!problem) return <ShimmerBlock lines={4} />;

  return (
    <motion.div
      layoutId={`card-${problem.id}`}
      transition={spring.soft}
      style={{ maxWidth: 780, background: 'transparent', border: 'none', padding: 0 }}
    >
      <motion.h1 layoutId={`title-${problem.id}`} style={{ margin: '0 0 8px' }}>
        {problem.title}
      </motion.h1>

      <motion.div className="panel" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
                  transition={{ ...spring.soft, delay: 0.05 }}
                  style={{ fontSize: 15, lineHeight: 1.65, color: 'var(--text-hi)' }}>
        {problem.prompt_ambiguous}
      </motion.div>

      <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.14 }}
                style={{ color: 'var(--text-dim)', maxWidth: 580 }}>
        That prompt is deliberately underspecified, exactly as it arrives in a real round. Extracting
        what it does <em>not</em> say is Phase 1 — and it is where most of the round is won.
      </motion.p>

      <h2>Drills</h2>
      <motion.div {...listParent(0.18)} style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
        <motion.button {...listChild} {...press} className="primary" onClick={onExtract}>
          Phase 1 · Extract requirements · 10 min
        </motion.button>
        <motion.button {...listChild} {...press} onClick={onSkeleton}>
          Phase 2 · Skeleton sprint · 12 min
        </motion.button>
      </motion.div>

      {problem.api_surface && (
        <>
          <h2>Target API surface</h2>
          <motion.pre
            initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
            transition={{ ...spring.soft, delay: 0.22 }}
            style={{
              background: 'var(--ink-800)', border: '1px solid var(--line)',
              padding: '14px 16px', borderRadius: 'var(--radius)', overflowX: 'auto',
              color: 'var(--text)', margin: 0,
            }}
          >
            {problem.api_surface.join('\n')}
          </motion.pre>
        </>
      )}
    </motion.div>
  );
}
```

- [ ] **Step 6: Create `ui-src/src/views/ExtractDrill.tsx`**

Carries **Trick 4, chip flight**: on grading, each found must-have flies into a Found column with a
35 ms stagger, missed items settling afterwards. Grading becomes a reveal, not a report card.

```tsx
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { api, type Phase1Result, type Problem } from '../api';
import { useClock } from '../useClock';
import { Expand } from '../components/Expand';
import { ClockRing } from '../components/ClockRing';
import { ShimmerBlock } from '../components/Shimmer';
import { spring, listParent, listChild, press, stagger, duration, ease } from '../motion';

const FIELDS = [
  { key: 'actors', label: 'Actors', hint: 'Who interacts with this system?' },
  { key: 'entities', label: 'Entities', hint: 'The nouns that will become classes.' },
  { key: 'requirements', label: 'Functional requirements', hint: 'What must it do? Five to eight.' },
  { key: 'out_of_scope', label: 'Out of scope', hint: 'What you are explicitly NOT building. Skipping this loses marks.' },
  { key: 'questions', label: 'Clarifying questions', hint: 'The three you would ask the interviewer.' },
] as const;

const LIMIT_MS = 10 * 60 * 1000;
const CHIP_KEYS = ['actors', 'entities', 'requirements', 'out_of_scope'] as const;

export function ExtractDrill({ problemId, onDone }: { problemId: string; onDone: () => void }) {
  const [problem, setProblem] = useState<Problem | null>(null);
  const [text, setText] = useState<Record<string, string>>(
    Object.fromEntries(FIELDS.map((f) => [f.key, ''])));
  const [result, setResult] = useState<Phase1Result | null>(null);
  const [busy, setBusy] = useState(false);
  const [typing, setTyping] = useState(false);
  const typingTimer = useRef<number | null>(null);
  const clock = useClock(LIMIT_MS, !result);

  // Feeds ClockRing so the clock recedes while you work and reasserts itself when you stall.
  function noteTyping() {
    setTyping(true);
    if (typingTimer.current) window.clearTimeout(typingTimer.current);
    typingTimer.current = window.setTimeout(() => setTyping(false), 1600);
  }

  useEffect(() => { api.drill(problemId).then((r) => setProblem(r.problem)); }, [problemId]);

  async function submit() {
    setBusy(true);
    try {
      const answer = Object.fromEntries(FIELDS.map((f) =>
        [f.key, text[f.key].split('\n').map((s) => s.trim()).filter(Boolean)])) as never;
      const { attemptId } = await api.startAttempt({ problemId, phase: 1 });
      setResult(await api.gradePhase1({ problemId, attemptId, answer }));
    } finally { setBusy(false); }
  }

  if (!problem) return <ShimmerBlock lines={5} />;

  const foundChips = CHIP_KEYS.flatMap((k) => (result?.found?.[k] ?? []).map((v) => ({ k, v })));
  const missedChips = CHIP_KEYS.flatMap((k) => (result?.missed?.[k] ?? []).map((v) => ({ k, v })));

  return (
    <div style={{ maxWidth: 780 }}>
      <h1>Extract requirements</h1>

      <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
        <ClockRing remaining={clock.remaining} limit={LIMIT_MS} typing={typing} />
        <span style={{ color: 'var(--text-dim)', fontSize: 12.5, lineHeight: 1.5 }}>
          No code. One item per line.<br />
          The clock is a guide, not a gate — it will not stop you.
        </span>
      </div>

      <div className="panel" style={{ fontSize: 15, lineHeight: 1.65, color: 'var(--text-hi)' }}>
        {problem.prompt_ambiguous}
      </div>

      <motion.div {...listParent(0.08)}>
        {FIELDS.map((f) => (
          <motion.div key={f.key} {...listChild} style={{ marginBottom: 18 }}>
            <label style={{ display: 'block', fontWeight: 600, color: 'var(--text-hi)', fontSize: 13 }}>
              {f.label}
            </label>
            <div style={{ fontSize: 12, color: 'var(--text-dim)', margin: '3px 0 7px' }}>{f.hint}</div>
            <textarea
              rows={4} value={text[f.key]} disabled={!!result} spellCheck={false}
              onChange={(e) => { setText({ ...text, [f.key]: e.target.value }); noteTyping(); }}
            />
          </motion.div>
        ))}
      </motion.div>

      <AnimatePresence>
        {!result && (
          <motion.button
            {...press}
            className="primary"
            initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}
            transition={spring.soft}
            onClick={submit} disabled={busy}
            style={{ position: 'relative', overflow: 'hidden', minWidth: 186 }}
          >
            {busy && (
              <motion.span
                aria-hidden
                initial={{ x: '-100%' }} animate={{ x: '100%' }}
                transition={{ duration: 1.1, repeat: Infinity, ease: ease.soft }}
                style={{
                  position: 'absolute', inset: 0,
                  background: 'linear-gradient(90deg, transparent, #ffffff2e, transparent)',
                }}
              />
            )}
            {busy ? 'Grading…' : 'Submit for grading'}
          </motion.button>
        )}
      </AnimatePresence>

      {result && (
        <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ marginTop: 26 }}>
          <motion.div
            className={`panel ${result.passed ? 'earned' : 'warning'}`}
            initial={{ opacity: 0, y: 12, scale: 0.99 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            transition={spring.bouncy}
          >
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
              <span className="numeral" style={{ fontSize: 40, color: result.passed ? 'var(--gold-300)' : 'var(--text-hi)' }}>
                {Math.round(result.fraction * 100)}%
              </span>
              <span style={{ color: 'var(--text-dim)' }}>
                {result.foundCount} of {result.total} must-haves
              </span>
            </div>
            <div style={{ marginTop: 8 }}>
              {result.passed
                ? 'That clears the 80% gate for this problem.'
                : 'The gate is 80%. What you missed is below — it is the useful part.'}
            </div>
            {result.feedback && <div style={{ marginTop: 8 }}>{result.feedback}</div>}
            <div className="label" style={{ marginTop: 12 }}>
              graded by {result.gradedBy} · confidence {result.confidence}
            </div>
          </motion.div>

          {/* Trick 4 — chip flight. Found chips fly in first, missed settle after. */}
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, marginTop: 20 }}>
            <div>
              <h2 style={{ marginTop: 0 }}>Found</h2>
              <motion.div
                initial="hidden" animate="shown"
                variants={{ hidden: {}, shown: { transition: { staggerChildren: stagger.base } } }}
                style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}
              >
                {foundChips.map(({ k, v }) => (
                  <motion.span
                    key={`${k}:${v}`}
                    variants={{
                      hidden: { opacity: 0, scale: 0.8, y: 16 },
                      shown: { opacity: 1, scale: 1, y: 0, transition: spring.bouncy },
                    }}
                    style={{
                      border: '1px solid var(--gold-glow)', background: '#d9b77914',
                      color: 'var(--gold-300)', borderRadius: 'var(--radius-pill)',
                      padding: '5px 11px', fontSize: 12,
                    }}
                  >{v}</motion.span>
                ))}
                {foundChips.length === 0 && <span style={{ color: 'var(--text-dim)', fontSize: 12 }}>Nothing matched yet.</span>}
              </motion.div>
            </div>

            <div>
              <h2 style={{ marginTop: 0 }}>Missed</h2>
              <motion.div
                initial="hidden" animate="shown"
                variants={{ hidden: {}, shown: { transition: { staggerChildren: stagger.tight, delayChildren: 0.28 } } }}
                style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}
              >
                {missedChips.map(({ k, v }) => (
                  <motion.span
                    key={`${k}:${v}`}
                    variants={{
                      hidden: { opacity: 0, y: -10 },
                      shown: { opacity: 1, y: 0, transition: spring.soft },
                    }}
                    style={{
                      border: '1px dashed var(--line-hi)', color: 'var(--text-dim)',
                      borderRadius: 'var(--radius-pill)', padding: '5px 11px', fontSize: 12,
                    }}
                  >{v}</motion.span>
                ))}
                {missedChips.length === 0 && <span style={{ color: 'var(--mint-400)', fontSize: 12 }}>Nothing missed.</span>}
              </motion.div>
            </div>
          </div>

          <div style={{ marginTop: 22 }}>
            <Expand title="The questions worth asking">
              <ul style={{ margin: 0, paddingLeft: 18 }}>
                {result.good_questions.map((q) => <li key={q} style={{ marginBottom: 6 }}>{q}</li>)}
              </ul>
            </Expand>

            {result.prompt_clarified && (
              <Expand title="What the prompt actually meant"
                      confirm="This reveals the clarified requirements. Continue?">
                <p style={{ margin: 0, lineHeight: 1.65 }}>{result.prompt_clarified}</p>
              </Expand>
            )}
          </div>

          <motion.button {...press} className="primary" style={{ marginTop: 20 }} onClick={onDone}>
            Back to the problem
          </motion.button>
        </motion.div>
      )}
    </div>
  );
}
```

- [ ] **Step 7: Create `ui-src/src/views/VocabDrill.tsx`**

```tsx
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { api, type Construct } from '../api';
import { ClockRing } from '../components/ClockRing';
import { ShimmerBlock } from '../components/Shimmer';
import { useClock } from '../useClock';
import { spring, press } from '../motion';

export function VocabDrill({ onDone }: { onDone: () => void }) {
  const [constructs, setConstructs] = useState<Construct[]>([]);
  const [i, setI] = useState(0);
  const [source, setSource] = useState('');
  const [result, setResult] = useState<{ passed: boolean; failures: string[] } | null>(null);
  const [typing, setTyping] = useState(false);
  const typingTimer = useRef<number | null>(null);
  const current = constructs[i];
  const clock = useClock((current?.targetSeconds ?? 90) * 1000, !!current && !result);

  function noteTyping() {
    setTyping(true);
    if (typingTimer.current) window.clearTimeout(typingTimer.current);
    typingTimer.current = window.setTimeout(() => setTyping(false), 1600);
  }

  useEffect(() => { api.vocab().then((r) => setConstructs(r.constructs)); }, []);

  async function check() {
    setResult(await api.gradeVocab({ constructId: current.id, source }));
  }

  function next() {
    setResult(null); setSource(''); setI((n) => Math.min(n + 1, constructs.length - 1));
  }

  if (!current) return <ShimmerBlock lines={4} />;

  return (
    <div style={{ maxWidth: 780 }}>
      <h1>{current.title}</h1>

      <div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 16 }}>
        <ClockRing remaining={clock.remaining} limit={current.targetSeconds * 1000} typing={typing} />
        <span style={{ color: 'var(--text-dim)', fontSize: 12.5 }}>
          {i + 1} of {constructs.length} · target {current.targetSeconds}s · type it from blank
        </span>
        <span style={{ flex: 1 }} />
        {/* Progress pips — cheap, and the ladder reads as finite */}
        <div style={{ display: 'flex', gap: 5 }}>
          {constructs.map((_, n) => (
            <motion.span
              key={n}
              animate={{
                backgroundColor: n < i ? 'var(--gold-400)' : n === i ? 'var(--iris-400)' : 'var(--line-hi)',
                scale: n === i ? 1.35 : 1,
              }}
              transition={spring.snappy}
              style={{ width: 5, height: 5, borderRadius: '50%', display: 'block' }}
            />
          ))}
        </div>
      </div>

      <div className="panel" style={{ fontSize: 14.5, lineHeight: 1.65, color: 'var(--text-hi)' }}>
        {current.prompt}
      </div>

      <textarea rows={16} value={source} spellCheck={false}
                style={{ fontFamily: 'var(--mono)', fontSize: 13, lineHeight: 1.65 }}
                placeholder={`// ${current.file}`}
                onChange={(e) => { setSource(e.target.value); noteTyping(); }} />

      <div style={{ display: 'flex', gap: 9, marginTop: 14 }}>
        <motion.button {...press} className="primary" onClick={check} disabled={!source.trim()}>Check</motion.button>
        <motion.button {...press} onClick={next} disabled={i >= constructs.length - 1}>Skip to next</motion.button>
        <span style={{ flex: 1 }} />
        <button className="ghost" onClick={onDone}>Done for now</button>
      </div>

      <AnimatePresence mode="wait">
        {result && (
          <motion.div
            key={result.passed ? 'pass' : 'fail'}
            className={`panel ${result.passed ? 'earned' : 'warning'}`}
            initial={{ opacity: 0, y: 12, scale: 0.99 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: -8 }}
            transition={result.passed ? spring.bouncy : spring.soft}
            style={{ marginTop: 18 }}
          >
            {result.passed
              ? <><strong style={{ color: 'var(--gold-300)' }}>Correct shape.</strong> Do it once more
                  from blank before moving on — the exit criterion is twice, not once.</>
              : <>
                  <strong>Not there yet.</strong>
                  <motion.ul
                    initial="hidden" animate="shown"
                    variants={{ hidden: {}, shown: { transition: { staggerChildren: 0.05 } } }}
                    style={{ margin: '8px 0 0', paddingLeft: 18 }}
                  >
                    {result.failures.map((f) => (
                      <motion.li
                        key={f}
                        variants={{ hidden: { opacity: 0, x: -8 }, shown: { opacity: 1, x: 0 } }}
                        transition={spring.soft}
                        style={{ marginBottom: 5 }}
                      >{f}</motion.li>
                    ))}
                  </motion.ul>
                </>}
            {result.passed && (
              <div style={{ marginTop: 12 }}>
                <motion.button {...press} onClick={next}>Next construct</motion.button>
              </div>
            )}
          </motion.div>
        )}
      </AnimatePresence>

      <p style={{ color: 'var(--ds-text-subtle)', fontSize: 12, marginTop: 24 }}>
        M1 checks structure in source. Reflection-based probes that actually instantiate your types
        arrive in M2.
      </p>
    </div>
  );
}
```

- [ ] **Step 8: Build and check the two drills by hand**

```bash
node tools/build-ui.mjs
node server/index.mjs
```

Open `http://127.0.0.1:7777`. Verify: the sidebar lists eight phases with 3–7 disabled; the ladder
lists **five** problems and **not** `trip-state-machine`; Phase 0 accepts a correct enum and rejects
a bare one; Phase 1 grades and reveals what was missed behind expand macros.

- [ ] **Step 9: Snapshot**

```bash
node tools/snapshot.mjs "task-13-views"
```

---

### Task 14: Editor v1, the Seam Lantern, and the Phase 2 skeleton sprint

Editor v1 is multi-file with a real Java mode and diagnostics rendered as gutter markers — the
minimum that is not a toy. Console panel and keystroke autosave are M2.

**This task contains the flagship trick.** The Seam Lantern (design system, Trick 1) is the single
most valuable animation in the app: it converts the silent 12-minute void that *is* the blank-page
fear into continuous feedback.

**Files:**
- Create: `ui-src/src/components/SeamLantern.tsx`
- Create: `ui-src/src/components/Editor.tsx`
- Create: `ui-src/src/views/SkeletonSprint.tsx`

- [ ] **Step 0: Create `ui-src/src/components/SeamLantern.tsx` — Trick 1**

Parses declarations client-side on a debounce. **No server round trip** — the whole point is that it
responds while you type, not when you submit.

```tsx
import { motion } from 'motion/react';
import { useEffect, useState } from 'react';
import { spring, duration, ease } from '../motion';

export type SeamKind = 'type' | 'seam';
export interface SeamNode { name: string; kind: SeamKind; lit: boolean; }

/** Debounced so a fast typist does not re-parse on every keystroke. */
export function useSeamNodes(source: string, requiredTypes: string[], requiredSeams: string[]) {
  const [nodes, setNodes] = useState<SeamNode[]>([]);

  useEffect(() => {
    const id = window.setTimeout(() => {
      const clean = source
        .replace(/\/\*[\s\S]*?\*\//g, ' ')
        .replace(/\/\/[^\n]*/g, ' ');
      const declared = (name: string) =>
        new RegExp(`\\b(class|interface|enum|record)\\s+${name}\\b`).test(clean);

      setNodes([
        ...requiredTypes.map((name) => ({ name, kind: 'type' as SeamKind, lit: declared(name) })),
        ...requiredSeams.map((name) => ({ name, kind: 'seam' as SeamKind, lit: declared(name) })),
      ]);
    }, 150);
    return () => window.clearTimeout(id);
  }, [source, requiredTypes, requiredSeams]);

  return nodes;
}

export function SeamLantern({ nodes }: { nodes: SeamNode[] }) {
  const litCount = nodes.filter((n) => n.lit).length;
  const allLit = nodes.length > 0 && litCount === nodes.length;

  return (
    <div style={{
      width: 208, flex: '0 0 208px', padding: '14px 12px',
      background: 'var(--ink-800)', borderLeft: '1px solid var(--line)',
      display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
    }}>
      <div className="label" style={{ marginBottom: 10, display: 'flex', justifyContent: 'space-between' }}>
        <span>Shape</span>
        <motion.span
          animate={{ color: allLit ? 'var(--gold-300)' : 'var(--text-dim)' }}
          transition={spring.snappy}
          style={{ fontFamily: 'var(--mono)' }}
        >{litCount}/{nodes.length}</motion.span>
      </div>

      {nodes.map((n, i) => (
        <div key={n.name} style={{ position: 'relative', paddingLeft: 20 }}>
          {/* Connector to the node above — draws itself once both ends are lit. */}
          {i > 0 && (
            <motion.span
              aria-hidden
              initial={false}
              animate={{
                opacity: n.lit && nodes[i - 1].lit ? 1 : 0.16,
                backgroundColor: n.lit && nodes[i - 1].lit ? 'var(--gold-400)' : 'var(--line-hi)',
              }}
              transition={{ duration: duration.slow, ease: ease.out }}
              style={{ position: 'absolute', left: 7, top: -6, width: 1.5, height: 14, display: 'block' }}
            />
          )}

          <motion.span
            aria-hidden
            animate={n.lit
              ? { backgroundColor: 'var(--gold-400)', scale: 1, boxShadow: '0 0 12px 1px var(--gold-glow)' }
              : { backgroundColor: 'var(--line-hi)', scale: 0.66, boxShadow: 'none' }}
            transition={spring.bouncy}
            style={{
              position: 'absolute', left: 3, top: 8,
              width: 9, height: 9, borderRadius: '50%', display: 'block',
            }}
          />

          <motion.div
            animate={{
              color: n.lit ? 'var(--text-hi)' : 'var(--text-dim)',
              opacity: n.lit ? 1 : 0.55,
              x: n.lit ? 0 : -2,
            }}
            transition={spring.soft}
            style={{
              fontFamily: 'var(--mono)', fontSize: 11.5, padding: '5px 0',
              whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
            }}
            title={n.kind === 'seam' ? 'Extensibility seam — its absence makes a curveball expensive' : 'Required type'}
          >
            {n.name}
            {n.kind === 'seam' && (
              <span style={{ color: 'var(--iris-400)', marginLeft: 5, fontSize: 9, opacity: .8 }}>seam</span>
            )}
          </motion.div>
        </div>
      ))}

      {allLit && (
        <motion.div
          className="panel earned"
          initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
          transition={spring.bouncy}
          style={{ marginTop: 14, padding: '10px 12px', fontSize: 11.5, lineHeight: 1.5 }}
        >
          Every type and seam declared. Compile it.
        </motion.div>
      )}
    </div>
  );
}
```

- [ ] **Step 1: Create `ui-src/src/components/Editor.tsx`**

```tsx
import { useEffect, useRef } from 'react';
import { EditorState, StateEffect, StateField } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, gutter, GutterMarker } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { StreamLanguage } from '@codemirror/language';
import { java } from '@codemirror/legacy-modes/mode/clike';
import type { Diagnostic } from '../api';

// Markers DROP IN from above with a stagger and land on their exact line — never pop.
class DiagMarker extends GutterMarker {
  constructor(
    private readonly severity: string,
    private readonly message: string,
    private readonly index = 0,
  ) { super(); }

  toDOM() {
    const el = document.createElement('div');
    el.textContent = this.severity === 'error' ? '●' : '▲';
    el.title = this.message;
    el.style.color = this.severity === 'error' ? 'var(--coral-400)' : 'var(--amber-400)';
    el.style.cursor = 'help';
    el.style.animation = `diag-drop .34s cubic-bezier(.22,1,.36,1) ${Math.min(this.index, 8) * 0.04}s both`;
    if (this.severity === 'error') el.style.filter = 'drop-shadow(0 0 5px #ff9d8d55)';
    return el;
  }
}

const setDiags = StateEffect.define<Diagnostic[]>();

const diagField = StateField.define<Diagnostic[]>({
  create: () => [],
  update: (value, tr) => {
    for (const e of tr.effects) if (e.is(setDiags)) return e.value;
    return value;
  },
});

const diagGutter = gutter({
  class: 'cm-diag-gutter',
  lineMarker: (view, line) => {
    const lineNo = view.state.doc.lineAt(line.from).number;
    const diags = view.state.field(diagField);
    const idx = diags.findIndex((x) => x.line === lineNo);
    if (idx === -1) return null;
    const d = diags[idx];
    return new DiagMarker(d.severity, `${d.severity}: ${d.message}`, idx);
  },
  initialSpacer: () => new DiagMarker('error', ''),
});

const THEME = EditorView.theme({
  '&': { backgroundColor: 'var(--ink-800)', color: 'var(--text)', height: '100%' },
  '.cm-content': { fontFamily: 'var(--mono)', fontSize: '13px', lineHeight: '1.7', padding: '10px 0' },
  '.cm-gutters': { backgroundColor: 'transparent', color: 'var(--text-dim)', border: 'none', paddingLeft: '6px' },
  '.cm-activeLine': { backgroundColor: '#ffffff06' },
  '.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--text)' },
  '.cm-cursor': { borderLeftColor: 'var(--iris-400)', borderLeftWidth: '2px' },
  '.cm-selectionBackground, ::selection': { backgroundColor: '#4d84f53d !important' },
  '.cm-diag-gutter': { width: '16px', textAlign: 'center' },
  '.cm-scroller': { overflow: 'auto' },
}, { dark: true });

const KEYFRAMES = `
@keyframes diag-drop {
  from { opacity: 0; transform: translateY(-6px) scale(.7); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes editor-sheen {
  from { opacity: .5; transform: translateY(-100%); }
  to   { opacity: 0;  transform: translateY(100%); }
}`;

export function Editor({ value, diagnostics, onChange, sheenKey = 0 }: {
  value: string;
  diagnostics: Diagnostic[];
  onChange: (v: string) => void;
  /** Bump to fire the success sheen. CodeMirror's DOM is never re-rendered by it. */
  sheenKey?: number;
}) {
  const host = useRef<HTMLDivElement>(null);
  const view = useRef<EditorView | null>(null);
  const onChangeRef = useRef(onChange);
  onChangeRef.current = onChange;

  useEffect(() => {
    if (!host.current) return;
    view.current = new EditorView({
      parent: host.current,
      state: EditorState.create({
        doc: value,
        extensions: [
          lineNumbers(), diagGutter, diagField, history(), highlightActiveLine(),
          highlightSelectionMatches(),
          keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]),
          StreamLanguage.define(java), THEME,
          EditorView.updateListener.of((u) => {
            if (u.docChanged) onChangeRef.current(u.state.doc.toString());
          }),
        ],
      }),
    });
    return () => { view.current?.destroy(); view.current = null; };
    // Intentionally mounts once per file tab; the parent remounts via `key`.
  }, []);

  useEffect(() => {
    view.current?.dispatch({ effects: setDiags.of(diagnostics) });
  }, [diagnostics]);

  return (
    <div style={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
      <style>{KEYFRAMES}</style>
      <div ref={host} style={{ height: '100%', overflow: 'auto' }} />
      {/* Trick 3 — a single sheen travels down the editor on a clean compile. */}
      {sheenKey > 0 && (
        <div
          key={sheenKey}
          aria-hidden
          style={{
            position: 'absolute', inset: 0, pointerEvents: 'none',
            background: 'linear-gradient(180deg, transparent, #6fdaae26 46%, transparent)',
            animation: 'editor-sheen .32s cubic-bezier(.22,1,.36,1) both',
          }}
        />
      )}
    </div>
  );
}
```

- [ ] **Step 2: Create `ui-src/src/views/SkeletonSprint.tsx`**

```tsx
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { api, type Problem, type Diagnostic, type SkeletonResult, type FileEntry } from '../api';
import { Editor } from '../components/Editor';
import { SeamLantern, useSeamNodes } from '../components/SeamLantern';
import { ClockRing } from '../components/ClockRing';
import { ShimmerBlock } from '../components/Shimmer';
import { useClock } from '../useClock';
import { spring, press, ease, duration } from '../motion';

const LIMIT_MS = 12 * 60 * 1000;

export function SkeletonSprint({ problemId, onDone }: { problemId: string; onDone: () => void }) {
  const [problem, setProblem] = useState<Problem | null>(null);
  const [attemptId, setAttemptId] = useState<number | null>(null);
  const [files, setFiles] = useState<FileEntry[]>([{ path: 'Main.java', content: 'public class Main {\n    public static void main(String[] args) {\n    }\n}\n' }]);
  const [active, setActive] = useState(0);
  const [diags, setDiags] = useState<Diagnostic[]>([]);
  const [result, setResult] = useState<SkeletonResult | null>(null);
  const [busy, setBusy] = useState(false);
  const [sheenKey, setSheenKey] = useState(0);
  const [settle, setSettle] = useState(0);
  const [typing, setTyping] = useState(false);
  const typingTimer = useRef<number | null>(null);
  const clock = useClock(LIMIT_MS, !!attemptId && !result);

  function noteTyping() {
    setTyping(true);
    if (typingTimer.current) window.clearTimeout(typingTimer.current);
    typingTimer.current = window.setTimeout(() => setTyping(false), 1600);
  }

  useEffect(() => {
    api.drill(problemId).then((r) => setProblem(r.problem));
    api.startAttempt({ problemId, phase: 2 }).then((r) => setAttemptId(r.attemptId));
  }, [problemId]);

  const current = files[active];
  const setContent = (content: string) => {
    setFiles(files.map((f, i) => (i === active ? { ...f, content } : f)));
    noteTyping();
  };

  // Trick 1 feeds off ALL files, not just the open one — seams often live in their own file.
  const allSource = files.map((f) => f.content).join('\n');
  const seamNodes = useSeamNodes(
    allSource,
    problem?.skeleton_spec.required_types ?? [],
    problem?.skeleton_spec.required_seams ?? [],
  );

  function addFile() {
    const name = window.prompt('New file name (e.g. ParkingSpot.java)');
    if (!name) return;
    const path = name.endsWith('.java') ? name : `${name}.java`;
    const type = path.replace(/\.java$/, '').split('/').pop()!;
    setFiles([...files, { path, content: `public class ${type} {\n}\n` }]);
    setActive(files.length);
  }

  async function build() {
    if (attemptId === null) return;
    setBusy(true);
    try {
      const r = await api.compile({ attemptId, files });

      if (r.compiled && r.diagnostics.length === 0) {
        setDiags([]);
        setSheenKey((n) => n + 1);          // Trick 3 — success sheen
      } else {
        setSettle((n) => n + 1);            // Trick 3 — a settle, never a shake
        setDiags(r.diagnostics.filter((d) => d.path === current.path));
        if (r.diagnostics.length && r.diagnostics[0].path !== current.path) {
          const first = r.diagnostics[0];
          const idx = files.findIndex((f) => f.path === first.path);
          if (idx >= 0) { setActive(idx); setDiags(r.diagnostics.filter((d) => d.path === first.path)); }
        }
      }
    } finally { setBusy(false); }
  }

  async function submit() {
    if (attemptId === null) return;
    setBusy(true);
    try {
      setResult(await api.gradeSkeleton({ attemptId, problemId, files, elapsedMs: Math.round(clock.elapsed) }));
    } finally { setBusy(false); }
  }

  if (!problem) return <ShimmerBlock lines={6} />;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 156px)' }}>
      <div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
        <ClockRing remaining={clock.remaining} limit={LIMIT_MS} typing={typing} />
        <div>
          <h1 style={{ margin: 0, fontSize: 26 }}>Skeleton sprint</h1>
          <span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
            Types, enums and interfaces only. No logic. It must compile.
          </span>
        </div>
        <span style={{ flex: 1 }} />
        <motion.button
          {...press} onClick={build} disabled={busy}
          style={{ position: 'relative', overflow: 'hidden', minWidth: 96 }}
        >
          {busy && (
            <motion.span
              aria-hidden
              initial={{ x: '-100%' }} animate={{ x: '100%' }}
              transition={{ duration: 0.9, repeat: Infinity, ease: ease.soft }}
              style={{ position: 'absolute', inset: 0, background: 'linear-gradient(90deg, transparent, #ffffff26, transparent)' }}
            />
          )}
          Compile
        </motion.button>
        <motion.button {...press} className="primary" onClick={submit} disabled={busy || !!result}>
          Submit
        </motion.button>
      </div>

      <div style={{ display: 'flex', flex: 1, minHeight: 0, gap: 0 }}>
        <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
          <div style={{ display: 'flex', gap: 2, alignItems: 'center', marginBottom: 6, flexWrap: 'wrap' }}>
            {files.map((f, i) => (
              <button
                key={f.path}
                onClick={() => { setActive(i); setDiags([]); }}
                className="ghost"
                style={{
                  position: 'relative', fontFamily: 'var(--mono)', fontSize: 11.5,
                  color: i === active ? 'var(--text-hi)' : 'var(--text-dim)',
                  padding: '7px 11px',
                }}
              >
                {i === active && (
                  <motion.span
                    layoutId="tab-active"
                    transition={spring.snappy}
                    style={{
                      position: 'absolute', inset: 0, borderRadius: 'var(--radius)',
                      background: 'var(--ink-600)', border: '1px solid var(--line)', zIndex: -1,
                    }}
                  />
                )}
                {f.path}
              </button>
            ))}
            <button className="ghost" onClick={addFile} style={{ color: 'var(--iris-400)', fontSize: 12 }}>
              + file
            </button>
          </div>

          {/* The settle: a 2px drop on failure. A settle, never a shake. */}
          <motion.div
            key={`shell-${settle}`}
            animate={settle > 0 ? { y: [0, 2, 0] } : {}}
            transition={spring.soft}
            style={{
              flex: 1, minHeight: 0,
              border: '1px solid var(--line)', borderRadius: 'var(--radius)',
              overflow: 'hidden', background: 'var(--ink-800)', boxShadow: 'var(--shadow-sm)',
            }}
          >
            <Editor
              key={current.path}
              value={current.content}
              diagnostics={diags}
              onChange={setContent}
              sheenKey={sheenKey}
            />
          </motion.div>
        </div>

        {/* Trick 1 — the Seam Lantern */}
        <SeamLantern nodes={seamNodes} />
      </div>

      <AnimatePresence mode="wait">
        {diags.length > 0 && !result && (
          <motion.div
            key="diags"
            className="panel danger"
            initial={{ height: 0, opacity: 0, marginTop: 0 }}
            animate={{ height: 'auto', opacity: 1, marginTop: 12 }}
            exit={{ height: 0, opacity: 0, marginTop: 0 }}
            transition={spring.soft}
            style={{ marginBottom: 0, overflow: 'hidden', maxHeight: 160, overflowY: 'auto' }}
          >
            {diags.map((d, i) => (
              <motion.div
                key={`${d.path}:${d.line}:${i}`}
                initial={{ opacity: 0, x: -8 }}
                animate={{ opacity: 1, x: 0 }}
                transition={{ ...spring.soft, delay: Math.min(i, 8) * 0.04 }}
                style={{ fontSize: 12.5, marginBottom: 3 }}
              >
                <code style={{ color: 'var(--coral-400)' }}>{d.path}:{d.line}</code>
                <span style={{ margin: '0 6px', opacity: .4 }}>—</span>
                {d.message}
              </motion.div>
            ))}
          </motion.div>
        )}

        {result && (
          <motion.div
            key="result"
            className={`panel ${result.passed ? 'earned' : 'warning'}`}
            initial={{ opacity: 0, y: 14, scale: 0.99 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            transition={result.passed ? spring.bouncy : spring.soft}
            style={{ marginBottom: 0, marginTop: 12 }}
          >
            <strong style={{ color: result.passed ? 'var(--gold-300)' : 'var(--text-hi)' }}>
              {result.passed
                ? 'Rung satisfied — compiled, every seam present, inside the clock.'
                : 'Not yet.'}
            </strong>
            <motion.ul
              initial="hidden" animate="shown"
              variants={{ hidden: {}, shown: { transition: { staggerChildren: 0.05, delayChildren: 0.1 } } }}
              style={{ margin: '10px 0 0', paddingLeft: 18 }}
            >
              {[
                `Compiled: ${result.compiled ? 'yes' : 'no'}`,
                `Inside 12 minutes: ${result.withinClock ? 'yes' : `no (${Math.round(result.elapsedMs / 1000)}s)`}`,
                ...(result.missingTypes.length ? [`Missing types: ${result.missingTypes.join(', ')}`] : []),
                ...(result.missingSeams.length
                  ? [`Missing seams: ${result.missingSeams.join(', ')} — a missing seam is exactly what makes a curveball expensive later`]
                  : []),
              ].map((line) => (
                <motion.li
                  key={line}
                  variants={{ hidden: { opacity: 0, x: -8 }, shown: { opacity: 1, x: 0 } }}
                  transition={{ duration: duration.base, ease: ease.out }}
                  style={{ marginBottom: 4 }}
                >{line}</motion.li>
              ))}
            </motion.ul>
            <motion.button {...press} className="primary" style={{ marginTop: 14 }} onClick={onDone}>
              Back to the problem
            </motion.button>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```

- [ ] **Step 3: Build and verify diagnostics land on the right line**

```bash
node tools/build-ui.mjs
node server/index.mjs
```

In Phase 2 for Parking Lot, type a deliberate error (`int x = 5` with no semicolon), press
**Compile**, and confirm: a red dot **drops in** to the gutter on that exact line, hovering shows the
message, the editor settles 2 px rather than shaking, and the panel below lists `Main.java:<line>`.
Then fix it and confirm the marker clears and a mint sheen travels down the editor.

- [ ] **Step 4: Verify the Seam Lantern lights live**

Still in Phase 2 for Parking Lot, type `interface PricingPolicy {}` and confirm — **without pressing
Compile or Submit** — that the `PricingPolicy` node in the right-hand rail springs to gold within
about 150 ms and its connector draws. Then declare all four required types plus both seams and
confirm the rail reads `6/6` and the earned panel appears.

**If the rail does not respond while typing, this task is not done.** It is the flagship trick and
the whole anti-fear rationale rests on it.

- [ ] **Step 5: Verify reduced motion is genuinely honoured**

In Windows: Settings → Accessibility → Visual effects → turn **Animation effects** off. Reload.
Confirm: no grain overlay, no ambient drift, no ring breathing, no shimmer travel — and that every
drill is still fully usable, with the Seam Lantern still changing colour on satisfaction. The
information design must survive with all motion removed.

- [ ] **Step 4: Verify the full test suite still passes**

Run: `node --test`
Expected: PASS — all tests

- [ ] **Step 5: Snapshot**

```bash
node tools/snapshot.mjs "task-14-editor"
```

---

### Task 15: Launchers and the shippable zip

**Files:**
- Create: `run.ps1`, `run.sh`
- Create: `tools/package.mjs`
- Create: `README.md`

- [ ] **Step 1: Create `run.ps1`**

```powershell
#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
Set-Location -Path $PSScriptRoot

$node = Get-Command node -ErrorAction SilentlyContinue
if ($null -eq $node) {
  Write-Host 'Node 22+ is required and was not found on PATH. Install from https://nodejs.org and re-run.'
  exit 1
}

$major = [int](( & node --version ).TrimStart('v').Split('.')[0])
if ($major -lt 22) { Write-Host "Node $major found; 22+ is required (node:sqlite)."; exit 1 }

node tools/bootstrap.mjs
if (-not $?) { exit 1 }

if (-not (Test-Path 'ui/index.html')) {
  Write-Host 'Prebuilt UI missing — building it now (needs npm, one time).'
  node tools/build-ui.mjs
}

Start-Process 'http://127.0.0.1:7777'
node server/index.mjs
```

- [ ] **Step 2: Create `run.sh`**

```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"

if ! command -v node >/dev/null 2>&1; then
  echo "Node 22+ is required and was not found on PATH. Install from https://nodejs.org and re-run."
  exit 1
fi

major="$(node --version | sed 's/^v//' | cut -d. -f1)"
if [ "$major" -lt 22 ]; then
  echo "Node $major found; 22+ is required (node:sqlite)."
  exit 1
fi

node tools/bootstrap.mjs

if [ ! -f ui/index.html ]; then
  echo "Prebuilt UI missing — building it now (needs npm, one time)."
  node tools/build-ui.mjs
fi

( sleep 1; open http://127.0.0.1:7777 ) &
node server/index.mjs
```

- [ ] **Step 3: Make `run.sh` executable and verify it parses**

```bash
chmod +x run.sh && bash -n run.sh && echo "run.sh OK"
```

Expected: `run.sh OK`

- [ ] **Step 4: Create `tools/package.mjs`**

```javascript
import { execFileSync } from 'node:child_process';
import { existsSync, rmSync, mkdirSync, cpSync } from 'node:fs';
import { join } from 'node:path';
import { ROOT } from '../server/lib/paths.mjs';

// Ships source, prebuilt UI and corpus. Never .toolchain (platform-specific),
// never data/workspace/snapshots (personal progress), never node_modules.
const INCLUDE = ['server', 'ui', 'corpus', 'tools', 'docs', 'run.ps1', 'run.sh', 'README.md'];

const stage = join(ROOT, 'dist-stage', 'lld-trainer');
rmSync(join(ROOT, 'dist-stage'), { recursive: true, force: true });
mkdirSync(stage, { recursive: true });

if (!existsSync(join(ROOT, 'ui', 'index.html'))) {
  throw new Error('ui/ is not built — run `node tools/build-ui.mjs` first');
}

for (const entry of INCLUDE) {
  const from = join(ROOT, entry);
  if (existsSync(from)) cpSync(from, join(stage, entry), { recursive: true });
}

const zip = join(ROOT, 'lld-trainer.zip');
rmSync(zip, { force: true });
if (process.platform === 'win32') {
  execFileSync('powershell.exe', ['-NoProfile', '-Command',
    `Compress-Archive -Path '${join(ROOT, 'dist-stage', 'lld-trainer')}' -DestinationPath '${zip}'`],
    { stdio: 'inherit' });
} else {
  execFileSync('zip', ['-rq', zip, 'lld-trainer'], { cwd: join(ROOT, 'dist-stage'), stdio: 'inherit' });
}
rmSync(join(ROOT, 'dist-stage'), { recursive: true, force: true });
console.log(`packaged -> ${zip}`);
```

- [ ] **Step 5: Create `README.md`**

```markdown
# LLD Dojo

Java low-level-design and machine-coding trainer. Local only. No accounts, no cloud, no git.

## Run

- **Windows:** `.\run.ps1`
- **macOS:** `./run.sh`

First run downloads a portable Temurin JDK 21 (~180 MB) and the JUnit standalone jar (~2.5 MB) into
`.toolchain/`. Every run after that is instant and fully offline. Then open
<http://127.0.0.1:7777>.

**Requires:** Node 22 or newer (for built-in `node:sqlite`).

## What is in M1

Phases 0–2 of the ladder: vocabulary reps, requirement extraction, and 12-minute skeleton sprints
across five problems. Phases 3–7 arrive in later milestones.

## Grading tiers

Detected at startup and shown in the header.

| Tier | Meaning |
|---|---|
| T1 | `claude` CLI found — full-quality grading, drawn from your Claude Code plan |
| T2 | `ollama` found — local model, lower confidence |
| T3 | Neither — deterministic checks and keyword overlap only |

Deterministic checks run in every tier and are never overridden by a model.

## Layout

| Path | Contents |
|---|---|
| `corpus/` | Problems — **the irreplaceable part** |
| `snapshots/` | Timestamped backups of `corpus/` and `data/`. Stands in for git |
| `workspace/` | Your Java, one directory per attempt |
| `data/dojo.db` | Progress |

## Development

```bash
node --test     # run the suite
node tools/fetch-fonts.mjs   # one-time: pull the bundled woff2 faces
node tools/build-ui.mjs      # rebuild the frontend
node tools/snapshot.mjs msg  # snapshot before risky changes
node tools/package.mjs       # produce lld-trainer.zip
```

Set your OS to reduce animations to check the reduced-motion path; every drill must stay fully
usable with all motion removed.
```

- [ ] **Step 6: End-to-end verification on Windows**

```bash
node tools/snapshot.mjs "pre-e2e"
node --test
node tools/fetch-fonts.mjs
node tools/build-ui.mjs
node tools/package.mjs
```

Then unzip `lld-trainer.zip` into a scratch directory, run `.\run.ps1` there, and confirm all five:

1. It bootstraps and serves without touching the original folder
2. The ladder lists **five** problems and never `trip-state-machine`
3. A Phase 2 compile error drops a gutter marker on the correct line
4. The Seam Lantern lights while typing, with no Compile press
5. Fonts render as Instrument Serif / Inter / JetBrains Mono **with the machine offline** — pull the
   network and reload to prove there is no CDN dependency

- [ ] **Step 7: Final snapshot**

```bash
node tools/snapshot.mjs "m1-complete"
```

---

## Self-review

**Spec coverage for M1.** Every M1 row in the spec's milestone table maps to a task: server
(Task 10), design foundation and shell (Task 12), JDK bootstrap (Task 3), editor v1 (Task 14), six
problems' prompts (Task 11), Phase 0 (Tasks 9, 13), Phase 1 (Tasks 8, 13), Phase 2 (Task 14). The
minimal LLM caller the spec requires for M1 is Task 7. Sealed enforcement in the loader is Task 5.
Snapshots as the git stand-in are Task 1. The macOS launcher is Task 15.

**Design-system coverage for M1.** The design system's M1 row lists: tokens (Task 12 Step 5), grain
(Step 7), fonts (Step 4), MotionProvider (Step 7), spring tables (Step 6), Shimmer (Step 8a),
ClockRing (Step 8b), **SeamLantern (Task 14 Step 0)**, chip flight (Task 13 Step 6),
card-becomes-page (Task 13 Steps 4–5), compile choreography (Task 14 Steps 1–2), and staggered lists
(throughout, via `listParent`/`listChild`). All present. Reduced motion is verified as its own step
(Task 14 Step 5) rather than left as an assertion.

**Deliberately deferred, and named as such in the spec:** base JUnit suites and Phase 3, curveballs
and diff measurement, the five-dimension code reviewer, inline-comment review UI, Phase 5–7,
concurrency harness, evidence ledger, readiness board, company modes, reflection-based Phase 0
probes, console panel, keystroke autosave, external-IDE `fs.watch` path. Each gets a task in the
M2–M6 plans.

**Two honest gaps in M1, both intentional:**

1. Phase 0's exit criterion is "compiles, twice" but M1's vocab check does **not** compile the
   source — it checks structure only, and Phase 2 is where compilation is exercised. Adding a
   compile call to `/api/grade/vocab` is a one-line change in M2 alongside the reflection probes.
2. The evidence ledger is not in M1, so `first_compile_at` is recorded but never displayed. The
   column exists from Task 4 so M5 needs no migration.

**Type consistency check.** `FileEntry {path, content}` is used identically in `api.ts`,
`writeFiles`, `db.saveFiles` and both drill views. `Diagnostic {path, line, column, severity,
message}` is produced by `parseDiagnostics` and consumed unchanged by `Editor`. `createApp` returns
an `http.Server`, which is what `server/test/api.test.mjs` calls `.listen(0)` on. `askJson(tier,
prompt, {run})` has the same signature in `grader-llm.mjs`, its tests, and `gradePhase1`'s `ask`
parameter. `scoreDeterministic` returns `{found, missed, total, foundCount, fraction, passed}` and
`gradePhase1` spreads exactly that shape on both fallback paths, so `Phase1Result` in `api.ts` holds
for every tier.

Front-end props checked too: `Shell` requires `routeKey` and `App` supplies it; `Editor`'s optional
`sheenKey` defaults to `0` so `VocabDrill` need not pass it; `ClockRing` takes
`{remaining, limit, typing?, size?}` and all three call sites pass `remaining` from
`useClock`'s return, which does expose `remaining`; `SeamNode {name, kind, lit}` is produced only by
`useSeamNodes` and consumed only by `SeamLantern`; the `layoutId` pairs `card-${id}` and
`title-${id}` appear in exactly two files, `Ladder.tsx` and `Brief.tsx`, spelled identically. The
`noteTyping`/`typing` pair is defined locally in all three drill views rather than shared — a
deliberate duplication of six lines, because hoisting it into a hook would couple three views for no
benefit. `useClock`'s `label` field is now unused by every caller; it is left in place because M2's
console panel will want it.

**One inconsistency found and fixed during this review:** `cardHover` originally animated
`box-shadow`, which the design system's own performance budget forbids. It is now transform-only,
with the shadow lift moved to a CSS transition on `.card:hover`.
