// Who's-here list (SOCIAL_RESET brief, Feature 4). Hold Tab: a translucent list // of the DJs in the booth (you first), each with their avatar hue as a swatch. // Pure view — the data comes from NetClient's roster; no network of its own. import { bodyCss, type AvatarLook } from '../net/avatarLook'; export interface RosterEntry { id: number; name: string; look: AvatarLook; } export class Roster { private readonly el: HTMLDivElement; private peers: RosterEntry[] = []; private self: { name: string; look: AvatarLook }; private shown = false; constructor(self: { name: string; look: AvatarLook }) { this.self = self; this.injectStyle(); this.el = document.createElement('div'); this.el.className = 'tcr-panel hidden'; document.body.appendChild(this.el); window.addEventListener('keydown', this.onDown); window.addEventListener('keyup', this.onUp); // Tab can steal focus / fire blur — hide defensively. window.addEventListener('blur', () => this.show(false)); } /** Local player's name/look (the mirror can change live). */ setSelf(self: { name: string; look: AvatarLook }): void { this.self = self; if (this.shown) this.render(); } setPeers(peers: RosterEntry[]): void { this.peers = peers; if (this.shown) this.render(); } private onDown = (e: KeyboardEvent): void => { if (e.code !== 'Tab') return; e.preventDefault(); // don't tab-focus the page furniture if (!e.repeat) this.show(true); }; private onUp = (e: KeyboardEvent): void => { if (e.code !== 'Tab') return; e.preventDefault(); this.show(false); }; private show(v: boolean): void { if (v === this.shown) return; this.shown = v; if (v) this.render(); this.el.classList.toggle('hidden', !v); } private render(): void { const rows: string[] = []; const row = (name: string, css: string, you: boolean) => `
` + `${escapeHtml(name)}${you ? 'you' : ''}
`; rows.push(row(this.self.name || 'you', bodyCss(this.self.look, 0), true)); for (const p of this.peers) rows.push(row(p.name, bodyCss(p.look, p.id), false)); const n = this.peers.length + 1; this.el.innerHTML = `
IN THE BOOTH — ${n} DJ${n === 1 ? '' : 's'}
${rows.join('')}`; } dispose(): void { window.removeEventListener('keydown', this.onDown); window.removeEventListener('keyup', this.onUp); this.el.remove(); } private injectStyle(): void { const css = document.createElement('style'); css.textContent = ` .tcr-panel{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:45; min-width:240px;max-height:70vh;overflow-y:auto;background:rgba(12,10,8,0.82); border:1px solid #4a4234;border-radius:10px;padding:12px 14px; font:12px/1.6 ui-monospace,monospace;color:#d8cdb4;pointer-events:none; backdrop-filter:blur(2px)} .tcr-panel.hidden{display:none} .tcr-head{color:#ffb028;font-size:11px;letter-spacing:.15em;margin-bottom:8px} .tcr-row{display:flex;align-items:center;gap:9px;padding:3px 0} .tcr-row i{width:12px;height:12px;border-radius:3px;flex:none; border:1px solid rgba(255,255,255,0.18)} .tcr-row b{margin-left:auto;color:#7ec46a;font-weight:400;font-size:10px;letter-spacing:.1em}`; document.head.appendChild(css); } } /** Names are server-sanitized, but this is user-controlled text in innerHTML. */ function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); }