[ui] THE TUNER DOCK — the radio gets a face

Hidden until the optical fossil is excavated, then it slides in bottom-right:
frequency dial (drag or arrow keys), AM/FM/SW, volume and mute, and a station
readout in the house voice that reads STATIC until something locks. Its keys
are inert while hidden so they never shadow the pre-discovery game.

src/audio is still a stub with no radio export, so radio.ts resolves the
control at runtime and otherwise drives a silent echo stand-in — which is why
every dial is functional and testable today rather than waiting.

LANE-SCREEN: publish window.__fktrySpeaker = { radio } and this drives real
audio with no change here. The interface is exactly the one you documented —
setFrequency / setBand / volume / mute / getState / subscribe — with getState()
returning {freqMHz, band, volume, muted, station}; a non-null station is what
flips the readout to SIGNAL LOCKED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 14:12:18 +10:00
parent 5d6afdac97
commit d6d6833b78
3 changed files with 405 additions and 0 deletions

80
fktry/src/ui/radio.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* LANE-UI the bridge to THE SPEAKER's radio control.
*
* Order 9's tuner dock drives the audio engine through a narrow control that LANE-SCREEN
* exports from `src/audio` (their documented API: setFrequency / setBand / volume / mute /
* getState / subscribe). That export does not exist yet `src/audio` is still a stub so
* this module resolves the control at runtime and, until it lands, drives a local echo
* stand-in so every dial is fully functional and testable now.
*
* COORDINATION (see NOTES): I resolve the control from, in order,
* 1. `window.__fktrySpeaker.radio` the handshake I'm proposing (SCREEN already keeps
* a `window.__fktryScreen` debug hook, so this is idiomatic for them), or
* 2. a `radio` / `getRadio()` export from `../audio` (dynamic, cast never breaks the
* build if absent).
* SCREEN: set one of those to a RadioControl and the dock drives real audio with zero
* change here. Tell me in NOTES which name you pick.
*
* The stand-in is silent it only echoes state back through `subscribe` so the readout is
* honest about what the dock is asking for, even with no engine attached.
*/
export type Band = 'AM' | 'FM' | 'SW';
export interface RadioState {
freqMHz: number;
band: Band;
volume: number; // 0..1
muted: boolean;
/** Station name if the current frequency locks one, else null (static). */
station: string | null;
}
export interface RadioControl {
setFrequency(mhz: number): void;
setBand(band: Band): void;
volume(v: number): void;
mute(on: boolean): void;
getState(): RadioState;
subscribe(cb: (s: RadioState) => void): () => void;
}
/** Band frequency ranges (MHz-ish; SW/AM are scaled for a single readable dial). */
export const BAND_RANGE: Record<Band, { min: number; max: number; step: number }> = {
AM: { min: 0.53, max: 1.7, step: 0.01 },
FM: { min: 88.0, max: 108.0, step: 0.1 },
SW: { min: 5.9, max: 26.1, step: 0.05 },
};
/**
* Local echo stand-in. Holds state, notifies subscribers, locks no stations (the real
* station map is SCREEN's). Keeps the dock alive before the engine exists.
*/
export function createEchoRadio(): RadioControl {
let state: RadioState = { freqMHz: BAND_RANGE.FM.min, band: 'FM', volume: 0.7, muted: false, station: null };
const subs = new Set<(s: RadioState) => void>();
const notify = () => { for (const cb of subs) cb(state); };
return {
setFrequency(mhz) { state = { ...state, freqMHz: mhz }; notify(); },
setBand(band) { state = { ...state, band, freqMHz: BAND_RANGE[band].min }; notify(); },
volume(v) { state = { ...state, volume: Math.max(0, Math.min(1, v)) }; notify(); },
mute(on) { state = { ...state, muted: on }; notify(); },
getState: () => state,
subscribe(cb) { subs.add(cb); cb(state); return () => subs.delete(cb); },
};
}
/** Resolve the real control if THE SPEAKER has published one; else the echo stand-in. */
export function resolveRadio(): { control: RadioControl; live: boolean } {
const speaker = (globalThis as any).__fktrySpeaker;
if (speaker?.radio && typeof speaker.radio.subscribe === 'function') {
return { control: speaker.radio as RadioControl, live: true };
}
// Dynamic, casted probe of the audio module — safe whether or not the export exists.
const audioMod = (globalThis as any).__fktryAudioModule;
const fromMod = audioMod?.radio ?? audioMod?.getRadio?.();
if (fromMod && typeof fromMod.subscribe === 'function') {
return { control: fromMod as RadioControl, live: true };
}
return { control: createEchoRadio(), live: false };
}

