--- name: codetrial-web description: The CodeTrial browser half and the boundary it talks across - why web/ has no build step, how a file reaches the browser embedded or from disk, the vendored checksum-pinned assets, the data-channel topics implemented once in web/lib.js and once against the TOPIC_ constants in src/runtime.rs, in both directions, and the wire fixtures that keep the two agreeing. Use when editing anything under web/, adding or changing a data-channel message, touching src/web/, or wondering why a browser change passed every Rust test and still broke the interview. --- # The CodeTrial browser boundary `web/` is plain ES modules loaded by the page. There is no bundler, no transpile step and no framework, which is what makes an edit in the checkout visible on reload. Keep it that way: a build step here would turn every browser fix into a build artifact somebody has to remember to regenerate. ## How a file reaches the browser `rust-embed` compiles `web/` into the binary, so a release build serves a complete application with no `web/` directory beside it. The fallback is per file rather than wholesale: a file present on disk wins, and one that is missing is filled in from the embedded copy. That is why a partially populated tree serves rather than 404s, and why running the binary from a checkout picks up your edit with nothing set. `--web-dir` or `CODETRIAL_WEB_DIR` points that root somewhere else. The consequence worth remembering: a debug build that reads from disk and a release build that serves embedded bytes can disagree, and only the second is what ships. `scripts/test.sh` covers this, and CI proves the binary serves with the web tree deleted. ## Vendored assets `web/vendor/` holds third-party bytes, some committed and some fetched. The LiveKit client, `avatar/three-vrm.js` and MediaPipe JavaScript loaders are in git. `FETCH` manifests name the downloadable Pyodide, PDF.js and MediaPipe model/wasm payloads; only those files can be restored by `scripts/fetch-vendor.sh`. Both kinds are pinned by a `SHA256SUMS` beside them, and `scripts/verify-vendor.sh` checks them; both scripts are wired into the gate. Never edit a file under `web/vendor/`, never lint it, and never add a dependency by dropping a file there by hand: the checksum is the whole mechanism. The avatar model is not even vendored, the browser fetches it from a pinned upstream URL against a pinned hash, and the interview falls back to a voice-only panel when it is unreachable. ## The data channel is two implementations Every topic on the LiveKit data channel is implemented twice, and a change that tests only its own side is exactly the shape that once let the integrity hash diverge and silently empty the evidence section of every camera interview while the gate stayed green. The direction decides what keeps the two sides honest. `web/lib.js` defines the topic set in `topics` and `src/runtime.rs` defines the matching `TOPIC_*` constants; that pair is the authority, and `grep -rn TOPIC_ src/` names every Rust publisher and consumer, several of them outside `src/agent/`. A browser-to-agent topic has a generated producer fixture, and the `files` map in `scripts/gen-wire-fixtures.mjs` is the list of them, because that script builds each fixture from the real producer. An agent-to-browser topic has none, which is why `web/lib.js` guards the report topic with `isAgent(participant)` by hand. `tests/fixtures/*.json` are the answer: generated by `node scripts/gen-wire-fixtures.mjs` from the real producer, consumed by the Rust tests, and held to `--check` in the gate. So when you change a message that map names: 1. Change the producer in `web/lib.js`. 2. Regenerate the fixtures and commit them with the change. 3. Change the consumer the grep named, against the new fixture. A topic with no entry in that map has no generated fixture to lean on, so pair the Rust producer with the browser consumer by hand and test both sides. Never hand-edit a fixture. Everything in the generator is deterministic, timestamps included, because a fixture that changes on every run cannot be checked. ## Generated pages `web/problems/`, `web/judges/` and the problem cards inside `web/index.html` are generated from `problem-bank/`. Edit the bank and rerun the generators; see codetrial-verify. ## The lint gate `eslint.config.mjs` is narrow on purpose: every rule in it fails only where the code cannot mean what it says, so the gate never becomes something people learn to argue with. `no-undef` is the reason it exists, since a typo'd identifier in `web/interview.js` reaches a candidate's browser while the same mistake in `src/web/mod.rs` never leaves the terminal. Three recommended rules are excluded with the reasoning written above them. Adding an `eslint-disable` is a stronger claim than it looks: the tree carries none, and the few `#[allow]`s on the Rust half each have their reason on the same line. Match that bar or do not suppress the rule. Browser behavior also gets a test, in `tests/browser/*.test.js` or, where it needs a real browser, `scripts/browser-check.sh`; codetrial-verify has what each lane requires.