docs: complete jsdoc pass and enforce newest-first changelog order

This commit is contained in:
Jage9
2026-02-22 17:23:33 -05:00
parent 8da737150e
commit 48fd90023e
9 changed files with 107 additions and 4 deletions

View File

@@ -49,3 +49,4 @@
## Changelog Policy ## Changelog Policy
- Footer changelog content is sourced from `client/public/changelog.json`. - Footer changelog content is sourced from `client/public/changelog.json`.
- Do not add or edit changelog lines unless the user explicitly instructs to do so. - Do not add or edit changelog lines unless the user explicitly instructs to do so.
- Within each date section, keep newest items at the top (reverse chronological order for that day).

View File

@@ -3,11 +3,11 @@
{ {
"date": "February 22, 2026", "date": "February 22, 2026",
"items": [ "items": [
"Added support for Dropbox links and HTTP streams for item sounds and radio stations.", "Moved Use item to Enter Key instead of u, moved speak users to U from Shift U.",
"You will no longer repeatedly hit walls.",
"Added user volume control and calibration.",
"Added up/down arrows and page up/page down for numeric field adjustment.", "Added up/down arrows and page up/page down for numeric field adjustment.",
"Moved Use item to Enter Key instead of u, moved speak users to U from Shift U." "Added user volume control and calibration.",
"Added support for Dropbox links and HTTP streams for item sounds and radio stations.",
"You will no longer repeatedly hit walls."
] ]
}, },
{ {

View File

@@ -145,6 +145,7 @@ export function connectEffectChain(
return runtime; return runtime;
} }
/** Generates a synthetic impulse buffer used by the reverb convolver effect. */
function createImpulseResponse(audioCtx: AudioContext, duration: number, decay: number): AudioBuffer { function createImpulseResponse(audioCtx: AudioContext, duration: number, decay: number): AudioBuffer {
const length = Math.floor(audioCtx.sampleRate * duration); const length = Math.floor(audioCtx.sampleRate * duration);
const impulse = audioCtx.createBuffer(2, length, audioCtx.sampleRate); const impulse = audioCtx.createBuffer(2, length, audioCtx.sampleRate);

View File

@@ -25,6 +25,7 @@ type EmitSpatialConfig = {
const ITEM_EMIT_BASE_GAIN = 0.3; const ITEM_EMIT_BASE_GAIN = 0.3;
/** Maps a 0-100 speed control to playback-rate range used by emitted audio. */
function resolveEmitPlaybackRate(raw: unknown): number { function resolveEmitPlaybackRate(raw: unknown): number {
const speed = Number(raw); const speed = Number(raw);
const clamped = Number.isFinite(speed) ? Math.max(0, Math.min(100, speed)) : 50; const clamped = Number.isFinite(speed) ? Math.max(0, Math.min(100, speed)) : 50;
@@ -34,6 +35,7 @@ function resolveEmitPlaybackRate(raw: unknown): number {
return 1 + ((clamped - 50) / 50) * 1; return 1 + ((clamped - 50) / 50) * 1;
} }
/** Sets browser-specific preserve-pitch flags when changing element playback rate. */
function setElementPreservesPitch(element: HTMLAudioElement, enabled: boolean): void { function setElementPreservesPitch(element: HTMLAudioElement, enabled: boolean): void {
const target = element as HTMLAudioElement & { const target = element as HTMLAudioElement & {
preservesPitch?: boolean; preservesPitch?: boolean;
@@ -45,6 +47,7 @@ function setElementPreservesPitch(element: HTMLAudioElement, enabled: boolean):
if ('webkitPreservesPitch' in target) target.webkitPreservesPitch = enabled; if ('webkitPreservesPitch' in target) target.webkitPreservesPitch = enabled;
} }
/** Resolves effective emit playback/pitch settings from item params with global fallbacks. */
function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitch: boolean } { function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitch: boolean } {
const globals = getItemTypeGlobalProperties(item.type); const globals = getItemTypeGlobalProperties(item.type);
const speed = resolveEmitPlaybackRate(item.params.emitSoundSpeed ?? globals.emitSoundSpeed ?? 50); const speed = resolveEmitPlaybackRate(item.params.emitSoundSpeed ?? globals.emitSoundSpeed ?? 50);

View File

@@ -50,6 +50,7 @@ export function normalizeRadioChannel(channel: unknown): RadioChannelMode {
return (RADIO_CHANNEL_OPTIONS as readonly string[]).includes(normalized) ? normalized : 'stereo'; return (RADIO_CHANNEL_OPTIONS as readonly string[]).includes(normalized) ? normalized : 'stereo';
} }
/** Connects a shared radio media source according to channel mode. */
function connectRadioChannelSource( function connectRadioChannelSource(
audioCtx: AudioContext, audioCtx: AudioContext,
sharedSource: MediaElementAudioSourceNode, sharedSource: MediaElementAudioSourceNode,
@@ -109,6 +110,7 @@ function connectRadioChannelSource(
}; };
} }
/** Returns whether a hostname belongs to Dropbox domains that need proxy support. */
function isDropboxHost(hostname: string): boolean { function isDropboxHost(hostname: string): boolean {
const host = hostname.toLowerCase(); const host = hostname.toLowerCase();
return host.endsWith('dropbox.com') || host.endsWith('dropboxusercontent.com'); return host.endsWith('dropbox.com') || host.endsWith('dropboxusercontent.com');
@@ -138,6 +140,7 @@ export function getProxyUrlForStream(streamUrl: string): string {
return proxy.toString(); return proxy.toString();
} }
/** Appends a cache-buster query parameter to avoid stale stream buffers between sessions. */
function freshStreamUrl(streamUrl: string): string { function freshStreamUrl(streamUrl: string): string {
const playbackSource = shouldProxyStreamUrl(streamUrl) ? getProxyUrlForStream(streamUrl) : streamUrl; const playbackSource = shouldProxyStreamUrl(streamUrl) ? getProxyUrlForStream(streamUrl) : streamUrl;
try { try {

View File

@@ -80,17 +80,20 @@ export function normalizeDegrees(value: number): number {
return wrapped < 0 ? wrapped + 360 : wrapped; return wrapped < 0 ? wrapped + 360 : wrapped;
} }
/** Computes compass bearing from source to listener where 0 is north and 90 is east. */
function bearingFromSourceToListener(dx: number, dy: number): number { function bearingFromSourceToListener(dx: number, dy: number): number {
// 0 degrees is north (+y), 90 is east (+x), matching screen-reader compass wording. // 0 degrees is north (+y), 90 is east (+x), matching screen-reader compass wording.
const degrees = Math.atan2(dx, dy) * (180 / Math.PI); const degrees = Math.atan2(dx, dy) * (180 / Math.PI);
return normalizeDegrees(degrees); return normalizeDegrees(degrees);
} }
/** Returns shortest absolute angular difference in degrees on a circle. */
function angularDifferenceDeg(a: number, b: number): number { function angularDifferenceDeg(a: number, b: number): number {
const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b)); const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b));
return raw > 180 ? 360 - raw : raw; return raw > 180 ? 360 - raw : raw;
} }
/** Computes directional attenuation profile based on listener angle vs source facing. */
function resolveDirectionalProfile( function resolveDirectionalProfile(
dx: number, dx: number,
dy: number, dy: number,

View File

@@ -102,6 +102,7 @@ export function mapTextInputKey(code: string, key: string): string {
return key; return key;
} }
/** Returns whether a character should be treated as part of a word for Ctrl+Arrow navigation. */
function isWordCharacter(ch: string): boolean { function isWordCharacter(ch: string): boolean {
return /[A-Za-z0-9_'\u2019]/.test(ch); return /[A-Za-z0-9_'\u2019]/.test(ch);
} }
@@ -121,6 +122,7 @@ export function moveCursorWordRight(text: string, cursorPos: number): number {
return pos; return pos;
} }
/** Returns the contiguous word under the current cursor index, or null when none. */
function wordAtCursor(text: string, cursorPos: number): string | null { function wordAtCursor(text: string, cursorPos: number): string | null {
if (cursorPos < 0 || cursorPos >= text.length || !isWordCharacter(text[cursorPos])) { if (cursorPos < 0 || cursorPos >= text.length || !isWordCharacter(text[cursorPos])) {
return null; return null;

View File

@@ -124,10 +124,12 @@ export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
Object.values(itemTypeEditableProperties).flatMap((keys) => keys), Object.values(itemTypeEditableProperties).flatMap((keys) => keys),
); );
/** Rebuilds the flattened editable-key lookup after item-type definitions are replaced. */
function rebuildEditablePropertyKeySet(): void { function rebuildEditablePropertyKeySet(): void {
EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(Object.values(itemTypeEditableProperties).flatMap((keys) => keys)); EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(Object.values(itemTypeEditableProperties).flatMap((keys) => keys));
} }
/** Normalizes server-provided property metadata into strict client metadata shape. */
function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefined): Record<string, ItemPropertyMetadata> { function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefined): Record<string, ItemPropertyMetadata> {
if (!raw) return {}; if (!raw) return {};
const normalized: Record<string, ItemPropertyMetadata> = {}; const normalized: Record<string, ItemPropertyMetadata> = {};
@@ -166,38 +168,47 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
return normalized; return normalized;
} }
/** Returns current timezone option list used by clock item properties. */
export function getClockTimeZoneOptions(): string[] { export function getClockTimeZoneOptions(): string[] {
return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)]; return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)];
} }
/** Returns default timezone used by clock items when no override is set. */
export function getDefaultClockTimeZone(): string { export function getDefaultClockTimeZone(): string {
return getClockTimeZoneOptions()[0] ?? 'America/Detroit'; return getClockTimeZoneOptions()[0] ?? 'America/Detroit';
} }
/** Returns item-type display order for add-item menus. */
export function getItemTypeSequence(): ItemType[] { export function getItemTypeSequence(): ItemType[] {
return [...itemTypeSequence]; return [...itemTypeSequence];
} }
/** Returns global per-type property defaults provided by server/item catalog. */
export function getItemTypeGlobalProperties(itemType: ItemType): Record<string, string | number | boolean> { export function getItemTypeGlobalProperties(itemType: ItemType): Record<string, string | number | boolean> {
return itemTypeGlobalProperties[itemType] ?? {}; return itemTypeGlobalProperties[itemType] ?? {};
} }
/** Returns item-type tooltip text, if defined. */
export function getItemTypeTooltip(itemType: ItemType): string | undefined { export function getItemTypeTooltip(itemType: ItemType): string | undefined {
return itemTypeTooltips[itemType]; return itemTypeTooltips[itemType];
} }
/** Returns metadata for a given item property on a specific type. */
export function getItemPropertyMetadata(itemType: ItemType, key: string): ItemPropertyMetadata | undefined { export function getItemPropertyMetadata(itemType: ItemType, key: string): ItemPropertyMetadata | undefined {
return itemTypePropertyMetadata[itemType]?.[key]; return itemTypePropertyMetadata[itemType]?.[key];
} }
/** Returns option-list values for list-based properties, if defined. */
export function getItemPropertyOptionValues(key: string): string[] | undefined { export function getItemPropertyOptionValues(key: string): string[] | undefined {
return optionItemPropertyValues[key]; return optionItemPropertyValues[key];
} }
/** Returns human-facing label for an item type. */
export function itemTypeLabel(type: ItemType): string { export function itemTypeLabel(type: ItemType): string {
return itemTypeLabels[type] ?? type; return itemTypeLabels[type] ?? type;
} }
/** Returns human-facing label for a property key. */
export function itemPropertyLabel(key: string): string { export function itemPropertyLabel(key: string): string {
if (key === 'use24Hour') return 'use 24 hour format'; if (key === 'use24Hour') return 'use 24 hour format';
if (key === 'emitRange') return 'emit range'; if (key === 'emitRange') return 'emit range';
@@ -215,10 +226,12 @@ export function itemPropertyLabel(key: string): string {
return key; return key;
} }
/** Returns editable properties for one item instance/type. */
export function getEditableItemPropertyKeys(item: WorldItem): string[] { export function getEditableItemPropertyKeys(item: WorldItem): string[] {
return [...(itemTypeEditableProperties[item.type] ?? ['title'])]; return [...(itemTypeEditableProperties[item.type] ?? ['title'])];
} }
/** Returns inspect-mode property key list (editable first, then system/global extras). */
export function getInspectItemPropertyKeys(item: WorldItem): string[] { export function getInspectItemPropertyKeys(item: WorldItem): string[] {
const editableKeys = getEditableItemPropertyKeys(item); const editableKeys = getEditableItemPropertyKeys(item);
const seen = new Set(editableKeys); const seen = new Set(editableKeys);
@@ -260,6 +273,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
return allKeys; return allKeys;
} }
/** Applies server-supplied UI/catalog definitions for item types, properties, and options. */
export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void { export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void {
if (!uiDefinitions) return; if (!uiDefinitions) return;

View File

@@ -169,6 +169,7 @@ dom.appVersion.textContent = APP_VERSION
? `Another AI experiment with Jage. Version ${APP_VERSION}` ? `Another AI experiment with Jage. Version ${APP_VERSION}`
: 'Another AI experiment with Jage. Version unknown'; : 'Another AI experiment with Jage. Version unknown';
const APP_BASE_URL = import.meta.env.BASE_URL || '/'; const APP_BASE_URL = import.meta.env.BASE_URL || '/';
/** Resolves an app-relative path against the configured Vite base path. */
function withBase(path: string): string { function withBase(path: string): string {
const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`; const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`;
return `${normalizedBase}${path.replace(/^\/+/, '')}`; return `${normalizedBase}${path.replace(/^\/+/, '')}`;
@@ -237,6 +238,7 @@ loadMicInputGain();
void loadHelp(); void loadHelp();
void loadChangelog(); void loadChangelog();
/** Fetches a required DOM element and casts it to the requested element type. */
function requiredById<T extends HTMLElement>(id: string): T { function requiredById<T extends HTMLElement>(id: string): T {
const found = document.getElementById(id); const found = document.getElementById(id);
if (!found) { if (!found) {
@@ -245,6 +247,7 @@ function requiredById<T extends HTMLElement>(id: string): T {
return found as T; return found as T;
} }
/** Returns the configured display timezone when valid, otherwise the default fallback. */
function resolveDisplayTimeZone(): string { function resolveDisplayTimeZone(): string {
const configured = String(window.CHGRID_TIME_ZONE ?? '').trim(); const configured = String(window.CHGRID_TIME_ZONE ?? '').trim();
if (configured) { if (configured) {
@@ -258,6 +261,7 @@ function resolveDisplayTimeZone(): string {
return DEFAULT_DISPLAY_TIME_ZONE; return DEFAULT_DISPLAY_TIME_ZONE;
} }
/** Formats epoch milliseconds as `YYYY-MM-DD HH:mm` in the configured display timezone. */
function formatTimestampMs(value: unknown): string { function formatTimestampMs(value: unknown): string {
const raw = Number(value); const raw = Number(value);
if (!Number.isFinite(raw)) { if (!Number.isFinite(raw)) {
@@ -280,6 +284,7 @@ function formatTimestampMs(value: unknown): string {
return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}`; return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}`;
} }
/** Toggles updates panel visibility and syncs associated ARIA state. */
function setUpdatesExpanded(expanded: boolean): void { function setUpdatesExpanded(expanded: boolean): void {
dom.updatesToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); dom.updatesToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
dom.updatesToggle.textContent = expanded ? 'Hide updates' : 'Show updates'; dom.updatesToggle.textContent = expanded ? 'Hide updates' : 'Show updates';
@@ -287,6 +292,7 @@ function setUpdatesExpanded(expanded: boolean): void {
dom.updatesPanel.classList.toggle('hidden', !expanded); dom.updatesPanel.classList.toggle('hidden', !expanded);
} }
/** Renders help sections into the footer help container and builds linearized viewer lines. */
function renderHelp(help: HelpData): void { function renderHelp(help: HelpData): void {
const lines: string[] = []; const lines: string[] = [];
dom.instructions.innerHTML = ''; dom.instructions.innerHTML = '';
@@ -312,6 +318,7 @@ function renderHelp(help: HelpData): void {
helpViewerIndex = 0; helpViewerIndex = 0;
} }
/** Loads runtime help content from `help.json` and applies it when available. */
async function loadHelp(): Promise<void> { async function loadHelp(): Promise<void> {
try { try {
const response = await fetch(withBase('help.json'), { cache: 'no-store' }); const response = await fetch(withBase('help.json'), { cache: 'no-store' });
@@ -328,6 +335,7 @@ async function loadHelp(): Promise<void> {
} }
} }
/** Renders changelog sections into the collapsible updates panel. */
function renderChangelog(changelog: ChangelogData): void { function renderChangelog(changelog: ChangelogData): void {
dom.updatesPanel.innerHTML = ''; dom.updatesPanel.innerHTML = '';
for (const section of changelog.sections) { for (const section of changelog.sections) {
@@ -345,6 +353,7 @@ function renderChangelog(changelog: ChangelogData): void {
} }
} }
/** Loads changelog entries from `changelog.json` and wires the panel toggle button. */
async function loadChangelog(): Promise<void> { async function loadChangelog(): Promise<void> {
try { try {
const response = await fetch(withBase('changelog.json'), { cache: 'no-store' }); const response = await fetch(withBase('changelog.json'), { cache: 'no-store' });
@@ -368,6 +377,7 @@ async function loadChangelog(): Promise<void> {
} }
} }
/** Announces status text via ARIA with brief de-duplication and auto-clear timing. */
function updateStatus(message: string): void { function updateStatus(message: string): void {
const normalized = String(message) const normalized = String(message)
.replace(/\s*\n+\s*/g, ' ') .replace(/\s*\n+\s*/g, ' ')
@@ -394,10 +404,12 @@ function updateStatus(message: string): void {
}, 4000); }, 4000);
} }
/** Sanitizes user nicknames to printable/safe characters and enforces max length. */
function sanitizeName(value: string): string { function sanitizeName(value: string): string {
return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH); return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH);
} }
/** Enables/disables the connect button based on state and nickname validity. */
function updateConnectAvailability(): void { function updateConnectAvailability(): void {
if (state.running) { if (state.running) {
dom.connectButton.disabled = true; dom.connectButton.disabled = true;
@@ -407,6 +419,7 @@ function updateConnectAvailability(): void {
dom.connectButton.disabled = connecting || !hasNickname; dom.connectButton.disabled = connecting || !hasNickname;
} }
/** Restores persisted outbound effect levels from local storage. */
function loadEffectLevels(): void { function loadEffectLevels(): void {
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY); const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
if (!raw) return; if (!raw) return;
@@ -420,10 +433,12 @@ function loadEffectLevels(): void {
} }
} }
/** Persists current outbound effect levels to local storage. */
function persistEffectLevels(): void { function persistEffectLevels(): void {
localStorage.setItem(EFFECT_LEVELS_STORAGE_KEY, JSON.stringify(audio.getEffectLevels())); localStorage.setItem(EFFECT_LEVELS_STORAGE_KEY, JSON.stringify(audio.getEffectLevels()));
} }
/** Restores local audio-layer toggles and applies initial voice-layer state. */
function loadAudioLayerState(): void { function loadAudioLayerState(): void {
const raw = localStorage.getItem(AUDIO_LAYER_STATE_STORAGE_KEY); const raw = localStorage.getItem(AUDIO_LAYER_STATE_STORAGE_KEY);
if (raw) { if (raw) {
@@ -442,15 +457,18 @@ function loadAudioLayerState(): void {
audio.setVoiceLayerEnabled(audioLayers.voice); audio.setVoiceLayerEnabled(audioLayers.voice);
} }
/** Persists current audio-layer toggles to local storage. */
function persistAudioLayerState(): void { function persistAudioLayerState(): void {
localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(audioLayers)); localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(audioLayers));
} }
/** Clamps microphone input gain to the supported calibration bounds. */
function clampMicInputGain(value: number): number { function clampMicInputGain(value: number): number {
if (!Number.isFinite(value)) return 1; if (!Number.isFinite(value)) return 1;
return Math.max(MIC_CALIBRATION_MIN_GAIN, Math.min(MIC_CALIBRATION_MAX_GAIN, value)); return Math.max(MIC_CALIBRATION_MIN_GAIN, Math.min(MIC_CALIBRATION_MAX_GAIN, value));
} }
/** Loads persisted microphone input gain and applies default when missing. */
function loadMicInputGain(): void { function loadMicInputGain(): void {
const raw = localStorage.getItem(MIC_INPUT_GAIN_STORAGE_KEY); const raw = localStorage.getItem(MIC_INPUT_GAIN_STORAGE_KEY);
if (!raw) { if (!raw) {
@@ -461,10 +479,12 @@ function loadMicInputGain(): void {
audio.setOutboundInputGain(clampMicInputGain(parsed)); audio.setOutboundInputGain(clampMicInputGain(parsed));
} }
/** Persists microphone input gain to local storage. */
function persistMicInputGain(value: number): void { function persistMicInputGain(value: number): void {
localStorage.setItem(MIC_INPUT_GAIN_STORAGE_KEY, String(value)); localStorage.setItem(MIC_INPUT_GAIN_STORAGE_KEY, String(value));
} }
/** Applies current layer toggles to peer voice, media streams, and item emitters. */
async function applyAudioLayerState(): Promise<void> { async function applyAudioLayerState(): Promise<void> {
audio.setVoiceLayerEnabled(audioLayers.voice); audio.setVoiceLayerEnabled(audioLayers.voice);
if (audioLayers.voice) { if (audioLayers.voice) {
@@ -476,6 +496,7 @@ async function applyAudioLayerState(): Promise<void> {
await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values()); await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values());
} }
/** Toggles a single audio layer and applies the change immediately. */
function toggleAudioLayer(layer: keyof AudioLayerState): void { function toggleAudioLayer(layer: keyof AudioLayerState): void {
audioLayers = { ...audioLayers, [layer]: !audioLayers[layer] }; audioLayers = { ...audioLayers, [layer]: !audioLayers[layer] };
persistAudioLayerState(); persistAudioLayerState();
@@ -484,6 +505,7 @@ function toggleAudioLayer(layer: keyof AudioLayerState): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Appends a chat/system line to the bounded status history buffer. */
function pushChatMessage(message: string): void { function pushChatMessage(message: string): void {
messageBuffer.push(message); messageBuffer.push(message);
if (messageBuffer.length > 300) { if (messageBuffer.length > 300) {
@@ -493,6 +515,7 @@ function pushChatMessage(message: string): void {
updateStatus(message); updateStatus(message);
} }
/** Classifies a system chat line into a corresponding notification sound, when applicable. */
function classifySystemMessageSound(message: string): keyof typeof SYSTEM_SOUND_URLS | null { function classifySystemMessageSound(message: string): keyof typeof SYSTEM_SOUND_URLS | null {
const normalized = message.trim().toLowerCase(); const normalized = message.trim().toLowerCase();
if (!normalized) return null; if (!normalized) return null;
@@ -508,6 +531,7 @@ function classifySystemMessageSound(message: string): keyof typeof SYSTEM_SOUND_
return null; return null;
} }
/** Resolves incoming sound references to playable URLs, including proxy routing when needed. */
function resolveIncomingSoundUrl(url: string): string { function resolveIncomingSoundUrl(url: string): string {
const raw = String(url || '').trim(); const raw = String(url || '').trim();
if (!raw) return ''; if (!raw) return '';
@@ -524,6 +548,7 @@ function resolveIncomingSoundUrl(url: string): string {
return raw; return raw;
} }
/** Navigates buffered chat lines and speaks the selected entry. */
function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void { function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void {
if (messageBuffer.length === 0) { if (messageBuffer.length === 0) {
updateStatus('No chat messages.'); updateStatus('No chat messages.');
@@ -545,6 +570,7 @@ function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Updates compact input/output device summary labels in the pre-connect UI. */
function updateDeviceSummary(): void { function updateDeviceSummary(): void {
if (preferredInputDeviceId) { if (preferredInputDeviceId) {
const text = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName || 'Saved microphone'; const text = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName || 'Saved microphone';
@@ -563,16 +589,19 @@ function updateDeviceSummary(): void {
} }
} }
/** Returns peer nicknames currently occupying the given grid cell. */
function getPeerNamesAtPosition(x: number, y: number): string[] { function getPeerNamesAtPosition(x: number, y: number): string[] {
return Array.from(state.peers.values()) return Array.from(state.peers.values())
.filter((peer) => peer.x === x && peer.y === y) .filter((peer) => peer.x === x && peer.y === y)
.map((peer) => peer.nickname); .map((peer) => peer.nickname);
} }
/** Returns a user-facing item label including type information. */
function itemLabel(item: WorldItem): string { function itemLabel(item: WorldItem): string {
return `${item.title} (${itemTypeLabel(item.type)})`; return `${item.title} (${itemTypeLabel(item.type)})`;
} }
/** Resolves effective spatial audio configuration for an item, with global fallbacks. */
function getItemSpatialConfig(item: WorldItem): { range: number; directional: boolean; facingDeg: number } { function getItemSpatialConfig(item: WorldItem): { range: number; directional: boolean; facingDeg: number } {
const global = getItemTypeGlobalProperties(item.type); const global = getItemTypeGlobalProperties(item.type);
const rawParamRange = Number(item.params.emitRange); const rawParamRange = Number(item.params.emitRange);
@@ -585,6 +614,7 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
return { range, directional, facingDeg }; return { range, directional, facingDeg };
} }
/** Enters help-view mode and announces the first help line. */
function openHelpViewer(): void { function openHelpViewer(): void {
if (helpViewerLines.length === 0) { if (helpViewerLines.length === 0) {
updateStatus('Help unavailable.'); updateStatus('Help unavailable.');
@@ -597,15 +627,18 @@ function openHelpViewer(): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Returns non-carried items occupying a given grid position. */
function getItemsAtPosition(x: number, y: number): WorldItem[] { function getItemsAtPosition(x: number, y: number): WorldItem[] {
return Array.from(state.items.values()).filter((item) => !item.carrierId && item.x === x && item.y === y); return Array.from(state.items.values()).filter((item) => !item.carrierId && item.x === x && item.y === y);
} }
/** Returns the item currently carried by the local player, if any. */
function getCarriedItem(): WorldItem | null { function getCarriedItem(): WorldItem | null {
if (!state.player.id) return null; if (!state.player.id) return null;
return Array.from(state.items.values()).find((item) => item.carrierId === state.player.id) || null; return Array.from(state.items.values()).find((item) => item.carrierId === state.player.id) || null;
} }
/** Opens the shared item-selection flow for the provided context and items. */
function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'inspect', items: WorldItem[]): void { function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'inspect', items: WorldItem[]): void {
if (items.length === 0) { if (items.length === 0) {
updateStatus('No items available.'); updateStatus('No items available.');
@@ -620,6 +653,7 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Opens item property browsing/editing mode for one item. */
function beginItemProperties(item: WorldItem, showAll = false): void { function beginItemProperties(item: WorldItem, showAll = false): void {
state.selectedItemId = item.id; state.selectedItemId = item.id;
state.mode = 'itemProperties'; state.mode = 'itemProperties';
@@ -638,10 +672,12 @@ function beginItemProperties(item: WorldItem, showAll = false): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Sends an item-use request for the selected item. */
function useItem(item: WorldItem): void { function useItem(item: WorldItem): void {
signaling.send({ type: 'item_use', itemId: item.id }); signaling.send({ type: 'item_use', itemId: item.id });
} }
/** Opens option-list selection mode for list-based item properties. */
function openItemPropertyOptionSelect(item: WorldItem, key: string): void { function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
const options = getItemPropertyOptionValues(key); const options = getItemPropertyOptionValues(key);
if (!options || options.length === 0) { if (!options || options.length === 0) {
@@ -657,6 +693,7 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Returns the active text-input max length for the current UI mode, if applicable. */
function textInputMaxLengthForMode(mode: typeof state.mode): number | null { function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
if (mode === 'nickname') return NICKNAME_MAX_LENGTH; if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
if (mode === 'chat') return 500; if (mode === 'chat') return 500;
@@ -665,6 +702,7 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
return null; return null;
} }
/** Applies pasted text into whichever mode currently owns the shared text edit buffer. */
function pasteIntoActiveTextInput(raw: string): boolean { function pasteIntoActiveTextInput(raw: string): boolean {
const maxLength = textInputMaxLengthForMode(state.mode); const maxLength = textInputMaxLengthForMode(state.mode);
if (maxLength === null) { if (maxLength === null) {
@@ -678,10 +716,12 @@ function pasteIntoActiveTextInput(raw: string): boolean {
return true; return true;
} }
/** Whether the current mode uses the shared single-line text editing pipeline. */
function isTextEditingMode(mode: typeof state.mode): boolean { function isTextEditingMode(mode: typeof state.mode): boolean {
return mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit' || mode === 'micGainEdit'; return mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit' || mode === 'micGainEdit';
} }
/** Applies keyboard edits to the shared text buffer and emits cursor/deletion speech hints. */
function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKey = false, allowReplaceOnNextType = false): void { function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKey = false, allowReplaceOnNextType = false): void {
if (ctrlKey && code === 'KeyA') { if (ctrlKey && code === 'KeyA') {
replaceTextOnNextType = true; replaceTextOnNextType = true;
@@ -731,6 +771,7 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
} }
} }
/** Returns a formatted display value for an item property key, with per-key normalization. */
function getItemPropertyValue(item: WorldItem, key: string): string { function getItemPropertyValue(item: WorldItem, key: string): string {
const toSoundDisplayName = (rawValue: unknown): string => { const toSoundDisplayName = (rawValue: unknown): string => {
const raw = String(rawValue ?? '').trim(); const raw = String(rawValue ?? '').trim();
@@ -783,6 +824,7 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
return ''; return '';
} }
/** Infers value type for item-property help when metadata is missing. */
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined { function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
if (key === 'useSound' || key === 'emitSound') return 'sound'; if (key === 'useSound' || key === 'emitSound') return 'sound';
if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean'; if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean';
@@ -814,6 +856,7 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde
return 'text'; return 'text';
} }
/** Provides tooltip fallbacks for inspect-only/system item properties. */
function getFallbackInspectPropertyTooltip(key: string): string | undefined { function getFallbackInspectPropertyTooltip(key: string): string | undefined {
if (key === 'type') return 'The item type identifier.'; if (key === 'type') return 'The item type identifier.';
if (key === 'x') return 'X coordinate on the grid.'; if (key === 'x') return 'X coordinate on the grid.';
@@ -831,10 +874,12 @@ function getFallbackInspectPropertyTooltip(key: string): string | undefined {
return undefined; return undefined;
} }
/** Returns whether a property is editable for the given item type. */
function isItemPropertyEditable(item: WorldItem, key: string): boolean { function isItemPropertyEditable(item: WorldItem, key: string): boolean {
return getEditableItemPropertyKeys(item).includes(key); return getEditableItemPropertyKeys(item).includes(key);
} }
/** Builds spoken tooltip/help text for the current item property row. */
function describeItemPropertyHelp(item: WorldItem, key: string): string { function describeItemPropertyHelp(item: WorldItem, key: string): string {
const metadata = getItemPropertyMetadata(item.type, key); const metadata = getItemPropertyMetadata(item.type, key);
const parts: string[] = []; const parts: string[] = [];
@@ -868,6 +913,7 @@ function describeItemPropertyHelp(item: WorldItem, key: string): string {
return parts.join(' '); return parts.join(' ');
} }
/** Validates and normalizes numeric item-property edits using metadata ranges/steps. */
function validateNumericItemPropertyInput( function validateNumericItemPropertyInput(
item: WorldItem, item: WorldItem,
key: string, key: string,
@@ -896,10 +942,12 @@ function validateNumericItemPropertyInput(
return { ok: true, value: parsed }; return { ok: true, value: parsed };
} }
/** Returns singular/plural square wording for distance announcements. */
function squareWord(distance: number): string { function squareWord(distance: number): string {
return distance === 1 ? 'square' : 'squares'; return distance === 1 ? 'square' : 'squares';
} }
/** Builds a spoken distance+direction phrase between two grid coordinates. */
function distanceDirectionPhrase(px: number, py: number, tx: number, ty: number): string { function distanceDirectionPhrase(px: number, py: number, tx: number, ty: number): string {
const distance = Math.round(Math.hypot(tx - px, ty - py)); const distance = Math.round(Math.hypot(tx - px, ty - py));
const direction = getDirection(px, py, tx, ty); const direction = getDirection(px, py, tx, ty);
@@ -907,6 +955,7 @@ function distanceDirectionPhrase(px: number, py: number, tx: number, ty: number)
return `${distance} ${squareWord(distance)} ${direction}`; return `${distance} ${squareWord(distance)} ${direction}`;
} }
/** Persists current local player coordinates for reconnect/refresh restore. */
function persistPlayerPosition(): void { function persistPlayerPosition(): void {
try { try {
localStorage.setItem( localStorage.setItem(
@@ -918,10 +967,12 @@ function persistPlayerPosition(): void {
} }
} }
/** Picks one random footstep sample URL. */
function randomFootstepUrl(): string { function randomFootstepUrl(): string {
return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)]; return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)];
} }
/** Main animation/update loop for movement, spatial audio, and rendering. */
function gameLoop(): void { function gameLoop(): void {
if (!state.running) return; if (!state.running) return;
handleMovement(); handleMovement();
@@ -933,6 +984,7 @@ function gameLoop(): void {
requestAnimationFrame(gameLoop); requestAnimationFrame(gameLoop);
} }
/** Applies held-arrow movement with bounds checks, tile cues, and server position sync. */
function handleMovement(): void { function handleMovement(): void {
if (state.mode !== 'normal') return; if (state.mode !== 'normal') return;
const now = Date.now(); const now = Date.now();
@@ -986,6 +1038,7 @@ function handleMovement(): void {
} }
} }
/** Checks microphone permission state when Permissions API support is available. */
async function checkMicPermission(): Promise<boolean> { async function checkMicPermission(): Promise<boolean> {
const permissionApi = navigator.permissions; const permissionApi = navigator.permissions;
if (!permissionApi?.query) return true; if (!permissionApi?.query) return true;
@@ -997,6 +1050,7 @@ async function checkMicPermission(): Promise<boolean> {
} }
} }
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
async function setupLocalMedia(audioDeviceId = ''): Promise<void> { async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
stopLocalMedia(); stopLocalMedia();
@@ -1023,6 +1077,7 @@ async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
await peerManager.replaceOutgoingTrack(outboundStream); await peerManager.replaceOutgoingTrack(outboundStream);
} }
/** Runs a short RMS sample to estimate and apply a usable microphone input gain. */
async function calibrateMicInputGain(): Promise<void> { async function calibrateMicInputGain(): Promise<void> {
if (calibratingMicInput) { if (calibratingMicInput) {
updateStatus('Mic calibration already running.'); updateStatus('Mic calibration already running.');
@@ -1102,6 +1157,7 @@ async function calibrateMicInputGain(): Promise<void> {
audio.sfxUiConfirm(); audio.sfxUiConfirm();
} }
/** Stops local capture tracks and clears outbound stream references. */
function stopLocalMedia(): void { function stopLocalMedia(): void {
if (localStream) { if (localStream) {
localStream.getTracks().forEach((track) => track.stop()); localStream.getTracks().forEach((track) => track.stop());
@@ -1110,6 +1166,7 @@ function stopLocalMedia(): void {
outboundStream = null; outboundStream = null;
} }
/** Maps browser media/capture errors to user-facing remediation text. */
function describeMediaError(error: unknown): string { function describeMediaError(error: unknown): string {
if (error instanceof DOMException) { if (error instanceof DOMException) {
if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.'; if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.';
@@ -1121,6 +1178,7 @@ function describeMediaError(error: unknown): string {
return 'Audio setup failed. Check browser permissions and selected input device.'; return 'Audio setup failed. Check browser permissions and selected input device.';
} }
/** Performs end-to-end connect flow: validation, media setup, then signaling connection. */
async function connect(): Promise<void> { async function connect(): Promise<void> {
if (connecting || state.running) { if (connecting || state.running) {
return; return;
@@ -1192,6 +1250,7 @@ async function connect(): Promise<void> {
} }
} }
/** Tears down active session state, media, peers, and UI back to pre-connect mode. */
function disconnect(): void { function disconnect(): void {
const wasRunning = state.running; const wasRunning = state.running;
if (state.running) { if (state.running) {
@@ -1290,6 +1349,7 @@ const onMessage = createOnMessageHandler({
}, },
}); });
/** Toggles local microphone track mute state. */
function toggleMute(): void { function toggleMute(): void {
state.isMuted = !state.isMuted; state.isMuted = !state.isMuted;
if (localStream) { if (localStream) {
@@ -1299,6 +1359,7 @@ function toggleMute(): void {
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.'); updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
} }
/** Handles command-mode keybindings while in main gameplay mode. */
function handleNormalModeInput(code: string, shiftKey: boolean): void { function handleNormalModeInput(code: string, shiftKey: boolean): void {
if (code !== 'Escape' && pendingEscapeDisconnect) { if (code !== 'Escape' && pendingEscapeDisconnect) {
pendingEscapeDisconnect = false; pendingEscapeDisconnect = false;
@@ -1610,6 +1671,7 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
} }
} }
/** Handles linear help viewer navigation and exit keys. */
function handleHelpViewModeInput(code: string): void { function handleHelpViewModeInput(code: string): void {
if (helpViewerLines.length === 0) { if (helpViewerLines.length === 0) {
state.mode = 'normal'; state.mode = 'normal';
@@ -1649,6 +1711,7 @@ function handleHelpViewModeInput(code: string): void {
} }
} }
/** Handles chat compose mode including submit/cancel and inline editing keys. */
function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void { function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void {
const editAction = getEditSessionAction(code); const editAction = getEditSessionAction(code);
if (editAction === 'submit') { if (editAction === 'submit') {
@@ -1679,6 +1742,7 @@ function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void
applyTextInputEdit(code, key, 500, ctrlKey); applyTextInputEdit(code, key, 500, ctrlKey);
} }
/** Handles direct microphone gain editing mode with keyboard stepping and validation. */
function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean): void { function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean): void {
if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'PageUp' || code === 'PageDown') { if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'PageUp' || code === 'PageDown') {
const raw = Number(state.nicknameInput.trim()); const raw = Number(state.nicknameInput.trim());
@@ -1733,6 +1797,7 @@ function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean)
applyTextInputEdit(code, key, 8, ctrlKey, true); applyTextInputEdit(code, key, 8, ctrlKey, true);
} }
/** Handles effect menu list navigation and selection. */
function handleEffectSelectModeInput(code: string, key: string): void { function handleEffectSelectModeInput(code: string, key: string): void {
const control = handleListControlKey(code, key, EFFECT_SEQUENCE, state.effectSelectIndex, (effect) => effect.label); const control = handleListControlKey(code, key, EFFECT_SEQUENCE, state.effectSelectIndex, (effect) => effect.label);
if (control.type === 'move') { if (control.type === 'move') {
@@ -1758,6 +1823,7 @@ function handleEffectSelectModeInput(code: string, key: string): void {
} }
} }
/** Handles list navigation for nearby/known users and teleport-on-select. */
function handleListModeInput(code: string, key: string): void { function handleListModeInput(code: string, key: string): void {
if (state.sortedPeerIds.length === 0) { if (state.sortedPeerIds.length === 0) {
state.mode = 'normal'; state.mode = 'normal';
@@ -1802,6 +1868,7 @@ function handleListModeInput(code: string, key: string): void {
} }
} }
/** Handles item list navigation and teleport-on-select. */
function handleListItemsModeInput(code: string, key: string): void { function handleListItemsModeInput(code: string, key: string): void {
if (state.sortedItemIds.length === 0) { if (state.sortedItemIds.length === 0) {
state.mode = 'normal'; state.mode = 'normal';
@@ -1847,6 +1914,7 @@ function handleListItemsModeInput(code: string, key: string): void {
} }
} }
/** Handles add-item type selection and item-type tooltip readout. */
function handleAddItemModeInput(code: string, key: string): void { function handleAddItemModeInput(code: string, key: string): void {
const itemTypeSequence = getItemTypeSequence(); const itemTypeSequence = getItemTypeSequence();
if (itemTypeSequence.length === 0) { if (itemTypeSequence.length === 0) {
@@ -1881,6 +1949,7 @@ function handleAddItemModeInput(code: string, key: string): void {
} }
} }
/** Handles generic selected-item list flow used by pickup/delete/edit/use/inspect contexts. */
function handleSelectItemModeInput(code: string, key: string): void { function handleSelectItemModeInput(code: string, key: string): void {
if (state.selectedItemIds.length === 0) { if (state.selectedItemIds.length === 0) {
state.mode = 'normal'; state.mode = 'normal';
@@ -1963,6 +2032,7 @@ const itemPropertyEditor = createItemPropertyEditor({
sfxUiCancel: () => audio.sfxUiCancel(), sfxUiCancel: () => audio.sfxUiCancel(),
}); });
/** Handles nickname edit mode submission/cancel and text editing keys. */
function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void { function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void {
const editAction = getEditSessionAction(code); const editAction = getEditSessionAction(code);
if (editAction === 'submit') { if (editAction === 'submit') {
@@ -1991,10 +2061,12 @@ function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): v
applyTextInputEdit(code, key, NICKNAME_MAX_LENGTH, ctrlKey, true); applyTextInputEdit(code, key, NICKNAME_MAX_LENGTH, ctrlKey, true);
} }
/** Returns whether a key code should be treated as a repeat-suppressed typing key. */
function isTypingKey(code: string): boolean { function isTypingKey(code: string): boolean {
return code.startsWith('Key') || code === 'Space'; return code.startsWith('Key') || code === 'Space';
} }
/** Wires global keyboard/paste input handlers and routes events by current mode. */
function setupInputHandlers(): void { function setupInputHandlers(): void {
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
const code = event.code; const code = event.code;
@@ -2084,6 +2156,7 @@ function setupInputHandlers(): void {
}); });
} }
/** Enumerates audio devices, updates selectors, and persists preferred choices. */
async function populateAudioDevices(): Promise<void> { async function populateAudioDevices(): Promise<void> {
if (!navigator.mediaDevices?.enumerateDevices) { if (!navigator.mediaDevices?.enumerateDevices) {
return; return;
@@ -2133,6 +2206,7 @@ async function populateAudioDevices(): Promise<void> {
} }
} }
/** Opens settings modal and focuses device controls. */
function openSettings(): void { function openSettings(): void {
lastFocusedElement = document.activeElement; lastFocusedElement = document.activeElement;
dom.settingsModal.classList.remove('hidden'); dom.settingsModal.classList.remove('hidden');
@@ -2140,6 +2214,7 @@ function openSettings(): void {
dom.audioInputSelect.focus(); dom.audioInputSelect.focus();
} }
/** Closes settings modal and restores focus back to prior element or game canvas. */
function closeSettings(): void { function closeSettings(): void {
dom.settingsModal.classList.add('hidden'); dom.settingsModal.classList.add('hidden');
if (lastFocusedElement instanceof HTMLElement) { if (lastFocusedElement instanceof HTMLElement) {
@@ -2149,6 +2224,7 @@ function closeSettings(): void {
} }
} }
/** Wires button/form handlers and lifecycle hooks for the main UI shell. */
function setupUiHandlers(): void { function setupUiHandlers(): void {
setupDomUiHandlers({ setupDomUiHandlers({
dom, dom,