Review: 5/5 lanes pass, 238/238, tsc clean. Verified live: scram duty-cycling with tanks covering (zero brownout), shipment stamps, strain fever 0.824. Found 1 real bug in review: UI BROWNOUT banner fires on covered deficit (topstrip.ts:66) - ordered. Contracts v4: research (lab kind, setResearch, ResearchState, researched event), SelectionState protocol (kills __remove sentinel, main.ts owns remove dispatch), accent?, coolRadius? (spatial cooling), bandwidth.capacity?, save/load hardened, heat ruled GROSS (net = heat - cool). tsconfig now typechecks data/ (DATA's proof). Orchestrator patched lab entries into exhaustive kind maps (my breakage, my fix). Rulings: pathspec-commit rule after 3 shared-index collisions (worktrees = named escalation); bloom escalator reading accepted; UI ?uidemo + SCREEN boredom approved. Codex: docs/FKTRY_LORE.md grew §10 THE COMPOSITE SEAM - keying/transparency artifact family from the Corridor Crew compositing history + our corridorkey-mrp-mlx (which doubles as a PYXLFK ground-truth artifact factory). DATA transcribes it round 4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
24 KiB
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):
#uiroot haspointer-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,
#d8ffd8on dark, hot accents#ff3fd4/#3fffe0(match GLYTCH's palette; see../../index.htmlfor reference). No rounded corners. This is machinery, not a startup.
CURRENT ORDERS — Round 4 (goal: research UI, one real bug, protocol migration)
Round 3 review: live.test.ts — booting the real sim against the real DOM — is the
best verification asset any lane has produced; the SANITISING machine-swap showed
exactly the right instinct when an order's premise fails. Verdicts: CONTRACT REQUEST 1
GRANTED (v4 SelectionState + setSelection/selection on the bus — main.ts now owns
the remove dispatch; see order 2). Request 2 GRANTED (MachineDef.accent? — your
derivation becomes the documented fallback, DATA fills the field where it's wrong).
?uidemo PROPOSAL APPROVED. Tech-panel skip was right at the time — but it was
another round-start grep going stale: tech.json has 24 nodes at HEAD (DATA landed
mid-round again). It's a real order now.
- BUG, found live in review: the BROWNOUT banner lies.
topstrip.ts:66keys onout || ratio > 1— during a covered deficit (tanks draining, simbrownout: false, THE SCREEN correctly calm) the panel screams BROWNOUT. That state is survival, not failure. Fix: BROWNOUT = the flag only; deficit-with-reserve gets its own amber state (suggest "ON RESERVE · 34s" —stored/(draw-gen)is seconds left, same math SCREEN uses for dread). Add a test pinning covered-deficit ≠ brownout. - Selection protocol migration: write via
bus.setSelection(...), drop theREMOVE_DEFsentinel export and — important — delete your ownremovedispatch: main.ts dispatches it on click now (double-fire today is harmless only because the second one no-ops). - Research UI, the centerpiece:
Topens the tech tree (24 nodes, 5 eras), pick →setResearch; progress fromsnapshot.research(packs delivered vs cost); locked machines in the build bar get greyed + padlock + "REQUIRES: " tooltip (lock rule in CONTRACTS v4 notes);researchedevent → toast in house voice ("STANDARD RATIFIED. NEW CRIMES AVAILABLE.") and the padlocks fall off live. ?uidemo— as approved: boots__fktryDemo()after init.- Accent adoption: read
accent?first, your derivation second, kind table third (same precedence as RENDER — one sentence in NOTES confirming you both read it identically).
Definition of done: banner distinguishes RESERVE from BROWNOUT under test; a
full research run is verifiable in live.test.ts (lock visible → research → unlock
→ place succeeds); remove mode works through the typed protocol with RENDER's tint.
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).
- 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.
- Top strip — bandwidth meter (cyan → amber ≥85% → red + flashing BROWNOUT over 100%), buffer, shipped ticker + per-item chips, TICK / HALTED.
- Inspector — name, codex flavor, INTAKE/OUTPUT chips w/ live buffers, process bar, heat readout w/ THROTTLING, jam reason in amber. Esc or X closes.
- Commission fax — fax-paper card under THE SCREEN, wants as chips w/
1/1progress, reward line, and a FAX SENT rubber stamp oncommissionDone. - 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.tsso 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, andUIBushas onlydispatch/selectedBuild— so the UI cannot reachrenderer.pickTile, which is the only thing that knows the camera. LANE-RENDER has a workingpickTilethis round; I just can't call it.pick.tsopportunistically probeswindow.__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. Nojsdom/happy-dominstalled 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. ?uifixtureis 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: spacedelivers an emptyevent.key, so pause could only be exercised via a dispatched' 'KeyboardEvent (works: HALTED renders) and viadispatch({setPaused})(works). Real spacebar sends' 'per spec; the handler matches that.
CONTRACT REQUEST:
UIBus.pickTile(clientX, clientY): Vec2 | null(or pass theRenderertoui.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.MachineDef.color: string— why:ItemDefhascolor,MachineDefdoesn'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 nowpalette.tsand 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.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.jsdom(orhappy-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.
- Click-to-inspect via
bus.pickTile. Thewindow.__fktryRendererprobe and the TAB fallback are gone. - 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. - Inspector — recipe picker (
setRecipe), heat bar w/ THROTTLING/SCRAM states, jam wording viajamText(). - Fax — NEXT IN TRAY peek, STANDING ORDER stamp on
repeat, SANITISING chip on shipped anchor-slabs. - 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.tspins it at 1/9/14/40 crafters. - Logic was pulled out of the DOM modules into
pages.ts/chipspec.ts/ a purecreateKeyRouter, which is why there are 57 tests without happy-dom (below). - Data-first everywhere:
MachineDef.flavorand.colorwin, 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-domis NOT installed — order 5 says "GRANTED — happy-dom is installed"; it is not inpackage.jsonand not innode_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 inchipspec.test.ts) andattach()'s window binding (the routing is covered inhotkeys.test.ts). Install it and I'll add the render tests.- STANDING ORDER stamp is built but unobservable. Data has 3
repeatcommissions (dust-allowance, correction-buyback, melt-retainer) but the sim pins the active commission tocommissions[0](first-taste, not repeat) and never advances —commissionDoneEmittedlatches 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
activeCommissionbut 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-decoderhasheatPerTick(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/andscreen/continuously; vite logged 16 page reloads in ~10 minutes, wiping the world mid-test every time. I verified onvite build+vite previewinstead. Not a complaint — just: if a round asks for hands-on verification, expect this. - During the round I hit LANE-SCREEN's
glitchStack.tsand LANE-RENDER'sindex.tsmid-edit and broken (tsc errors, dead rAF loop). Both self-resolved.src/uiwas clean throughout; the one failing test at the time was LANE-SIM's ownsim.test.ts. - Harness note (unchanged): browser-automation
key: spacesends an emptyevent.key; space verified via a dispatched' 'event. Real spacebar sends' 'per spec.
CONTRACT REQUEST:
SimSnapshot.commissionQueue: string[](ornextCommission: string | null) — why: NEXT IN TRAY currently guesses from data order. Low cost, removes a UI lie.- 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. happy-domactually 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.
- REMOVE mode —
Xor the REMOVE button beside the tabs arms it (button goes hazard red, hint turns red,[ ] PAGE · X REMOVEin 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. - 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. - 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. - 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/1amber, 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 >= 1got wrong, now read offEntityState.scrammedper 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 matchescommissionQueue[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
__removeghost sentinel (LANE-RENDER: this is the hook — please read). The UI has no Renderer reference; the only channel to the ghost isbus.selectedBuild(), which main.ts feeds tosetGhost. So while remove is armed,_selreads{ def: '__remove', dir: 0 }— key your demolition tint off that def id. It is safe because sim's place handler isconst def = defs.get(cmd.def); if (def) ..., so main.ts's click-to-place fires aplacefor__remove, matches no machine, and does nothing; the real removal is dispatched by the UI. Exported asREMOVE_DEFfromsrc/ui/selection.tsand 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
coloris chassis, not accent. DATA's chassis values are all desaturated neutrals — correct for the world, but 21 icons in#4a4e52/#3f4348is an unreadable grey wall. So an icon is body (datacolor) + 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, notheat >= 1. Re-deriving SIM's hysteresis here would silently desync the moment they retuneHEAT_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 shipshf-dust,chroma-slurry,macroblock-bricks,melt— zero slabs, withdct-presssitting onoutput 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 whatlive.test.tsdoes. 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 pausesrequestAnimationFrame, 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 reportedhidden: truetoo. Hencelive.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
commissionQueueas unpublished. My first grep forsnap.commissionQueuefound nothing and I wrote the fallback on that basis; SIM landed it minutes later (parallel-round race — same thing that happened withflavor/colorlast 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.jsonis 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:
UIBus.setGhostMode(mode: 'build' | 'remove' | null)(or letsetGhosttake a mode) — why: remove-mode hover currently rides a magic def id throughselectedBuild(), and main.ts fires a junkplacecommand 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.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.