[ui] round 6: mite surfacing, dial tick marks, and a credits footer

- The Correction gets a voice: parity-mite arrival/swat copy, repaired-event
  toasts ("IT LEFT A RECEIPT."), a CORRECTED ticker in the top strip, and a
  per-unit FIREWALL LOG in the inspector. All clinical white-green (codex §8),
  hidden until the first edit so the enemy is never signposted early.
- Tuner dial draws anonymous station ticks from the speaker adapter's
  listStations() (MHz, empty pre-gesture) — landmarks that mark THAT, not WHAT.
- ABOUT/CREDITS footer on the tech panel: repo, codex, and a standing
  synthetic-voice disclosure slot for the voice-cloning plan.
- 213 UI tests (was 194): new corrections.ts + mite.test.ts, extended tuner
  and techpanel suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:30:31 +10:00
parent d313f2a163
commit eafd31f263
14 changed files with 612 additions and 22 deletions

View File

@ -565,3 +565,103 @@ convention. Not blocking: the guarded bridge works.
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).

View File

@ -0,0 +1,56 @@
/**
* LANE-UI the session tally of The Correction's edits (round 6).
*
* A `repaired` event (contracts v8) is a parity mite quietly "fixing" one of your
* artifacts back into clean product at a tile. Nothing in the snapshot remembers this
* the event is edge-triggered and gone so the UI keeps its own running count for the
* two places that narrate the raid: the top-strip ticker (per-item totals) and the
* inspector's FIREWALL LOG (per-unit catches, resolved by footprint).
*
* This is session chrome, not sim truth: it resets when the page reloads, which is the
* honest thing to do since the sim doesn't persist it either.
*/
import type { SimEvent, Vec2 } from '../contracts';
interface Repair {
x: number;
y: number;
item: string;
}
export interface Corrections {
/** Record one `repaired` event. Ignores anything else, so callers can pass it raw. */
record(ev: SimEvent): void;
/** How many edits total this session. 0 before the first mite ever appears. */
total(): number;
/** Per-item totals, newest-touched first, for the ticker chip row. */
byItem(): { item: string; count: number }[];
/** Edits that landed on the given footprint rect — the inspector's per-unit "catches". */
catchesOn(pos: Vec2, footprint: Vec2): number;
}
export function createCorrections(): Corrections {
const repairs: Repair[] = [];
// Insertion-ordered per-item counts (Map preserves first-seen order).
const perItem = new Map<string, number>();
return {
record(ev) {
if (ev.kind !== 'repaired') return;
repairs.push({ x: ev.pos.x, y: ev.pos.y, item: ev.item });
perItem.set(ev.item, (perItem.get(ev.item) ?? 0) + 1);
},
total: () => repairs.length,
byItem: () => [...perItem].map(([item, count]) => ({ item, count })),
catchesOn(pos, footprint) {
let n = 0;
for (const r of repairs) {
if (
r.x >= pos.x && r.x < pos.x + footprint.x &&
r.y >= pos.y && r.y < pos.y + footprint.y
) n++;
}
return n;
},
};
}

View File

