openvibers/openvibe.chat Commits
Chat composer: the command list and the server emoji, designed (#530)
Changes
@@@ -812,4 +812,26 @@ div.oc-set-row { cursor: default; }.oc-kind-agent_stream { color: var(--accent, #3b82f6); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }@media (max-width: 480px) { .oc-agent-banner, .oc-agent-poll { padding: 9px 11px; } }.oc-server-emoji { vertical-align: middle; object-fit: contain; }--.oc-emoji-choices { display: flex; flex-wrap: wrap; max-height: 12rem; overflow: auto; }++/* The composer's command list and emoji picker (Opus, 2026-11-11): both float above the box. A command is its name in++ the accent and what it does under it; the marked one (arrows, Enter or Tab) is tinted. The emoji are a grid of 44 px++ tiles; the report link sits small at the end. */++.oc-compose { position: relative; }++.oc-command-menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 8px); z-index: 30; display: grid; gap: 2px; max-height: min(320px, 50vh); overflow: auto; padding: 6px;++ border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel, var(--oc-bg)); box-shadow: 0 16px 40px rgba(0, 0, 0, .35); }++.oc-command-menu[hidden] { display: none; }++.oc-cmd { display: grid; gap: 1px; width: 100%; min-height: 44px; padding: 7px 10px; border: 0; border-radius: 10px; background: none; color: var(--oc-text); font: inherit; text-align: left; cursor: pointer; }++.oc-cmd b { font: 650 .92rem/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--oc-accent); }++.oc-cmd span { font-size: .84rem; color: var(--oc-muted); line-height: 1.35; overflow-wrap: anywhere; }++.oc-cmd:hover, .oc-cmd[aria-selected="true"] { background: var(--oc-panel-2, rgba(255, 255, 255, .06)); }++.oc-emoji-picker { position: relative; flex: none; }++.oc-emoji-toggle { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; list-style: none; color: var(--oc-muted); }++.oc-emoji-toggle::-webkit-details-marker { display: none; }++.oc-emoji-toggle:hover, .oc-emoji-picker[open] .oc-emoji-toggle { color: var(--oc-text); background: var(--oc-panel-2, rgba(255, 255, 255, .06)); }++.oc-emoji-toggle .oc-i { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }++.oc-emoji-choices { position: absolute; bottom: calc(100% + 8px); left: 0; z-index: 30; width: min(320px, calc(100vw - 32px)); display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 4px;++ max-height: 14rem; overflow: auto; padding: 8px; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel, var(--oc-bg)); box-shadow: 0 16px 40px rgba(0, 0, 0, .35); }++.oc-emoji-choice { display: grid; place-items: center; height: 44px; border: 0; border-radius: 10px; background: none; cursor: pointer; }++.oc-emoji-choice:hover, .oc-emoji-choice:focus-visible { background: var(--oc-panel-2, rgba(255, 255, 255, .06)); }++.oc-emoji-report { grid-column: 1 / -1; justify-self: start; margin-top: 4px; font-size: .8rem; color: var(--oc-muted); }++.oc-emoji-none { grid-column: 1 / -1; margin: 6px 4px; color: var(--oc-muted); font-size: .88rem; }++@media (min-width: 761px) { .oc-command-menu { right: auto; width: min(420px, 100%); } }@@@ -9,11 +9,13 @@if (document.documentElement && document.documentElement.classList) document.documentElement.classList.add('oc-js');// The icons the server draws (server/web/icons.js), for controls made here: constant markup, never user text.var TRASH = '<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg>';++ var SMILE = '<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0"/><path d="M9 9.5h.01M15 9.5h.01"/></svg>';var feed = document.getElementById('oc-feed');var form = document.getElementById('oc-compose');var input = document.getElementById('oc-input');if (cfg.commandServer && input && form) {-- var commandMenu = el('div', 'oc-command-menu'); commandMenu.setAttribute('role', 'listbox'); commandMenu.hidden = true;++ var commandMenu = el('div', 'oc-command-menu'); commandMenu.setAttribute('role', 'listbox'); commandMenu.setAttribute('aria-label', 'Commands'); commandMenu.id = 'oc-command-menu'; commandMenu.hidden = true;++ input.setAttribute('aria-controls', 'oc-command-menu');form.insertBefore(commandMenu, input);var commandTimer = 0;input.addEventListener('input', function () {@@@ -25,17 +27,37 @@.then(function (r) { return r.ok ? r.json() : { commands: [] }; }).then(function (body) {if (input.value !== '/' + found[1]) return;commandMenu.replaceChildren();-- (body.commands || []).forEach(function (command) {-- var button = el('button', 'ovk-btn ovk-btn--quiet', '/' + command.name + ' · ' + command.description);-- button.type = 'button'; button.setAttribute('role', 'option');-- button.addEventListener('click', function () { input.value = '/' + command.name + ' '; commandMenu.hidden = true; input.focus(); });++ (body.commands || []).forEach(function (command, i) {++ // A row: the command in the accent, what it does under it (Opus, 2026-11-11).++ var button = el('button', 'oc-cmd');++ button.type = 'button'; button.setAttribute('role', 'option'); button.id = 'oc-cmd-' + i;++ button.setAttribute('aria-selected', i === 0 ? 'true' : 'false');++ button.appendChild(el('b', '', '/' + command.name));++ if (command.description) button.appendChild(el('span', '', command.description));++ button.addEventListener('click', function () { input.value = '/' + command.name + ' '; commandMenu.hidden = true; input.removeAttribute('aria-activedescendant'); input.focus(); });commandMenu.appendChild(button);});commandMenu.hidden = !commandMenu.children.length;++ if (!commandMenu.hidden) input.setAttribute('aria-activedescendant', 'oc-cmd-0');}).catch(function () { commandMenu.hidden = true; });}, 120);});-- input.addEventListener('keydown', function (event) { if (event.key === 'Escape') commandMenu.hidden = true; });++ // Up and down move through the commands, Enter or Tab takes the one marked, Escape closes the list.++ input.addEventListener('keydown', function (event) {++ if (commandMenu.hidden) return;++ var rows = Array.prototype.slice.call(commandMenu.querySelectorAll('.oc-cmd'));++ var at = rows.findIndex(function (r) { return r.getAttribute('aria-selected') === 'true'; });++ if (event.key === 'Escape') { commandMenu.hidden = true; input.removeAttribute('aria-activedescendant'); return; }++ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {++ event.preventDefault(); event.stopImmediatePropagation();++ var next = rows[(at + (event.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length];++ rows.forEach(function (r) { r.setAttribute('aria-selected', r === next ? 'true' : 'false'); });++ input.setAttribute('aria-activedescendant', next.id); next.scrollIntoView({ block: 'nearest' });++ return;++ }++ // Taken here, not sent: the composer's own Enter-to-send listener (added after this one) never sees it.++ if ((event.key === 'Enter' || event.key === 'Tab') && rows[at]) { event.preventDefault(); event.stopImmediatePropagation(); rows[at].click(); }++ });}var serverEmojis = new Map((Array.isArray(cfg.serverEmojis) ? cfg.serverEmojis : []).map(function (e) { return [String(e.name).toLowerCase(), e]; }));function textWithRoomEmoji(parent, text) {@@@ -55,24 +77,25 @@}if (cfg.serverEmojiSlug && form && input) {var picker = el('details', 'oc-emoji-picker');-- var toggle = el('summary', 'ovk-btn ovk-btn--quiet', 'Emoji'); toggle.setAttribute('aria-label', 'Choose server emoji'); picker.appendChild(toggle);++ var toggle = el('summary', 'oc-emoji-toggle'); toggle.setAttribute('aria-label', 'Choose server emoji'); toggle.title = 'Server emoji';++ toggle.innerHTML = SMILE; picker.appendChild(toggle);var choices = el('div', 'oc-emoji-choices'); picker.appendChild(choices);function showChoices() {choices.replaceChildren();Array.from(serverEmojis.values()).forEach(function (emoji) {-- var button = el('button', 'ovk-btn ovk-btn--quiet'); button.type = 'button'; button.title = ':' + emoji.name + ':';-- var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = ':' + emoji.name + ':'; img.width = 22; img.height = 22;-- img.addEventListener('error', function () { this.replaceWith(document.createTextNode(this.alt)); });-- button.appendChild(img); button.appendChild(document.createTextNode(' :' + emoji.name + ':'));++ var button = el('button', 'oc-emoji-choice'); button.type = 'button'; button.title = ':' + emoji.name + ':'; button.setAttribute('aria-label', ':' + emoji.name + ':');++ var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = ''; img.width = 28; img.height = 28;++ img.addEventListener('error', function () { this.replaceWith(document.createTextNode(':' + emoji.name + ':')); });++ button.appendChild(img);button.addEventListener('click', function () {var start = input.selectionStart, end = input.selectionEnd, value = ':' + emoji.name + ':';input.setRangeText(value, start, end, 'end'); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); picker.open = false;});choices.appendChild(button);});-- if (!choices.children.length) choices.textContent = 'No server emoji yet.';++ if (!choices.children.length) choices.appendChild(el('p', 'oc-emoji-none', 'No server emoji yet.'));else {-- var report = el('a', 'ovk-btn ovk-btn--quiet', 'Report an emoji');++ var report = el('a', 'oc-emoji-report', 'Report an emoji');report.href = 'https://openvibe.community/submissions?kind=report#submit';choices.appendChild(report);}nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++// The composer's command list and server emoji (public/web/chat.js; designed 2026-11-11), in a real headless Chrome on a++// stand-in server channel: "/" lists the server's commands as rows (the name and what it does) above the box; the arrows++// move the mark and Enter takes it; Escape closes; the smiley opens a grid of the server's emoji and a tap puts its++// :name: in the box. Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('openvibe-shared/browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('composer-commands-chrome: skipped (no Chrome)');++ process.exit(0);++}++const chat = fs.readFileSync(path.join(__dirname, '..', 'public/web/chat.js'));++const css = fs.readFileSync(path.join(__dirname, '..', 'public/web/chat.css'));++const DOT = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><circle cx="14" cy="14" r="12" fill="gold"/></svg>');++const page = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/chat.css">++<body class="oc"><main style="padding:300px 16px 16px"><div id="oc-feed"></div>++<form id="oc-compose" class="oc-compose" action="/send" method="post"><textarea id="oc-input" name="text"></textarea><button type="submit">Send</button></form></main>++<script>window.__OV_PAGE = { chat: { commandServer: 'game-night', serverEmojiSlug: 'game-night', serverEmojis: [{ name: 'gg', url: ${JSON.stringify(DOT)} }, { name: 'pog', url: ${JSON.stringify(DOT)} }] } };</script>++<script src="/chat.js"></script>`;++const server = http.createServer((req, res) => {++ if (req.url === '/chat.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(chat); }++ if (req.url === '/chat.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(css); }++ if (req.url.startsWith('/api/chat/commands')) {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ commands: [{ name: 'roll', description: 'Roll a die for everyone' }, { name: 'poll', description: 'Ask the room a question' }] }));++ }++ if (req.url.startsWith('/api/')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{}'); }++ res.writeHead(200, { 'content-type': 'text/html' }); res.end(page);++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-commands-'));++ const browser = await h.launch({ tmpDir: tmp });++ const wait = (ms) => new Promise((ok) => setTimeout(ok, ms));++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, height: 844 });++ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });++ await p.evaluate(`(() => { const i = document.getElementById('oc-input'); i.focus(); i.value = '/'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ await wait(500);++ const menu = await p.evaluate(`(() => { const m = document.querySelector('.oc-command-menu'); const r = m.getBoundingClientRect(); const f = document.getElementById('oc-compose').getBoundingClientRect();++ return { shown: !m.hidden, rows: [...m.querySelectorAll('.oc-cmd')].map((b) => b.querySelector('b').textContent + '|' + b.querySelector('span').textContent),++ above: r.bottom <= f.top + 1, marked: (m.querySelector('[aria-selected=true] b') || {}).textContent, small: [...m.querySelectorAll('.oc-cmd')].filter((b) => b.getBoundingClientRect().height < 44).length }; })()`);++ assert.deepStrictEqual(menu, { shown: true, rows: ['/roll|Roll a die for everyone', '/poll|Ask the room a question'], above: true, marked: '/roll', small: 0 });++ // Down, then Enter: the second command goes in the box.++ await p.evaluate(`(() => { const i = document.getElementById('oc-input'); i.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); i.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()`);++ await wait(100);++ assert.deepStrictEqual(await p.evaluate(`({ value: document.getElementById('oc-input').value, hidden: document.querySelector('.oc-command-menu').hidden })`), { value: '/poll ', hidden: true });++ // Escape closes an open list.++ await p.evaluate(`(() => { const i = document.getElementById('oc-input'); i.value = '/'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ await wait(500);++ await p.evaluate(`document.getElementById('oc-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);++ assert.strictEqual(await p.evaluate(`document.querySelector('.oc-command-menu').hidden`), true, 'Escape closes the list');++ // The smiley: a grid of the server's emoji; a tap puts :name: in the box.++ await p.evaluate(`(() => { document.getElementById('oc-input').value = ''; document.querySelector('.oc-emoji-toggle').click(); })()`);++ await wait(200);++ const grid = await p.evaluate(`(() => { const c = [...document.querySelectorAll('.oc-emoji-choice')]; return { n: c.length, labels: c.map((b) => b.getAttribute('aria-label')), tile: Math.round(c[0].getBoundingClientRect().height) }; })()`);++ assert.deepStrictEqual(grid, { n: 2, labels: [':gg:', ':pog:'], tile: 44 });++ await p.evaluate(`document.querySelector('.oc-emoji-choice').click()`);++ assert.strictEqual(await p.evaluate(`document.getElementById('oc-input').value`), ':gg:');++ console.log('composer-commands-chrome: the command list above the box, arrows and Enter, Escape; the emoji grid puts :name: in the box');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -812,4 +812,26 @@ div.oc-set-row { cursor: default; }
@@ -812,4 +812,26 @@ div.oc-set-row { cursor: default; }
.oc-kind-agent_stream { color: var(--accent, #3b82f6); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
.oc-kind-agent_stream { color: var(--accent, #3b82f6); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
@media (max-width: 480px) { .oc-agent-banner, .oc-agent-poll { padding: 9px 11px; } }
@media (max-width: 480px) { .oc-agent-banner, .oc-agent-poll { padding: 9px 11px; } }
.oc-server-emoji { vertical-align: middle; object-fit: contain; }
.oc-server-emoji { vertical-align: middle; object-fit: contain; }
-.oc-emoji-choices { display: flex; flex-wrap: wrap; max-height: 12rem; overflow: auto; }
+/* The composer's command list and emoji picker (Opus, 2026-11-11): both float above the box. A command is its name in
+ the accent and what it does under it; the marked one (arrows, Enter or Tab) is tinted. The emoji are a grid of 44 px
+ tiles; the report link sits small at the end. */
+.oc-compose { position: relative; }
+.oc-command-menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 8px); z-index: 30; display: grid; gap: 2px; max-height: min(320px, 50vh); overflow: auto; padding: 6px;
+ border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel, var(--oc-bg)); box-shadow: 0 16px 40px rgba(0, 0, 0, .35); }
+.oc-command-menu[hidden] { display: none; }
+.oc-cmd { display: grid; gap: 1px; width: 100%; min-height: 44px; padding: 7px 10px; border: 0; border-radius: 10px; background: none; color: var(--oc-text); font: inherit; text-align: left; cursor: pointer; }
+.oc-cmd b { font: 650 .92rem/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--oc-accent); }
+.oc-cmd span { font-size: .84rem; color: var(--oc-muted); line-height: 1.35; overflow-wrap: anywhere; }
+.oc-cmd:hover, .oc-cmd[aria-selected="true"] { background: var(--oc-panel-2, rgba(255, 255, 255, .06)); }
+.oc-emoji-picker { position: relative; flex: none; }
+.oc-emoji-toggle { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; list-style: none; color: var(--oc-muted); }
+.oc-emoji-toggle::-webkit-details-marker { display: none; }
+.oc-emoji-toggle:hover, .oc-emoji-picker[open] .oc-emoji-toggle { color: var(--oc-text); background: var(--oc-panel-2, rgba(255, 255, 255, .06)); }
+.oc-emoji-toggle .oc-i { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
+.oc-emoji-choices { position: absolute; bottom: calc(100% + 8px); left: 0; z-index: 30; width: min(320px, calc(100vw - 32px)); display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 4px;
+ max-height: 14rem; overflow: auto; padding: 8px; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel, var(--oc-bg)); box-shadow: 0 16px 40px rgba(0, 0, 0, .35); }
+.oc-emoji-choice { display: grid; place-items: center; height: 44px; border: 0; border-radius: 10px; background: none; cursor: pointer; }
+.oc-emoji-choice:hover, .oc-emoji-choice:focus-visible { background: var(--oc-panel-2, rgba(255, 255, 255, .06)); }
+.oc-emoji-report { grid-column: 1 / -1; justify-self: start; margin-top: 4px; font-size: .8rem; color: var(--oc-muted); }
+.oc-emoji-none { grid-column: 1 / -1; margin: 6px 4px; color: var(--oc-muted); font-size: .88rem; }
+@media (min-width: 761px) { .oc-command-menu { right: auto; width: min(420px, 100%); } }
@@ -9,11 +9,13 @@
@@ -9,11 +9,13 @@
if (document.documentElement && document.documentElement.classList) document.documentElement.classList.add('oc-js');
if (document.documentElement && document.documentElement.classList) document.documentElement.classList.add('oc-js');
// The icons the server draws (server/web/icons.js), for controls made here: constant markup, never user text.
// The icons the server draws (server/web/icons.js), for controls made here: constant markup, never user text.
var TRASH = '<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg>';
var TRASH = '<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg>';
+ var SMILE = '<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0"/><path d="M9 9.5h.01M15 9.5h.01"/></svg>';
var feed = document.getElementById('oc-feed');
var feed = document.getElementById('oc-feed');
var form = document.getElementById('oc-compose');
var form = document.getElementById('oc-compose');
var input = document.getElementById('oc-input');
var input = document.getElementById('oc-input');
if (cfg.commandServer && input && form) {
if (cfg.commandServer && input && form) {
- var commandMenu = el('div', 'oc-command-menu'); commandMenu.setAttribute('role', 'listbox'); commandMenu.hidden = true;
+ var commandMenu = el('div', 'oc-command-menu'); commandMenu.setAttribute('role', 'listbox'); commandMenu.setAttribute('aria-label', 'Commands'); commandMenu.id = 'oc-command-menu'; commandMenu.hidden = true;
+ input.setAttribute('aria-controls', 'oc-command-menu');
form.insertBefore(commandMenu, input);
form.insertBefore(commandMenu, input);
var commandTimer = 0;
var commandTimer = 0;
input.addEventListener('input', function () {
input.addEventListener('input', function () {
@@ -25,17 +27,37 @@
@@ -25,17 +27,37 @@
.then(function (r) { return r.ok ? r.json() : { commands: [] }; }).then(function (body) {
.then(function (r) { return r.ok ? r.json() : { commands: [] }; }).then(function (body) {
if (input.value !== '/' + found[1]) return;
if (input.value !== '/' + found[1]) return;
commandMenu.replaceChildren();
commandMenu.replaceChildren();
- (body.commands || []).forEach(function (command) {
- var button = el('button', 'ovk-btn ovk-btn--quiet', '/' + command.name + ' · ' + command.description);
- button.type = 'button'; button.setAttribute('role', 'option');
- button.addEventListener('click', function () { input.value = '/' + command.name + ' '; commandMenu.hidden = true; input.focus(); });
+ (body.commands || []).forEach(function (command, i) {
+ // A row: the command in the accent, what it does under it (Opus, 2026-11-11).
+ var button = el('button', 'oc-cmd');
+ button.type = 'button'; button.setAttribute('role', 'option'); button.id = 'oc-cmd-' + i;
+ button.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
+ button.appendChild(el('b', '', '/' + command.name));
+ if (command.description) button.appendChild(el('span', '', command.description));
+ button.addEventListener('click', function () { input.value = '/' + command.name + ' '; commandMenu.hidden = true; input.removeAttribute('aria-activedescendant'); input.focus(); });
commandMenu.appendChild(button);
commandMenu.appendChild(button);
});
});
commandMenu.hidden = !commandMenu.children.length;
commandMenu.hidden = !commandMenu.children.length;
+ if (!commandMenu.hidden) input.setAttribute('aria-activedescendant', 'oc-cmd-0');
}).catch(function () { commandMenu.hidden = true; });
}).catch(function () { commandMenu.hidden = true; });
}, 120);
}, 120);
});
});
- input.addEventListener('keydown', function (event) { if (event.key === 'Escape') commandMenu.hidden = true; });
+ // Up and down move through the commands, Enter or Tab takes the one marked, Escape closes the list.
+ input.addEventListener('keydown', function (event) {
+ if (commandMenu.hidden) return;
+ var rows = Array.prototype.slice.call(commandMenu.querySelectorAll('.oc-cmd'));
+ var at = rows.findIndex(function (r) { return r.getAttribute('aria-selected') === 'true'; });
+ if (event.key === 'Escape') { commandMenu.hidden = true; input.removeAttribute('aria-activedescendant'); return; }
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault(); event.stopImmediatePropagation();
+ var next = rows[(at + (event.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length];
+ rows.forEach(function (r) { r.setAttribute('aria-selected', r === next ? 'true' : 'false'); });
+ input.setAttribute('aria-activedescendant', next.id); next.scrollIntoView({ block: 'nearest' });
+ return;
+ }
+ // Taken here, not sent: the composer's own Enter-to-send listener (added after this one) never sees it.
+ if ((event.key === 'Enter' || event.key === 'Tab') && rows[at]) { event.preventDefault(); event.stopImmediatePropagation(); rows[at].click(); }
+ });
}
}
var serverEmojis = new Map((Array.isArray(cfg.serverEmojis) ? cfg.serverEmojis : []).map(function (e) { return [String(e.name).toLowerCase(), e]; }));
var serverEmojis = new Map((Array.isArray(cfg.serverEmojis) ? cfg.serverEmojis : []).map(function (e) { return [String(e.name).toLowerCase(), e]; }));
function textWithRoomEmoji(parent, text) {
function textWithRoomEmoji(parent, text) {
@@ -55,24 +77,25 @@
@@ -55,24 +77,25 @@
}
}
if (cfg.serverEmojiSlug && form && input) {
if (cfg.serverEmojiSlug && form && input) {
var picker = el('details', 'oc-emoji-picker');
var picker = el('details', 'oc-emoji-picker');
- var toggle = el('summary', 'ovk-btn ovk-btn--quiet', 'Emoji'); toggle.setAttribute('aria-label', 'Choose server emoji'); picker.appendChild(toggle);
+ var toggle = el('summary', 'oc-emoji-toggle'); toggle.setAttribute('aria-label', 'Choose server emoji'); toggle.title = 'Server emoji';
+ toggle.innerHTML = SMILE; picker.appendChild(toggle);
var choices = el('div', 'oc-emoji-choices'); picker.appendChild(choices);
var choices = el('div', 'oc-emoji-choices'); picker.appendChild(choices);
function showChoices() {
function showChoices() {
choices.replaceChildren();
choices.replaceChildren();
Array.from(serverEmojis.values()).forEach(function (emoji) {
Array.from(serverEmojis.values()).forEach(function (emoji) {
- var button = el('button', 'ovk-btn ovk-btn--quiet'); button.type = 'button'; button.title = ':' + emoji.name + ':';
- var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = ':' + emoji.name + ':'; img.width = 22; img.height = 22;
- img.addEventListener('error', function () { this.replaceWith(document.createTextNode(this.alt)); });
- button.appendChild(img); button.appendChild(document.createTextNode(' :' + emoji.name + ':'));
+ var button = el('button', 'oc-emoji-choice'); button.type = 'button'; button.title = ':' + emoji.name + ':'; button.setAttribute('aria-label', ':' + emoji.name + ':');
+ var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = ''; img.width = 28; img.height = 28;
+ img.addEventListener('error', function () { this.replaceWith(document.createTextNode(':' + emoji.name + ':')); });
+ button.appendChild(img);
button.addEventListener('click', function () {
button.addEventListener('click', function () {
var start = input.selectionStart, end = input.selectionEnd, value = ':' + emoji.name + ':';
var start = input.selectionStart, end = input.selectionEnd, value = ':' + emoji.name + ':';
input.setRangeText(value, start, end, 'end'); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); picker.open = false;
input.setRangeText(value, start, end, 'end'); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); picker.open = false;
});
});
choices.appendChild(button);
choices.appendChild(button);
});
});
- if (!choices.children.length) choices.textContent = 'No server emoji yet.';
+ if (!choices.children.length) choices.appendChild(el('p', 'oc-emoji-none', 'No server emoji yet.'));
else {
else {
- var report = el('a', 'ovk-btn ovk-btn--quiet', 'Report an emoji');
+ var report = el('a', 'oc-emoji-report', 'Report an emoji');
report.href = 'https://openvibe.community/submissions?kind=report#submit';
report.href = 'https://openvibe.community/submissions?kind=report#submit';
choices.appendChild(report);
choices.appendChild(report);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+// The composer's command list and server emoji (public/web/chat.js; designed 2026-11-11), in a real headless Chrome on a
+// stand-in server channel: "/" lists the server's commands as rows (the name and what it does) above the box; the arrows
+// move the mark and Enter takes it; Escape closes; the smiley opens a grid of the server's emoji and a tap puts its
+// :name: in the box. Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('openvibe-shared/browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('composer-commands-chrome: skipped (no Chrome)');
+ process.exit(0);
+}
+const chat = fs.readFileSync(path.join(__dirname, '..', 'public/web/chat.js'));
+const css = fs.readFileSync(path.join(__dirname, '..', 'public/web/chat.css'));
+const DOT = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><circle cx="14" cy="14" r="12" fill="gold"/></svg>');
+const page = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/chat.css">
+<body class="oc"><main style="padding:300px 16px 16px"><div id="oc-feed"></div>
+<form id="oc-compose" class="oc-compose" action="/send" method="post"><textarea id="oc-input" name="text"></textarea><button type="submit">Send</button></form></main>
+<script>window.__OV_PAGE = { chat: { commandServer: 'game-night', serverEmojiSlug: 'game-night', serverEmojis: [{ name: 'gg', url: ${JSON.stringify(DOT)} }, { name: 'pog', url: ${JSON.stringify(DOT)} }] } };</script>
+<script src="/chat.js"></script>`;
+const server = http.createServer((req, res) => {
+ if (req.url === '/chat.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(chat); }
+ if (req.url === '/chat.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(css); }
+ if (req.url.startsWith('/api/chat/commands')) {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ commands: [{ name: 'roll', description: 'Roll a die for everyone' }, { name: 'poll', description: 'Ask the room a question' }] }));
+ }
+ if (req.url.startsWith('/api/')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{}'); }
+ res.writeHead(200, { 'content-type': 'text/html' }); res.end(page);
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-commands-'));
+ const browser = await h.launch({ tmpDir: tmp });
+ const wait = (ms) => new Promise((ok) => setTimeout(ok, ms));
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, height: 844 });
+ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
+ await p.evaluate(`(() => { const i = document.getElementById('oc-input'); i.focus(); i.value = '/'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ await wait(500);
+ const menu = await p.evaluate(`(() => { const m = document.querySelector('.oc-command-menu'); const r = m.getBoundingClientRect(); const f = document.getElementById('oc-compose').getBoundingClientRect();
+ return { shown: !m.hidden, rows: [...m.querySelectorAll('.oc-cmd')].map((b) => b.querySelector('b').textContent + '|' + b.querySelector('span').textContent),
+ above: r.bottom <= f.top + 1, marked: (m.querySelector('[aria-selected=true] b') || {}).textContent, small: [...m.querySelectorAll('.oc-cmd')].filter((b) => b.getBoundingClientRect().height < 44).length }; })()`);
+ assert.deepStrictEqual(menu, { shown: true, rows: ['/roll|Roll a die for everyone', '/poll|Ask the room a question'], above: true, marked: '/roll', small: 0 });
+ // Down, then Enter: the second command goes in the box.
+ await p.evaluate(`(() => { const i = document.getElementById('oc-input'); i.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); i.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()`);
+ await wait(100);
+ assert.deepStrictEqual(await p.evaluate(`({ value: document.getElementById('oc-input').value, hidden: document.querySelector('.oc-command-menu').hidden })`), { value: '/poll ', hidden: true });
+ // Escape closes an open list.
+ await p.evaluate(`(() => { const i = document.getElementById('oc-input'); i.value = '/'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ await wait(500);
+ await p.evaluate(`document.getElementById('oc-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
+ assert.strictEqual(await p.evaluate(`document.querySelector('.oc-command-menu').hidden`), true, 'Escape closes the list');
+ // The smiley: a grid of the server's emoji; a tap puts :name: in the box.
+ await p.evaluate(`(() => { document.getElementById('oc-input').value = ''; document.querySelector('.oc-emoji-toggle').click(); })()`);
+ await wait(200);
+ const grid = await p.evaluate(`(() => { const c = [...document.querySelectorAll('.oc-emoji-choice')]; return { n: c.length, labels: c.map((b) => b.getAttribute('aria-label')), tile: Math.round(c[0].getBoundingClientRect().height) }; })()`);
+ assert.deepStrictEqual(grid, { n: 2, labels: [':gg:', ':pog:'], tile: 44 });
+ await p.evaluate(`document.querySelector('.oc-emoji-choice').click()`);
+ assert.strictEqual(await p.evaluate(`document.getElementById('oc-input').value`), ':gg:');
+ console.log('composer-commands-chrome: the command list above the box, arrows and Enter, Escape; the emoji grid puts :name: in the box');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });