openvibers/openvibe.live Commits

Chat: go-live and Rundown notices as event cards, with timestamps (#653)

OP OpenVibers committed 6 days ago · 4b2fc1f0ca · 3 files, +219 −2

* Chat: go-live and Rundown notices are event cards with the viewer's timestamp

They were italic blue sentences with a bare URL. Each is now a small card like the deploy card: a pulsing
live dot (or the Rundown's paper), who and what, the time with the viewer's timestamp setting (always in
global chat, as messages are), a Watch/Read button and copy-link. The same person going live again within
15 minutes updates their card. Old text-only rows are recognised; only OpenVibe addresses become links.

* Merge remote-tracking branch 'origin/main' into chat/event-cards

* Merge main; budgets: the home's JS with chat's event cards (1305.6 KB raw, 305.5 br)

Changes

ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex dcccdd3c..e2983f9b 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2833,6 +2833,10 @@ function addChatMessage(msg) {
_fcwAddMessage(msg);
return;
}
++ // A go-live or Rundown notice is an event card, like the deploy card (2026-11-07, the owner: "make it more
++ // properly styled like the rest of chat").
++ const ev = _chatEventOf(msg);
++ if (ev) { renderChatEventCard(ev, { isGlobal: chatEl.isGlobal }); return; }
const el = buildChatMessageEl(msg, { isGlobal: chatEl.isGlobal });
if (!_loadingHistory) el.classList.add('chat-msg-new');
@@@ -2986,6 +2990,166 @@ function addRichSystemMessage(html, style = 'info') {
if (_chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
}
++/* ── Event cards: someone went live, a Rundown episode is out ──────────────────
++ * Live posts these as system rows ("🔴 <name> is live. Share the stream: <url>", metadata.kind 'live_link';
++ * "📰 <name>: “<title>” → <url>", metadata.rundown_episode). They were italic blue sentences with a bare URL; now
++ * each is a small card like the deploy card: what happened, who, when (with the viewer's timestamp setting, always in
++ * global chat as messages are), a Watch/Read button and a copy-link button. Old rows are recognised by their text.
++ * The same person going live again within 15 minutes updates the card instead of adding another. Built from DOM
++ * nodes; a link is used only when it is an OpenVibe address. */
++const _CHAT_EV_LIVE_RE = /^\s*\u{1F534}\s*(.+?) is live\. Share the stream:\s*(https?:\/\/\S+)\s*$/u;
++const _CHAT_EV_RUNDOWN_RE = /^\s*\u{1F4F0}\s*(.+?):\s*[“"](.+)[”"]\s*→\s*(https?:\/\/\S+)\s*$/u;
++const _CHAT_EV_FOLD_MS = 15 * 60 * 1000;
++function _chatEvSafeUrl(u) {
++ try {
++ let x; try { x = new URL(String(u || '')); } catch { x = new URL(String(u || ''), window.location.origin); }
++ if (x.protocol !== 'https:' && x.origin !== window.location.origin) return null;
++ const h = x.hostname.toLowerCase();
++ if (x.origin === window.location.origin || h === 'openvibe.live' || h.endsWith('.openvibe.live')) return x.href;
++ } catch { /* not a URL */ }
++ return null;
++}
++function _chatEventOf(m) {
++ if (!m || m.message_type !== 'system') return null;
++ let meta = null; try { meta = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata; } catch { meta = null; }
++ const text = String(m.message || m.text || '');
++ const at = m.timestamp || m.created_at || null;
++ const live = text.match(_CHAT_EV_LIVE_RE);
++ if ((meta && meta.kind === 'live_link') || live) {
++ const url = _chatEvSafeUrl((meta && meta.url) || (live && live[2]));
++ if (!url) return null;
++ const handle = (url.match(/\/@([^/?#]+)/) || [])[1];
++ const name = (live && live[1]) || (handle ? decodeURIComponent(handle) : 'Someone');
++ return { kind: 'live', id: m.id, name, url, at, key: `live:${name.toLowerCase()}` };
++ }
++ const rd = text.match(_CHAT_EV_RUNDOWN_RE);
++ if ((meta && meta.rundown_episode) || rd) {
++ const url = _chatEvSafeUrl((meta && meta.url) || (rd && rd[3]));
++ if (!url) return null;
++ return { kind: 'rundown', id: m.id, name: rd ? rd[1] : 'A streamer', title: rd ? rd[2] : 'A new episode', url, at, key: `rundown:${url}` };
++ }
++ return null;
++}
++function _chatEvCss() {
++ if (document.getElementById('chat-ev-css')) return;
++ const st = document.createElement('style'); st.id = 'chat-ev-css';
++ 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--live{--ev:var(--live,#ef4444)}
++.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}}
++.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:360px){@media (pointer:coarse){.chat-ev-cta{width:44px}}}
++@media (prefers-reduced-motion:reduce){.chat-ev.chat-msg-new{animation:none}.chat-ev--live .chat-ev-icon::before{animation:none}}`;
++ document.head.appendChild(st);
++}
++function _chatEvTime(at) {
++ const d = at ? _parseMsgTime(at) : new Date();
++ const t = d instanceof Date && !isNaN(d) ? d : new Date();
++ const opts = chatSettings.timestampFormat === '24h' ? { hour: '2-digit', minute: '2-digit', hour12: false } : { hour: '2-digit', minute: '2-digit' };
++ return { date: t, text: t.toLocaleTimeString([], opts) };
++}
++function renderChatEventCard(ev, opts = {}) {
++ const { messages: container } = getChatEl();
++ if (!container || !ev) return;
++ _chatEvCss();
++ if (ev.id != null && container.querySelector(`[data-msg-id="${CSS.escape(String(ev.id))}"]`)) return;
++ const when = _chatEvTime(ev.at);
++ const showTime = !!opts.isGlobal || !!chatSettings.showTimestamps;
++ // The same person going live again within 15 minutes: the card they already have is brought up to date.
++ if (ev.kind === 'live') {
++ const recent = [...container.children].slice(-12).reverse().find((c) => c.dataset && c.dataset.evKey === ev.key);
++ 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';
++ return;
++ }
++ }
++ const el = document.createElement('div');
++ el.className = `chat-msg system chat-ev chat-ev--${ev.kind}`;
++ if (!_loadingHistory) el.classList.add('chat-msg-new');
++ if (ev.id != null) el.dataset.msgId = String(ev.id);
++ el.dataset.evKey = ev.key;
++ el.dataset.evAt = String(when.date.getTime());
++ el.setAttribute('role', 'article');
++
++ const icon = document.createElement('span');
++ icon.className = 'chat-ev-icon';
++ icon.setAttribute('aria-hidden', 'true');
++ if (ev.kind === 'rundown') icon.innerHTML = '<i class="fa-solid fa-newspaper"></i>';
++
++ const body = document.createElement('div');
++ body.className = 'chat-ev-body';
++ const line = document.createElement('div');
++ line.className = 'chat-ev-line';
++ const who = document.createElement('b');
++ who.textContent = ev.name;
++ if (ev.kind === 'live') {
++ line.append(who, ' is live');
++ } else {
++ line.append(who, ' on The Rundown');
++ const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
++ line.append(title);
++ }
++ body.appendChild(line);
++ // The time, with the viewer's timestamp setting (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);
++ }
++
++ const acts = document.createElement('div');
++ acts.className = 'chat-ev-acts';
++ const cta = document.createElement('a');
++ cta.className = 'chat-ev-cta';
++ cta.href = ev.url;
++ cta.innerHTML = ev.kind === 'live' ? '<i class="fa-solid fa-play" aria-hidden="true"></i> Watch' : '<i class="fa-solid fa-book-open" aria-hidden="true"></i> Read';
++ cta.setAttribute('aria-label', ev.kind === 'live' ? `Watch ${ev.name} live` : `Read ${ev.name}'s Rundown episode`);
++ cta.addEventListener('click', (e) => {
++ try {
++ const u = new URL(ev.url);
++ 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);
++ el.append(icon, body, acts);
++ container.appendChild(el);
++ if (!_loadingHistory) {
++ if (typeof _chatTrim === 'function') _chatTrim(container, MAIN_CHAT_MAX_MESSAGES);
++ if (typeof queueFullscreenChatEntry === 'function') {
++ queueFullscreenChatEntry({ kind: 'system', html: `<div class="fullscreen-chat-text">${esc(ev.kind === 'live' ? `${ev.name} went live` : `${ev.name} on The Rundown: “${ev.title}”`)}</div>` });
++ }
++ }
++ scrollChat();
++ if (_chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
++}
++
/* ── Deploy notices ─────────────────────────────────────────────
* One card for a run of deploys: "🚀 5 updates shipped", the time range, the commit subjects (each
* listed once), a link to the full notes. Rendered from data (metadata.kind === 'deploy'), so times
@@@ -3762,6 +3926,8 @@ function _renderGlobalHistoryMessages(msgs) {
_foldDeployRows(msgs).forEach(m => {
if (m.__deploy) {
renderDeployCard(m.__deploy);
++ } else if (m.message_type === 'system' && _chatEventOf(m)) {
++ renderChatEventCard(_chatEventOf(m), { isGlobal: true });
} else if (m.message_type === 'system') {
// Other system messages keep the plain system styling
addRichSystemMessage(esc(m.message), 'update');
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex c44d7aa5..ca8525f4 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 129.5, // 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1294.8, // 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
-- jsBrotliKB: 303.1, // 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
++ jsRawKB: 1305.6, // 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
++ jsBrotliKB: 305.5, // 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
ddiff --git a/test/chat-event-cards.test.js b/test/chat-event-cards.test.js
nnew file mode 100644
iindex 00000000..7e7ebabf
---- /dev/null
++++ b/test/chat-event-cards.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++// Go-live and Rundown notices in chat are event cards (public/js/chat.js _chatEventOf / renderChatEventCard;
++// 2026-11-07, the owner: "improve how these go live chat notifications look and add timestamp ... styled like the
++// rest of chat"). What this proves:
++// - Live's go-live row (metadata.kind live_link) and an old text-only row both become a 'live' event with the name
++// and the stream URL; a Rundown row becomes a 'rundown' event with the name, title and episode URL;
++// - only an OpenVibe address is ever a link (a row naming another site is left as plain text);
++// - other system rows (deploys, bans, plain notes) are not events;
++// - the card is built from DOM nodes (no innerHTML of the name or title), shows the time with the viewer's setting,
++// and folds the same person going live again within 15 minutes.
++// node test/chat-event-cards.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
++const start = src.indexOf('const _CHAT_EV_LIVE_RE');
++const end = src.indexOf('function _chatEvCss(');
++assert.ok(start > 0 && end > start, 'the event code is in chat.js');
++const window = { location: { origin: 'https://openvibe.live' } };
++const { _chatEventOf } = new Function('window', `${src.slice(start, end)}; return { _chatEventOf };`)(window);
++
++const live = _chatEventOf({ id: 7, message_type: 'system', message: '\u{1F534} moosely is live. Share the stream: https://openvibe.live/@moosely',
++ metadata: JSON.stringify({ kind: 'live_link', url: 'https://openvibe.live/@moosely' }), timestamp: '2026-11-07T09:03:00Z' });
++assert.deepStrictEqual({ kind: live.kind, name: live.name, url: live.url, key: live.key },
++ { kind: 'live', name: 'moosely', url: 'https://openvibe.live/@moosely', key: 'live:moosely' });
++
++const old = _chatEventOf({ message_type: 'system', message: '\u{1F534} AmberFieraGames37 is live. Share the stream: https://openvibe.live/@AmberFieraGames37' });
++assert.strictEqual(old.kind, 'live'); assert.strictEqual(old.name, 'AmberFieraGames37');
++
++const fromMeta = _chatEventOf({ message_type: 'system', message: 'something else', metadata: { kind: 'live_link', url: 'https://openvibe.live/@iZoR_One' } });
++assert.strictEqual(fromMeta.name, 'iZoR_One', 'the name comes from the link when the text is not the usual one');
++
++const rd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Second McDoanld\'s Run: Bug Fix Later” → https://openvibe.live/rundown/e/2891',
++ metadata: { rundown_episode: 2891, url: 'https://openvibe.live/rundown/e/2891' } });
++assert.deepStrictEqual({ kind: rd.kind, name: rd.name, title: rd.title, url: rd.url },
++ { kind: 'rundown', name: 'goosely', title: "Second McDoanld's Run: Bug Fix Later", url: 'https://openvibe.live/rundown/e/2891' });
++
++assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: https://evil.example/@x' }), null, 'never a link to another site');
++assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: javascript:alert(1)' }), null);
++assert.strictEqual(_chatEventOf({ message_type: 'system', message: '🚀 1 update shipped' }), null, 'a deploy row is not an event');
++assert.strictEqual(_chatEventOf({ message_type: 'chat', message: '\u{1F534} me is live. Share the stream: https://openvibe.live/@me' }), null, 'a person typing the words is just chat');
++
++// The card: DOM nodes only, the viewer's timestamp setting, the 15-minute fold.
++const card = src.slice(src.indexOf('function renderChatEventCard('), src.indexOf('/* ── Deploy notices'));
++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(/prefers-reduced-motion:reduce/.test(src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('))), 'motion stops for people who ask');
++console.log('chat event cards: ok');