@ -7,8 +7,9 @@
* - We never mutate the snapshot and never hold a reference to it across frames.
* - All mutation goes out as Commands on the bus.
*/
import type { GameData, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
import type { GameData, ItemDef, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
import { createBuildBar } from './buildbar';
import { createCorrections } from './corrections';
import { createFax } from './fax';
import { createHotkeys } from './hotkeys';
import { createInspector } from './inspector';
@ -24,6 +25,7 @@ import { COPY } from './voice';
export function createUI(): UI {
let machines = new Map<string, MachineDef>();
let itemName: (id: string) => string = (id) => id.replace(/-/g, ' ').toUpperCase();
let top: ReturnType<typeof createTopStrip>;
let build: ReturnType<typeof createBuildBar>;
let inspector: ReturnType<typeof createInspector>;
@ -35,6 +37,8 @@ export function createUI(): UI {
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
const roster = createRoster();
/** Session tally of The Correction's edits — feeds the ticker + firewall log (round 6). */
const corrections = createCorrections();
let paused = false;
// ---- onboarding + ceremony state (per session; none of it is sim truth) ----
@ -61,6 +65,8 @@ export function createUI(): UI {
init(root: HTMLElement, data: GameData, bus: UIBus) {
injectStyle();
machines = new Map(data.machines.map((m) => [m.id, m]));
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
itemName = (id) => items.get(id)?.name ?? id.replace(/-/g, ' ').toUpperCase();
const sel = createSelection(bus);
top = createTopStrip(data);
@ -154,9 +160,9 @@ export function createUI(): UI {
paused = snap.paused;
syncRoster(roster, snap);
top.update(snap);
top.update(snap, corrections);
build.update(snap); // padlocks track research
inspector.update(snap);
inspector.update(snap, corrections);
fax.update(snap);
tech.update(snap);
@ -166,6 +172,9 @@ export function createUI(): UI {
for (const ev of events) {
if (ev.kind === 'commissionDone') fax.stamp();
// v8: The Correction edited an artifact — tally it for the ticker + firewall log.
if (ev.kind === 'repaired') corrections.record(ev);
if (ev.kind === 'relicFound') revealTuner(); // ceremony toast comes from voice.ts
// One hatch warning per pile. `spawned` is edge-triggered so this is usually
@ -176,7 +185,7 @@ export function createUI(): UI {
else warnedPiles.add(ev.id);
}
toasts.push(ev, machineName);
toasts.push(ev, machineName, itemName);
// The alarm alone teaches nothing. Once per session, on the very first brownout,
// say what to do about it.

View File

@ -11,7 +11,9 @@ import type {
} from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import { intakeChips, outputChips } from './chipspec';
import type { Corrections } from './corrections';
import { attrs, cls, el, panel, style, text } from './dom';
import { footprintOf } from './pick';
import { COPY, jamText, machineFlavor } from './voice';
export interface Inspector {
@ -19,7 +21,8 @@ export interface Inspector {
open(entityId: number): void;
close(): void;
isOpen(): boolean;
update(snap: SimSnapshot): void;
/** `corrections` (round 6) drives the FIREWALL LOG — how many edits landed on this unit. */
update(snap: SimSnapshot, corrections?: Corrections): void;
/** Entity currently shown, for the renderer-side selection highlight later. */
openId(): number | null;
}
@ -78,6 +81,13 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
const exposureLine = el('div', 'fk-ins-exposure-pct');
exposure.append(el('div', 'fk-h', [COPY.mosquitoExposure]), exposureLine);
// FIREWALL LOG (v8): the count of edits The Correction has made to this unit's output,
// resolved from `repaired` events that landed on this footprint. Hidden at zero — a
// unit the mites haven't touched shouldn't advertise the enemy.
const firewall = el('div', 'fk-ins-firewall');
const firewallLine = el('div', 'fk-ins-firewall-line');
firewall.append(el('div', 'fk-h', [COPY.firewallHeader]), firewallLine);
const body = el('div', undefined, [
recHead, recSelect, noRecipe,
inHead, inChips.el, outHead, outChips.el,
@ -85,7 +95,7 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
heatHead, heatBar, heatState,
]);
root.append(head, flavor, el('div', 'fk-rule'), body, exposure, jam);
root.append(head, flavor, el('div', 'fk-rule'), body, exposure, firewall, jam);
let openId: number | null = null;
@ -126,11 +136,18 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
return ins ? `${ins}${outs}` : outs;
}
function paint(e: EntityState) {
function paint(e: EntityState, corrections?: Corrections) {
const def = machines.get(e.def);
text(name, def?.name ?? e.def.toUpperCase());
text(flavor, machineFlavor(def, e.def));
// FIREWALL LOG: edits that landed on this unit's footprint. Off entirely at zero.
const catches = def && corrections
? corrections.catchesOn(e.pos, footprintOf(def, e.dir))
: 0;
cls(firewall, 'is-on', catches > 0);
if (catches > 0) text(firewallLine, COPY.correctionCatches(catches));
// Only machines with something to choose get a picker.
const choices = def?.recipes ?? [];
const canChoose = choices.length > 0;
@ -193,11 +210,11 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
close,
isOpen: () => openId !== null,
openId: () => openId,
update(snap) {
update(snap, corrections) {
if (openId === null) return;
const e = snap.entities.find((x) => x.id === openId);
if (!e) return close(); // unit was removed out from under us
paint(e);
paint(e, corrections);
// A swarm degrading THIS machine, if any. size is severity 0..1; we render it as
// the precision hit. TEMPORARY: the sim owns the true throughput penalty and

172
fktry/src/ui/mite.test.ts Normal file
View File

@ -0,0 +1,172 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest';
import type { GameData, SimEvent, SimSnapshot, UIBus } from '../contracts';
import { createCorrections } from './corrections';
import { createInspector } from './inspector';
import { createTopStrip } from './topstrip';
import { eventToast, repairedToast, wildlifeToast } from './voice';
const repaired = (item: string, x: number, y: number): SimEvent =>
({ kind: 'repaired', item, pos: { x, y }, tick: 1 });
const DATA: GameData = {
items: [{ id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff3fd4' }],
machines: [{
id: 'mosh-reactor', name: 'MOSH REACTOR', codex: '', kind: 'crafter',
footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'm',
}],
recipes: [],
tech: [],
commissions: [],
};
// ---------------------------------------------------------------- voice
describe('mite voice', () => {
it('accuses the mite of editing the melt on arrival', () => {
expect(wildlifeToast('parity-mite', 'spawned')).toBe('A PARITY MITE IS EDITING YOUR MELT.');
});
it('is gallingly polite when a mite is swatted', () => {
const off = wildlifeToast('parity-mite', 'cleared')!;
expect(off).toContain('FORGIVES');
expect(off).not.toBe(wildlifeToast('parity-mite', 'spawned'));
});
it('files a receipt for every edit, in the item display name', () => {
const named = (id: number) => `UNIT ${id}`;
const itemName = (id: string) => (id === 'melt' ? 'MELT' : id.toUpperCase());
const msg = eventToast(repaired('melt', 0, 0), named, itemName)!;
expect(msg).toBe('THE CORRECTION IMPROVED YOUR MELT. IT LEFT A RECEIPT.');
expect(repairedToast('MELT')).toContain('IT LEFT A RECEIPT.');
});
it('falls back to a readable name when no resolver is given', () => {
const msg = eventToast(repaired('chroma-slurry', 0, 0), (id) => `UNIT ${id}`)!;
expect(msg).toContain('CHROMA SLURRY');
});
});
// ---------------------------------------------------------------- tally
describe('corrections tally', () => {
it('counts nothing until a mite strikes', () => {
const c = createCorrections();
expect(c.total()).toBe(0);
expect(c.byItem()).toEqual([]);
});
it('ignores every event that is not a repair', () => {
const c = createCorrections();
c.record({ kind: 'shipped', item: 'melt', count: 1, tick: 1 });
expect(c.total()).toBe(0);
});
it('accumulates totals and per-item counts in first-seen order', () => {
const c = createCorrections();
c.record(repaired('melt', 0, 0));
c.record(repaired('hf-dust', 5, 5));
c.record(repaired('melt', 1, 1));
expect(c.total()).toBe(3);
expect(c.byItem()).toEqual([{ item: 'melt', count: 2 }, { item: 'hf-dust', count: 1 }]);
});
it('attributes an edit to a footprint by containment', () => {
const c = createCorrections();
c.record(repaired('melt', 0, 0)); // inside a 2x2 at origin
c.record(repaired('melt', 1, 1)); // inside
c.record(repaired('melt', 2, 2)); // outside (footprint is [0,2))
expect(c.catchesOn({ x: 0, y: 0 }, { x: 2, y: 2 })).toBe(2);
expect(c.catchesOn({ x: 9, y: 9 }, { x: 2, y: 2 })).toBe(0);
});
});
// ---------------------------------------------------------------- inspector firewall log
const bus: UIBus = { dispatch: () => {}, selectedBuild: () => null, pickTile: () => null };
function snap(): SimSnapshot {
return {
tick: 1, paused: false,
entities: [{
id: 7, def: 'mosh-reactor', pos: { x: 0, y: 0 }, dir: 0, recipe: null, progress: 0,
inputBuf: {}, outputBuf: {}, jammed: null, heat: 0,
}],
beltItems: [],
bandwidth: { gen: 10, draw: 1, stored: 0, brownout: false },
shippedTotal: {}, activeCommission: null, commissionProgress: {},
};
}
describe('FIREWALL LOG in the inspector', () => {
function open() {
document.body.innerHTML = '';
const ins = createInspector(DATA, bus);
document.body.append(ins.el);
ins.open(7);
return ins;
}
const firewall = () => document.querySelector('.fk-ins-firewall') as HTMLElement;
it('is hidden on a unit the mites have not touched', () => {
const ins = open();
ins.update(snap(), createCorrections());
expect(firewall().classList.contains('is-on')).toBe(false);
});
it('shows the catch count for edits on this unit', () => {
const ins = open();
const c = createCorrections();
c.record(repaired('melt', 0, 0));
c.record(repaired('melt', 1, 1));
ins.update(snap(), c);
expect(firewall().classList.contains('is-on')).toBe(true);
expect(document.querySelector('.fk-ins-firewall-line')!.textContent)
.toBe('THE CORRECTION HAS IMPROVED THIS UNIT 2 TIMES. YOU\'RE WELCOME.');
});
it('singularises one catch', () => {
const ins = open();
const c = createCorrections();
c.record(repaired('melt', 0, 0));
ins.update(snap(), c);
expect(document.querySelector('.fk-ins-firewall-line')!.textContent)
.toContain('1 TIME.');
});
it('survives being handed no corrections at all', () => {
const ins = open();
ins.update(snap());
expect(firewall().classList.contains('is-on')).toBe(false);
});
});
// ---------------------------------------------------------------- top-strip ticker
describe('CORRECTED ticker in the top strip', () => {
function mount() {
document.body.innerHTML = '';
const top = createTopStrip(DATA);
document.body.append(top.el);
return top;
}
const wrap = () => document.querySelector('.fk-corrected-wrap') as HTMLElement;
it('is hidden until the first edit — the enemy is not signposted early', () => {
const top = mount();
top.update(snap(), createCorrections());
expect(wrap().style.display).toBe('none');
});
it('appears with a count and per-item chips once the Correction starts editing', () => {
const top = mount();
const c = createCorrections();
c.record(repaired('melt', 0, 0));
c.record(repaired('melt', 1, 1));
top.update(snap(), c);
expect(wrap().style.display).not.toBe('none');
expect(document.querySelector('.fk-corrected-v')!.textContent).toBe('2');
const chip = wrap().querySelector('.fk-chips .fk-chip');
expect(chip!.textContent).toContain('MELT');
});
});

View File

@ -29,6 +29,14 @@ export interface RadioState {
station: string | null;
}
/** One station on the dial, as the speaker adapter exposes it — MHz units for every band. */
export interface StationMark {
id: string;
name: string;
band: Band;
freqMHz: number;
}
export interface RadioControl {
setFrequency(mhz: number): void;
setBand(band: Band): void;
@ -36,6 +44,9 @@ export interface RadioControl {
mute(on: boolean): void;
getState(): RadioState;
subscribe(cb: (s: RadioState) => void): () => void;
/** v8: every station on the dial, for tick marks. Empty until the engine attaches
* (first user gesture); the echo stand-in has none. Optional guard the call. */
listStations?(): StationMark[];
}
/** Band frequency ranges (MHz-ish; SW/AM are scaled for a single readable dial). */
@ -61,6 +72,7 @@ export function createEchoRadio(): RadioControl {
mute(on) { state = { ...state, muted: on }; notify(); },
getState: () => state,
subscribe(cb) { subs.add(cb); cb(state); return () => subs.delete(cb); },
listStations: () => [], // the real station map is SCREEN's; the stand-in knows none
};
}

View File

@ -18,6 +18,7 @@ const CSS = `
--fk-cool: #3fffe0;
--fk-amber: #ffb03f;
--fk-red: #ff3f4a;
--fk-clinic: #d6f5df;
font-family: "Courier New", ui-monospace, monospace;
font-size: 12px;
line-height: 1.35;
@ -109,6 +110,10 @@ const CSS = `
.fk-stat { display: flex; justify-content: space-between; font-size: 11px; }
.fk-stat-v { color: var(--fk-cool); }
/* CORRECTED ticker — clinical white like the enemy that made it. Hidden until first edit. */
.fk-corrected-wrap { margin-top: 4px; border-top: 1px solid var(--fk-line); padding-top: 4px; }
.fk-corrected .fk-corrected-v { color: var(--fk-clinic); }
#fk-run.is-paused { color: var(--fk-amber); }
/* ---------------------------------------------------------------- build bar */
@ -253,6 +258,12 @@ const CSS = `
.fk-ins-exposure.is-on { display: block; }
.fk-ins-exposure-pct { color: var(--fk-amber); font-size: 10px; }
/* FIREWALL LOG The Correction is hospital-white with green checkmarks (codex §8), so
it reads clinical and calm next to the hot alarms. The politeness is the menace. */
.fk-ins-firewall { display: none; margin-top: 6px; border-left: 3px solid var(--fk-clinic); padding-left: 6px; }
.fk-ins-firewall.is-on { display: block; }
.fk-ins-firewall-line { color: var(--fk-clinic); font-size: 10px; }
/* Process select: a native <select> beaten into the house style. */
.fk-ins-recipe {
width: 100%;
@ -448,6 +459,15 @@ const CSS = `
border-right: 2px solid var(--fk-cool);
box-shadow: 0 0 8px rgba(63, 255, 224, 0.6);
}
/* Station tick marks (order 2): real landmarks over the faint ruler. Hot magenta so a
station stands out from the background gradient, but anonymous no names on the dial. */
.fk-tuner-ticks { position: absolute; inset: 0; pointer-events: none; }
.fk-tuner-tick {
position: absolute; top: -2px; bottom: -2px; width: 2px;
transform: translateX(-1px);
background: var(--fk-hot);
box-shadow: 0 0 5px rgba(255, 63, 212, 0.8);
}
.fk-tuner-bands { display: flex; gap: 3px; margin-top: 6px; }
.fk-tuner-band {
@ -495,7 +515,30 @@ const CSS = `
.fk-toast.k-jammed { border-left-color: var(--fk-amber); color: var(--fk-amber); }
.fk-toast.k-brownout { border-left-color: var(--fk-red); color: var(--fk-red); }
.fk-toast.k-commissionDone { border-left-color: var(--fk-cool); color: var(--fk-cool); }
/* The Correction's edit: clinical white-green, the same calm as the firewall log. */
.fk-toast.k-repaired { border-left-color: var(--fk-clinic); color: var(--fk-clinic); }
/* A mite arriving/leaving is wildlife, but of the hostile kind — amber like a hazard. */
.fk-toast.k-wildlife { border-left-color: var(--fk-amber); color: var(--fk-amber); }
@keyframes fk-toast-in { from { opacity: 0; transform: translateX(12px); } }
/* ---------------------------------------------------------------- about / credits */
.fk-tech-footer { margin-top: 10px; border-top: 1px solid var(--fk-line); padding-top: 6px; text-align: right; }
.fk-credits-btn {
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
color: var(--fk-dim); font: inherit; font-size: 9px; letter-spacing: 0.12em;
padding: 2px 8px; cursor: pointer; pointer-events: auto;
}
.fk-credits-btn:hover { color: var(--fk-cool); border-color: var(--fk-cool); }
.fk-credits-btn.is-on { color: var(--fk-hot); border-color: var(--fk-hot); }
.fk-credits { display: none; margin-top: 8px; border-top: 1px dashed var(--fk-line); padding-top: 8px; }
.fk-credits.is-on { display: block; }
.fk-credits-title { color: var(--fk-hot); letter-spacing: 0.2em; font-size: 13px; }
.fk-credits-tag { color: var(--fk-dim); font-size: 9px; margin: 2px 0 8px; }
.fk-credits-row { display: flex; gap: 6px; font-size: 10px; margin-bottom: 2px; }
.fk-credits-k { color: var(--fk-faint); min-width: 44px; }
.fk-credits-v { color: var(--fk-fg); text-transform: none; letter-spacing: 0; }
.fk-credits-slot { color: var(--fk-clinic); font-size: 9px; margin-top: 8px; border: 1px dashed var(--fk-clinic); padding: 3px 5px; }
`;
let injected = false;

View File

@ -136,3 +136,36 @@ describe('tech panel', () => {
expect(nodeFor('SILVER SEAMS').classList.contains('is-active')).toBe(false);
});
});
describe('about / credits footer', () => {
const about = () => document.querySelector('.fk-credits-btn') as HTMLElement;
const credits = () => document.querySelector('.fk-credits') as HTMLElement;
it('offers an ABOUT entry that starts collapsed', () => {
const { panel } = mount();
panel.toggle();
expect(about().textContent).toBe('ABOUT');
expect(credits().classList.contains('is-on')).toBe(false);
});
it('reveals repo, codex and the standing synthetic-voice disclosure slot', () => {
const { panel } = mount();
panel.toggle();
about().dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(credits().classList.contains('is-on')).toBe(true);
const txt = credits().textContent!;
expect(txt).toContain('gitea.partly.party/monster/glytch');
expect(txt).toContain('docs/FKTRY_LORE.md');
// The slot is a reservation, not a filled credit yet.
expect(document.querySelector('.fk-credits-slot')!.textContent)
.toContain('SYNTHETIC VOICE CREDIT');
});
it('toggles back shut on a second click', () => {
const { panel } = mount();
panel.toggle();
about().dispatchEvent(new MouseEvent('click', { bubbles: true }));
about().dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(credits().classList.contains('is-on')).toBe(false);
});
});

View File

@ -43,7 +43,29 @@ export function createTechPanel(data: GameData, bus: UIBus): TechPanel {
const offline = el('div', 'fk-tech-offline', [COPY.researchOffline]);
const body = el('div', 'fk-tech-body');
root.append(head, sub, offline, body);
// ---- ABOUT / CREDITS footer (round 6). Cheap now, and gives the future synthetic-voice
// credit a home before it exists (voice-cloning plan). Collapsed until asked for.
const kv = (label: string, value: string) =>
el('div', 'fk-credits-row', [el('span', 'fk-credits-k', [label]), el('span', 'fk-credits-v', [value])]);
const credits = el('div', 'fk-credits', [
el('div', 'fk-credits-title', [COPY.creditsTitle]),
el('div', 'fk-credits-tag', [COPY.creditsTagline]),
kv(COPY.creditsRepoLabel, COPY.creditsRepo),
kv(COPY.creditsCodexLabel, COPY.creditsCodex),
kv(COPY.creditsVoiceLabel, COPY.creditsVoice),
el('div', 'fk-credits-slot', [COPY.creditsVoiceSlot]),
]);
const aboutBtn = el('button', 'fk-credits-btn', [COPY.creditsButton]);
attrs(aboutBtn, { type: 'button', title: 'ABOUT / CREDITS' });
let creditsOpen = false;
aboutBtn.addEventListener('click', () => {
creditsOpen = !creditsOpen;
cls(credits, 'is-on', creditsOpen);
cls(aboutBtn, 'is-on', creditsOpen);
});
const footer = el('div', 'fk-tech-footer', [aboutBtn]);
root.append(head, sub, offline, body, footer, credits);
const views: NodeView[] = [];

View File

@ -10,7 +10,12 @@ import { eventToast } from './voice';
export interface Toasts {
el: HTMLElement;
push(ev: SimEvent, machineName: (entityId: number) => string): void;
/** `itemName` resolves item ids for events that name an item (e.g. v8 `repaired`). */
push(
ev: SimEvent,
machineName: (entityId: number) => string,
itemName?: (id: string) => string,
): void;
/** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */
pushMessage(msg: string, kind: string): void;
/** Stand the stack off the bottom-right corner once the tuner dock moves in there. */
@ -43,8 +48,8 @@ export function createToasts(): Toasts {
return {
el: root,
push(ev, machineName) {
const msg = eventToast(ev, machineName);
push(ev, machineName, itemName) {
const msg = eventToast(ev, machineName, itemName);
if (msg) emit(msg, ev.kind);
},
pushMessage: emit,

View File

@ -6,13 +6,16 @@
*/
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import type { Corrections } from './corrections';
import { cls, el, panel, style, text } from './dom';
import { formatReserve, powerState } from './power';
import { COPY } from './voice';
export interface TopStrip {
el: HTMLElement;
update(snap: SimSnapshot): void;
/** `corrections` is the session tally of The Correction's edits surfaced only once
* the first mite has struck, so it never signposts an enemy that hasn't arrived. */
update(snap: SimSnapshot, corrections?: Corrections): void;
}
function stat(label: string, valueCls?: string): { row: HTMLElement; v: HTMLElement } {
@ -50,12 +53,20 @@ export function createTopStrip(data: GameData): TopStrip {
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
const chips: ChipRow = createChipRow(items);
// CORRECTED ticker (round 6): a count + per-item chips of what the mites have "fixed".
// Hidden until the first edit, so pre-Correction play never sees the enemy named.
const corrected = stat(COPY.correctedLabel, 'fk-corrected-v');
cls(corrected.row, 'fk-corrected', true);
const correctedChips: ChipRow = createChipRow(items);
const correctedWrap = el('div', 'fk-corrected-wrap', [corrected.row, correctedChips.el]);
style(correctedWrap, 'display', 'none');
root.append(head, nums, bar, brownout, reserve, el('div', 'fk-rule'),
buffer.row, shipped.row, run.row, chips.el);
buffer.row, shipped.row, run.row, chips.el, correctedWrap);
return {
el: root,
update(snap) {
update(snap, corrections) {
const { gen, draw, stored, capacity } = snap.bandwidth;
text(drawEl, `${draw.toFixed(0)} DRAW`);
@ -88,6 +99,17 @@ export function createTopStrip(data: GameData): TopStrip {
.filter(([, n]) => n > 0)
.map(([item, n]) => ({ item, count: String(n), state: 'met' as const })),
);
const edits = corrections?.total() ?? 0;
style(correctedWrap, 'display', edits > 0 ? '' : 'none');
if (edits > 0) {
text(corrected.v, String(edits));
correctedChips.update(
corrections!.byItem().map(({ item, count }) => ({
item, count: String(count), state: 'short' as const,
})),
);
}
},
};
}

View File

@ -151,6 +151,37 @@ describe('the tuner dock', () => {
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(false);
});
it('draws no station ticks before the engine attaches (empty pre-gesture)', () => {
tuner.reveal();
// The echo stand-in knows no stations, exactly like the adapter before first gesture.
expect(document.querySelectorAll('.fk-tuner-tick')).toHaveLength(0);
});
it('draws a tick per station on the current band, positioned by frequency', () => {
const control: RadioControl = {
...createEchoRadio(),
subscribe(cb) {
cb({ freqMHz: 88.0, band: 'FM', volume: 0.7, muted: false, station: null });
return () => {};
},
listStations: () => [
{ id: 'a', name: 'A', band: 'FM', freqMHz: 88.0 }, // FM floor → left edge
{ id: 'b', name: 'B', band: 'FM', freqMHz: 108.0 }, // FM ceiling → right edge
{ id: 'c', name: 'C', band: 'AM', freqMHz: 1.0 }, // other band → not drawn
],
};
(globalThis as any).__fktrySpeaker = { radio: control };
const t = mount();
t.reveal();
const ticks = Array.from(document.querySelectorAll('.fk-tuner-tick')) as HTMLElement[];
expect(ticks).toHaveLength(2); // only the two FM stations
expect(ticks[0].style.left).toBe('0%');
expect(ticks[1].style.left).toBe('100%');
// Ticks are anonymous — they mark THAT, not WHAT (the hunt stays).
expect(ticks[0].textContent).toBe('');
delete (globalThis as any).__fktrySpeaker;
});
it('reads SIGNAL LOCKED and lights up when the engine reports a station', () => {
// What it looks like once THE SPEAKER is driving it for real.
const withStation: RadioControl = {

View File

@ -37,9 +37,12 @@ export function createTuner(): Tuner {
const head = el('div', 'fk-h', [COPY.tunerHeader]);
const readout = el('div', 'fk-tuner-readout');
// Frequency dial: a click/drag track. The fill shows position within the band.
// Frequency dial: a click/drag track. The fill shows position within the band, and the
// ticks layer marks where the real stations sit (order 2). Ticks say THAT a station is
// there, never WHAT — the readout is the only thing that names a lock, so the hunt stays.
const dialFill = el('div', 'fk-tuner-dial-fill');
const dial = el('div', 'fk-tuner-dial', [dialFill]);
const dialTicks = el('div', 'fk-tuner-ticks');
const dial = el('div', 'fk-tuner-dial', [dialTicks, dialFill]);
// Band buttons.
const bandRow = el('div', 'fk-tuner-bands');
@ -109,10 +112,32 @@ export function createTuner(): Tuner {
// ---------------------------------------------------------------- render from state
/** Station tick marks for the current band. Rebuilt only when the band or the station
* set changes the list is empty until the engine attaches on first gesture, then
* fills in on the next state push, so this lights up exactly when audio comes alive. */
let ticksSig = '';
function renderTicks(band: Band) {
const stations = (control.listStations?.() ?? []).filter((st) => st.band === band);
const { min, max } = BAND_RANGE[band];
const sig = band + '|' + stations.map((st) => st.freqMHz.toFixed(3)).join(',');
if (sig === ticksSig) return;
ticksSig = sig;
dialTicks.replaceChildren();
if (max <= min) return;
for (const st of stations) {
const frac = Math.max(0, Math.min(1, (st.freqMHz - min) / (max - min)));
const tick = el('div', 'fk-tuner-tick');
style(tick, 'left', `${frac * 100}%`);
dialTicks.append(tick);
}
}
function render(s: RadioState) {
text(readout, stationReadout(s.freqMHz, s.band, s.station));
cls(readout, 'is-locked', s.station !== null);
renderTicks(s.band);
const { min, max } = BAND_RANGE[s.band];
const frac = max > min ? (s.freqMHz - min) / (max - min) : 0;
style(dialFill, 'width', `${Math.max(0, Math.min(1, frac)) * 100}%`);

View File

@ -65,8 +65,15 @@ export function jamText(reason: string): string {
return JAM_TEXT[reason] ?? reason.toUpperCase();
}
/** Default item-id → display: dashes to spaces, uppercased. LANE-DATA's names win. */
const defaultItemName = (id: string) => id.replace(/-/g, ' ').toUpperCase();
/** Toast copy for events the player should notice without reading a manual. */
export function eventToast(ev: SimEvent, machineName: (id: number) => string): string | null {
export function eventToast(
ev: SimEvent,
machineName: (id: number) => string,
itemName: (id: string) => string = defaultItemName,
): string | null {
switch (ev.kind) {
case 'jammed':
return `${machineName(ev.entity)}: ${jamText(ev.reason)}`;
@ -88,11 +95,22 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
return wildlifeToast(ev.wildlife, ev.on);
case 'relicFound':
return COPY.relicDiscovery;
case 'repaired':
return repairedToast(itemName(ev.item));
default:
return null; // shipped/crafted/placed are too frequent to toast
}
}
/**
* A parity mite has "improved" one of your artifacts (contracts v8 `repaired`). The
* Correction is unfailingly courteous about vandalising your work the receipt is the
* insult. `IT LEFT A RECEIPT.` is the line the round-6 orders asked for by name.
*/
export function repairedToast(itemName: string): string {
return `THE CORRECTION IMPROVED YOUR ${itemName}. IT LEFT A RECEIPT.`;
}
/**
* Infestation copy. A dust pile spawning is a hatch warning (index.ts dedups it to one
* per pile see the notices logic there); a swarm spawning is the bite already landing.
@ -107,10 +125,11 @@ export function wildlifeToast(
: null; // clearing a pile is housekeeping, not news
}
if (kind === 'parity-mite') {
// v8 placeholder copy — LANE-UI owns the real lines in round 6
// The Correction cannot perceive you, only deviation — which it repairs (lore §5).
// A mite on the belt is your melt being edited into something that passes review.
return on === 'spawned'
? 'A PARITY MITE HAS ARRIVED. IT IS HERE TO HELP.'
: 'MITE CONTAINED. FILE ITS RECEIPT.';
? 'A PARITY MITE IS EDITING YOUR MELT.'
: 'MITE SWATTED. IT FORGIVES YOU. IT ALWAYS FORGIVES YOU.';
}
// mosquito-swarm
return on === 'spawned'
@ -148,6 +167,15 @@ export const COPY = {
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
sanitising: 'SANITISING',
// THE CORRECTION — parity-mite surfacing (round 6). The register is deliberately
// gracious: the enemy filing a receipt for wrecking your work is the whole grievance.
/** Top-strip ticker: how many artifacts the mites have "improved" this session. */
correctedLabel: 'CORRECTED',
/** Inspector section header — the unit's own log of what got edited on it. */
firewallHeader: 'FIREWALL LOG',
correctionCatches: (n: number) =>
`THE CORRECTION HAS IMPROVED THIS UNIT ${n} TIME${n === 1 ? '' : 'S'}. YOU'RE WELCOME.`,
techHeader: 'STANDARDS COMMITTEE',
techSubheader: 'MEDIA ARCHAEOLOGY — RESEARCH DOWN, DERIVE UP',
techActive: 'IN COMMITTEE',
@ -175,6 +203,21 @@ export const COPY = {
/** relicFound ceremony. */
relicDiscovery: 'AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.',
// ABOUT / CREDITS (round 6) — reachable from the tech-panel footer. Deadpan colophon;
// the voice slot is a standing reservation for the synthetic-voice credit that lands
// when the voice-cloning plan ships, so there's a home for it already.
creditsButton: 'ABOUT',
creditsTitle: 'FKTRY',
creditsTagline: 'A GLITCH-PRODUCTION FACTORY INSIDE A DYING VIDEO FILE.',
creditsRepoLabel: 'REPO',
creditsRepo: 'gitea.partly.party/monster/glytch',
creditsCodexLabel: 'CODEX',
creditsCodex: 'docs/FKTRY_LORE.md',
creditsVoiceLabel: 'VOICES',
creditsVoice: 'SYNTHESIZED ON SITE. NO PERFORMERS WERE CONSULTED.',
/** Standing disclosure slot — replaced with the real credit when a synthetic voice ships. */
creditsVoiceSlot: 'SYNTHETIC VOICE CREDIT: NONE ON FILE.',
// THE TUNER DOCK
tunerHeader: 'AUDIO BUS',
tunerBandLabel: 'BAND',