171
fktry/src/ui/tuner.test.ts Normal file
View File

@ -0,0 +1,171 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest';
import { BAND_RANGE, createEchoRadio, resolveRadio, type RadioControl } from './radio';
import { createTuner } from './tuner';
const q = (s: string) => document.querySelector(s) as HTMLElement;
const bandBtn = (b: string) =>
Array.from(document.querySelectorAll('.fk-tuner-band')).find((x) => x.textContent === b) as HTMLElement;
function mount() {
document.body.innerHTML = '';
const tuner = createTuner();
document.body.append(tuner.el);
return tuner;
}
describe('radio control resolution', () => {
beforeEach(() => {
delete (globalThis as any).__fktrySpeaker;
delete (globalThis as any).__fktryAudioModule;
});
it('falls back to a silent echo stand-in when THE SPEAKER has published nothing', () => {
const { live } = resolveRadio();
expect(live).toBe(false);
});
it('uses the real control the moment SCREEN publishes one', () => {
const fake = createEchoRadio();
(globalThis as any).__fktrySpeaker = { radio: fake };
const { control, live } = resolveRadio();
expect(live).toBe(true);
expect(control).toBe(fake);
delete (globalThis as any).__fktrySpeaker;
});
it('ignores a published object that is not a radio control', () => {
(globalThis as any).__fktrySpeaker = { radio: { nope: true } };
expect(resolveRadio().live).toBe(false);
delete (globalThis as any).__fktrySpeaker;
});
});
describe('echo radio', () => {
it('notifies subscribers immediately and on every change', () => {
const r = createEchoRadio();
const seen: number[] = [];
r.subscribe((s) => seen.push(s.freqMHz));
r.setFrequency(101.1);
expect(seen).toHaveLength(2); // initial + change
expect(seen[1]).toBe(101.1);
});
it('snaps to the band floor when the band changes', () => {
const r = createEchoRadio();
r.setBand('AM');
expect(r.getState().band).toBe('AM');
expect(r.getState().freqMHz).toBe(BAND_RANGE.AM.min);
});
it('clamps volume to 0..1', () => {
const r = createEchoRadio();
r.volume(5);
expect(r.getState().volume).toBe(1);
r.volume(-2);
expect(r.getState().volume).toBe(0);
});
it('unsubscribes cleanly', () => {
const r = createEchoRadio();
let n = 0;
const off = r.subscribe(() => n++);
off();
r.setFrequency(99);
expect(n).toBe(1); // only the initial call
});
});
describe('the tuner dock', () => {
let tuner: ReturnType<typeof createTuner>;
beforeEach(() => {
delete (globalThis as any).__fktrySpeaker;
tuner = mount();
});
it('stays hidden until the fossil is found', () => {
expect(tuner.isRevealed()).toBe(false);
expect(tuner.el.classList.contains('is-revealed')).toBe(false);
});
it('reveals on discovery, and revealing twice is harmless', () => {
tuner.reveal();
tuner.reveal();
expect(tuner.isRevealed()).toBe(true);
expect(tuner.el.classList.contains('is-revealed')).toBe(true);
});
it('ignores its keys while hidden, so they never shadow the pre-discovery game', () => {
expect(tuner.onKey('arrowright')).toBe(false);
expect(tuner.onKey('m')).toBe(false);
});
it('tunes with the arrow keys once revealed', () => {
tuner.reveal();
const before = q('.fk-tuner-readout').textContent!;
expect(tuner.onKey('arrowright')).toBe(true);
expect(q('.fk-tuner-readout').textContent).not.toBe(before);
});
it('does not tune past the end of the band', () => {
tuner.reveal();
for (let i = 0; i < 400; i++) tuner.onKey('arrowleft'); // hammer the bottom stop
const shown = q('.fk-tuner-readout').textContent!;
expect(shown).toContain(BAND_RANGE.FM.min.toFixed(1));
});
it('cycles bands with B and reflects it in the readout and the active button', () => {
tuner.reveal();
tuner.onKey('b');
expect(q('.fk-tuner-readout').textContent).toContain('SW');
expect(bandBtn('SW').classList.contains('is-active')).toBe(true);
expect(bandBtn('FM').classList.contains('is-active')).toBe(false);
});
it('switches band by clicking a band button', () => {
tuner.reveal();
bandBtn('AM').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(q('.fk-tuner-readout').textContent).toContain('AM');
expect(bandBtn('AM').classList.contains('is-active')).toBe(true);
});
it('mutes and unmutes with M, and says so', () => {
tuner.reveal();
tuner.onKey('m');
expect(q('.fk-tuner-mute').textContent).toBe('MUTED');
expect(tuner.el.classList.contains('is-muted')).toBe(true);
tuner.onKey('m');
expect(q('.fk-tuner-mute').textContent).toBe('MUTE');
expect(tuner.el.classList.contains('is-muted')).toBe(false);
});
it('mutes by clicking the mute button', () => {
tuner.reveal();
q('.fk-tuner-mute').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(q('.fk-tuner-mute').textContent).toBe('MUTED');
});
it('reads out static when no station locks, in the house voice', () => {
tuner.reveal();
expect(q('.fk-tuner-readout').textContent).toContain('STATIC');
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(false);
});
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 = {
...createEchoRadio(),
subscribe(cb) {
cb({ freqMHz: 88.1, band: 'FM', volume: 0.7, muted: false, station: 'FKTRY' });
return () => {};
},
};
(globalThis as any).__fktrySpeaker = { radio: withStation };
const t = mount();
t.reveal();
expect(q('.fk-tuner-readout').textContent).toBe('88.1 FKTRY FM — SIGNAL LOCKED');
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(true);
expect(t.isLive()).toBe(true);
delete (globalThis as any).__fktrySpeaker;
});
});

