openvibers/openvibe.live Commits
Chat: go-live and Rundown notices as event cards, with timestamps (#653)
Changes
@@@ -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 stylingaddRichSystemMessage(esc(m.message), 'update');@@@ -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 beforehtmlBrotliKB: 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)};nnew file mode 100644@@@ -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');
@@ -2833,6 +2833,10 @@ function addChatMessage(msg) {
@@ -2833,6 +2833,10 @@ function addChatMessage(msg) {
_fcwAddMessage(msg);
_fcwAddMessage(msg);
return;
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 });
const el = buildChatMessageEl(msg, { isGlobal: chatEl.isGlobal });
if (!_loadingHistory) el.classList.add('chat-msg-new');
if (!_loadingHistory) el.classList.add('chat-msg-new');
@@ -2986,6 +2990,166 @@ function addRichSystemMessage(html, style = 'info') {
@@ -2986,6 +2990,166 @@ function addRichSystemMessage(html, style = 'info') {
if (_chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
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 ─────────────────────────────────────────────
/* ── Deploy notices ─────────────────────────────────────────────
* One card for a run of deploys: "🚀 5 updates shipped", the time range, the commit subjects (each
* 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
* listed once), a link to the full notes. Rendered from data (metadata.kind === 'deploy'), so times
@@ -3762,6 +3926,8 @@ function _renderGlobalHistoryMessages(msgs) {
@@ -3762,6 +3926,8 @@ function _renderGlobalHistoryMessages(msgs) {
_foldDeployRows(msgs).forEach(m => {
_foldDeployRows(msgs).forEach(m => {
if (m.__deploy) {
if (m.__deploy) {
renderDeployCard(m.__deploy);
renderDeployCard(m.__deploy);
+ } else if (m.message_type === 'system' && _chatEventOf(m)) {
+ renderChatEventCard(_chatEventOf(m), { isGlobal: true });
} else if (m.message_type === 'system') {
} else if (m.message_type === 'system') {
// Other system messages keep the plain system styling
// Other system messages keep the plain system styling
addRichSystemMessage(esc(m.message), 'update');
addRichSystemMessage(esc(m.message), 'update');
@@ -22,8 +22,8 @@ const BUDGETS = {
@@ -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
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,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
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)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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');