openvibers/openvibe.live Commits

Chat: a message's actions in one cluster at its corner, editing in the chat box, "edited" after the words (#848)

OP OpenVibers committed 2 days ago · 73f3ebb6ca · 5 files, +207 −56

The owner, 11-12, with phone screenshots of a stream's chat: every one of your own
messages carried a full-size "Edit" pill inline in its words, editing opened the
browser's prompt box, and "edited · 06:49 PM" fell onto a line of its own.

- Reply, edit (a pencil) and translate are one cluster that floats just above the
  message's right end: on hover or keyboard focus on a computer, on a tap of the
  message on a phone (thumb-sized there). The message's own words stay readable.
- Edit puts the words in the chat box with an "Editing your message" bar (Enter
  saves, Esc or × puts back what was in the box); a message sent without TTS stays
  without TTS. Reply and edit never share a bar.
- "edited" is one quiet word right after the words, the time on hover.
- test/chat-msg-actions.test.js keeps it that way (openvibe.design rule 138).

Changes

ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex 0dc7af65..94038192 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -91,7 +91,7 @@
".chat-msg",
".chat-msg .chat-user",
".chat-msg-crossfeed",
-- ".chat-msg-hoverable .chat-reply-btn",
++ ".chat-msg-actions",
".chat-msg.system",
".chat-nickels-float .rewards-coin-balance",
".chat-relay-badge",
ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
iindex 7effb4b7..34d5c3e4 100644
---- a/public/css/i18n-star.css
++++ b/public/css/i18n-star.css
@@@ -111,15 +111,7 @@
html[data-ov-motion=off] .chat-translation,html[data-ov-motion=off] .ch-caption,html[data-ov-motion=off] .star-card-border{opacity: 1;transform: none}
--/* ── Chat: "translate to my language" hover button (sits left of the reply button) ── */
--.chat-msg-hoverable .chat-translate-btn {
-- display: none; position: absolute; right: 30px; top: 2px;
-- padding: 2px 6px; font-size: 0.65rem; line-height: 1; z-index: 5; cursor: pointer;
-- background: var(--bg-card, #1e1e2e); border: 1px solid var(--border, #333); border-radius: var(--radius-sm, 4px);
-- color: var(--text-muted, #888); transition: color var(--ov-dur-2, 0.15s), border-color var(--ov-dur-2, 0.15s);
--}
--.chat-msg-hoverable:hover .chat-translate-btn { display: inline-flex; }
--.chat-msg-hoverable .chat-translate-btn:hover { color: var(--accent-light); border-color: var(--accent); }
++/* "Translate to my language" is one of a message's actions: style.css .chat-msg-actions. */
.chat-translation[hidden] { display: none; }
/* Lite mode (phones / weak GPUs): no blur, no filters, no shadow animations */
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 40936308..a9990813 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2631,27 +2631,43 @@ html[data-ov-motion=off] .ov-rail-track{scroll-behavior: auto}
position: relative;
}
--/* ── Reply button (hover action) ─────────────────────────── */
--.chat-msg-hoverable .chat-reply-btn {
-- display: none;
-- position: absolute;
-- right: 4px; top: 2px;
-- padding: 2px 6px;
-- font-size: 0.65rem;
++/* ── A message's actions: one cluster at its corner (openvibe.design rule 138) ──────────────
++ Reply, edit and translate float just above the message's right end, on hover or keyboard focus (a computer) or a
++ tap on the message (a phone). They never sit in the words, and nothing about a message is shown until asked for. */
++.chat-msg-actions {
++ position: absolute; bottom: calc(100% - 6px); right: 6px; z-index: 6;
++ display: none; align-items: center; gap: 2px; padding: 2px;
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border, #333);
-- border-radius: var(--radius-sm, 4px);
-- color: var(--text-muted, #888);
-- cursor: pointer;
-- z-index: 5;
-- line-height: 1;
-- transition: color var(--ov-dur-2, 0.15s), border-color var(--ov-dur-2, 0.15s);
--}
--.chat-msg-hoverable:hover .chat-reply-btn { display: inline-flex; }
--.chat-msg-hoverable .chat-reply-btn:hover {
-- color: var(--accent, var(--accent));
-- border-color: var(--accent, var(--accent));
--}
++ border-radius: 10px;
++ box-shadow: 0 6px 18px rgba(0, 0, 0, .35);
++}
++/* Above the message, over the line before it, so its own words stay readable; the first message has none above. */
++.chat-msg:first-child > .chat-msg-actions { bottom: auto; top: calc(100% - 6px); }
++.chat-msg-hoverable:hover > .chat-msg-actions,
++.chat-msg-hoverable:focus-within > .chat-msg-actions,
++.chat-msg-hoverable.is-acting > .chat-msg-actions { display: inline-flex; }
++.chat-msg-actions > button {
++ display: grid; place-items: center; width: 28px; height: 26px; padding: 0;
++ border: 0; border-radius: 7px; background: transparent; color: var(--text-muted, #888);
++ font-size: 0.78rem; line-height: 1; cursor: pointer;
++ transition: color var(--ov-dur-2, 0.15s), background-color var(--ov-dur-2, 0.15s);
++}
++.chat-msg-actions > button:hover,
++.chat-msg-actions > button:focus-visible { color: var(--text-primary, #fff); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
++.chat-msg-actions > button:focus-visible { outline: 2px solid var(--accent-light, var(--accent)); outline-offset: -2px; }
++/* A phone has no hover: the tap decides, and each button is big enough for a thumb. */
++@media (hover: none) {
++ .chat-msg-hoverable:hover:not(.is-acting):not(:focus-within) > .chat-msg-actions { display: none; }
++ .chat-msg-actions { right: 4px; border-radius: 12px; }
++ .chat-msg-actions > button { width: 44px; height: 40px; font-size: 0.95rem; border-radius: 9px; }
++}
++@media (hover: hover) { .chat-msg-hoverable:hover { background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); border-radius: 6px; } }
++.chat-msg-hoverable.is-acting { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); border-radius: 6px; }
++.chat-msg.is-editing { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); border-radius: 6px; }
++/* "edited": one quiet word after the words, on the same line (the time is its title). */
++.chat-edited { margin-left: 5px; font-size: 0.72em; color: var(--text-muted, #888); opacity: .85; white-space: nowrap; }
++.chat-edit-bar .chat-reply-bar-content i { color: var(--accent-light, var(--accent)); }
/* ── Reply header (on replied messages) ──────────────────── */
.chat-reply-header {
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex e00679d1..5c89c293 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -56,6 +56,7 @@ let chatSlowModeCooldownEnd = 0;
// ── Reply-to state ──────────────────────────────────────────
let _chatReplyTo = null; // { id, username, user_id, message }
++let _chatEditing = null; // { id, muted, draft }: your own message, being changed in the chat box
// Relay username colors, keyed by source_platform. Used for BOTH live and history
// so external (RS/Twitch/Kick/YouTube) names keep a consistent color — history rows
@@@ -1906,6 +1907,31 @@ function _applyChatVipBadge(evt) {
if (!evt || evt.id == null || !evt.vip_badge) return;
document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => _placeVipBadge(el, evt.vip_badge));
}
++/** "edited": one quiet word right after the message's words, on its line; the time it changed on hover. */
++function _chatEditedMark(el, at) {
++ const text = el.querySelector('.chat-text');
++ if (!text) return;
++ let mark = el.querySelector('.chat-edited');
++ if (!mark) { mark = document.createElement('small'); mark.className = 'chat-edited'; }
++ if (mark.previousSibling !== text) text.after(mark);
++ const when = at ? new Date(at) : null;
++ mark.textContent = 'edited';
++ mark.title = when && !isNaN(when) ? `Edited at ${when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'Edited';
++}
++
++/** One cluster of a message's actions (reply, edit, translate), at its corner, shown on hover, focus or a tap. */
++function _chatActions(el) {
++ let acts = el.querySelector(':scope > .chat-msg-actions');
++ if (!acts) { acts = document.createElement('span'); acts.className = 'chat-msg-actions'; el.appendChild(acts); }
++ return acts;
++}
++function _chatActionBtn(cls, icon, label) {
++ const b = document.createElement('button');
++ b.type = 'button'; b.className = cls; b.title = label; b.setAttribute('aria-label', label);
++ b.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i>`;
++ return b;
++}
++
function _applyChatEdited(evt) {
const m = evt && evt.message;
if (!m || !Number.isSafeInteger(Number(evt.id))) return;
@@@ -1917,9 +1943,7 @@ function _applyChatEdited(evt) {
el.classList.toggle('chat-msg-tts-off', muted);
const shown = muted ? raw.replace(/^\s*\.\s?/, '') : raw;
text.innerHTML = typeof parseEmotes === 'function' ? parseEmotes(shown) : esc(shown);
-- let mark = el.querySelector('.chat-edited');
-- if (!mark) { mark = document.createElement('small'); mark.className = 'chat-edited'; el.appendChild(mark); }
-- mark.textContent = ` edited · ${new Date(m.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
++ _chatEditedMark(el, m.edited_at);
el._ovRaw = raw;
if (el._ovMsg) el._ovMsg.message = raw.slice(0, 100);
});
@@@ -2552,7 +2576,7 @@ function buildChatMessageEl(msg, opts = {}) {
// tts-off messages drop the ":" so it reads "name 🔇 message" after the mute marker.
const separator = (ttsOff || msg.message_type === 'soundboard' || msg.message_type === 'channel-sound') ? ' ' : ': ';
el.innerHTML = `${replyHtml}${timestamp}${streamBadge}${voiceBadge}${gameBadge}${widget ? '' : `<span class="chat-avatar-wrap">${avatarHtml}</span>`}${badge}${_relayBadge}${_aiBadge}${hatHtml}${particleWrapOpen}<span class="chat-user${nameFXClass}" style="color:${esc(nameColor)}" data-username="${displayName}" data-core-username="${coreUsername}" data-user-id="${userId}" data-anon="${isAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>${particleWrapClose}${separator}<span class="chat-text">${text}</span>`;
-- if (msg.edited_at) { const mark = document.createElement('small'); mark.className = 'chat-edited'; mark.textContent = ` edited · ${new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; el.appendChild(mark); }
++ if (msg.edited_at) _chatEditedMark(el, msg.edited_at);
// Auto-translation under the message (live 'chat_translation' event, or persisted
// metadata.translation on history). See server/i18n/translate.js for the direction rules.
@@@ -2565,13 +2589,10 @@ function buildChatMessageEl(msg, opts = {}) {
if (msg.id && !widget) {
el._ovMsg = { id: msg.id, username: msg.username || msg.displayName || 'anon', user_id: msg.user_id, message: (msg.message || msg.text || '').slice(0, 100) };
el._ovRaw = rawText;
-- const replyBtn = document.createElement('button');
-- replyBtn.className = 'chat-reply-btn';
-- replyBtn.title = 'Reply';
-- replyBtn.innerHTML = '<i class="fa-solid fa-reply"></i>';
-- el.appendChild(replyBtn);
++ const acts = _chatActions(el);
++ acts.appendChild(_chatActionBtn('chat-reply-btn', 'fa-reply', 'Reply'));
if ((!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {
-- const editBtn = document.createElement('button'); editBtn.className = 'ovk-btn ovk-btn--quiet chat-edit-btn'; editBtn.type = 'button'; editBtn.textContent = 'Edit'; el.appendChild(editBtn);
++ acts.appendChild(_chatActionBtn('chat-edit-btn', 'fa-pen', 'Edit your message'));
}
// "Translate to my language", only on lines confidently in another language than the viewer's (decided
// locally, public/js/lang-detect.js) and not already translated into it by the channel's auto-translation.
@@@ -2579,11 +2600,8 @@ function buildChatMessageEl(msg, opts = {}) {
const _foreign = window.OVLang ? OVLang.foreignFor(displayRaw || '', _me) : null;
if (_foreign && !(_tr && _tr.to === _me)) {
el.dataset.lang = _foreign;
-- const trBtn = document.createElement('button');
-- trBtn.className = 'chat-translate-btn';
-- trBtn.title = `Translate from ${_LANG_LABEL[_foreign] || _foreign} to ${_LANG_LABEL[_me] || 'my language'}`;
-- trBtn.innerHTML = '<i class="fa-solid fa-language"></i>';
-- el.appendChild(trBtn);
++ const trBtn = _chatActionBtn('chat-translate-btn', 'fa-language', `Translate from ${_LANG_LABEL[_foreign] || _foreign} to ${_LANG_LABEL[_me] || 'my language'}`);
++ acts.prepend(trBtn);
_autoTr.add(el, displayRaw);
}
el.classList.add('chat-msg-hoverable');
@@@ -3396,6 +3414,7 @@ function sendChat(overrideInput = null) {
const input = overrideInput || getChatEl().input;
if (!input) return;
let text = input.value.trim();
++ if (_chatEditing) { _saveChatEdit(input, text); return; }
if (!text) return;
// TTS-off toggle: when the TTS button for this chat surface is OFF, prepend a
@@@ -3471,10 +3490,11 @@ function sendChat(overrideInput = null) {
* Set the reply target — shows the reply bar above the chat input.
*/
function setChatReply(replyData) {
++ if (_chatEditing) clearChatEdit();
_chatReplyTo = replyData;
// Show/update reply bar on all chat input areas
document.querySelectorAll('.chat-input-area').forEach(area => {
-- let bar = area.querySelector('.chat-reply-bar');
++ let bar = area.querySelector('.chat-reply-bar:not(.chat-edit-bar)');
if (!bar) {
bar = document.createElement('div');
bar.className = 'chat-reply-bar';
@@@ -3499,11 +3519,77 @@ function setChatReply(replyData) {
*/
function clearChatReply() {
_chatReplyTo = null;
-- document.querySelectorAll('.chat-reply-bar').forEach(bar => {
++ document.querySelectorAll('.chat-reply-bar:not(.chat-edit-bar)').forEach(bar => {
bar.style.display = 'none';
});
}
++/* ── Editing your own message, in the chat box ───────────────────── */
++
++/** The box takes the message's words and a bar says so: Enter saves, Escape (or ×) puts back what was in the box. */
++function setChatEdit(row) {
++ if (!row || !row._ovMsg) return;
++ if (_chatReplyTo) clearChatReply();
++ const input = getChatEl().input;
++ if (!input) return;
++ const raw = String(row._ovRaw || '');
++ const muted = raw.trimStart().startsWith('.');
++ if (!_chatEditing) _chatEditing = { draft: input.value };
++ Object.assign(_chatEditing, { id: row._ovMsg.id, muted });
++ document.querySelectorAll('.chat-msg.is-editing').forEach((r) => r.classList.remove('is-editing'));
++ row.classList.add('is-editing');
++ document.querySelectorAll('.chat-input-area').forEach((area) => {
++ let bar = area.querySelector('.chat-edit-bar');
++ if (!bar) {
++ bar = document.createElement('div');
++ bar.className = 'chat-reply-bar chat-edit-bar';
++ const inputRow = area.querySelector('.chat-input-row');
++ if (inputRow) area.insertBefore(bar, inputRow); else area.prepend(bar);
++ const content = document.createElement('div'); content.className = 'chat-reply-bar-content';
++ content.innerHTML = '<i class="fa-solid fa-pen" aria-hidden="true"></i> <strong>Editing your message</strong><span class="chat-reply-bar-snippet">Enter saves · Esc cancels</span>';
++ const close = document.createElement('button');
++ close.type = 'button'; close.className = 'chat-reply-bar-close'; close.title = 'Stop editing'; close.setAttribute('aria-label', 'Stop editing');
++ close.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
++ close.addEventListener('click', () => clearChatEdit());
++ bar.append(content, close);
++ }
++ bar.style.display = '';
++ area.classList.add('is-editing');
++ });
++ input.value = muted ? raw.replace(/^\s*\.\s?/, '') : raw;
++ _autoResizeTextarea(input);
++ input.focus();
++ requestAnimationFrame(() => { input.selectionStart = input.selectionEnd = input.value.length; });
++}
++
++function clearChatEdit({ keepText = false } = {}) {
++ const was = _chatEditing;
++ _chatEditing = null;
++ document.querySelectorAll('.chat-edit-bar').forEach((bar) => { bar.style.display = 'none'; });
++ document.querySelectorAll('.chat-input-area.is-editing').forEach((a) => a.classList.remove('is-editing'));
++ document.querySelectorAll('.chat-msg.is-editing').forEach((r) => r.classList.remove('is-editing'));
++ const input = getChatEl().input;
++ if (input && was && !keepText) { input.value = was.draft || ''; _autoResizeTextarea(input); }
++}
++
++function _saveChatEdit(input, text) {
++ const ed = _chatEditing;
++ if (!ed) return;
++ if (!text) { addSystemMessage('A message can\'t be empty. To take it back, delete it from its menu.'); return; }
++ if (messageHitsSlurPolicy(text)) { showSlurNudgeModal(); return; }
++ const body = ed.muted && !text.startsWith('.') ? '.' + text : text;
++ input.disabled = true;
++ fetch(`/api/chat/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
++ .then(async (r) => {
++ const data = await r.json().catch(() => ({}));
++ if (!r.ok) throw Error(data.error || 'Could not change that message');
++ _applyChatEdited({ id: ed.id, message: data.message });
++ clearChatEdit();
++ })
++ .catch((err) => addSystemMessage(err.message))
++ .finally(() => { input.disabled = false; input.focus(); });
++}
++
/**
* Scroll to and highlight the message being replied to.
*/
@@@ -3552,7 +3638,12 @@ function _chatTextareaKeydown(e) {
return;
}
-- // Escape: clear reply
++ // Escape: stop editing, or clear the reply
++ if (e.key === 'Escape' && _chatEditing) {
++ clearChatEdit();
++ e.preventDefault();
++ return;
++ }
if (e.key === 'Escape' && _chatReplyTo) {
clearChatReply();
e.preventDefault();
@@@ -5230,11 +5321,12 @@ function _buildCrossfeedEl(msg) {
const cfUserId = msg.user_id || '';
const cfIsAnon = !cfUserId && !isRelayMsg;
const cfCore = msg.core_username || (isRelayMsg ? '' : (msg.username || ''));
-- el.innerHTML = `${tsHtml}${sourceBadge}${badge}${_relayBadge}${_aiBadge}<span class="chat-name" style="color:${nameColor}" data-username="${displayName}" data-core-username="${esc(cfCore)}" data-user-id="${esc(String(cfUserId))}" data-anon="${cfIsAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>: <span class="chat-text">${text}</span>${msg.edited_at ? `<small class="chat-edited"> edited · ${esc(new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))}</small>` : ''}`;
++ el.innerHTML = `${tsHtml}${sourceBadge}${badge}${_relayBadge}${_aiBadge}<span class="chat-name" style="color:${nameColor}" data-username="${displayName}" data-core-username="${esc(cfCore)}" data-user-id="${esc(String(cfUserId))}" data-anon="${cfIsAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>: <span class="chat-text">${text}</span>${msg.edited_at ? `<small class="chat-edited" title="Edited at ${esc(new Date(msg.edited_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))}">edited</small>` : ''}`;
if (msg.id && (!msg.message_type || msg.message_type === 'chat') && typeof currentUser !== 'undefined' && currentUser && Number(msg.user_id) === Number(currentUser.id)) {
el._ovMsg = { id: msg.id, username: msg.username, user_id: msg.user_id, message: rawText.slice(0, 100) };
el._ovRaw = rawText;
-- const editBtn = document.createElement('button'); editBtn.className = 'ovk-btn ovk-btn--quiet chat-edit-btn'; editBtn.type = 'button'; editBtn.textContent = 'Edit'; el.appendChild(editBtn);
++ _chatActions(el).appendChild(_chatActionBtn('chat-edit-btn', 'fa-pen', 'Edit your message'));
++ el.classList.add('chat-msg-hoverable');
}
return el;
}
@@@ -6703,15 +6795,14 @@ function closeChatUsersPanel(btn) {
const edit = t.closest('.chat-edit-btn');
if (edit) {
const row = edit.closest('.chat-msg');
-- if (!row || !row._ovMsg) return;
-- const body = window.prompt('Edit message', row._ovRaw || '');
-- if (body == null) return;
-- edit.disabled = true;
-- fetch(`/api/chat/messages/${encodeURIComponent(row._ovMsg.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
-- .then(async (r) => { const data = await r.json(); if (!r.ok) throw Error(data.error || 'Could not edit message'); _applyChatEdited({ id: row._ovMsg.id, message: data.message }); })
-- .catch((err) => addSystemMessage(err.message)).finally(() => { edit.disabled = false; });
++ if (row && row._ovMsg) { e.stopPropagation(); setChatEdit(row); }
return;
}
++ const row = t.closest('.chat-msg-hoverable');
++ if (!t.closest('.chat-msg-actions')) {
++ document.querySelectorAll('.chat-msg.is-acting').forEach((r) => { if (r !== row) r.classList.remove('is-acting'); });
++ if (row && window.matchMedia && matchMedia('(hover: none)').matches && !t.closest('a, button, .chat-name, .chat-user, .chat-avatar-wrap, input, textarea, [contenteditable]')) row.classList.toggle('is-acting');
++ }
const tr = t.closest('.chat-translate-btn');
if (tr) {
const row = tr.closest('.chat-msg');
ddiff --git a/test/chat-msg-actions.test.js b/test/chat-msg-actions.test.js
nnew file mode 100644
iindex 00000000..1a315f5f
---- /dev/null
++++ b/test/chat-msg-actions.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * A chat message's actions (openvibe.design rule 138). The owner, 11-12, with phone screenshots of a stream's chat:
++ * "The edit button needs improvement … so it never happens again", "it also looks stupid as shit after you edit it".
++ * Until then every one of your own messages carried a full-size "Edit" pill inline in its words, editing opened the
++ * browser's prompt box, and "edited · 06:49 PM" fell onto a line of its own under the message.
++ *
++ * Now: reply, edit and translate are one cluster at the message's corner (hover or focus on a computer, a tap on a
++ * phone); editing happens in the chat box with an "Editing your message" bar; "edited" is one quiet word after the words.
++ * node test/chat-msg-actions.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const chat = fs.readFileSync(path.join(root, 'public/js/chat.js'), 'utf8');
++const css = fs.readFileSync(path.join(root, 'public/css/style.css'), 'utf8');
++const i18n = fs.readFileSync(path.join(root, 'public/css/i18n-star.css'), 'utf8');
++
++// No message action is a kit button with a word in it, and none is appended into the message's line.
++assert.ok(!/chat-edit-btn'; editBtn\.type = 'button'; editBtn\.textContent = 'Edit'/.test(chat), 'no "Edit" pill');
++assert.ok(!/ovk-btn[^'"]*chat-(edit|reply|translate)-btn/.test(chat), 'message actions are not kit buttons');
++for (const cls of ['chat-reply-btn', 'chat-edit-btn', 'chat-translate-btn']) {
++ assert.ok(new RegExp(`_chatActionBtn\\('${cls}'`).test(chat), `${cls} is made by _chatActionBtn (an icon with a label)`);
++}
++assert.ok(!/el\.appendChild\((replyBtn|editBtn|trBtn)\)/.test(chat), 'no action button is appended straight into the message');
++assert.ok(/function _chatActions\(el\)[\s\S]{0,300}chat-msg-actions/.test(chat), 'the actions go in one .chat-msg-actions cluster');
++
++// Editing is in the chat box, never the browser's prompt or alert.
++assert.ok(!/window\.prompt\('Edit message'/.test(chat), 'no prompt box for an edit');
++assert.ok(/function setChatEdit\(row\)/.test(chat) && /function clearChatEdit\(/.test(chat) && /function _saveChatEdit\(/.test(chat));
++assert.ok(/if \(_chatEditing\) \{ _saveChatEdit\(input, text\); return; \}/.test(chat), 'Enter in the box saves the edit');
++assert.ok(/e\.key === 'Escape' && _chatEditing/.test(chat), 'Escape stops editing');
++assert.ok(/Editing your message/.test(chat), 'the bar says what is happening');
++assert.ok(/'\.chat-reply-bar:not\(\.chat-edit-bar\)'/.test(chat), 'replying and editing never reuse each other\'s bar');
++assert.ok(/ed\.muted && !text\.startsWith\('\.'\) \? '\.' \+ text : text/.test(chat), 'a message sent without TTS stays without TTS when edited');
++
++// "edited" is one word after the words, with the time as its title.
++assert.ok(/function _chatEditedMark\(el, at\)[\s\S]{0,400}text\.after\(mark\)[\s\S]{0,300}mark\.textContent = 'edited'/.test(chat), '"edited" sits right after the words');
++assert.ok(!/edited · \$\{/.test(chat), 'no "edited · time" line');
++
++// The cluster: absolute at the corner, shown only on hover, focus or a tap; thumb-sized where there is no hover.
++const block = (sel) => { const i = css.indexOf(`${sel} {`); assert.ok(i >= 0, `${sel} is styled`); return css.slice(i, css.indexOf('}', i)); };
++assert.match(block('.chat-msg-actions'), /position: absolute/);
++assert.match(block('.chat-msg-actions'), /display: none/);
++assert.match(css, /\.chat-msg-hoverable:hover > \.chat-msg-actions,\s*\.chat-msg-hoverable:focus-within > \.chat-msg-actions,\s*\.chat-msg-hoverable\.is-acting > \.chat-msg-actions \{ display: inline-flex; \}/);
++assert.match(css, /@media \(hover: none\) \{[\s\S]{0,400}width: 44px; height: 40px/, 'a phone gets thumb-sized actions');
++assert.match(block('.chat-edited'), /white-space: nowrap/);
++assert.ok(!/\.chat-msg-hoverable \.chat-(reply|translate)-btn \{/.test(css + i18n), 'no button keeps a position of its own');
++
++console.log('chat-msg-actions: one cluster at the corner, editing in the box, "edited" after the words');