glytch/fktry/lanes/LANE-UI.md
type-two 8dd4616580 [orchestrator] MEGA round 5: the paint and the pipe
Supersedes unexecuted round-5 orders. Folds in the live first-eyes review findings
(silence, void floor, onboarding cliff, quiet payoffs) + John's hidden-radio idea.

Contracts v6: AudioFX (THE SPEAKER - all Web Audio synthesis, no asset files),
RelicState + excavate + relicFound (the optical-fossil hidden pipe: find it and the
game grows a tuner - FKTRY FM, Correction propaganda subtitled by THE SCREEN's
chyron, a shortwave numbers station reading hidden-seam coordinates, dead air).
main.ts wires audio + first-gesture unlock; src/audio stub awaits LANE-SCREEN.

Lane orders: SIM = wildlife + seams-become-real + the relic + research time;
RENDER = the floor (seam glints, strata whispers), relic shimmer (no signposting),
juice, break-room cabinet -> match.html; UI = mercy toast, tuner dock, SCREEN
click-to-enlarge, camera hints; SCREEN = era feed + THE SPEAKER (3 audio layers,
4 stations, spectral verification); DATA = the commission-ladder tutorial,
stations.json broadcast schedule, seams.json final.

Ship gate: after review, redeploy partly.party/glytch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:56:09 +10:00

32 KiB
Raw Blame History

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 5 (MEGA) — supersedes the earlier round-5 text

Review verdicts you're executing: first-click punishment without guidance, camera controls taught nowhere, THE SCREEN's payoff too quiet, and one suspect tab click. Plus the radio — the game's sound is a discovery, and you build its face.

PART A — carried from round 5

  1. Scrolling tab strip (your design, approved).
  2. Publish inspect mode: inspector open → setSelection({mode:'inspect', entity}); clear on close; Esc order preserved (drop tool → close inspector).
  3. Wildlife surfacing: infestation toasts in voice; MOSQUITO EXPOSURE line + speed penalty in the inspector; one hatch-warning toast per pile, not a nag.
  4. Migrate research tests to grantResearch; **verify RENDER's demolition tint
    • inspect highlight with screenshots** (third round owed — close it).

PART B — onboarding + the radio face (new)

  1. First-brownout mercy toast (once per session): "NOTHING IS GENERATING. THE COMMITTEE SUGGESTS: POWER." — fires on the first brownout event only. The alarm currently teaches nothing; this is the one hint the opening needs.
  2. Camera hint in the idle line ("MMB/WASD PAN · WHEEL ZOOM") and a tab hitbox check: in review, a direct click on the POWER tab did nothing once (keyboard paging worked; may be harness — reproduce with a real mouse, fix or file it as harness with evidence).
  3. New jam voice: 'no seam' lands from SIM — give it the line it deserves ("SEAM EXTRACTOR: THERE IS NOTHING DOWN THERE. MOVE.").
  4. THE SCREEN click-to-enlarge: click toggles a centered ~2.5× overlay (CSS transform + backdrop dim), Esc/click closes. The sky is the score; let people look at it.
  5. THE TUNER DOCK (the radio's face). Hidden until relicFound/relics.found. Then: a dock slides in (bottom-right) — frequency dial (drag + arrow keys), band AM/FM/SW, volume + mute, station readout in voice ("88.1 FKTRY FM — SIGNAL LOCKED"). Controls talk to THE SPEAKER via its exported radio control (narrow sanctioned import from src/audio — SCREEN documents the API; coordinate in NOTES). Discovery ceremony: relicFound → "AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE." toast + dock slide-in. Radio state persists per session only (the fossil's found is the durable part, sim-side).

Definition of done: cold-start first-brownout shows the mercy toast; tuner dock appears only after excavation and drives the real audio engine; enlarge verified; tint/highlight screenshots in NOTES; tab click adjudicated.


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: stringwhy: 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 modeX 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 truthsnap.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?: stringwhy: 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/(drawgen) — 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).