glytch/fktry/lanes/LANE-UI.md
type-two 83e3ad2107 [orchestrator] round 3 review + contracts v4 + composite seam codex + round 4 orders
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>
2026-07-17 19:43:48 +10:00

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):

  • #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 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.

  1. BUG, found live in review: the BROWNOUT banner lies. topstrip.ts:66 keys on out || ratio > 1 — during a covered deficit (tanks draining, sim brownout: 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.
  2. Selection protocol migration: write via bus.setSelection(...), drop the REMOVE_DEF sentinel export and — important — delete your own remove dispatch: main.ts dispatches it on click now (double-fire today is harmless only because the second one no-ops).
  3. Research UI, the centerpiece: T opens the tech tree (24 nodes, 5 eras), pick → setResearch; progress from snapshot.research (packs delivered vs cost); locked machines in the build bar get greyed + padlock + "REQUIRES: " tooltip (lock rule in CONTRACTS v4 notes); researched event → toast in house voice ("STANDARD RATIFIED. NEW CRIMES AVAILABLE.") and the padlocks fall off live.
  4. ?uidemo — as approved: boots __fktryDemo() after init.
  5. 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).

  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.