/** * 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, deviceRange, initialDeviceState, normalizeDeviceCommand, } from "../devices/types.ts"; import type { DeviceCapability, DeviceCommand, DeviceDeclaration, DeviceRange, 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__status { margin: 0; font-size: 9px; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); } .tera-device__status[data-reachable="false"] { color: var(--ink-2, rgba(255,255,255,.56)); } .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, derived from the range rather than tabled against * the capability. * * It used to be a constant per capability — 1 for gain, 0.01 for volume — which * was right only while every gain in the world was the default −12…+36 dB. A * Yeti Nano declares 0–100 "%" and a step of 1 is still right there; a * hypothetical 0–1 gain would need 0.01. So the rule is about the span: a wide * range moves in whole units, a narrow one in hundredths. */ function sliderStep(range: DeviceRange): number { return range.max - range.min > 5 ? 1 : (range.max - range.min) / 100; } interface Row { capability: DeviceCapability; /** This device's own bounds and unit, or `null` for a switch. */ range: DeviceRange | null; /** * 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[]; /** * The one line per instrument that says which of the two sentences the viewer * is reading, and whether anybody answered the door. */ status: HTMLElement; disclosure: HTMLElement; } /** * One reading, in the unit the *declaration* says it is in. * * The unit comes from the range and never from the capability, and that is the * whole reason `DeviceDeclaration.ranges` exists. A Blue Yeti Nano's capture * level is an ALSA position on a 0–50 scale; there is no arithmetic that turns * 68% of it into decibels, and printing "+20.6 dB" beside it would present a * guess in the typography of a measurement. The field on `DeviceState` is still * called `gainDb` — renaming it would break four consumers to fix a label — so * this is the one place that decides what the number is called on screen, and it * asks the declaration. */ function formatReading( capability: DeviceCapability, reading: unknown, range: DeviceRange | null, ): 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 "—"; const unit = range?.unit ?? ""; // A fraction is the one unit nobody wants to read as a fraction. if (unit === "fraction") return `${Math.round(reading * 100)}%`; if (unit === "%") return `${Math.round(reading)}%`; if (unit === "dB") return `${reading >= 0 ? "+" : ""}${reading.toFixed(0)} dB`; if (unit === "dBFS") return `${reading.toFixed(0)} dBFS`; if (unit === "") return String(reading); return `${reading.toFixed(reading % 1 === 0 ? 0 : 2)} ${unit}`; } /** * A level as a fraction of the meter's travel. On the default range, `-60` dBFS * is empty and `0` is full. * * `range` is optional and defaults to the global one so that every existing * caller — and every test that already asserts on this — is unchanged. A device * that declared its own level range gets its own travel. */ export function meterFraction(levelDb: number | undefined, range?: DeviceRange): number { if (levelDb === undefined || !Number.isFinite(levelDb)) return 0; const { min, max } = range ?? DEVICE_RANGES.level; if (!(max > min)) return 0; return Math.min(1, Math.max(0, (levelDb - min) / (max - min))); } /** * How old a reading is, in the coarsest unit that is still true. * * Deliberately vague past an hour. A panel that said "not reached — last reading * 3h 41m ago" would be inviting a precision the underlying clock does not have: * `observedAt` is the bridge's own `checkedAt`, restamped through two caches. */ function sinceLabel(observedAt: number | undefined, nowMs: number = Date.now()): string { if (typeof observedAt !== "number" || !Number.isFinite(observedAt) || observedAt <= 0) { return "at an unknown time"; } const seconds = Math.max(0, Math.round((nowMs - observedAt) / 1000)); if (seconds < 90) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 90) return `${minutes} min ago`; const hours = Math.round(minutes / 60); return hours < 36 ? `${hours}h ago` : "over a day ago"; } // ---- 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 `