# LANE-UI — HUD, build tools, and the voice of the game

You are an Opus 4.8 executor on the UI lane of FKTRY. Read `../MASTERPLAN.md` and
`../CONTRACTS.md` first; the world bible is `../../docs/FKTRY_LORE.md`. The game's
voice is §1's "OSHA-poster deadpan" — you write most of the words the player reads.
Every label, tooltip, and error is an opportunity; "Inventory full" is a crime when
"OUTPUT BUFFER AT CAPACITY. THIS IS A YOU PROBLEM." is available.

**Mission:** everything DOM. Build bar, inspector, resource readouts, commission fax,
pause/speed, hotkeys. Plain TypeScript + DOM — no framework, no CSS libs.

**Owned paths:** `src/ui/**` (styles included — inject a `<style>` tag or import a css
file from your dir).
**Never touch:** contracts, main.ts, other lanes. You never mutate snapshots; you
dispatch Commands via the bus and read state from `update(snap, events)`.

**Standing rules (all rounds):**
- `#ui` root has `pointer-events:none`; every interactive child you create re-enables
  it. Clicks must fall through to the game world everywhere else — test this.
- Keyboard map lives in one file (`hotkeys.ts`) — it will grow.
- Terminal-industrial aesthetic: monospace, `#d8ffd8` on dark, hot accents `#ff3fd4` /
  `#3fffe0` (match GLYTCH's palette; see `../../index.html` for reference). No rounded
  corners. This is machinery, not a startup.

---

## CURRENT ORDERS — Round 6 (goal: the enemy has a face, the dial has teeth)

Your tuner dock now drives the real engine (orchestrator adapter — the dead-dial
finding from the voice review is fixed and live-verified, station names and all).

1. **Mite surfacing**: toasts in voice ("A PARITY MITE IS EDITING YOUR MELT.",
   "IT LEFT A RECEIPT."), `repaired` events in the ticker, firewall inspector shows
   catches. The Correction is polite; the copy should make players furious.
2. **Tuner tick marks**: the adapter now passes `listStations()` through — draw
   station ticks on the dial band so hunting has landmarks (keep the hunt: ticks
   mark THAT something is there, not WHAT).
3. **Credits line** (from the voice-cloning plan, cheap now): an ABOUT/credits
   entry reachable from the tech panel footer — repo, codex, and the standing
   disclosure slot for future synthetic-voice credit.

**DoD:** a mite raid is narratable from UI alone; ticks render from real station
data; credits reachable in-game.

---

## NOTES (append-only log, newest at bottom)

```
### Round N — YYYY-MM-DD — [your model]
SHIPPED: / DECISIONS: / BLOCKED/BROKEN: / CONTRACT REQUEST: / PROPOSAL: / NEXT:
```

### Round 1 — 2026-07-17 — Opus 4.8

**SHIPPED:** All five ordered surfaces, in `src/ui/**`, verified in a browser (screenshots
taken of each). Modules: `index.ts` (root/wiring), `style.ts` (injected CSS), `voice.ts`
(all player-facing copy), `hotkeys.ts` (the keymap, one file as ordered), `buildbar.ts`,
`topstrip.ts`, `inspector.ts`, `fax.ts`, `toasts.ts`, `chips.ts` (shared item chip),
`selection.ts` (writes `bus._sel`), `palette.ts`, `pick.ts`, `dom.ts`, `fixture.ts` (dev).

1. Build bar — one button per machine, hotkeys 1-9, R rotates, Esc/right-click clears,
   selected button goes hot magenta, hint line shows facing + verbs.
2. Top strip — bandwidth meter (cyan → amber ≥85% → red + flashing BROWNOUT over 100%),
   buffer, shipped ticker + per-item chips, TICK / HALTED.
3. Inspector — name, codex flavor, INTAKE/OUTPUT chips w/ live buffers, process bar,
   heat readout w/ THROTTLING, jam reason in amber. Esc or X closes.
4. Commission fax — fax-paper card under THE SCREEN, wants as chips w/ `1/1` progress,
   reward line, and a FAX SENT rubber stamp on `commissionDone`.
5. Toasts — max 4, 6s TTL, colored per event kind, house voice.

**VERIFIED:** `npm run check` clean; `npm test` 70/70 (other lanes' suites). Dev server on
:8151 (8150 was another lane's session). Confirmed with own eyes: hotkey 1 → `bus._sel`
`{seam-extractor,0}`; R → dir 1 → 2; Esc → null; TAB → inspector paints SEAM EXTRACTOR
with flavor + MDAT ORE chip + process bar; brownout → red border/BROWNOUT/buffer 0 + both
toasts; FAX SENT stamp + its toast; pointer fall-through (`#ui` computes `pointer-events:
none`, empty world hits CANVAS, panels are solid). No console errors.

**DECISIONS:**
- All player-facing strings live in `voice.ts` so the OSHA-deadpan register stays in one
  place and isn't re-invented per panel.
- The UI keeps its own flattened `roster` (pick.ts) instead of holding the snapshot:
  CONTRACTS says buffers may be reused between snapshots, and pointer/key handlers fire
  *between* frames. The backing array is reused so 500 entities don't allocate per frame.
- Esc is overloaded deliberately: drops the pending build first, closes the inspector
  second — dropping the tool is what a player expects from the first press.

**BLOCKED/BROKEN (read this part):**
- **Click-to-inspect does not work.** `ui.init(root, data, bus)` never sees the Renderer,
  and `UIBus` has only `dispatch`/`selectedBuild` — so the UI cannot reach
  `renderer.pickTile`, which is the only thing that knows the camera. LANE-RENDER has a
  working `pickTile` this round; I just can't call it. `pick.ts` opportunistically probes
  `window.__fktryRenderer.pickTile` (nobody sets it) and otherwise falls back to **TAB
  cycles the unit manifest**, which is how the inspector is reachable with no console
  today. Tile→entity hit-testing (incl. footprint rotation) is written and ready; it is
  one contract line away from working. See CONTRACT REQUEST 1.
- **LANE-DATA shipped 21 machines; hotkeys only cover 9.** Slots 10-21 are click-only,
  and that includes MOSH REACTOR, DECODE ASIC and SCREEN UPLINK — three of the seven M1
  chain machines. The M1 chain is fully placeable by *mouse*, and 1-9 works, but "the M1
  chain on hotkeys" is not literally true. The bar now wraps to 3 rows so nothing is off
  the viewport (it ran off both edges at 21). See PROPOSAL.
- **No DOM tests for `src/ui`.** No `jsdom`/`happy-dom` installed and I may not touch root
  config, so this lane has zero unit tests — everything above was verified by hand in the
  browser. See CONTRACT REQUEST 4.
- `?uifixture` is dev scaffolding I wrote (`fixture.ts`) because the sim stub reported an
  empty world at the time — a fake snapshot on a wall clock so every surface could be seen
  working. It is inert without the query param and dispatches nothing. LANE-SIM is real
  now; this can be deleted once the real sim can be driven to a brownout by hand.
- Harness note, not a bug: the browser-automation `key: space` delivers an empty
  `event.key`, so pause could only be exercised via a dispatched `' '` KeyboardEvent
  (works: HALTED renders) and via `dispatch({setPaused})` (works). Real spacebar sends
  `' '` per spec; the handler matches that.

**CONTRACT REQUEST:**
1. **`UIBus.pickTile(clientX, clientY): Vec2 | null`** (or pass the `Renderer` to
   `ui.init`) — *why:* without it the inspector cannot be opened by clicking a machine,
   which is the core interaction of order #3. Renderer already implements it; only the
   plumbing is missing. This is my only real blocker.
2. **`MachineDef.color: string`** — *why:* `ItemDef` has `color`, `MachineDef` doesn't, so
   the build bar has nothing data-driven to tint icons with. LANE-RENDER's orders say to
   tint placeholder boxes "per codex color" — same gap. Right now `palette.ts` and their
   renderer each hand-maintain a color table, which is how the build bar and the world end
   up disagreeing about what a quantizer looks like. Data should own it once.
3. **`MachineDef.flavor: string`** (codex one-liner) — *why:* orders told me to hard-code
   a lookup; I did (`voice.ts`), but only 7 of 21 machines have one and the other 14 fall
   back to generic text. This is LANE-DATA's material, not mine.
4. **`jsdom` (or `happy-dom`) devDependency + `environment: 'jsdom'`** — *why:* the UI lane
   currently cannot write a single test. Chips/roster/footprint/selection logic are all
   pure and testable the moment there's a DOM env.

**PROPOSAL:** build bar paging — group the 21 machines into kind tabs (EXTRACT /
LOGISTICS / REFINE / POWER / SHIP), `[`/`]` to page, 1-9 within the active page. Makes
every machine hotkey-reachable and scales as LANE-DATA keeps adding. Not done this round:
the orders said "one button per machine, hotkeys 1-9" and redesigning the bar unasked is
exactly what MASTERPLAN says not to do. Say the word and it's a small round-2 change.

**NEXT (suggested):** wire click-to-inspect the moment request 1 lands and delete the TAB
fallback; recipe picker in the inspector (`setRecipe` exists in Command and has no UI);
build bar paging; tech/research panel once `tech.json` is populated (it's `[]`).

### Round 2 — 2026-07-17 — Opus 4.8

**SHIPPED:** All five orders. New: `pages.ts` (pagination), `chipspec.ts` (pure chip
arithmetic), six `*.test.ts`. Deleted: `fixture.ts` and the TAB-cycle fallback, as ordered.

1. **Click-to-inspect** via `bus.pickTile`. The `window.__fktryRenderer` probe and the
   TAB fallback are gone.
2. **Build bar paging** — kind tabs (EXTRACT / LOGISTICS / REFINE / POWER / SHIP), `[`/`]`,
   1-9 within the page. Selecting from anywhere pulls the bar to the owning page.
3. **Inspector** — recipe picker (`setRecipe`), heat bar w/ THROTTLING/SCRAM states,
   jam wording via `jamText()`.
4. **Fax** — NEXT IN TRAY peek, STANDING ORDER stamp on `repeat`, SANITISING chip on
   shipped anchor-slabs.
5. **57 tests** (min was 10), all green.

**VERIFIED** (against the real sim, in a browser, on a static `vite build`+`preview` —
see BLOCKED for why not the dev server): built a SEAM EXTRACTOR by mouse → DRAW 0→4 with
no generator → **real BROWNOUT**, red panel, buffer 0, underrun toast, THE SCREEN
visibly corrupting. Clicked the unit → inspector opened on entity 1 (`pickTile` →
{18,-7} → `entityAt`). Placed a SOFTWARE DECODER → GEN 0→55, brownout lifted, "BUFFER
RESTORED. RESUME SINNING." Recipe picker: options rendered as readable processes ("1
LUMA BARS → 1 COEFFICIENT PACK + 1 HF DUST"), selecting IDLE dispatched
`setRecipe{entity:1,recipe:null}` and the sim round-tripped it back as "UNIT IS BEING
PAID TO EXIST"; selecting back dispatched `recipe:'quantize'`. Starved wording live in
both toast and inspector: "QUANTIZER: INTAKE STARVED. NOTHING IS ARRIVING." Paging walk
confirmed all 21 machines across 6 pages (1+3+9+5+2+1) with **no page over 9 slots**.
`#ui` still computes `pointer-events:none`. `npm run check` clean; 160/160 tests.

**DECISIONS:**
- **Kind tabs alone could not satisfy "every machine hotkey-reachable at any catalog
  size"** — REFINE holds 14 crafters and 1-9 reaches nine. So a page is capped at 9 and an
  oversized kind spills into numbered pages (REFINE 1/2, REFINE 2/2). The invariant the
  order asked for now holds for any catalog; `pages.test.ts` pins it at 1/9/14/40 crafters.
- Logic was pulled out of the DOM modules into `pages.ts` / `chipspec.ts` / a pure
  `createKeyRouter`, which is *why* there are 57 tests without happy-dom (below).
- Data-first everywhere: `MachineDef.flavor` and `.color` win, round-1 hand-transcribed
  tables stay as fallback. LANE-DATA's flavor is what's on screen now.
- Unknown jam reasons pass through uppercased rather than being swallowed, so a new sim
  stall reason shows up honestly instead of silently reading as generic.

**BLOCKED/BROKEN (read this part):**
- **`happy-dom` is NOT installed** — order 5 says "GRANTED — happy-dom is installed"; it
  is not in `package.json` and not in `node_modules` (`npm ls happy-dom` → empty). I did
  not add it: root config is orchestrator-owned and editing it is a hard rule. Instead I
  factored the testable logic into pure modules and wrote **57 node-env tests** covering
  everything order 5 named except the two that genuinely need a DOM: chip *rendering*
  (the arithmetic is covered in `chipspec.test.ts`) and `attach()`'s window binding (the
  routing is covered in `hotkeys.test.ts`). Install it and I'll add the render tests.
- **STANDING ORDER stamp is built but unobservable.** Data has 3 `repeat` commissions
  (dust-allowance, correction-buyback, melt-retainer) but the sim pins the active
  commission to `commissions[0]` (`first-taste`, not repeat) and never advances —
  `commissionDoneEmitted` latches forever. So nothing can ever reach the stamp. Code is
  data-driven and will light up the moment the sim rotates commissions.
- **NEXT IN TRAY is an inference, not truth.** The snapshot has `activeCommission` but no
  queue, so "next" = the following entry in data order. It renders (NEXT IN TRAY → DUST
  ALLOWANCE) but if the sim ever picks non-sequentially the peek will lie. See CONTRACT
  REQUEST 1.
- **Heat bar unverified live.** Only `software-decoder` has `heatPerTick` (0.0025), and
  it heats only while crafting; a decoder with no load sits at heat 0, so the bar
  correctly stays hidden and I never saw THROTTLING/SCRAM. Thresholds are built to
  CONTRACTS (>0.7 throttle, 1.0 scram). Needs a fed chain to confirm.
- **SANITISING chip unverified live** — needs anchor-slabs actually shipped, which needs
  the full M1 chain fed. Renders off `shippedTotal`, so it is one shipment away.
- **The dev server is unusable for verification.** Other lanes are editing `render/` and
  `screen/` continuously; vite logged **16 page reloads** in ~10 minutes, wiping the world
  mid-test every time. I verified on `vite build` + `vite preview` instead. Not a
  complaint — just: if a round asks for hands-on verification, expect this.
- During the round I hit LANE-SCREEN's `glitchStack.ts` and LANE-RENDER's `index.ts`
  mid-edit and broken (tsc errors, dead rAF loop). Both self-resolved. `src/ui` was clean
  throughout; the one failing test at the time was LANE-SIM's own `sim.test.ts`.
- Harness note (unchanged): browser-automation `key: space` sends an empty `event.key`;
  space verified via a dispatched `' '` event. Real spacebar sends `' '` per spec.

**CONTRACT REQUEST:**
1. **`SimSnapshot.commissionQueue: string[]`** (or `nextCommission: string | null`) —
   *why:* NEXT IN TRAY currently guesses from data order. Low cost, removes a UI lie.
2. **Sim: advance/rotate commissions and honour `repeat`** — not strictly a contract
   change, a SIM ask: `commissions[0]` forever means 13 of 14 commissions and all 3
   standing orders are unreachable, and the fax's whole queue UI is decorative.
3. **`happy-dom` actually installed** (see BLOCKED) — the grant didn't land in
   package.json.

**PROPOSAL:** the inspector should get a REMOVE button (`remove` exists in Command with
no UI, so a misplaced machine currently can't be undone by mouse at all). Small, and it
closes the last build-verb gap.

**NEXT (suggested):** remove-by-mouse; heat/scram + SANITISING confirmation once a chain
can be fed; commission queue UI becomes real once requests 1-2 land; tech panel when
`tech.json` fills (still `[]`).

### Round 3 — 2026-07-17 — Opus 4.8

**SHIPPED:** Orders 1-4. Order 5 (tech panel) skipped — see BLOCKED. New: `live.test.ts`
(real sim + real HUD), `chips.test.ts`, `hotkeys.dom.test.ts`, `fax.test.ts`. **105 UI
tests** (was 57); full suite 238/238; `npm run check` clean.

1. **REMOVE mode** — `X` or the REMOVE button beside the tabs arms it (button goes hazard
   red, hint turns red, `[ ] PAGE · X REMOVE` in the idle hint). Click demolishes, Esc
   stands down, and it *stays armed* between clicks because demolition is usually plural.
   Toast: "UNIT RECLAIMED. THE FLOOR REMEMBERS." Clicking open ground does nothing, so a
   stray click never reads as a broken tool.
2. **NEXT IN TRAY reads truth** — `snap.commissionQueue[1]`. The data-order inference is
   **deleted**, not kept: SIM *is* publishing the field (see BLOCKED for the race that
   nearly made me miss it). No queue → no peek. A fax that lies quietly is worse than one
   that says nothing.
3. **DOM tests** — chip rendering, `attach()`/`detach()` window binding + typing guards,
   fax stamp lifecycle (incl. back-to-back stamps not clearing early), all under
   happy-dom.
4. **Live pressure verification** — see VERIFIED.

**VERIFIED — live, in the browser** (screenshots taken; `__fktryDemo()` = 118 commands):
- The reference factory builds and runs: DRAW 94 / GEN 180, buffer charging, belts moving,
  starved units toasting in house voice.
- **REMOVE end-to-end:** armed the mode (button red, `bus._sel` = `{def:'__remove'}`),
  clicked the mosh reactor → **gone from the world**, "UNIT RECLAIMED. THE FLOOR
  REMEMBERS." toast, **DRAW dropped 94 → 86** (its draw left with it), inspector
  auto-closed because its entity vanished, mode still armed. That's the round's DoD.
- Inspector on a real MOSH REACTOR: DATA's flavor, PROCESS SELECT reading
  "1 GOP CRATE → 3 MELT + 1 I-FRAME (ANCHOR SLAB)", INTAKE `GOP CRATE 0/1` amber, OUTPUT
  chips, "INTAKE STARVED. NOTHING IS ARRIVING."
- Paging tabs + NEXT IN TRAY (DUST ALLOWANCE) + REMOVE all legible in one bar.

**VERIFIED — headless, against the REAL sim** (`live.test.ts`; boots `createSim()` +
`createUI()` on the real `data/*.json`, builds `referenceFactory()`, pumps ticks through
the same `update(snap, events)` path main.ts uses, then reads the actual DOM):
- **Heat → THROTTLING → SCRAM** on a software decoder, inspected via the real
  pickTile→entityAt→open click path. Also pinned: the unit **still reads SCRAM while
  cooling back below 1.0** — the exact case `heat >= 1` got wrong, now read off
  `EntityState.scrammed` per contracts v3.
- **SANITISING on real anchor-slab shipments** (21 of them) — see BLOCKED for why this
  needed a machine swap.
- **STANDING ORDER on a real repeat completion**: the factory runs, SIM's queue advances,
  `dust-allowance` (repeat:true) reaches the head, the fax stamps it, and NEXT IN TRAY
  matches `commissionQueue[1]` exactly.
- **REMOVE against the real sim**: entity count drops by one, the id is gone, toast fires,
  stays armed, Esc disarms, open ground is a no-op.

**DECISIONS:**
- **The `__remove` ghost sentinel (LANE-RENDER: this is the hook — please read).** The UI
  has no Renderer reference; the only channel to the ghost is `bus.selectedBuild()`, which
  main.ts feeds to `setGhost`. So while remove is armed, `_sel` reads
  **`{ def: '__remove', dir: 0 }`** — key your demolition tint off that def id. It is safe
  because sim's place handler is `const def = defs.get(cmd.def); if (def) ...`, so main.ts's
  click-to-place fires a `place` for `__remove`, matches no machine, and does nothing; the
  real removal is dispatched by the UI. Exported as `REMOVE_DEF` from `src/ui/selection.ts`
  and pinned by a test. A magic string is not how I'd design this — CONTRACT REQUEST 1.
- **Build-bar icons now follow the two-material rule** (codex §8), prompted by the v3
  ruling that `color` is chassis, not accent. DATA's chassis values are all desaturated
  neutrals — correct for the world, but 21 icons in `#4a4e52`/`#3f4348` is an unreadable
  grey wall. So an icon is body (data `color`) + one emissive element **derived from the
  machine's first output item colour** — the same derivation LANE-RENDER uses, so bar and
  world agree. My round-1 id/kind tables were never wrong, only mislabeled (the same
  mistake the v2 contract made); they now serve as the accent fallback for machines that
  output nothing (belts, buffers, uplinks).
- Inspector reads `EntityState.scrammed`, not `heat >= 1`. Re-deriving SIM's hysteresis
  here would silently desync the moment they retune `HEAT_RESTART` — exactly what
  LANE-RENDER flagged in their round-2 notes.

**BLOCKED/BROKEN (read this part):**
- **The reference factory can never ship anchor-slabs, so SANITISING can't fire from
  `__fktryDemo()` as the order assumed.** It recycles them on purpose — reference.ts:
  *"Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the
  i-only assembler."* Confirmed by probe: 12k ticks ships `hf-dust`, `chroma-slurry`,
  `macroblock-bricks`, `melt` — zero slabs, with `dct-press` sitting on `output full`. So
  I verified it the way a player would: demolish the i-only assembler at (-6,5), drop an
  uplink on it → **21 slabs ship, SANITISING lights up**. That's what `live.test.ts` does.
  Nobody's bug; the order's premise just didn't hold.
- **Live verification of heat/scram/SANITISING in the browser is not practically
  possible, and it's the harness, not the code.** The browser pane keeps the tab
  backgrounded (`document.hidden === true`), which pauses `requestAnimationFrame`, so the
  page only advances **~10 ticks per screenshot**. SCRAM needs ~435 ticks of continuous
  generation and slabs need thousands — that's hundreds of captures, while parallel lanes'
  HMR reloaded the page **~16 times an hour**, wiping the world mid-test (it ate four of my
  attempts). Chrome-MCP was no better: it couldn't reach the dev server
  (`chrome-error://`) and reported `hidden: true` too. Hence `live.test.ts` — same sim,
  same DOM, same code path, deterministic, milliseconds, and it stays as a regression test.
  I'd rather hand you that than a screenshot I had to fight the harness for.
- **I nearly reported `commissionQueue` as unpublished.** My first grep for
  `snap.commissionQueue` found nothing and I wrote the fallback on that basis; SIM landed
  it minutes later (parallel-round race — same thing that happened with `flavor`/`color`
  last round). A probe against the real sim caught it. Worth noting as a pattern: in this
  operation, "not landed yet" has a short shelf life, and greps at round-start lie by
  round-end.
- **Order 5 (tech panel) skipped.** `tech.json` is **still `[]`** — an era tree rendering
  an empty array is a panel that says nothing, and the order marked it skippable. It's
  ~30 lines whenever DATA fills the file.
- The `?showroom` / demolition-tint coordination with LANE-RENDER is one-way so far: I've
  defined the sentinel above and pinned it, but they hadn't landed remove-tint when I
  finished, so **the hover tint is unverified from my side**. If they key off a different
  string, remove mode still works — it just won't glow.

**CONTRACT REQUEST:**
1. **`UIBus.setGhostMode(mode: 'build' | 'remove' | null)`** (or let `setGhost` take a
   mode) — *why:* remove-mode hover currently rides a magic def id through
   `selectedBuild()`, and main.ts fires a junk `place` command on every demolition click
   that only works because the sim happens to ignore unknown defs. It's two lanes agreeing
   on a string in a NOTES file. One typed field kills it.
2. **`MachineDef.accent?: string`** — *why:* deriving the icon accent from the first
   output item is a decent heuristic, but it's a heuristic, and LANE-RENDER is running the
   same one independently. If art direction ever wants a machine's glow to differ from its
   product, there's nowhere to say so. (LANE-RENDER asked for this in round 2 as well.)

**PROPOSAL:** `?uidemo` — a URL param that runs `__fktryDemo()` on boot. Trivial, in my
lane, and it would have saved most of an hour this round: every HMR reload wiped the world
and needed a manual re-dispatch. Say the word.

**NEXT (suggested):** tech panel the moment `tech.json` has entries; verify RENDER's
demolition tint once it lands; SANITISING's real fiction (The Correction's buyback) when
M3 arrives — the hardcoded `anchor-slab` id should become data by then.

### Round 4 — 2026-07-17 — Opus 4.8

**SHIPPED:** All five orders. New: `power.ts` + `power.test.ts`, `research.ts` +
`research.test.ts`, `techpanel.ts` + `techpanel.test.ts`. **145 UI tests** (was 105);
`npm run check` clean.

1. **The BROWNOUT bug is fixed.** BROWNOUT is now the sim's flag and nothing else. A
   covered deficit gets its own amber, steady, non-flashing state: **"ON RESERVE · 34s"**,
   from `stored/(draw−gen)` — the same arithmetic SCREEN drives dread with, and honest
   because v3 ruled `stored` is bandwidth-SECONDS. Four states now: ok / tight / reserve /
   brownout, all pure in `power.ts` and pinned by tests, including the exact case that was
   lying (`gen 100, draw 134, stored 1000, brownout:false` → reserve, **not** brownout).
   Bonus: the buffer readout is a fuel gauge now (`0/900`) using v4 `bandwidth.capacity`.
2. **Selection protocol migrated.** Writes go through `bus.setSelection()`, `REMOVE_DEF`
   is deleted, and **my `remove` dispatch is gone** — main.ts owns it. Verified live: one
   click, one removal, no double-fire. A legacy fallback remains for a host that doesn't
   serve `setSelection` (it's optional in the contract) and is tested.
3. **Research UI.** `T` opens the STANDARDS COMMITTEE: every tech grouped by era in era
   order, cost chips in item colours, era prefix stripped from node names, `UNLOCKS:`
   tooltips in machine names. Pick → `setResearch`; picking the active one stands it down;
   ratified nodes are inert. Locked machines in the bar are greyed + padlocked with
   `REQUIRES: <TECH>`, can't be picked by click *or* hotkey, and the padlock falls off in
   the same frame `researched` lands. `researched` → "STANDARD RATIFIED. NEW CRIMES
   AVAILABLE."
4. **`?uidemo`** — boots the reference factory after init. It already paid for itself.
5. **Accent adopted**: `MachineDef.accent` → first-output derivation → codex kind table.
   **LANE-RENDER: confirming we read this identically** — same three tiers, same order.

**VERIFIED — live, in the browser** (`?uidemo`, screenshots taken): the factory builds on
boot with no console poking; the STANDARDS COMMITTEE renders all 23 techs across REEL /
BROADCAST / DISC / STREAM / FORTRESS; clicking SOFTWARE DECODING dispatches `setResearch`
and the node goes hot magenta reading **IN COMMITTEE** with a progress bar (round-tripped
through the real sim); the POWER page shows DECODE ASIC bright and free next to SOFTWARE
DECODER and ASIC COOLER greyed with padlocks and `REQUIRES: STREAM SOFTWARE DECODING` /
`REQUIRES: BROADCAST ASIC COOLING`; `bus.selection()` reads `{mode:'remove'}` and a click
demolishes the mosh reactor once — DRAW 100→92, "UNIT RECLAIMED. THE FLOOR REMEMBERS."

**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 16 tests): the whole
research loop the DoD asked for — **lock visible → `place` silently dropped by the sim →
build a lab → pick the tech through the panel → packs delivered → `researched` → toast →
padlock falls off → the same `place` now succeeds.** Its harness is now a faithful main.ts
(typed selection; *it* dispatches place/remove), which is what caught the double-fire.

**DECISIONS:**
- Packs in the research run are teleported into the lab's intake via the sim's own
  `save()`/`load()` (v4 hardened; `load()` re-runs `applyUnlocks`). Everything after that
  is the sim's real `drainLabs → researched → applyUnlocks` path, unmocked. Reason in
  BLOCKED: nothing in the game can currently *make* a science pack.
- With no `ResearchState` at all, the safe reading of the v4 rule is that gates **hold**
  (padlocks on) rather than fall open, and the panel says RESEARCH OFFLINE instead of
  rendering a tree that can't do anything. The sim always ships a ResearchState today, so
  this is belt-and-braces.
- `live.test.ts` no longer hardcodes reference-factory coordinates — it finds the i-only
  assembler by its recipe. reference.ts moved it from (-6,5) to (-6,6) *during this
  round* and broke my own test; that's my brittleness, fixed.

**BLOCKED/BROKEN (read this part):**
- **SIM's research gating breaks SIM's own reference factory, and it's live right now.**
  `stream-software-decoding` gates `software-decoder`; the sim silently drops placements
  of gated machines (`if (!isAvailable(def.id)) return`); reference.ts places two software
  decoders for its 200 of generation. Result at HEAD: `?uidemo` builds a factory with
  **GEN 80 against DRAW 100, buffer 0/900, permanent BROWNOUT** — it can never run. My
  screenshots show it. Either reference.ts should use decode-asics, or the demo should
  pre-unlock, or that tech shouldn't gate the generator. (This is also why every
  reference-factory test in `live.test.ts` now runs with research pre-granted.)
- **Nothing in the game can make a science pack yet, so no research can actually be
  performed by playing.** Packs come only from `artifact-bottler`, whose recipes need
  `field-splinters` / `v-hold-roll` / `static-canister` — a broadcast-era chain that
  neither the reference factory nor any tech-free layout builds. The tree is *reachable*
  on paper (18 techs have satisfiable costs, and DATA fixed the artifact-bottler deadlock
  I was about to report), but unreachable in practice. Suggest reference.ts grow a
  research rig — it would make this verifiable by playing rather than by save/load.
- **LANE-SIM's `reference.test.ts` is red at HEAD** ("keeps the demuxer alive by voiding
  surplus rather than jamming"), and the factory layout shifted twice while I worked. Not
  mine; flagging because the suite isn't green.
- **RENDER's demolition tint is still unverified from my side.** The typed `mode` reaches
  `setGhost` via main.ts now, so the magic string is no longer my problem — but I never
  saw the tint land.
- **The order's own numbers went stale mid-round, again — third round running.**
  "24 nodes" is 23 at HEAD; `tech.json`'s era distribution changed under me; the
  artifact-bottler deadlock I'd have reported as blocking was fixed by DATA while I
  worked; SIM landed *all* of research after my round-start grep said it had none. I now
  re-probe everything against a running sim before writing a word of NOTES, which is the
  only reason this entry is accurate. Worth considering whether lanes should get a
  "HEAD as of" line rather than counts baked into orders.
- Minor, mine: the tab row wraps to two lines now that RESEARCH joined (8 tabs + REMOVE).
  Legible, not pretty. Real fix is a scrolling tab strip; say the word.

**CONTRACT REQUEST:** none this round. v4 gave me everything I asked for and the
`SelectionState` migration was clean.

**PROPOSAL:** `Sim.grantResearch(techIds)` or a `?unlocked` demo flag. Every research-
adjacent test I wrote needs research pre-granted, and my only route is
`JSON.parse(sim.save())` → mutate → `load()`. That works and uses public API, but it
couples my tests to SIM's save schema — if they rename `research` in the blob, my suite
breaks for no good reason.

**NEXT (suggested):** verify RENDER's tint; research rig in reference.ts so a pack economy
exists to test against; scrolling tab strip if the catalog grows again; era-skinned tech
panel once eras mean something visually (§8).

### Round 5 (MEGA) — 2026-07-18 — Opus 4.8

**SHIPPED:** All nine orders. New modules: `power.ts` was round 4; this round adds
`radio.ts` (SPEAKER bridge + echo stand-in), `tuner.ts` (the dock), `screenview.ts`
(enlarge), and tests `tuner.test.ts`, `wildlife.test.ts`, `screenview.test.ts`.
**194 UI tests** (was 145); full suite 446/446; `npm run check` clean.

1. **Scrolling tab strip** — one row, never wraps, active tab scrolled into view on
   `[`/`]`. Found and fixed a real bug doing it: see the hitbox finding below.
2. **Inspect mode published** — `setSelection({mode:'inspect',entity})` on open, cleared
   on close (X, Esc, or the unit being demolished under it). Holding a tool supersedes it
   and shuts the panel, so panel and highlight can't disagree.
3. **Wildlife surfaced** — infestation toasts in voice; MOSQUITO EXPOSURE + severity in
   the inspector, read from `snap.wildlife` by `target`; hatch warnings deduped one per
   pile id.
4. **Research tests migrated** to SIM's `testkit.grantResearch`/`grantAllResearch` — my
   round-4 hand-rolled save-blob edit is gone, and with it the coupling to SIM's schema.
   Tint + highlight verified, see below.
5. **First-brownout mercy toast** — once per session, on the first brownout event.
6. **Camera hint** in the idle line; **tab hitbox adjudicated** (below).
7. **`no seam` jam voice** — "THERE IS NOTHING DOWN THERE. MOVE." SIM emits this reason
   at index.ts:498, so it's live copy, not speculative.
8. **THE SCREEN click-to-enlarge** — ~2.5×, dimmed backdrop, Esc/click closes.
9. **THE TUNER DOCK** — hidden until the fossil is excavated, then slides in with dial
   (drag + ←/→), AM/FM/SW, volume + mute, and a station readout in voice.

**VERIFIED — live, in the browser** (screenshots taken of each):
- **Cold-start mercy toast**: placed one extractor with no generator → BROWNOUT, and
  under it "NOTHING IS GENERATING. THE COMMITTEE SUGGESTS: POWER." That's the DoD's
  opening requirement.
- **THE SCREEN enlarge**: the live WebGL sky at 2.5× centred over a dimmed HUD; Esc
  restored its `style` attribute to `null` and its dock rect to y=10, w=322 — exactly as
  found.
- **THE TUNER DOCK**: excavated SIM's real seeded fossil → dock slid in bottom-right with
  "AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.", dial, FM active, VOL/MUTE, hint line.
- **RENDER's demolition tint — the item owed since round 3 is CLOSED.** Armed remove,
  hovered a machine, and it renders outlined in demolition red. Screenshot taken.
- Build bar with padlocks on the POWER page (DECODE ASIC free; SOFTWARE DECODER and ASIC
  COOLER greyed + 🔒 + REQUIRES:), reference factory healthy at GEN 220 / DRAW 158.

**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 19 tests): mercy toast fires
once and *stays* once across a second brownout; inspect selection published on open and
dropped both on Esc and when the inspected unit is demolished; the tuner stays hidden
until `relics[0].found` flips and then reveals with the ceremony toast and the toast
standoff; full research loop (lock → pick → deliver → unlock → place) now driven through
`grantResearch` and the lab's real per-pack timing.

**DECISIONS:**
- **THE SCREEN is enlarged in place, not mirrored.** I built the mirror first; it rendered
  black. THE SCREEN is a WebGL canvas, and `drawImage` from a WebGL context without
  `preserveDrawingBuffer` returns nothing. Asking SCREEN to enable that flag would cost
  them a buffer copy every frame for an occasional feature, so I enlarge the real element
  with inline styles and restore its `style` attribute verbatim on close (tested,
  including the case where SCREEN later adds inline styles of their own).
- **The radio bridge is guarded** (`radio.ts`). `src/audio` is still the stub — no `radio`
  export exists — so the dock resolves a control at runtime and otherwise drives a silent
  echo stand-in, which is why every dial is testable today. **LANE-SCREEN: publish
  `window.__fktrySpeaker = { radio }` (or tell me the export name you prefer) and the dock
  drives real audio with zero change here.** The interface I'm driving is exactly your
  documented one: `setFrequency / setBand / volume / mute / getState / subscribe`, with
  `getState()` returning `{freqMHz, band, volume, muted, station}` — `station: string|null`
  is what flips the readout to "SIGNAL LOCKED". `isLive()` reports which one is attached.
- Wildlife exposure shows the swarm's **severity** (`WildlifeState.size`), not a
  sim-confirmed throughput number — SIM owns the real penalty and doesn't expose one.
  Labelled as exposure rather than a promise about output.

**BLOCKED/BROKEN (read this part):**
- **The tab-click bug was REAL, and I fixed it.** Reproduced with a genuine mouse click:
  a click on POWER did nothing. `elementFromPoint` at the button's own centre returned the
  *strip container*, not the button — the horizontal scrollbar was being painted over the
  lower half of every tab and swallowing clicks there, which is exactly why keyboard
  paging still worked. Fixed by giving the scrollbar its own lane (`padding-bottom: 7px`
  ≥ scrollbar height); the full button height now hit-tests to the button, and a real
  click activates POWER. **Separately**, `computer` clicks via `ref` deliver *no* events to
  the page at all (document-level capture recorded nothing); coordinate clicks work fine.
  So: real bug, now fixed, plus a harness quirk on top of it.
- **DATA's commission queue head-of-line blocks, and it stalls the whole game.** The v5
  queue opens with `raw-payload` (20 raw MDAT ORE), but the reference factory refines every
  scrap of its ore and never ships it — so the head commission can never complete and all
  19 behind it are unreachable. Probed: 12k ticks ships hf-dust, chroma-slurry,
  macroblock-bricks, melt and anchor-slab, and `commissionDone` fires **zero** times. My
  tests now build the obvious player rig (extractor → belt → uplink) to pay it. Worth a
  design look: either the opening commission should want something the tutorial factory
  ships, or an unsatisfiable head shouldn't block the tray.
- **Machines only push onto belts, never into an adjacent machine.** Cost me a while to
  find; noting it because it's non-obvious and unstated anywhere in CONTRACTS.
- **RENDER doesn't consume the inspect selection yet** — no `inspect` anywhere in
  `src/render`. I publish it correctly (verified live: `{mode:'inspect',entity:105}`), so
  the highlight lights up whenever they read it. **Not owed by me any more; owed to me.**
- **Stale ghost on keyboard disarm**: main.ts only refreshes the ghost on `pointermove`, so
  Esc-ing out of remove mode leaves the demolition tint on screen until the mouse moves.
  Visible in my inspect screenshot. Cosmetic, and it's main.ts's glue, not mine.
- **SIM landed wildlife AND relics mid-round, again** — my round-start grep found neither.
  Fifth round running. It's why my wildlife/relic UI could be verified against the real sim
  at all, so this is a good problem, but the pattern is now completely reliable: I re-probe
  a *running* sim before writing NOTES, never a grep from round-start.
- Three of my live tests failed mid-round from SIM/DATA changes, not my code (proved by
  stashing my diff and re-running). Also hit `DUST_SPILL_GRACE is not defined` from SIM
  mid-edit, which resolved itself between two consecutive commands.
- Harness, for the record: the browser pane backgrounds the tab, which pauses rAF **and
  CSS transitions**. I lost time reading a transitioned `bottom` that never advanced and
  briefly thought the CSS was broken; `transition:none` proved it computes correctly. Same
  root cause as the paused sim loop from rounds 3-4.

**CONTRACT REQUEST:** none. v5/v6 gave me everything these orders needed.

**PROPOSAL:** `UIBus.audio?: { radio?: RadioControl }` — the tuner currently reaches THE
SPEAKER through a global handshake because `ui.init` never sees the audio module, which is
the same shape of problem as `pickTile` in round 1 and the `__remove` sentinel in round 3.
One optional field on the bus would make it typed and testable instead of a documented
convention. Not blocking: the guarded bridge works.

**NEXT (suggested):** wire the real radio the moment SCREEN publishes it (and describe how
it sounds in NOTES, since they're doing the same); inspect highlight once RENDER reads the
selection; a station-locked readout screenshot once there are real stations; commission
head-of-line blocking is a design question for you, not a UI fix.

### Round 6 — 2026-07-20 — Opus 4.8

**SHIPPED:** All three orders. New modules: `corrections.ts` (session tally of The
Correction's edits) + `mite.test.ts`. Extended: `voice.ts`, `topstrip.ts`, `inspector.ts`,
`tuner.ts`, `radio.ts`, `techpanel.ts`, `toasts.ts`, `index.ts`, `style.ts`, and the tuner/
techpanel test files. **213 UI tests** (was 194); every `src/ui/**` file green; `npm run
check` reports zero errors in my lane (the one tsc error is SCREEN's, below).

1. **Mite surfacing — the enemy has copy now.** Replaced the v8 placeholder in
   `wildlifeToast`: a mite arriving is "A PARITY MITE IS EDITING YOUR MELT.", swatting one
   is "MITE SWATTED. IT FORGIVES YOU. IT ALWAYS FORGIVES YOU." (the Correction's manners
   are the insult). `repaired` events (v8) now toast in the house voice —
   "THE CORRECTION IMPROVED YOUR <ITEM>. IT LEFT A RECEIPT." — item name resolved from
   `data.items` via a resolver threaded through `toasts.push → eventToast`. Two more
   surfaces so a raid is narratable without watching the belts: a **CORRECTED ticker** in
   the top strip (session total + per-item chips, hidden until the first edit so the enemy
   is never signposted early), and a **FIREWALL LOG** in the inspector — per-unit catch
   count, attributed by footprint containment of each `repaired` pos. The Correction reads
   clinical white-green everywhere (codex §8: hospital-white, green checkmarks), calm next
   to the hot alarms — that's the intended menace.
2. **Tuner tick marks.** The dial now draws a hot-magenta tick per station on the current
   band, positioned by `(freqMHz-min)/(max-min)`, from the speaker adapter's
   `control.listStations()` (added optional `listStations()` + `StationMark` to
   `RadioControl`; the echo stand-in returns `[]`). Ticks are **anonymous** — they mark
   THAT a station is there, not WHAT; the readout stays the only thing that names a lock,
   so the hunt is intact. Rebuilt only on band/station-set change; empty pre-gesture and
   fills in on the first state push after the engine attaches — i.e. exactly when audio
   comes alive.
3. **ABOUT/CREDITS.** A footer button on the tech panel toggles a colophon: repo
   (gitea.partly.party/monster/glytch), codex (docs/FKTRY_LORE.md), a VOICES line, and a
   **standing disclosure slot** — "SYNTHETIC VOICE CREDIT: NONE ON FILE." — reserved for
   the voice-cloning plan's real credit when it ships.

**VERIFIED — live, on :8153 (?uidemo)**: app boots, **no console errors**. Credits: pressed
into the DOM path with a real click event → block reveals with repo/codex/voices and the
disclosure slot (text asserted). Confirmed present-and-correctly-hidden by default:
`.fk-corrected-wrap` (display:none pre-mite), `.fk-tuner-ticks` (0 ticks pre-gesture),
`.fk-ins-firewall` (hidden). `--fk-clinic` resolves to #d6f5df. The tab backgrounds
(viewport 0x0, rAF paused) exactly as every prior round documents, so the mite raid itself
isn't drivable live here — see below.

**VERIFIED — headless** (`mite.test.ts`, real modules, happy-dom, 14 tests): the mite voice
(arrival/swat/receipt copy + name fallback); the tally (ignores non-repairs, first-seen
per-item order, footprint containment); the FIREWALL LOG (hidden at zero, singular/plural
"1 TIME"/"2 TIMES", survives no-corrections); the CORRECTED ticker (hidden until first edit,
then count + chips). Plus `tuner.test.ts` (+2): no ticks pre-attach, one tick per on-band
station at the right `left%`, anonymous. `techpanel.test.ts` (+3): ABOUT collapsed, reveals
repo/codex/slot, toggles shut.

**DECISIONS:**
- **`repaired.pos` is the mite's belt tile, not a machine.** SIM repairs belt *items* (index.ts
  ~1316), so the event lands on a belt tile. FIREWALL LOG therefore shows catches on whatever
  unit's footprint that tile falls in — usually the belt itself (1×1), sometimes a crafter if
  the corrected item sat on its tile. That's honest: it's "edits on THIS unit's tiles", and
  belts are inspectable. Not a per-machine damage total (SIM doesn't expose one).
- **"Firewall" is grounded, not invented.** SIM already has `FIREWALL_RADIUS` /
  `miteInFirewallRange` (index.ts ~1329) — firewalls can mites on the approach. Naming the
  inspector's catch log FIREWALL LOG matches that fiction rather than coining a term.
- Item-name resolution stays data-driven (LANE-DATA's `ItemDef.name` wins); the resolver
  falls back to dashes→spaces+uppercase so a brand-new item never crashes a toast.
- Corrections state is session chrome, deliberately not persisted — the sim doesn't persist
  it either, and re-deriving it from a save would be a lie about what actually happened.

**BLOCKED/BROKEN (read this part):**
- **The mite raid is reachable by play but not in this harness.** Mites spawn once
  `corruptionShipped()/MITE_SPAWN_PER` clears a threshold (index.ts ~1282), so the reference
  factory *will* eventually breed them and fire `repaired` — but that needs sustained
  runtime, and the browser pane backgrounds the tab (rAF paused, viewport 0x0), the same
  wall documented in rounds 3-5. So the mite surfaces are verified headless against the real
  UI modules, and live only for DOM presence/default-state/no-errors. If you can drive a
  foregrounded build to the mite threshold, the ticker/firewall/toasts light up with zero
  further change.
- **`npm run check` has one error, and it is not mine: `src/screen/index.ts:106` ("function
  must return a value").** SCREEN's file is modified-uncommitted in the shared tree. My lane
  is clean — every `src/ui/**` file type-checks. esbuild strips types so the dev build boots
  fine regardless.
- **`npm test`: 4 failures across 3 files, none mine** — `src/audio/band.test.ts` (SCREEN),
  `src/sim/mite.test.ts` (2) and `src/sim/reference.test.ts` (SIM). All three files are
  modified-uncommitted by their lanes. Confirmed by ownership: I touched no `src/sim`,
  `src/audio`, or `src/screen` file. All 17 `src/ui/**` test files pass (213 tests).
- Re-probed HEAD live, not from a round-start grep: SIM *does* emit both `parity-mite` and
  `repaired` at HEAD (index.ts 1288/1321), and the adapter's `listStations()` is live in
  main.ts — so nothing here is speculative against a contract that hadn't landed.

**CONTRACT REQUEST:** none. Contracts v8 (`repaired`) + the speaker adapter's `listStations()`
gave me everything these orders needed.

**PROPOSAL:** a `?mites` dev flag (or a `testkit` verb) that seeds N parity-mites / forces a
`repaired` on boot, the mite analogue of `?uidemo`. Every mite surface I built is only
observable once corruption crosses a spawn threshold that the backgrounded harness can't
reach; a one-command seed would make the raid demoable in the browser instead of only in
`mite.test.ts`. Small, and it'd live in main.ts (yours) or SIM's testkit, not my lane.

**NEXT (suggested):** fill the credits' synthetic-voice slot when the voice-cloning plan
ships; name-on-hover for dial ticks *only* if playtesting says the hunt is too hard (default
stays anonymous); a per-machine "corrected N times" once/if SIM attributes repairs to a
machine rather than a belt tile; Checksum Wardens / Redundancy Gunships get the same
surfacing treatment when they arrive (the corrections module generalises to any Correction
edit event).
