openvibers/openvibe.chat Commits

Chat composer: the command list and the server emoji, designed (#530)

OP OpenVibers committed 2 days ago · 59ffd14b5a · 3 files, +130 −14

The slash commands (chat-v3) were plain buttons reading "/name · description" stacked in the form; the emoji were a
details labelled "Emoji" with a wrapping list of buttons. Now:
- "/" opens a list above the box: each command its name in the accent and what it does under it, 44 px rows; the
  arrows move the mark (aria-activedescendant on the box), Enter or Tab takes it without sending, Escape closes
- the emoji are a round smiley button and a grid of 44 px tiles above it (the name in the title and label); a tap puts
  :name: in the box; "Report an emoji" sits small at the end; none yet says so
Test: composer-commands-chrome.

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex b16bbcf..c10c257 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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%); } }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 45d4d70..0a2e9b0 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -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);
}
ddiff --git a/test/composer-commands-chrome.test.js b/test/composer-commands-chrome.test.js
nnew file mode 100644
iindex 0000000..8e737fc
---- /dev/null
++++ b/test/composer-commands-chrome.test.js
@@@ -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); });