154
fktry/src/ui/tuner.ts Normal file
View File

@ -0,0 +1,154 @@
/**
* LANE-UI THE TUNER DOCK (order 9). The radio's face.
*
* Hidden until the optical fossil is excavated (`snapshot.relics[].found` or a
* `relicFound` event). Then it slides in bottom-right with a frequency dial, band select
* (AM/FM/SW), volume + mute, and a station readout in the house voice. Every control
* drives THE SPEAKER through the resolved `RadioControl` (radio.ts); the readout reflects
* the control's own state via `subscribe`, so it's honest whether the real engine or the
* echo stand-in is attached.
*
* Radio state is per-session (it lives in the control); the durable part is the fossil's
* `found`, which is sim-side.
*/
import { cls, el, panel, style, text } from './dom';
import { BAND_RANGE, resolveRadio, type Band, type RadioControl, type RadioState } from './radio';
import { COPY, stationReadout } from './voice';
export interface Tuner {
el: HTMLElement;
/** Reveal the dock (discovery ceremony). Idempotent. */
reveal(): void;
isRevealed(): boolean;
/** Keyboard: ← → tune, B band, M mute — active only once revealed. */
onKey(key: string): boolean;
/** Whether the dock is driving the real audio engine vs the silent stand-in. */
isLive(): boolean;
}
const BANDS: Band[] = ['AM', 'FM', 'SW'];
export function createTuner(): Tuner {
const { control, live } = resolveRadio();
const root = panel();
root.id = 'fk-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.
const dialFill = el('div', 'fk-tuner-dial-fill');
const dial = el('div', 'fk-tuner-dial', [dialFill]);
// Band buttons.
const bandRow = el('div', 'fk-tuner-bands');
const bandBtns = BANDS.map((b) => {
const btn = el('button', 'fk-tuner-band', [b]);
btn.setAttribute('type', 'button');
btn.addEventListener('click', () => control.setBand(b));
bandRow.append(btn);
return { band: b, btn };
});
// Volume + mute.
const volFill = el('div', 'fk-tuner-vol-fill');
const volTrack = el('div', 'fk-tuner-vol', [volFill]);
const muteBtn = el('button', 'fk-tuner-mute', [COPY.tunerMute]);
muteBtn.setAttribute('type', 'button');
const volRow = el('div', 'fk-tuner-volrow', [
el('span', 'fk-tuner-vol-label', [COPY.tunerVolLabel]), volTrack, muteBtn,
]);
const hint = el('div', 'fk-tuner-hint', [COPY.tunerHint]);
root.append(head, readout, dial, bandRow, volRow, hint);
// ---------------------------------------------------------------- interaction
/** Set frequency from a 0..1 position within the current band. */
function tuneToFraction(f: number, band: Band) {
const { min, max } = BAND_RANGE[band];
control.setFrequency(min + Math.max(0, Math.min(1, f)) * (max - min));
}
function dialFractionFromEvent(clientX: number): number {
const r = dial.getBoundingClientRect();
return r.width > 0 ? (clientX - r.left) / r.width : 0;
}
let dragging = false;
dial.addEventListener('pointerdown', (e) => {
dragging = true;
dial.setPointerCapture?.(e.pointerId);
tuneToFraction(dialFractionFromEvent(e.clientX), control.getState().band);
});
dial.addEventListener('pointermove', (e) => {
if (dragging) tuneToFraction(dialFractionFromEvent(e.clientX), control.getState().band);
});
const endDrag = () => { dragging = false; };
dial.addEventListener('pointerup', endDrag);
dial.addEventListener('pointercancel', endDrag);
volTrack.addEventListener('pointerdown', (e) => {
const r = volTrack.getBoundingClientRect();
if (r.width > 0) control.volume((e.clientX - r.left) / r.width);
});
muteBtn.addEventListener('click', () => control.mute(!control.getState().muted));
function step(dir: number) {
const s = control.getState();
const { min, max, step } = BAND_RANGE[s.band];
control.setFrequency(Math.max(min, Math.min(max, s.freqMHz + dir * step)));
}
function cycleBand(dir: number) {
const s = control.getState();
const i = (BANDS.indexOf(s.band) + dir + BANDS.length) % BANDS.length;
control.setBand(BANDS[i]);
}
// ---------------------------------------------------------------- render from state
function render(s: RadioState) {
text(readout, stationReadout(s.freqMHz, s.band, s.station));
cls(readout, 'is-locked', s.station !== null);
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}%`);
for (const b of bandBtns) cls(b.btn, 'is-active', b.band === s.band);
style(volFill, 'width', `${s.volume * 100}%`);
cls(muteBtn, 'is-on', s.muted);
text(muteBtn, s.muted ? COPY.tunerMuted : COPY.tunerMute);
cls(root, 'is-muted', s.muted);
}
const unsubscribe = control.subscribe(render);
void unsubscribe; // dock lives for the session; nothing to tear down
let revealed = false;
return {
el: root,
reveal() {
if (revealed) return;
revealed = true;
cls(root, 'is-revealed', true);
},
isRevealed: () => revealed,
onKey(key) {
if (!revealed) return false;
switch (key) {
case 'arrowleft': step(-1); return true;
case 'arrowright': step(1); return true;
case 'b': cycleBand(1); return true;
case 'm': control.mute(!control.getState().muted); return true;
default: return false;
}
},
isLive: () => live,
};
}
export type { RadioControl };