/** * The mic and the monitor speaker, drawn as instruments rather than as a table * of key/value pairs. * * There was no device concept anywhere in this product a week ago — no type, no * route, no state — so this panel is not a rewrite of anything and has no legacy * to preserve. What it does have is a rule it must not break, and it is the same * rule the media-surface panel and `RobotOperationsDefinition` already live * under: **a reading that was invented has to say so, on the same surface as the * reading.** A level meter bouncing next to a photograph of a desk is a claim * about a real room. `DeviceDeclaration.disclosure` is the sentence that makes it * an honest one, `validateDeviceDeclaration` refuses a `simulated` device whose * disclosure does not contain the word, and this panel prints it under every * instrument rather than once at the top — because a panel that scrolls is a * panel whose header you have already scrolled past. * * ### One control per declared capability, and never a `switch` on kind * * The controls are built by walking `declaration.capabilities` and looking each * one up in `CAPABILITY_READING`. Nothing here asks whether a device is a mic. * That is the whole design of `src/devices/types.ts` and it is what makes a * third kind — a smart light, a thermostat — data rather than a code change: the * capability is declared, the reading is named by the table, and the control * falls out. A `if (kind === "mic") renderGain()` ladder would have to be found * and edited in four consumers the first time somebody added one. * * `level` is the odd one and is deliberately not a control: it is a *reading*. * You can ask a microphone for its programme level; you cannot set it. * `DeviceCommandOp` is `Exclude` for exactly this * reason, so the type system agrees with the panel about which rows are knobs. * * ### Anonymous visitors get the panel * * Declarations are authored into the office pack, which is bundled into the * static build, which means they are public by construction — the hardware in * the room is a description of the room. What an account buys is the live * readings from the authenticated route. So this mounts for everyone and shows * the at-rest state from `initialDeviceState` until something better arrives, * and the disclosure line is what tells you which of the two you are looking at. */ import { CAPABILITY_READING, DEVICE_RANGES, initialDeviceState, normalizeDeviceCommand, } from "../devices/types.ts"; import type { DeviceCapability, DeviceCommand, DeviceDeclaration, DeviceState, } from "../devices/types.ts"; import { TOUCH_TARGET_PX } from "./tokens.ts"; // ---- Options and handle --------------------------------------------------- export interface DevicePanelOptions { /** * The authored hardware in this room, from the resolved `Plan`. * * Required, and this is the one addition to the signature the build spec * fixed: the panel's structure is a function of the declarations and there is * no honest way to render a control without one — the capability list, the * label, the ranges and the disclosure all come from here. States alone would * leave the panel guessing which readings a device is *supposed* to have, * which is precisely the distinction `undefined` is reserved for. */ declarations: readonly DeviceDeclaration[]; /** * Send one instruction. Already validated and clamped by * `normalizeDeviceCommand` before it arrives; a refusal is a silent no-op here * and never reaches this callback. */ onCommand(command: DeviceCommand): void; /** * False while nothing is connected — an anonymous visitor, or a build with no * server. The controls stay visible and stay pressable, because a disabled * mixing desk teaches nothing; what changes is the line under them. */ live?: boolean; } export interface DevicePanelHandle { root: HTMLElement; apply(states: readonly DeviceState[]): void; dispose(): void; } // ---- Style ---------------------------------------------------------------- /** * Exported so the stylesheet test can assert the two properties that are easy to * lose and impossible to see in a Node test: that every interactive row clears * the 44px touch target, and that nothing in here writes a raw `z-index`. */ export const DEVICE_PANEL_CSS = ` .tera-devices { display: flex; flex-direction: column; gap: var(--s2, 8px); } .tera-device { display: flex; flex-direction: column; gap: var(--s2, 8px); padding: var(--s3, 12px); border: 1px solid var(--hairline, rgba(255,255,255,.11)); border-radius: var(--r-sm, 5px); background: rgba(255, 255, 255, 0.03); } .tera-device__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s2, 8px); } .tera-device__name { font-size: 11px; letter-spacing: .04em; color: var(--ink, rgba(255,255,255,.78)); } .tera-device__kind { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); } .tera-device__row { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr) 3.25rem; align-items: center; gap: var(--s2, 8px); min-height: ${TOUCH_TARGET_PX}px; } .tera-device__row[data-unavailable="true"] { display: none; } .tera-device__label { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); } .tera-device__value { font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2, rgba(255,255,255,.56)); } .tera-device__switch { min-height: ${TOUCH_TARGET_PX}px; min-width: ${TOUCH_TARGET_PX}px; padding: 0 var(--s3, 12px); font: inherit; font-size: 10px; letter-spacing: .08em; text-transform: uppercase; cursor: pointer; color: var(--ink-2, rgba(255,255,255,.56)); background: rgba(255, 255, 255, 0.06); border: 1px solid var(--hairline, rgba(255,255,255,.11)); border-radius: var(--r-pill, 999px); } .tera-device__switch[aria-pressed="true"] { color: var(--amber-ink, #ffd68a); background: rgba(242, 177, 52, 0.2); border-color: rgba(242, 177, 52, 0.5); } .tera-device__slider { width: 100%; min-height: ${TOUCH_TARGET_PX}px; accent-color: var(--amber, #f2b134); } .tera-device__meter { position: relative; height: 6px; border-radius: 3px; overflow: hidden; background: rgba(255, 255, 255, 0.08); } .tera-device__meter i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg, #6fd58f 0%, #f2b134 78%, #ff8f6b 100%); transition: width 120ms linear; } .tera-device__disclosure { margin: 0; font-size: 9px; line-height: 1.6; color: var(--ink-3, rgba(255,255,255,.4)); border-left: 2px solid rgba(242, 177, 52, 0.4); padding-left: var(--s2, 8px); } @media (prefers-reduced-motion: reduce) { .tera-device__meter i { transition: none; } } `; const STYLE_ID = "tera-device-panel-style"; // ---- Rows ---------------------------------------------------------------- /** How each capability presents itself. `level` is a meter because it is a reading. */ type RowShape = "switch" | "slider" | "meter"; const ROW_SHAPE: Readonly> = { power: "switch", mute: "switch", playback: "switch", gain: "slider", volume: "slider", level: "meter", }; const ROW_LABEL: Readonly> = { power: "Power", mute: "Mute", playback: "Play", gain: "Gain", volume: "Volume", level: "Level", }; /** The step a slider moves in, in the reading's own unit. */ const SLIDER_STEP: Readonly> = { gain: 1, volume: 0.01 }; interface Row { capability: DeviceCapability; /** * How this row presents itself, carried rather than re-derived. * * Deliberately not an `instanceof HTMLButtonElement` check at apply time: * `HTMLButtonElement` is not a global in Node, so an `instanceof` against it * throws a `ReferenceError` rather than returning false — which would make * this module untestable outside a browser for no benefit at all. */ shape: RowShape; root: HTMLElement; control: HTMLElement; /** The slider itself, for the two rows that have one. */ input: HTMLInputElement | null; value: HTMLElement; meterFill: HTMLElement | null; } interface Instrument { declaration: DeviceDeclaration; rows: readonly Row[]; } function formatReading(capability: DeviceCapability, reading: unknown): string { if (reading === undefined || reading === null) return "—"; if (typeof reading === "boolean") { if (capability === "power") return reading ? "On" : "Off"; if (capability === "mute") return reading ? "Muted" : "Open"; return reading ? "Playing" : "Stopped"; } if (typeof reading !== "number" || !Number.isFinite(reading)) return "—"; if (capability === "gain") return `${reading >= 0 ? "+" : ""}${reading.toFixed(0)} dB`; if (capability === "level") return `${reading.toFixed(0)} dBFS`; if (capability === "volume") return `${Math.round(reading * 100)}%`; return String(reading); } /** A level in dBFS as a fraction of the meter's travel. `-60` is empty, `0` is full. */ export function meterFraction(levelDb: number | undefined): number { if (levelDb === undefined || !Number.isFinite(levelDb)) return 0; const { min, max } = DEVICE_RANGES.level; return Math.min(1, Math.max(0, (levelDb - min) / (max - min))); } // ---- Mount ---------------------------------------------------------------- export function mountDevicePanel( host: HTMLElement, options: DevicePanelOptions, ): DevicePanelHandle { const doc = host.ownerDocument; const root = doc.createElement("section"); root.className = "tera-devices"; root.setAttribute("aria-label", "Studio hardware"); // One stylesheet per document, not per panel: this panel is torn down and // rebuilt on every office switch, and a `