openvibers/openvibe.live Commits

Recently Online cards are even, every replay has a picture, and chat's live cards are one row (#760)

OP OpenVibers committed 3 days ago · 3691cadfad · 5 files, +125 −64

Recently Online. A card had from two to five chips that wrapped onto one to four lines, so cards stretched to the
tallest and the short ones had a hole in the middle. Every card now has the same places in the same order: who and
when; "This week" over its numbers, each with its icon (streams, hours, days in a row); when they are usually on and
how long the last stream ran; two lines about the channel, with More on the end of the second line where something
was cut; two replays. Measured with four people who have a lot, a little and nothing to say: every card 295 px.

Replays. A replay with no picture, or one whose picture failed to load, was an empty grey box. It now asks for the
picture Live can make from the recording, and if there is none it shows a play mark. Never an empty box.

Chat. The "is live" and Rundown cards took more room than the messages around them (two lines, a 44 px round
button and a copy button). They are one row the height of a message: the streamer's picture with the live ring
round it, who and what and the time on one line, and a small Watch or Read whose link is stretched over the card, so
the whole row opens it. The copy button goes: the link's own menu copies it.

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 83ede74d..a1e8d6e7 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -74,15 +74,30 @@
transition: border-color var(--ov-dur-2, 0.2s), box-shadow var(--ov-dur-2, 0.2s);
}
.streamer-group-card:hover { border-color: var(--accent-dark); box-shadow: var(--ring-accent); }
--.recent-streamer-card { display: flex; flex-direction: column; height: 100%; min-height: 322px; }
--.recent-streamer-head { min-height: 58px; padding: 8px 12px; flex-wrap: nowrap; }
++/* Recently Online: every card has the same places in the same order (who and when; this week in numbers, each with
++ its icon; when they are usually on; two lines about the channel; two replays), so cards are the same height
++ without a hole in the short ones. */
++.recent-streamer-card { display: flex; flex-direction: column; height: 100%; }
++.recent-streamer-head { min-height: 54px; padding: 8px 12px 6px; flex-wrap: nowrap; }
.recent-streamer-face { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 50%; overflow: hidden; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }
.recent-streamer-face img { width: 100%; height: 100%; object-fit: cover; }
--.recent-streamer-facts { display: flex; flex-wrap: wrap; gap: 5px; padding: 2px 12px 8px; min-height: 49px; }
--.recent-streamer-chip { align-self: start; padding: 4px 7px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .75rem; line-height: 1.2; }
--.recent-streamer-summary { margin: 0 12px; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.recent-streamer-head .streamer-group-last-online.is-live { color: var(--live, #ef4444); font-weight: 800; }
++.recent-streamer-stats { position: relative; display: flex; align-items: center; gap: 9px; flex-wrap: nowrap; height: 38px; box-sizing: border-box; overflow: hidden; padding: 15px 12px 0; font-size: .75rem; line-height: 1.3; }
++.recent-streamer-cap { position: absolute; left: 12px; top: 0; font-size: .64rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
++.recent-streamer-stat { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-weight: 700; color: var(--text-primary); }
++.recent-streamer-stat i { font-size: .7rem; color: var(--accent); }
++.recent-streamer-habit { margin: 3px 12px 6px; min-height: 1.3em; font-size: .76rem; line-height: 1.3; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.recent-streamer-about { position: relative; margin: 0 12px 8px; }
++.recent-streamer-summary { margin: 0; min-height: 2.8em; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.recent-streamer-summary.expanded { display: block; }
--.recent-streamer-more { align-self: start; min-width: 44px; min-height: 44px; margin: 0 8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: .78rem; cursor: pointer; }
++/* More sits on the end of the second line, over a fade into the card, not on a row of its own; its box for a finger
++ is larger than its word (::after). */
++.recent-streamer-more { position: absolute; right: 0; bottom: 0; padding: 0 0 0 30px; border: 0; color: var(--accent); font: inherit; font-size: .76rem; font-weight: 700; line-height: 1.47; cursor: pointer;
++ background: linear-gradient(90deg, transparent, var(--bg-card) 26px); }
++.recent-streamer-more::after { content: ''; position: absolute; inset: -14px -10px -12px 0; }
++.recent-streamer-more[hidden] { display: none; }
++.recent-streamer-about.is-open .recent-streamer-more { position: relative; display: block; margin: 4px 0 0; padding: 0; background: none; }
++.streamer-group-stream-thumb.is-blank { display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); color: var(--text-muted); font-size: .9rem; }
.recent-streamer-history { margin-top: auto; padding-bottom: 0; }
.recent-stream-row { min-height: 61px; }
.recent-stream-row .streamer-group-stream-thumb { aspect-ratio: 16/9; height: auto; }
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 18c891a3..a668e6e8 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1511,42 +1511,65 @@ function renderRecentlyOnline(containerId, streamers, append) {
img.onerror = () => img.remove(); face.append(img);
}
const name = el('strong', 'streamer-group-name', s.display_name || s.username);
-- const when = el('span', 'streamer-group-last-online', s.live ? 'Live now' : 'Live ' + timeAgo(s.last_online_at));
++ const when = el('span', 'streamer-group-last-online' + (s.live ? ' is-live' : ''), s.live ? 'Live now' : timeAgo(s.last_online_at));
++ if (!s.live) when.title = 'Last live ' + timeAgo(s.last_online_at);
head.append(face, name, when);
card.append(head);
-- const chips = el('div', 'recent-streamer-facts');
-- const chip = (value) => { if (value) chips.append(el('span', 'recent-streamer-chip', value)); };
-- if (facts.streams_this_week) chip(facts.streams_this_week + ' streams this week');
-- if (facts.hours_this_week) chip(facts.hours_this_week + ' h this week');
-- chip(facts.usual_time);
-- if (facts.days_in_a_row >= 3) chip(facts.days_in_a_row + ' days in a row');
-- if (facts.last_stream_seconds) chip('Last stream ' + formatDuration(facts.last_stream_seconds));
-- card.append(chips);
++ // The same three places for everyone, so a card with little to say is not a card with a hole in it: this
++ // week in numbers (each with its icon), when they are usually on, and two lines about the channel.
++ const stats = el('div', 'recent-streamer-stats');
++ const stat = (icon, value, label) => {
++ if (!value) return;
++ const n = el('span', 'recent-streamer-stat');
++ n.title = label;
++ const i = el('i', 'fa-solid ' + icon); i.setAttribute('aria-hidden', 'true');
++ n.append(i, el('span', null, value));
++ stats.append(n);
++ };
++ stat('fa-tower-broadcast', facts.streams_this_week ? facts.streams_this_week + (Number(facts.streams_this_week) === 1 ? ' stream' : ' streams') : '', 'Streams this week');
++ stat('fa-clock', facts.hours_this_week ? facts.hours_this_week + ' h' : '', 'Hours live this week');
++ stat('fa-fire', facts.days_in_a_row >= 3 ? facts.days_in_a_row + ' days' : '', 'Days in a row');
++ if (stats.childElementCount) stats.prepend(el('span', 'recent-streamer-cap', 'This week'));
++ card.append(stats);
++ const lastLine = facts.last_stream_seconds ? 'Last stream ' + formatDuration(facts.last_stream_seconds) : '';
++ card.append(el('p', 'recent-streamer-habit', [facts.usual_time, lastLine].filter(Boolean).join(' · ')));
const overview = String(s.ai_overview_short || s.ai_overview || '').trim();
++ const about = el('div', 'recent-streamer-about');
++ const summary = el('p', 'recent-streamer-summary', overview);
++ about.append(summary);
if (overview) {
-- const summary = el('p', 'recent-streamer-summary', overview);
const more = el('button', 'recent-streamer-more', 'More');
-- more.type = 'button'; more.setAttribute('aria-expanded', 'false');
++ more.type = 'button'; more.setAttribute('aria-expanded', 'false'); more.hidden = true;
more.addEventListener('click', () => {
-- const open = summary.classList.toggle('expanded');
++ const open = about.classList.toggle('is-open');
++ summary.classList.toggle('expanded', open);
more.textContent = open ? 'Less' : 'More';
more.setAttribute('aria-expanded', String(open));
});
-- card.append(summary, more);
++ about.append(more);
++ // "More" only where the two lines cut something off; it sits on the end of the second line.
++ requestAnimationFrame(() => { more.hidden = summary.scrollHeight <= summary.clientHeight + 2; });
}
++ card.append(about);
const history = el('div', 'streamer-group-streams recent-streamer-history');
for (const h of (facts.streams || []).slice(0, 2)) {
const replay = !h.is_live && h.vod_id;
const row = el('a', 'streamer-group-stream recent-stream-row');
row.href = replay ? videoHref('vod', h.vod_id) : channelHref;
++ // A picture on every row: the replay's own, else the one Live can make from the recording, else a mark
++ // that says what the row is. Never an empty box.
const thumb = el('span', 'streamer-group-stream-thumb');
-- const src = _sgThumbSrc(h.thumbnail_url);
++ const made = replay ? `/api/thumbnails/generate/vod/${encodeURIComponent(h.vod_id)}` : null;
++ const src = _sgThumbSrc(h.thumbnail_url) || made;
++ const blank = () => { thumb.replaceChildren(); thumb.classList.add('is-blank'); const i = el('i', 'fa-solid ' + (h.is_live ? 'fa-tower-broadcast' : 'fa-play')); i.setAttribute('aria-hidden', 'true'); thumb.append(i); };
if (src) {
const img = el('img', 'streamer-group-stream-thumb-img');
-- img.src = src; img.alt = ''; img.loading = 'lazy';
-- img.onerror = () => img.remove();
++ img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
++ img.onerror = () => { if (made && img.dataset.made !== '1') { img.dataset.made = '1'; img.src = made; } else blank(); };
++ if (src === made) img.dataset.made = '1';
++ img.src = src;
thumb.append(img);
-- }
++ } else blank();
const info = el('span', 'streamer-group-stream-info');
info.append(el('span', 'streamer-group-stream-title', h.title || 'Stream'));
info.append(el('span', 'streamer-group-stream-time muted', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 56c9b6cc..eaa13dcf 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2812,28 +2812,36 @@ function _chatEventOf(m) {
function _chatEvCss() {
if (document.getElementById('chat-ev-css')) return;
const st = document.createElement('style'); st.id = 'chat-ev-css';
++ // One row a card (the owner, 2026-11-10: "they just take up a lot of space in the chat versus chat messages"): the
++ // streamer's picture with the live ring round it, who and what in one line with the time after it, and a small
++ // Watch or Read. The whole row opens it (the button's own link is stretched over the card), so the button can be
++ // small without being hard to hit; the link's own menu copies its address.
st.textContent = `.chat-msg.system.chat-ev,.chat-msg.system.chat-ev *{font-style:normal}
--.chat-ev{--ev:var(--accent,#3b82f6);container-type:inline-size;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:4px 10px;margin:6px 0;padding:8px 8px 8px 10px;border-radius:12px;border:1px solid color-mix(in srgb,var(--ev) 30%,transparent);background:linear-gradient(90deg,color-mix(in srgb,var(--ev) 12%,transparent),color-mix(in srgb,var(--ev) 4%,transparent) 70%);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.35}
++.chat-ev{--ev:var(--accent,#3b82f6);container-type:inline-size;position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:0 8px;min-height:36px;margin:3px 0;padding:3px 5px 3px 4px;border-radius:999px;border:1px solid color-mix(in srgb,var(--ev) 26%,transparent);background:linear-gradient(90deg,color-mix(in srgb,var(--ev) 13%,transparent),color-mix(in srgb,var(--ev) 3%,transparent) 75%);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.3;transition:border-color .15s ease,background-color .15s ease}
++.chat-ev:hover{border-color:color-mix(in srgb,var(--ev) 55%,transparent)}
.chat-ev--live{--ev:var(--live,#ef4444)}
++.chat-ev--rundown{border-radius:16px;padding:5px 6px 5px 5px}
.chat-ev.chat-msg-new{animation:chatEvIn .32s var(--ov-ease-out,cubic-bezier(.2,.8,.2,1)) both}
--@keyframes chatEvIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
--.chat-ev-icon{position:relative;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:color-mix(in srgb,var(--ev) 18%,transparent);color:var(--ev);font-size:.85rem}
--.chat-ev--live .chat-ev-icon::before{content:'';width:10px;height:10px;border-radius:50%;background:var(--ev);box-shadow:0 0 0 0 color-mix(in srgb,var(--ev) 60%,transparent);animation:chatEvPulse 1.8s ease-out infinite}
--@keyframes chatEvPulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ev) 55%,transparent)}70%{box-shadow:0 0 0 9px transparent}100%{box-shadow:0 0 0 0 transparent}}
++@keyframes chatEvIn{from{opacity:0;transform:translateY(calc(6px * var(--ov-move,1))) scale(.98)}to{opacity:1;transform:none}}
++.chat-ev-icon{position:relative;display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:color-mix(in srgb,var(--ev) 18%,transparent);color:var(--ev);font-size:.78rem;flex:none}
++.chat-ev-face{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;object-fit:cover}
++.chat-ev--live .chat-ev-icon{box-shadow:0 0 0 2px var(--ev);animation:chatEvPulse 1.8s ease-out infinite}
++.chat-ev--live .chat-ev-icon::before{content:'';width:9px;height:9px;border-radius:50%;background:var(--ev)}
++.chat-ev--live .chat-ev-icon:has(.chat-ev-face)::before{content:none}
++@keyframes chatEvPulse{0%{box-shadow:0 0 0 2px var(--ev),0 0 0 2px color-mix(in srgb,var(--ev) 55%,transparent)}70%{box-shadow:0 0 0 2px var(--ev),0 0 0 9px transparent}100%{box-shadow:0 0 0 2px var(--ev),0 0 0 2px transparent}}
.chat-ev-body{min-width:0}
--.chat-ev-line{display:flex;flex-wrap:wrap;column-gap:.3em;color:var(--text-primary,#e6edf7)}.chat-ev-line b{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700}
--.chat-ev-title{display:block;flex-basis:100%;min-width:0;margin-top:2px;color:var(--text-secondary,#a8b3c4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.chat-ev-meta{display:block;margin-top:2px;font-size:.72rem;color:var(--text-muted,#7d8aa0)}
--.chat-ev-acts{display:flex;align-items:center;gap:4px}
--.chat-ev-cta{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:0 11px;border-radius:999px;background:var(--ev);color:#fff;font-weight:700;font-size:.76rem;text-decoration:none;white-space:nowrap}
--.chat-ev-cta:hover{filter:brightness(1.1)}.chat-ev-cta:focus-visible,.chat-ev-copy:focus-visible{outline:2px solid var(--ev);outline-offset:2px}
--.chat-ev-copy{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:0;background:transparent;color:var(--text-muted,#7d8aa0);cursor:pointer}
--.chat-ev-copy:hover{background:color-mix(in srgb,var(--text-primary,#fff) 8%,transparent);color:var(--text-primary,#e6edf7)}
--@media (pointer:coarse){.chat-ev-cta{min-height:44px;padding:0 14px}.chat-ev-copy{width:44px;height:44px}}
--@container (max-width:360px){.chat-ev-cta{padding:0;width:36px;justify-content:center;font-size:0}.chat-ev-cta i{font-size:.8rem}}
--@container (max-width:240px){.chat-ev{grid-template-columns:auto minmax(0,1fr)}.chat-ev-acts{grid-column:2;justify-self:start}}
--.chat-ev--here{grid-template-columns:auto minmax(0,1fr);padding:6px 10px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}.chat-ev--here .chat-ev-icon::before{width:8px;height:8px}
--@container (max-width:360px){@media (pointer:coarse){.chat-ev-cta{width:44px}}}
++.chat-ev-line{display:flex;align-items:baseline;column-gap:.3em;min-width:0;color:var(--text-primary,#e6edf7);white-space:nowrap}.chat-ev-line b{min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:700}
++.chat-ev-what{flex:none}
++.chat-ev-title{display:block;min-width:0;color:var(--text-secondary,#a8b3c4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.chat-ev-meta{flex:none;margin-left:.2em;font-size:.72rem;color:var(--text-muted,#7d8aa0)}
++.chat-ev-acts{display:flex;align-items:center}
++.chat-ev-cta{display:inline-flex;align-items:center;gap:5px;min-height:28px;padding:0 11px;border-radius:999px;background:var(--ev);color:#fff;font-weight:700;font-size:.74rem;text-decoration:none;white-space:nowrap;transition:transform .12s ease,filter .15s ease}
++.chat-ev-cta::after{content:'';position:absolute;inset:-4px 0}
++.chat-ev-cta i{font-size:.68rem}
++.chat-ev:hover .chat-ev-cta{filter:brightness(1.1)}.chat-ev:active .chat-ev-cta{transform:scale(.95)}
++.chat-ev-cta:focus-visible{outline:2px solid var(--ev);outline-offset:2px}
++@container (max-width:260px){.chat-ev-cta{padding:0;width:28px;justify-content:center;font-size:0}.chat-ev-cta i{font-size:.7rem}.chat-ev-meta{display:none}}
++.chat-ev--here{grid-template-columns:auto minmax(0,1fr);min-height:30px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}
`;
document.head.appendChild(st);
}
@@@ -2867,8 +2875,8 @@ function renderChatEventCard(ev, opts = {}) {
if (recent && Math.abs(when.date - Number(recent.dataset.evAt || 0)) < _CHAT_EV_FOLD_MS) {
recent.dataset.evAt = String(when.date.getTime());
let meta = recent.querySelector('.chat-ev-meta');
-- if (!meta) { meta = document.createElement('span'); meta.className = 'chat-ev-meta'; recent.querySelector('.chat-ev-body').appendChild(meta); }
-- meta.textContent = showTime ? `${when.text} · went live again` : 'Went live again';
++ if (!meta) { meta = document.createElement('span'); meta.className = 'chat-ev-meta'; recent.querySelector('.chat-ev-line').appendChild(meta); }
++ meta.textContent = showTime ? `${when.text} · again` : 'again';
return;
}
}
@@@ -2888,6 +2896,15 @@ function renderChatEventCard(ev, opts = {}) {
icon.className = 'chat-ev-icon';
icon.setAttribute('aria-hidden', 'true');
if (ev.kind === 'rundown') icon.innerHTML = '<i class="fa-solid fa-newspaper"></i>';
++ // The streamer's own picture (the network's address for it, by name); the live dot stays where there is none.
++ if (ev.kind === 'live' && /^[A-Za-z0-9_]{2,32}$/.test(String(ev.name || ''))) {
++ const face = document.createElement('img');
++ face.className = 'chat-ev-face';
++ face.alt = ''; face.loading = 'lazy'; face.decoding = 'async';
++ face.src = `https://openvibe.network/avatar/${encodeURIComponent(ev.name)}?s=64`;
++ face.onerror = () => face.remove();
++ icon.appendChild(face);
++ }
const body = document.createElement('div');
body.className = 'chat-ev-body';
@@@ -2895,22 +2912,30 @@ function renderChatEventCard(ev, opts = {}) {
line.className = 'chat-ev-line';
const who = document.createElement('b');
who.textContent = ev.name;
++ const what = document.createElement('span');
++ what.className = 'chat-ev-what';
if (here && here.mine) {
-- line.append('You went live');
++ what.textContent = 'You went live';
++ line.append(what);
} else if (ev.kind === 'live') {
-- line.append(who, here ? ' went live' : ' is live');
++ what.textContent = here ? 'went live' : 'is live';
++ line.append(who, what);
} else {
-- line.append(who, ' on The Rundown');
-- const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
-- line.append(title);
++ what.textContent = 'on The Rundown';
++ line.append(who, what);
}
-- body.appendChild(line);
-- // The time, with the viewer's timestamp setting (the Watch/Read button is the link, so no address is repeated).
++ // The time, with the viewer's timestamp setting, after the words on the same line (the Watch/Read button is the
++ // link, so no address is repeated).
if (showTime) {
const meta = document.createElement('span');
meta.className = 'chat-ev-meta';
meta.textContent = when.text;
-- body.appendChild(meta);
++ line.appendChild(meta);
++ }
++ body.appendChild(line);
++ if (ev.kind === 'rundown') {
++ const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
++ body.appendChild(title);
}
const acts = document.createElement('div');
@@@ -2926,16 +2951,7 @@ function renderChatEventCard(ev, opts = {}) {
if (u.origin === window.location.origin && typeof handleLinkClick === 'function') handleLinkClick(e, u.pathname + u.search + u.hash);
} catch { /* a normal link */ }
});
-- const copy = document.createElement('button');
-- copy.type = 'button';
-- copy.className = 'chat-ev-copy';
-- copy.title = 'Copy the link';
-- copy.setAttribute('aria-label', 'Copy the link');
-- copy.innerHTML = '<i class="fa-solid fa-link" aria-hidden="true"></i>';
-- copy.addEventListener('click', async () => {
-- try { await navigator.clipboard.writeText(ev.url); toast('Link copied', 'success'); } catch { toast('Could not copy the link', 'error'); }
-- });
-- acts.append(cta, copy);
++ acts.append(cta);
if (here) el.append(icon, body);
else el.append(icon, body, acts);
container.appendChild(el);
ddiff --git a/test/chat-event-cards.test.js b/test/chat-event-cards.test.js
iindex 1c8ddbca..aa4a0244 100644
---- a/test/chat-event-cards.test.js
++++ b/test/chat-event-cards.test.js
@@@ -46,6 +46,13 @@ const card = src.slice(src.indexOf('function renderChatEventCard('), src.indexOf
assert.ok(!/innerHTML\s*=\s*[^;]*ev\.(name|title)/.test(card), 'the name and title never go through innerHTML');
assert.ok(/who\.textContent = ev\.name/.test(card) && /title\.textContent = `“\$\{ev\.title\}”`/.test(card), 'they are text nodes');
assert.ok(/showTime = !!opts\.isGlobal \|\| !!chatSettings\.showTimestamps/.test(card), 'time: global always, elsewhere the viewer\'s setting');
--assert.ok(/_CHAT_EV_FOLD_MS/.test(card) && /went live again/.test(card), 'the same person within 15 minutes updates the card');
++assert.ok(/_CHAT_EV_FOLD_MS/.test(card) && /\$\{when\.text\} · again/.test(card), 'the same person within 15 minutes updates the card');
++// One row a card (the owner, 2026-11-10: the cards took more room than the messages around them).
++assert.ok(/line\.appendChild\(meta\)/.test(card), 'the time is on the line, not under it');
++assert.ok(/face\.src = `https:\/\/openvibe\.network\/avatar\/\$\{encodeURIComponent\(ev\.name\)\}\?s=64`/.test(card) && /face\.onerror = \(\) => face\.remove\(\)/.test(card), "the streamer's picture, and the live dot where there is none");
++assert.ok(!/chat-ev-copy/.test(src), 'no copy button: the link has its own menu');
++const evCss = src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('));
++assert.ok(/\.chat-ev\{[^}]*min-height:36px;margin:3px 0;/.test(evCss), 'a row the height of a message');
++assert.ok(/\.chat-ev-cta::after\{content:'';position:absolute;inset:-4px 0\}/.test(evCss), 'the whole row opens it, so the button can be small');
assert.ok(!/prefers-reduced-motion/.test(src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('))), 'Calm plays the cards (openvibe-shared 4.183.0); only motion off stops them, by the loader\'s one rule');
console.log('chat event cards: ok');
ddiff --git a/test/studio-live-card-and-recording.test.js b/test/studio-live-card-and-recording.test.js
iindex aa849ca1..0679402b 100644
---- a/test/studio-live-card-and-recording.test.js
++++ b/test/studio-live-card-and-recording.test.js
@@@ -32,10 +32,10 @@ assert.strictEqual(isHere({ path: '/@moosely/studio' }, 'goosely'), null, 'someo
const card = chat.slice(chat.indexOf('function renderChatEventCard('), chat.indexOf('/* ── Deploy notices'));
assert.ok(/const here = ev\.kind === 'live' && _chatEvIsHere\(ev\)/.test(card), 'the card asks where it is');
--assert.ok(card.includes("line.append('You went live')"), 'the streamer reads "You went live"');
++assert.ok(card.includes("what.textContent = 'You went live'"), 'the streamer reads "You went live"');
assert.ok(/if \(here\) el\.append\(icon, body\);\s*else el\.append\(icon, body, acts\);/.test(card), 'no Watch button or copy link here');
const css = chat.slice(chat.indexOf('function _chatEvCss('), chat.indexOf('function _chatEvTime('));
--assert.ok(/@container \(max-width:240px\)\{\.chat-ev\{grid-template-columns:auto minmax\(0,1fr\)\}\.chat-ev-acts\{grid-column:2/.test(css), 'narrow: buttons under the text');
++assert.ok(/@container \(max-width:260px\)\{\.chat-ev-cta\{padding:0;width:28px;/.test(css), 'narrow: the button is its icon alone, the words keep the row');
const studio = read('js/broadcast-studio.js');
assert.ok(/if \(pv && pv\.paused && pv\.srcObject && pv\.style\.display !== 'none'\) pv\.play\(\)\.catch\(\(\) => \{\}\);/.test(studio), 'a paused self-view plays again');