openvibers/openvibe.live Commits
Chat: the emote and GIF pickers close (a ×, any tap outside them, Escape; the phone studio closes one when it takes over); a phone sent no click for a tap on a chat line, so they stayed open. A page that never loaded emotes loads them when the picker opens (#176)
Changes
@@@ -4187,6 +4187,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.emote-tab-btn.active {color: var(--accent); border-bottom-color: var(--accent);}++.emote-picker-close {++ flex: 0 0 auto; min-width: 44px; padding: 5px 12px; background: transparent; border: none;++ border-bottom: 2px solid transparent; color: var(--text-muted); font-size: 1rem; cursor: pointer;++}++.emote-picker-close:hover, .emote-picker-close:focus-visible { color: var(--text-primary); }++.gif-picker-close { float: right; min-width: 36px; padding: 2px 8px; }.emote-picker-grid {display: grid;@@@ -199,6 +199,8 @@document.body.append(st.root);// The overlay chat replaces the phone chat sheet.try { if (typeof _bcMobileChatOpen !== 'undefined' && _bcMobileChatOpen && typeof toggleBroadcastMobileChat === 'function') toggleBroadcastMobileChat(); } catch { /* not open */ }++ // The studio chat has no emote or GIF buttons: a picker opened before it would have no way to close.++ try { if (typeof closeEmotePicker === 'function') closeEmotePicker(); if (typeof closeGifPicker === 'function') closeGifPicker(); } catch { /* none open */ }const v = $('bc-video-preview');if (v) v.play().catch(() => {});const msgs = $('bc-chat-messages');@@@ -277,7 +277,8 @@ function _buildEmoteMenuTabs(picker) {if (!tabs) return;tabs.innerHTML = `<button class="emote-tab-btn ${_emoteMenuMode === 'emotes' ? 'active' : ''}" data-tab="emotes" onclick="renderEmotePicker('emotes')"><i class="fa-solid fa-face-smile"></i> Emotes</button>-- <button class="emote-tab-btn ${_emoteMenuMode === 'gif' ? 'active' : ''}" data-tab="gif" onclick="renderEmotePicker('gif')"><i class="fa-solid fa-film"></i> GIF</button>`;++ <button class="emote-tab-btn ${_emoteMenuMode === 'gif' ? 'active' : ''}" data-tab="gif" onclick="renderEmotePicker('gif')"><i class="fa-solid fa-film"></i> GIF</button>++ <button class="emote-picker-close" type="button" onclick="closeEmotePicker()" aria-label="Close emotes" title="Close"><i class="fa-solid fa-xmark"></i></button>`;const searchEl = picker.querySelector('.emote-search');if (searchEl) searchEl.placeholder = 'Search emotes or GIFs…';}@@@ -302,6 +303,8 @@ function toggleEmotePicker(inputId) {_buildEmoteMenuTabs(picker);// Lazy-load this channel's sound commands the first time the menu opens for it.const _sid = (typeof chatStreamId !== 'undefined') ? chatStreamId : 0;++ // A chat whose page never loaded the emotes (the broadcast studio, an offline channel): load them now.++ if (!emotesLoaded && typeof loadEmotes === 'function') Promise.resolve(loadEmotes(_sid)).then(_refreshOpenEmotePicker).catch(() => {});if (typeof loadChannelSounds === 'function' && _soundsLoadContext !== String(_sid || 0)) {loadChannelSounds(_sid).then(_refreshOpenEmotePicker);}@@@ -338,7 +341,7 @@ function renderEmotePicker(mode) {if (cat.key === 'channel') { html += soundsHtml; soundsInserted = true; }}if (!soundsInserted) html += soundsHtml;-- grid.innerHTML = html || '<div class="emote-picker-empty">No emotes found</div>';++ grid.innerHTML = html || `<div class="emote-picker-empty">${emotesLoaded || query ? 'No emotes found' : 'Loading emotes…'}</div>`;}/** Render the "Channel Sounds" section: click a tile to send its !command. */@@@ -577,14 +580,20 @@ async function deleteDashEmote(id) {} catch (e) { toast(e.message, 'error'); }}--/* ── Close picker on outside click ────────────────────────────── */--document.addEventListener('click', (e) => {-- if (!_emotePickerOpen) return;-- if (e.target.closest('.emote-picker') || e.target.closest('.emote-picker-btn')) return;++/* ── Closing the picker ───────────────────────────────────────── */++/** Close every emote picker (main / broadcast / offline / global): its ×, a tap outside it, Escape, the phone studio. */++function closeEmotePicker() {_emotePickerOpen = false;-- // Close all four pickers (main / broadcast / offline / global).['emote-picker', 'bc-emote-picker', 'oc-emote-picker', 'gc-emote-picker'].forEach(id => {const el = document.getElementById(id);if (el) el.style.display = 'none';});--});++}++// pointerdown, not click: a phone sends no click for a tap on plain text (chat lines), so the picker stayed open there.++document.addEventListener('pointerdown', (e) => {++ if (!_emotePickerOpen) return;++ const t = e.target && e.target.closest ? e.target : null;++ if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn'))) return;++ closeEmotePicker();++}, true);++document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _emotePickerOpen) closeEmotePicker(); });@@@ -65,6 +65,7 @@ function _getGifPicker() {picker.style.display = 'none';picker.innerHTML = `<div class="gif-picker-header">++ <button class="emote-picker-close gif-picker-close" type="button" onclick="closeGifPicker()" aria-label="Close GIFs" title="Close"><i class="fa-solid fa-xmark"></i></button><div class="gif-picker-provider-row"></div><input type="text" class="gif-search" placeholder="Search GIFs..." oninput="onGifSearch(this.value)"></div>@@@ -79,11 +80,14 @@ function _getGifPicker() {// rebuild used to add another pair of document listeners.if (!_gifDocListeners) {_gifDocListeners = true;-- document.addEventListener('click', (event) => {++ // pointerdown, not click: a phone sends no click for a tap on plain text (chat lines). The GIF buttons toggle it++ // themselves, so a tap on one is left to them.++ document.addEventListener('pointerdown', (event) => {if (!_gifPickerOpen || !_gifPickerEl) return;-- if (_gifPickerEl.contains(event.target)) return;++ const t = event.target;++ if (_gifPickerEl.contains(t) || (t && t.closest && t.closest('.gif-btn'))) return;closeGifPicker();-- });++ }, true);document.addEventListener('keydown', (event) => {if (event.key === 'Escape' && _gifPickerOpen) closeGifPicker();});nnew file mode 100644@@@ -0,0 +1,87 @@++/**++ * The chat's emote picker closes (owner 2026-10-17, from a phone: "this ui sucks i cant close it stuck open"). The studio++ * hides the chat's emote button (the picker's only toggle), and a phone sends no click for a tap on plain text, so the++ * outside-click close never ran. Now: a × in the picker, any tap outside it (pointerdown), Escape, and the studio closes++ * a picker when it takes over. A page that never loaded the emotes loads them when the picker opens ("Loading emotes…",++ * not "No emotes found").++ *++ * node test/emote-picker-close.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'emotes.js'), 'utf8');++++function el(id, cls = '') {++ const node = {++ id, className: cls, style: { display: 'none' }, dataset: {}, innerHTML: '', value: '', placeholder: '', children: [],++ classList: { toggle() {}, add() {}, remove() {}, contains: () => false },++ querySelector: (sel) => (sel === '.emote-picker-tabs' ? node.tabs : sel === '.emote-search' ? node.search : null),++ querySelectorAll: () => [],++ closest: (sel) => (node.within || []).find((x) => x.sel === sel)?.node || null,++ };++ return node;++}++++const listeners = {};++const picker = el('bc-emote-picker', 'emote-picker');++picker.tabs = el('tabs');++picker.search = el('search');++const grid = el('bc-emote-picker-grid');++const els = { 'bc-emote-picker': picker, 'bc-emote-picker-grid': grid };++let loads = 0;++const ctx = {++ document: {++ addEventListener: (type, fn) => { listeners[type] = fn; },++ getElementById: (id) => els[id] || null,++ querySelector: (sel) => (sel === '#bc-emote-picker .emote-search' ? picker.search : null),++ },++ window: {}, console, setTimeout, Promise, Map, Set,++ api: async () => { loads++; return { defaults: [{ id: 'd1', code: 'LUL', url: 'https://x/1.png' }] }; },++};++vm.createContext(ctx);++vm.runInContext(`${src}\nthis.toggleEmotePicker = toggleEmotePicker; this.closeEmotePicker = closeEmotePicker; this.state = () => ({ open: _emotePickerOpen, loaded: emotesLoaded });`, ctx);++++(async () => {++ // Opening on a page that never loaded the emotes: it says so while they load, then shows them.++ ctx.toggleEmotePicker('bc-chat-input');++ assert.strictEqual(picker.style.display, 'flex');++ assert.match(picker.tabs.innerHTML, /class="emote-picker-close"[^>]*onclick="closeEmotePicker\(\)"[^>]*aria-label="Close emotes"/, 'a × to close it');++ assert.match(grid.innerHTML, /Loading emotes…/);++ await new Promise((r) => setTimeout(r, 10));++ assert.strictEqual(loads, 1, 'the emotes load when the picker opens');++ assert.ok(ctx.state().loaded);++++ // A tap inside the picker (or on its button) keeps it open; a tap anywhere else closes it.++ const inside = { closest: (sel) => (sel === '.emote-picker' ? picker : null) };++ listeners.pointerdown({ target: inside });++ assert.strictEqual(picker.style.display, 'flex');++ const onButton = { closest: (sel) => (sel === '.emote-picker-btn' ? {} : null) };++ listeners.pointerdown({ target: onButton });++ assert.strictEqual(picker.style.display, 'flex');++ listeners.pointerdown({ target: { closest: () => null } }); // a chat line++ assert.strictEqual(picker.style.display, 'none', 'a tap on a chat line closes it');++ assert.strictEqual(ctx.state().open, false);++++ // Escape closes it; so does its × (closeEmotePicker, also what the phone studio calls when it takes over).++ ctx.toggleEmotePicker('bc-chat-input');++ assert.strictEqual(picker.style.display, 'flex');++ listeners.keydown({ key: 'Escape' });++ assert.strictEqual(picker.style.display, 'none');++ ctx.toggleEmotePicker('bc-chat-input');++ ctx.closeEmotePicker();++ assert.strictEqual(picker.style.display, 'none');++ assert.strictEqual(ctx.state().open, false);++++ const studio = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-studio.js'), 'utf8');++ assert.match(studio.slice(studio.indexOf('function mount()'), studio.indexOf('function unmount()')), /closeEmotePicker\(\)/, 'the studio closes a picker when it mounts');++ // The GIF picker the chat's GIF button opens: the same rules (a ×, any tap outside, its own button left to toggle it).++ const gif = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'gif-picker.js'), 'utf8');++ assert.match(gif, /class="emote-picker-close gif-picker-close"[^>]*onclick="closeGifPicker\(\)"/);++ assert.match(gif, /document\.addEventListener\('pointerdown'[\s\S]{0,260}closest\('\.gif-btn'\)/);++ assert.ok(!/document\.addEventListener\('click'/.test(gif), 'no click-only close left');++ console.log('emote picker close: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -4187,6 +4187,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -4187,6 +4187,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.emote-tab-btn.active {
.emote-tab-btn.active {
color: var(--accent); border-bottom-color: var(--accent);
color: var(--accent); border-bottom-color: var(--accent);
}
}
+.emote-picker-close {
+ flex: 0 0 auto; min-width: 44px; padding: 5px 12px; background: transparent; border: none;
+ border-bottom: 2px solid transparent; color: var(--text-muted); font-size: 1rem; cursor: pointer;
+}
+.emote-picker-close:hover, .emote-picker-close:focus-visible { color: var(--text-primary); }
+.gif-picker-close { float: right; min-width: 36px; padding: 2px 8px; }
.emote-picker-grid {
.emote-picker-grid {
display: grid;
display: grid;
@@ -199,6 +199,8 @@
@@ -199,6 +199,8 @@
document.body.append(st.root);
document.body.append(st.root);
// The overlay chat replaces the phone chat sheet.
// The overlay chat replaces the phone chat sheet.
try { if (typeof _bcMobileChatOpen !== 'undefined' && _bcMobileChatOpen && typeof toggleBroadcastMobileChat === 'function') toggleBroadcastMobileChat(); } catch { /* not open */ }
try { if (typeof _bcMobileChatOpen !== 'undefined' && _bcMobileChatOpen && typeof toggleBroadcastMobileChat === 'function') toggleBroadcastMobileChat(); } catch { /* not open */ }
+ // The studio chat has no emote or GIF buttons: a picker opened before it would have no way to close.
+ try { if (typeof closeEmotePicker === 'function') closeEmotePicker(); if (typeof closeGifPicker === 'function') closeGifPicker(); } catch { /* none open */ }
const v = $('bc-video-preview');
const v = $('bc-video-preview');
if (v) v.play().catch(() => {});
if (v) v.play().catch(() => {});
const msgs = $('bc-chat-messages');
const msgs = $('bc-chat-messages');
@@ -277,7 +277,8 @@ function _buildEmoteMenuTabs(picker) {
@@ -277,7 +277,8 @@ function _buildEmoteMenuTabs(picker) {
if (!tabs) return;
if (!tabs) return;
tabs.innerHTML = `
tabs.innerHTML = `
<button class="emote-tab-btn ${_emoteMenuMode === 'emotes' ? 'active' : ''}" data-tab="emotes" onclick="renderEmotePicker('emotes')"><i class="fa-solid fa-face-smile"></i> Emotes</button>
<button class="emote-tab-btn ${_emoteMenuMode === 'emotes' ? 'active' : ''}" data-tab="emotes" onclick="renderEmotePicker('emotes')"><i class="fa-solid fa-face-smile"></i> Emotes</button>
- <button class="emote-tab-btn ${_emoteMenuMode === 'gif' ? 'active' : ''}" data-tab="gif" onclick="renderEmotePicker('gif')"><i class="fa-solid fa-film"></i> GIF</button>`;
+ <button class="emote-tab-btn ${_emoteMenuMode === 'gif' ? 'active' : ''}" data-tab="gif" onclick="renderEmotePicker('gif')"><i class="fa-solid fa-film"></i> GIF</button>
+ <button class="emote-picker-close" type="button" onclick="closeEmotePicker()" aria-label="Close emotes" title="Close"><i class="fa-solid fa-xmark"></i></button>`;
const searchEl = picker.querySelector('.emote-search');
const searchEl = picker.querySelector('.emote-search');
if (searchEl) searchEl.placeholder = 'Search emotes or GIFs…';
if (searchEl) searchEl.placeholder = 'Search emotes or GIFs…';
}
}
@@ -302,6 +303,8 @@ function toggleEmotePicker(inputId) {
@@ -302,6 +303,8 @@ function toggleEmotePicker(inputId) {
_buildEmoteMenuTabs(picker);
_buildEmoteMenuTabs(picker);
// Lazy-load this channel's sound commands the first time the menu opens for it.
// Lazy-load this channel's sound commands the first time the menu opens for it.
const _sid = (typeof chatStreamId !== 'undefined') ? chatStreamId : 0;
const _sid = (typeof chatStreamId !== 'undefined') ? chatStreamId : 0;
+ // A chat whose page never loaded the emotes (the broadcast studio, an offline channel): load them now.
+ if (!emotesLoaded && typeof loadEmotes === 'function') Promise.resolve(loadEmotes(_sid)).then(_refreshOpenEmotePicker).catch(() => {});
if (typeof loadChannelSounds === 'function' && _soundsLoadContext !== String(_sid || 0)) {
if (typeof loadChannelSounds === 'function' && _soundsLoadContext !== String(_sid || 0)) {
loadChannelSounds(_sid).then(_refreshOpenEmotePicker);
loadChannelSounds(_sid).then(_refreshOpenEmotePicker);
}
}
@@ -338,7 +341,7 @@ function renderEmotePicker(mode) {
@@ -338,7 +341,7 @@ function renderEmotePicker(mode) {
if (cat.key === 'channel') { html += soundsHtml; soundsInserted = true; }
if (cat.key === 'channel') { html += soundsHtml; soundsInserted = true; }
}
}
if (!soundsInserted) html += soundsHtml;
if (!soundsInserted) html += soundsHtml;
- grid.innerHTML = html || '<div class="emote-picker-empty">No emotes found</div>';
+ grid.innerHTML = html || `<div class="emote-picker-empty">${emotesLoaded || query ? 'No emotes found' : 'Loading emotes…'}</div>`;
}
}
/** Render the "Channel Sounds" section: click a tile to send its !command. */
/** Render the "Channel Sounds" section: click a tile to send its !command. */
@@ -577,14 +580,20 @@ async function deleteDashEmote(id) {
@@ -577,14 +580,20 @@ async function deleteDashEmote(id) {
} catch (e) { toast(e.message, 'error'); }
} catch (e) { toast(e.message, 'error'); }
}
}
-/* ── Close picker on outside click ────────────────────────────── */
-document.addEventListener('click', (e) => {
- if (!_emotePickerOpen) return;
- if (e.target.closest('.emote-picker') || e.target.closest('.emote-picker-btn')) return;
+/* ── Closing the picker ───────────────────────────────────────── */
+/** Close every emote picker (main / broadcast / offline / global): its ×, a tap outside it, Escape, the phone studio. */
+function closeEmotePicker() {
_emotePickerOpen = false;
_emotePickerOpen = false;
- // Close all four pickers (main / broadcast / offline / global).
['emote-picker', 'bc-emote-picker', 'oc-emote-picker', 'gc-emote-picker'].forEach(id => {
['emote-picker', 'bc-emote-picker', 'oc-emote-picker', 'gc-emote-picker'].forEach(id => {
const el = document.getElementById(id);
const el = document.getElementById(id);
if (el) el.style.display = 'none';
if (el) el.style.display = 'none';
});
});
-});
+}
+// pointerdown, not click: a phone sends no click for a tap on plain text (chat lines), so the picker stayed open there.
+document.addEventListener('pointerdown', (e) => {
+ if (!_emotePickerOpen) return;
+ const t = e.target && e.target.closest ? e.target : null;
+ if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn'))) return;
+ closeEmotePicker();
+}, true);
+document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _emotePickerOpen) closeEmotePicker(); });
@@ -65,6 +65,7 @@ function _getGifPicker() {
@@ -65,6 +65,7 @@ function _getGifPicker() {
picker.style.display = 'none';
picker.style.display = 'none';
picker.innerHTML = `
picker.innerHTML = `
<div class="gif-picker-header">
<div class="gif-picker-header">
+ <button class="emote-picker-close gif-picker-close" type="button" onclick="closeGifPicker()" aria-label="Close GIFs" title="Close"><i class="fa-solid fa-xmark"></i></button>
<div class="gif-picker-provider-row"></div>
<div class="gif-picker-provider-row"></div>
<input type="text" class="gif-search" placeholder="Search GIFs..." oninput="onGifSearch(this.value)">
<input type="text" class="gif-search" placeholder="Search GIFs..." oninput="onGifSearch(this.value)">
</div>
</div>
@@ -79,11 +80,14 @@ function _getGifPicker() {
@@ -79,11 +80,14 @@ function _getGifPicker() {
// rebuild used to add another pair of document listeners.
// rebuild used to add another pair of document listeners.
if (!_gifDocListeners) {
if (!_gifDocListeners) {
_gifDocListeners = true;
_gifDocListeners = true;
- document.addEventListener('click', (event) => {
+ // pointerdown, not click: a phone sends no click for a tap on plain text (chat lines). The GIF buttons toggle it
+ // themselves, so a tap on one is left to them.
+ document.addEventListener('pointerdown', (event) => {
if (!_gifPickerOpen || !_gifPickerEl) return;
if (!_gifPickerOpen || !_gifPickerEl) return;
- if (_gifPickerEl.contains(event.target)) return;
+ const t = event.target;
+ if (_gifPickerEl.contains(t) || (t && t.closest && t.closest('.gif-btn'))) return;
closeGifPicker();
closeGifPicker();
- });
+ }, true);
document.addEventListener('keydown', (event) => {
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && _gifPickerOpen) closeGifPicker();
if (event.key === 'Escape' && _gifPickerOpen) closeGifPicker();
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+/**
+ * The chat's emote picker closes (owner 2026-10-17, from a phone: "this ui sucks i cant close it stuck open"). The studio
+ * hides the chat's emote button (the picker's only toggle), and a phone sends no click for a tap on plain text, so the
+ * outside-click close never ran. Now: a × in the picker, any tap outside it (pointerdown), Escape, and the studio closes
+ * a picker when it takes over. A page that never loaded the emotes loads them when the picker opens ("Loading emotes…",
+ * not "No emotes found").
+ *
+ * node test/emote-picker-close.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'emotes.js'), 'utf8');
+
+function el(id, cls = '') {
+ const node = {
+ id, className: cls, style: { display: 'none' }, dataset: {}, innerHTML: '', value: '', placeholder: '', children: [],
+ classList: { toggle() {}, add() {}, remove() {}, contains: () => false },
+ querySelector: (sel) => (sel === '.emote-picker-tabs' ? node.tabs : sel === '.emote-search' ? node.search : null),
+ querySelectorAll: () => [],
+ closest: (sel) => (node.within || []).find((x) => x.sel === sel)?.node || null,
+ };
+ return node;
+}
+
+const listeners = {};
+const picker = el('bc-emote-picker', 'emote-picker');
+picker.tabs = el('tabs');
+picker.search = el('search');
+const grid = el('bc-emote-picker-grid');
+const els = { 'bc-emote-picker': picker, 'bc-emote-picker-grid': grid };
+let loads = 0;
+const ctx = {
+ document: {
+ addEventListener: (type, fn) => { listeners[type] = fn; },
+ getElementById: (id) => els[id] || null,
+ querySelector: (sel) => (sel === '#bc-emote-picker .emote-search' ? picker.search : null),
+ },
+ window: {}, console, setTimeout, Promise, Map, Set,
+ api: async () => { loads++; return { defaults: [{ id: 'd1', code: 'LUL', url: 'https://x/1.png' }] }; },
+};
+vm.createContext(ctx);
+vm.runInContext(`${src}\nthis.toggleEmotePicker = toggleEmotePicker; this.closeEmotePicker = closeEmotePicker; this.state = () => ({ open: _emotePickerOpen, loaded: emotesLoaded });`, ctx);
+
+(async () => {
+ // Opening on a page that never loaded the emotes: it says so while they load, then shows them.
+ ctx.toggleEmotePicker('bc-chat-input');
+ assert.strictEqual(picker.style.display, 'flex');
+ assert.match(picker.tabs.innerHTML, /class="emote-picker-close"[^>]*onclick="closeEmotePicker\(\)"[^>]*aria-label="Close emotes"/, 'a × to close it');
+ assert.match(grid.innerHTML, /Loading emotes…/);
+ await new Promise((r) => setTimeout(r, 10));
+ assert.strictEqual(loads, 1, 'the emotes load when the picker opens');
+ assert.ok(ctx.state().loaded);
+
+ // A tap inside the picker (or on its button) keeps it open; a tap anywhere else closes it.
+ const inside = { closest: (sel) => (sel === '.emote-picker' ? picker : null) };
+ listeners.pointerdown({ target: inside });
+ assert.strictEqual(picker.style.display, 'flex');
+ const onButton = { closest: (sel) => (sel === '.emote-picker-btn' ? {} : null) };
+ listeners.pointerdown({ target: onButton });
+ assert.strictEqual(picker.style.display, 'flex');
+ listeners.pointerdown({ target: { closest: () => null } }); // a chat line
+ assert.strictEqual(picker.style.display, 'none', 'a tap on a chat line closes it');
+ assert.strictEqual(ctx.state().open, false);
+
+ // Escape closes it; so does its × (closeEmotePicker, also what the phone studio calls when it takes over).
+ ctx.toggleEmotePicker('bc-chat-input');
+ assert.strictEqual(picker.style.display, 'flex');
+ listeners.keydown({ key: 'Escape' });
+ assert.strictEqual(picker.style.display, 'none');
+ ctx.toggleEmotePicker('bc-chat-input');
+ ctx.closeEmotePicker();
+ assert.strictEqual(picker.style.display, 'none');
+ assert.strictEqual(ctx.state().open, false);
+
+ const studio = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-studio.js'), 'utf8');
+ assert.match(studio.slice(studio.indexOf('function mount()'), studio.indexOf('function unmount()')), /closeEmotePicker\(\)/, 'the studio closes a picker when it mounts');
+ // The GIF picker the chat's GIF button opens: the same rules (a ×, any tap outside, its own button left to toggle it).
+ const gif = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'gif-picker.js'), 'utf8');
+ assert.match(gif, /class="emote-picker-close gif-picker-close"[^>]*onclick="closeGifPicker\(\)"/);
+ assert.match(gif, /document\.addEventListener\('pointerdown'[\s\S]{0,260}closest\('\.gif-btn'\)/);
+ assert.ok(!/document\.addEventListener\('click'/.test(gif), 'no click-only close left');
+ console.log('emote picker close: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });