openvibers/openvibe.live Commits
The one Share sheet on the channel page, the recap and the guide (#617)
Changes
@@@ -675,7 +675,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:.live-tab-badges { display: none; }.live-tabs-summary { font-size: 0.7rem; padding: 0 8px; }.ch-cumulative-viewers { gap: 6px; }-- .ch-share-stream { display: none; }.ch-offline-header { flex-wrap: wrap; gap: 12px; padding: 16px; }.ch-avatar-lg { width: 56px; height: 56px; font-size: 1.4rem; }.viewer-badge, .uptime-badge { font-size: 0.68rem; padding: 2px 5px; }@@@ -818,14 +817,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);transition: all var(--ov-dur-2, 0.15s);}--.ch-share-stream {-- font-size: 0.72rem; color: var(--text-muted);-- background: none; border: 1px solid var(--border);-- padding: 2px 8px; border-radius: 10px; cursor: pointer;-- display: inline-flex; align-items: center; gap: 4px;-- font-family: var(--font); transition: all var(--ov-dur-2, 0.15s);--}--.ch-share-stream:hover { color: var(--accent); border-color: var(--accent); }.ch-info-bar {display: flex; align-items: flex-start; gap: 16px;padding: 12px 16px;@@@ -103,7 +103,8 @@"channel": {"fragment": "channel","css": [-- "/css/features/channel.css"++ "/css/features/channel.css",++ "/shared/kit.css"],"deps": ["player",@@@ -186,7 +187,8 @@},"guide": {"css": [-- "/css/ov-guide.css"++ "/css/ov-guide.css",++ "/shared/kit.css"],"js": ["/js/ov-guide.js",@@@ -253,7 +255,8 @@"rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},"recap": {"css": [-- "/css/recap.css"++ "/css/recap.css",++ "/shared/kit.css"],"js": ["/js/recap.js"@@@ -56,6 +56,7 @@<button class="vc-btn" id="btn-clip" title="Create Clip (30s)"><i class="fa-solid fa-scissors"></i></button>++<button class="vc-btn" id="btn-fullscreen" title="Fullscreen"><i class="fa-solid fa-expand"></i></button>@@@ -128,6 +129,10 @@</div></div><div class="ch-info-bar-right">++ <!-- The channel's Share button (the one share sheet every OpenVibe site opens). It sits in the++ stream's own action row while the channel is live and in the header's actions when it is++ not, so the page always has one Share button and never two. -->++ <span id="ch-share-slot"><button class="ovk-btn ovk-btn--quiet" id="ch-btn-share" type="button" data-ov-share-sheet><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button></span><span class="ch-follower-count" id="ch-follower-count">0 followers</span><a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a><button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>@@@ -851,6 +851,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIdocument.getElementById('ch-live-area').style.display = '';document.getElementById('ch-offline-area').style.display = 'none';_setChannelLive(true);++ _placeShareButton('live');++ updateShareButton(null);_channelFooter(true);_placeBelowFold();@@@ -934,6 +936,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIdocument.getElementById('ch-offline-area').style.display = '';_channelFooter(false);_setChannelLive(false);++ _placeShareButton('offline');++ updateShareButton(null);_wireCamButton();_updateCamButton(null);_placeBelowFold();@@@ -1232,15 +1236,46 @@ function scrollActiveTabIntoView() {}/**-- * Copy the current stream-specific URL to clipboard.++ * The channel's Share button (the one share sheet every OpenVibe site opens, openvibe-shared 4.114.0).++ *++ * Where it sits: in the stream's own action row (the info bar under the player) while the channel is live, and in++ * the header's actions when the channel is not, so the channel page always has one Share button and never two.++ *++ * What it shares: the channel's own address, its name and its description. While a recording plays (the DVR), the++ * recording's own page on OpenVibe.Video is what is shared, so the sheet can offer "Start at …" on the player.*/--function shareStreamUrl() {-- const url = window.location.href;-- navigator.clipboard.writeText(url).then(-- () => toast('Stream link copied!', 'success'),-- () => toast('Failed to copy link', 'error')-- );--}++function _placeShareButton(where) {++ const b = document.getElementById('ch-btn-share');++ if (!b) return;++ const live = where !== 'offline';++ const host = live ? document.getElementById('ch-share-slot') : document.querySelector('#ch-offline-area .ch-actions');++ if (!host || b.parentElement === host) return;++ if (live) host.appendChild(b);++ else host.insertBefore(b, host.firstChild);++ b.className = 'ovk-btn ovk-btn--quiet';++ b.title = live ? 'Share this stream' : 'Share this channel';++}++++/** What the channel's Share button shares: the address, the name, a line about it, and the picture. */++function updateShareButton(stream) {++ const b = document.getElementById('ch-btn-share');++ if (!b) return;++ const dvr = (typeof dvrState !== 'undefined' && dvrState) ? dvrState : null;++ const recording = dvr && dvr.active && dvr.vodId ? videoHref('vod', dvr.vodId) : '';++ b.dataset.url = recording || `${location.origin}${location.pathname}`;++ const s = stream || currentStreamData || null;++ const name = (s && (s.display_name || s.username)) || '';++ const title = (s && s.title) || name || document.title;++ b.dataset.title = title;++ const text = (s && s.description ? String(s.description) : '').replace(/\s+/g, ' ').trim().slice(0, 240);++ if (text) b.dataset.text = text; else b.removeAttribute('data-text');++ const face = document.querySelector('#ch-avatar img, #ch-avatar-offline img');++ if (face && face.src) b.dataset.image = face.src; else b.removeAttribute('data-image');++ if (recording) b.dataset.timeFrom = '#video-element'; else b.removeAttribute('data-time-from');++}++++// The DVR found the stream's recording: what the Share button shares is now the recording's own page.++document.addEventListener('ov-live:dvr-ready', () => updateShareButton(currentStreamData));/*** Load tabs for the current channel's live streams.@@@ -1620,11 +1655,6 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n}}-- // Share button (copies stream-specific URL)-- if (streamCount > 1) {-- html += `<button class="ch-share-stream" onclick="shareStreamUrl()" title="Copy link to this specific stream"><i class="fa-solid fa-link"></i> Share stream</button>`;-- }--if (html) {el.innerHTML = html;el.style.display = '';@@@ -1856,6 +1886,7 @@ function activateChannelStream(stream) {currentStreamData = stream;document.getElementById('ch-stream-title').textContent = stream.title || 'Untitled Stream';setPageTitle(`${stream.title || 'Live'} — ${stream.display_name || stream.username || ''}`.trim());++ updateShareButton(stream);// Category pill reflects THIS live slot's category (set in /broadcast), not the// channel's stale default.if (stream.category) _setCategoryBadge(document.getElementById('ch-category-badge'), stream.category, _isInferredCategory(stream));@@@ -1104,6 +1104,26 @@ function whenRouteReady(pageId, render) {});}++/**++ * The Share button, exactly as the shared kit builds it (openvibe-shared 4.114.0 kit.share): the same quiet button++ * with the same share icon on every OpenVibe site. Live draws its pages in the browser, so this is the kit's markup.++ *++ * url: the page's own public address — the link that is shared++ * title: what the thing is called++ * text: a line sent with it (the thing's own summary)++ * image: a picture of it (a thumbnail), for the places that take one++ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"++ */++function shareButtonHtml({ url = '', title = '', text = '', image = '', timeFrom = '' } = {}) {++ const attrs = ['data-ov-share-sheet'];++ if (title) attrs.push(`data-title="${esc(title)}"`);++ if (url) attrs.push(`data-url="${esc(url)}"`);++ if (text) attrs.push(`data-text="${esc(text)}"`);++ if (image) attrs.push(`data-image="${esc(image)}"`);++ if (timeFrom) attrs.push(`data-time-from="${esc(timeFrom)}"`);++ return `<button type="button" class="ovk-btn ovk-btn--quiet" ${attrs.join(' ')}><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button>`;++}++/*** A recording's or clip's page on openvibe.video (the shell's ov-video-base meta names the origin): videoHref('vod', 12),* videoHref('clip', 7), videoHref('vod', 12, 95) for a moment. Plain links: they leave Live, so no SPA navigation.@@@ -317,7 +317,7 @@null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');-- one('share', 'Get your first follower', 'fa-share-nodes', async () => { const u = me() || {}; const link = `${location.origin}/@${u.username || ''}`; return `${ui.lead('Followers get pinged the moment you go live, and they show up on your channel. Share the link where your people already are.')}${ui.kv('Your channel', link, true)}<div class="ovg-row"><a class="btn btn-outline" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(`I'm streaming on OpenVibe.Live — follow me at ${link}`)}"><i class="fa-brands fa-x-twitter"></i> Post</a><a class="btn btn-outline" target="_blank" rel="noopener" href="https://discord.gg/M6MuRUaeJj"><i class="fa-brands fa-discord"></i> Say hi in the Discord</a></div><ul class="ovg-tips"><li>Restream to where you already have viewers and tell them the chat lives here.</li><li>Go live at a regular time — the after-show reports and the Star of the day reward showing up.</li></ul>`; },++ one('share', 'Get your first follower', 'fa-share-nodes', async () => { const u = me() || {}; const link = `${location.origin}/@${u.username || ''}`; return `${ui.lead('Followers get pinged the moment you go live, and they show up on your channel. Share the link where your people already are.')}${ui.kv('Your channel', link, true)}<div class="ovg-row">${shareButtonHtml({ url: link, title: u.display_name || u.username || 'My channel', text: `I'm streaming on OpenVibe.Live — follow me at ${link}` })}<a class="btn btn-outline" target="_blank" rel="noopener" href="https://discord.gg/M6MuRUaeJj"><i class="fa-brands fa-discord"></i> Say hi in the Discord</a></div><ul class="ovg-tips"><li>Restream to where you already have viewers and tell them the chat lives here.</li><li>Go live at a regular time — the after-show reports and the Star of the day reward showing up.</li></ul>`; },(el) => ui.wire(el), null, 'Done');// ── Journey: the setup hub (checklist) ────────────────────@@@ -82,15 +82,12 @@${clips}${vod}<div class="recap-share">-- <button type="button" class="btn btn-primary" id="recap-copy"><i class="fa-solid fa-link"></i> Copy link</button>-- <a class="btn btn-outline" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}"><i class="fa-brands fa-x-twitter"></i> Post</a>++ ${shareButtonHtml({ url: shareUrl, title: w.headline || s.title, text: shareText, image: (r.vod && r.vod.thumbnail_url) || '' })}<a class="btn btn-outline" ${go(path)}><i class="fa-solid fa-user"></i> ${esc(u.display_name)}'s channel</a>${owner ? `<button type="button" class="btn btn-outline" id="recap-regen"><i class="fa-solid fa-rotate"></i> Rewrite report</button>` : ''}</div>${more}</article>`;-- const copy = root.querySelector('#recap-copy');-- if (copy) copy.onclick = async () => { try { await navigator.clipboard.writeText(shareUrl); copy.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { copy.innerHTML = '<i class="fa-solid fa-link"></i> Copy link'; }, 1800); } catch { prompt('Copy this link', shareUrl); } };const regen = root.querySelector('#recap-regen');if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }@@@ -2391,6 +2391,8 @@ async function pollDVR(streamId) {if ((dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) || dvrState.earlier.length) {dvrState.active = true;showDVRControls();++ // The stream's recording is playing: the Share button now shares that recording's page.++ try { window.dispatchEvent(new CustomEvent('ov-live:dvr-ready')); } catch (_) { /* the page may be gone */ }}// Continue polling every 20s@@@ -23,7 +23,7 @@ const BUDGETS = {htmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")jsRawKB: 1290, // 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: 301.2, // 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)++ jsBrotliKB: 301.6, // 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,62 @@++'use strict';++/**++ * The one Share button (openvibe-shared 4.114.0): Live's own share links are gone, and every page that shares++ * something carries one [data-ov-share-sheet] button, built like the shared kit's (kit.share): the same quiet++ * button with the same share icon on every OpenVibe site.++ *++ * Live's pages are drawn by the browser (the channel page, the recap), so what this checks is what the server++ * sends and what the page's own scripts put on the button.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');++++const channel = read('public/fragments/channel.html');++const app = read('public/js/app.js');++const appChannel = read('public/js/app-channel.js');++const recap = read('public/js/recap.js');++const guides = read('public/js/guides-live.js');++++// The button is the kit's: a quiet kit button with the share icon.++assert.ok(app.includes('class="ovk-btn ovk-btn--quiet"'), "the kit's quiet button");++assert.ok(app.includes("<svg class=\"ovk-i\" viewBox=\"0 0 24 24\""), 'the kit\'s share icon');++assert.ok(app.includes('data-ov-share-sheet'), 'the share attribute');++++// The channel page: one Share button, in the stream's own action row.++const buttons = channel.match(/data-ov-share-sheet/g) || [];++assert.strictEqual(buttons.length, 1, 'the channel page has one Share button');++assert.ok(channel.includes('id="ch-btn-share"'), "it is the channel's Share button");++assert.ok(channel.includes('id="ch-share-slot"'), "and it sits in the stream's own action row");++assert.ok(channel.includes('class="ovk-btn ovk-btn--quiet"'), 'the kit\'s button');++assert.ok(!appChannel.includes('shareStreamUrl'), 'the old copy-the-link button is gone');++assert.ok(!read('public/css/features/channel.css').includes('ch-share-stream'), 'and so is its CSS');++++// What it shares: the channel's own public address (never an internal or an API URL).++assert.ok(++ appChannel.includes('b.dataset.url = recording || `${location.origin}${location.pathname}`;'),++ "the button shares the channel page's own address",++);++assert.ok(appChannel.includes("b.dataset.timeFrom = '#video-element'"), 'a recording that plays can be shared from the moment it plays');++assert.ok(appChannel.includes("videoHref('vod', dvr.vodId)"), 'a recording that plays is shared as that recording');++++// The recap page: one Share button, with the recap's own address.++assert.strictEqual((recap.match(/shareButtonHtml\(/g) || []).length, 1, 'the recap page has one Share button');++assert.ok(recap.includes('const shareUrl = `${location.origin}/recap/${id}`;'), "the recap's own address");++assert.ok(!recap.includes('twitter.com/intent'), 'the old X link is gone');++++// The guide task that posted on X now offers the sheet.++assert.strictEqual((guides.match(/shareButtonHtml\(/g) || []).length, 1, 'the guide offers the sheet');++assert.ok(!guides.includes('twitter.com/intent'), 'the old X link is gone');++++// No page of the site keeps a link that shares the page by hand any more.++for (const rel of ['public/js/recap.js', 'public/js/guides-live.js', 'public/js/app-channel.js', 'public/fragments/channel.html']) {++ const src = read(rel);++ for (const old of ['twitter.com/intent', 'x.com/intent', 'bsky.app/intent', 'reddit.com/submit', 'wa.me/?text', 't.me/share/url']) {++ assert.ok(!src.includes(old), rel + ' still has ' + old);++ }++}++++console.log('share sheet: all checks passed');
@@ -675,7 +675,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -675,7 +675,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.live-tab-badges { display: none; }
.live-tab-badges { display: none; }
.live-tabs-summary { font-size: 0.7rem; padding: 0 8px; }
.live-tabs-summary { font-size: 0.7rem; padding: 0 8px; }
.ch-cumulative-viewers { gap: 6px; }
.ch-cumulative-viewers { gap: 6px; }
- .ch-share-stream { display: none; }
.ch-offline-header { flex-wrap: wrap; gap: 12px; padding: 16px; }
.ch-offline-header { flex-wrap: wrap; gap: 12px; padding: 16px; }
.ch-avatar-lg { width: 56px; height: 56px; font-size: 1.4rem; }
.ch-avatar-lg { width: 56px; height: 56px; font-size: 1.4rem; }
.viewer-badge, .uptime-badge { font-size: 0.68rem; padding: 2px 5px; }
.viewer-badge, .uptime-badge { font-size: 0.68rem; padding: 2px 5px; }
@@ -818,14 +817,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -818,14 +817,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);
background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);
transition: all var(--ov-dur-2, 0.15s);
transition: all var(--ov-dur-2, 0.15s);
}
}
-.ch-share-stream {
- font-size: 0.72rem; color: var(--text-muted);
- background: none; border: 1px solid var(--border);
- padding: 2px 8px; border-radius: 10px; cursor: pointer;
- display: inline-flex; align-items: center; gap: 4px;
- font-family: var(--font); transition: all var(--ov-dur-2, 0.15s);
-}
-.ch-share-stream:hover { color: var(--accent); border-color: var(--accent); }
.ch-info-bar {
.ch-info-bar {
display: flex; align-items: flex-start; gap: 16px;
display: flex; align-items: flex-start; gap: 16px;
padding: 12px 16px;
padding: 12px 16px;
@@ -103,7 +103,8 @@
@@ -103,7 +103,8 @@
"channel": {
"channel": {
"fragment": "channel",
"fragment": "channel",
"css": [
"css": [
- "/css/features/channel.css"
+ "/css/features/channel.css",
+ "/shared/kit.css"
],
],
"deps": [
"deps": [
"player",
"player",
@@ -186,7 +187,8 @@
@@ -186,7 +187,8 @@
},
},
"guide": {
"guide": {
"css": [
"css": [
- "/css/ov-guide.css"
+ "/css/ov-guide.css",
+ "/shared/kit.css"
],
],
"js": [
"js": [
"/js/ov-guide.js",
"/js/ov-guide.js",
@@ -253,7 +255,8 @@
@@ -253,7 +255,8 @@
"rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},
"rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},
"recap": {
"recap": {
"css": [
"css": [
- "/css/recap.css"
+ "/css/recap.css",
+ "/shared/kit.css"
],
],
"js": [
"js": [
"/js/recap.js"
"/js/recap.js"
@@ -56,6 +56,7 @@
@@ -56,6 +56,7 @@
<button class="vc-btn" id="btn-clip" title="Create Clip (30s)">
<button class="vc-btn" id="btn-clip" title="Create Clip (30s)">
<i class="fa-solid fa-scissors"></i>
<i class="fa-solid fa-scissors"></i>
</button>
</button>
+
<button class="vc-btn" id="btn-fullscreen" title="Fullscreen">
<button class="vc-btn" id="btn-fullscreen" title="Fullscreen">
<i class="fa-solid fa-expand"></i>
<i class="fa-solid fa-expand"></i>
</button>
</button>
@@ -128,6 +129,10 @@
@@ -128,6 +129,10 @@
</div>
</div>
</div>
</div>
<div class="ch-info-bar-right">
<div class="ch-info-bar-right">
+ <!-- The channel's Share button (the one share sheet every OpenVibe site opens). It sits in the
+ stream's own action row while the channel is live and in the header's actions when it is
+ not, so the page always has one Share button and never two. -->
+ <span id="ch-share-slot"><button class="ovk-btn ovk-btn--quiet" id="ch-btn-share" type="button" data-ov-share-sheet><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button></span>
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>
<button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>
@@ -851,6 +851,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -851,6 +851,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-offline-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = 'none';
_setChannelLive(true);
_setChannelLive(true);
+ _placeShareButton('live');
+ updateShareButton(null);
_channelFooter(true);
_channelFooter(true);
_placeBelowFold();
_placeBelowFold();
@@ -934,6 +936,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -934,6 +936,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-offline-area').style.display = '';
document.getElementById('ch-offline-area').style.display = '';
_channelFooter(false);
_channelFooter(false);
_setChannelLive(false);
_setChannelLive(false);
+ _placeShareButton('offline');
+ updateShareButton(null);
_wireCamButton();
_wireCamButton();
_updateCamButton(null);
_updateCamButton(null);
_placeBelowFold();
_placeBelowFold();
@@ -1232,15 +1236,46 @@ function scrollActiveTabIntoView() {
@@ -1232,15 +1236,46 @@ function scrollActiveTabIntoView() {
}
}
/**
/**
- * Copy the current stream-specific URL to clipboard.
+ * The channel's Share button (the one share sheet every OpenVibe site opens, openvibe-shared 4.114.0).
+ *
+ * Where it sits: in the stream's own action row (the info bar under the player) while the channel is live, and in
+ * the header's actions when the channel is not, so the channel page always has one Share button and never two.
+ *
+ * What it shares: the channel's own address, its name and its description. While a recording plays (the DVR), the
+ * recording's own page on OpenVibe.Video is what is shared, so the sheet can offer "Start at …" on the player.
*/
*/
-function shareStreamUrl() {
- const url = window.location.href;
- navigator.clipboard.writeText(url).then(
- () => toast('Stream link copied!', 'success'),
- () => toast('Failed to copy link', 'error')
- );
-}
+function _placeShareButton(where) {
+ const b = document.getElementById('ch-btn-share');
+ if (!b) return;
+ const live = where !== 'offline';
+ const host = live ? document.getElementById('ch-share-slot') : document.querySelector('#ch-offline-area .ch-actions');
+ if (!host || b.parentElement === host) return;
+ if (live) host.appendChild(b);
+ else host.insertBefore(b, host.firstChild);
+ b.className = 'ovk-btn ovk-btn--quiet';
+ b.title = live ? 'Share this stream' : 'Share this channel';
+}
+
+/** What the channel's Share button shares: the address, the name, a line about it, and the picture. */
+function updateShareButton(stream) {
+ const b = document.getElementById('ch-btn-share');
+ if (!b) return;
+ const dvr = (typeof dvrState !== 'undefined' && dvrState) ? dvrState : null;
+ const recording = dvr && dvr.active && dvr.vodId ? videoHref('vod', dvr.vodId) : '';
+ b.dataset.url = recording || `${location.origin}${location.pathname}`;
+ const s = stream || currentStreamData || null;
+ const name = (s && (s.display_name || s.username)) || '';
+ const title = (s && s.title) || name || document.title;
+ b.dataset.title = title;
+ const text = (s && s.description ? String(s.description) : '').replace(/\s+/g, ' ').trim().slice(0, 240);
+ if (text) b.dataset.text = text; else b.removeAttribute('data-text');
+ const face = document.querySelector('#ch-avatar img, #ch-avatar-offline img');
+ if (face && face.src) b.dataset.image = face.src; else b.removeAttribute('data-image');
+ if (recording) b.dataset.timeFrom = '#video-element'; else b.removeAttribute('data-time-from');
+}
+
+// The DVR found the stream's recording: what the Share button shares is now the recording's own page.
+document.addEventListener('ov-live:dvr-ready', () => updateShareButton(currentStreamData));
/**
/**
* Load tabs for the current channel's live streams.
* Load tabs for the current channel's live streams.
@@ -1620,11 +1655,6 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n
@@ -1620,11 +1655,6 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n
}
}
}
}
- // Share button (copies stream-specific URL)
- if (streamCount > 1) {
- html += `<button class="ch-share-stream" onclick="shareStreamUrl()" title="Copy link to this specific stream"><i class="fa-solid fa-link"></i> Share stream</button>`;
- }
-
if (html) {
if (html) {
el.innerHTML = html;
el.innerHTML = html;
el.style.display = '';
el.style.display = '';
@@ -1856,6 +1886,7 @@ function activateChannelStream(stream) {
@@ -1856,6 +1886,7 @@ function activateChannelStream(stream) {
currentStreamData = stream;
currentStreamData = stream;
document.getElementById('ch-stream-title').textContent = stream.title || 'Untitled Stream';
document.getElementById('ch-stream-title').textContent = stream.title || 'Untitled Stream';
setPageTitle(`${stream.title || 'Live'} — ${stream.display_name || stream.username || ''}`.trim());
setPageTitle(`${stream.title || 'Live'} — ${stream.display_name || stream.username || ''}`.trim());
+ updateShareButton(stream);
// Category pill reflects THIS live slot's category (set in /broadcast), not the
// Category pill reflects THIS live slot's category (set in /broadcast), not the
// channel's stale default.
// channel's stale default.
if (stream.category) _setCategoryBadge(document.getElementById('ch-category-badge'), stream.category, _isInferredCategory(stream));
if (stream.category) _setCategoryBadge(document.getElementById('ch-category-badge'), stream.category, _isInferredCategory(stream));
@@ -1104,6 +1104,26 @@ function whenRouteReady(pageId, render) {
@@ -1104,6 +1104,26 @@ function whenRouteReady(pageId, render) {
});
});
}
}
+/**
+ * The Share button, exactly as the shared kit builds it (openvibe-shared 4.114.0 kit.share): the same quiet button
+ * with the same share icon on every OpenVibe site. Live draws its pages in the browser, so this is the kit's markup.
+ *
+ * url: the page's own public address — the link that is shared
+ * title: what the thing is called
+ * text: a line sent with it (the thing's own summary)
+ * image: a picture of it (a thumbnail), for the places that take one
+ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"
+ */
+function shareButtonHtml({ url = '', title = '', text = '', image = '', timeFrom = '' } = {}) {
+ const attrs = ['data-ov-share-sheet'];
+ if (title) attrs.push(`data-title="${esc(title)}"`);
+ if (url) attrs.push(`data-url="${esc(url)}"`);
+ if (text) attrs.push(`data-text="${esc(text)}"`);
+ if (image) attrs.push(`data-image="${esc(image)}"`);
+ if (timeFrom) attrs.push(`data-time-from="${esc(timeFrom)}"`);
+ return `<button type="button" class="ovk-btn ovk-btn--quiet" ${attrs.join(' ')}><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button>`;
+}
+
/**
/**
* A recording's or clip's page on openvibe.video (the shell's ov-video-base meta names the origin): videoHref('vod', 12),
* A recording's or clip's page on openvibe.video (the shell's ov-video-base meta names the origin): videoHref('vod', 12),
* videoHref('clip', 7), videoHref('vod', 12, 95) for a moment. Plain links: they leave Live, so no SPA navigation.
* videoHref('clip', 7), videoHref('vod', 12, 95) for a moment. Plain links: they leave Live, so no SPA navigation.
@@ -317,7 +317,7 @@
@@ -317,7 +317,7 @@
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,
null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');
null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');
- one('share', 'Get your first follower', 'fa-share-nodes', async () => { const u = me() || {}; const link = `${location.origin}/@${u.username || ''}`; return `${ui.lead('Followers get pinged the moment you go live, and they show up on your channel. Share the link where your people already are.')}${ui.kv('Your channel', link, true)}<div class="ovg-row"><a class="btn btn-outline" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(`I'm streaming on OpenVibe.Live — follow me at ${link}`)}"><i class="fa-brands fa-x-twitter"></i> Post</a><a class="btn btn-outline" target="_blank" rel="noopener" href="https://discord.gg/M6MuRUaeJj"><i class="fa-brands fa-discord"></i> Say hi in the Discord</a></div><ul class="ovg-tips"><li>Restream to where you already have viewers and tell them the chat lives here.</li><li>Go live at a regular time — the after-show reports and the Star of the day reward showing up.</li></ul>`; },
+ one('share', 'Get your first follower', 'fa-share-nodes', async () => { const u = me() || {}; const link = `${location.origin}/@${u.username || ''}`; return `${ui.lead('Followers get pinged the moment you go live, and they show up on your channel. Share the link where your people already are.')}${ui.kv('Your channel', link, true)}<div class="ovg-row">${shareButtonHtml({ url: link, title: u.display_name || u.username || 'My channel', text: `I'm streaming on OpenVibe.Live — follow me at ${link}` })}<a class="btn btn-outline" target="_blank" rel="noopener" href="https://discord.gg/M6MuRUaeJj"><i class="fa-brands fa-discord"></i> Say hi in the Discord</a></div><ul class="ovg-tips"><li>Restream to where you already have viewers and tell them the chat lives here.</li><li>Go live at a regular time — the after-show reports and the Star of the day reward showing up.</li></ul>`; },
(el) => ui.wire(el), null, 'Done');
(el) => ui.wire(el), null, 'Done');
// ── Journey: the setup hub (checklist) ────────────────────
// ── Journey: the setup hub (checklist) ────────────────────
@@ -82,15 +82,12 @@
@@ -82,15 +82,12 @@
${clips}
${clips}
${vod}
${vod}
<div class="recap-share">
<div class="recap-share">
- <button type="button" class="btn btn-primary" id="recap-copy"><i class="fa-solid fa-link"></i> Copy link</button>
- <a class="btn btn-outline" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}"><i class="fa-brands fa-x-twitter"></i> Post</a>
+ ${shareButtonHtml({ url: shareUrl, title: w.headline || s.title, text: shareText, image: (r.vod && r.vod.thumbnail_url) || '' })}
<a class="btn btn-outline" ${go(path)}><i class="fa-solid fa-user"></i> ${esc(u.display_name)}'s channel</a>
<a class="btn btn-outline" ${go(path)}><i class="fa-solid fa-user"></i> ${esc(u.display_name)}'s channel</a>
${owner ? `<button type="button" class="btn btn-outline" id="recap-regen"><i class="fa-solid fa-rotate"></i> Rewrite report</button>` : ''}
${owner ? `<button type="button" class="btn btn-outline" id="recap-regen"><i class="fa-solid fa-rotate"></i> Rewrite report</button>` : ''}
</div>
</div>
${more}
${more}
</article>`;
</article>`;
- const copy = root.querySelector('#recap-copy');
- if (copy) copy.onclick = async () => { try { await navigator.clipboard.writeText(shareUrl); copy.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { copy.innerHTML = '<i class="fa-solid fa-link"></i> Copy link'; }, 1800); } catch { prompt('Copy this link', shareUrl); } };
const regen = root.querySelector('#recap-regen');
const regen = root.querySelector('#recap-regen');
if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };
if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };
try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }
try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }
@@ -2391,6 +2391,8 @@ async function pollDVR(streamId) {
@@ -2391,6 +2391,8 @@ async function pollDVR(streamId) {
if ((dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) || dvrState.earlier.length) {
if ((dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) || dvrState.earlier.length) {
dvrState.active = true;
dvrState.active = true;
showDVRControls();
showDVRControls();
+ // The stream's recording is playing: the Share button now shares that recording's page.
+ try { window.dispatchEvent(new CustomEvent('ov-live:dvr-ready')); } catch (_) { /* the page may be gone */ }
}
}
// Continue polling every 20s
// Continue polling every 20s
@@ -23,7 +23,7 @@ const BUDGETS = {
@@ -23,7 +23,7 @@ const BUDGETS = {
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsRawKB: 1290, // 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)
jsRawKB: 1290, // 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: 301.2, // 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)
+ jsBrotliKB: 301.6, // 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,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The one Share button (openvibe-shared 4.114.0): Live's own share links are gone, and every page that shares
+ * something carries one [data-ov-share-sheet] button, built like the shared kit's (kit.share): the same quiet
+ * button with the same share icon on every OpenVibe site.
+ *
+ * Live's pages are drawn by the browser (the channel page, the recap), so what this checks is what the server
+ * sends and what the page's own scripts put on the button.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
+
+const channel = read('public/fragments/channel.html');
+const app = read('public/js/app.js');
+const appChannel = read('public/js/app-channel.js');
+const recap = read('public/js/recap.js');
+const guides = read('public/js/guides-live.js');
+
+// The button is the kit's: a quiet kit button with the share icon.
+assert.ok(app.includes('class="ovk-btn ovk-btn--quiet"'), "the kit's quiet button");
+assert.ok(app.includes("<svg class=\"ovk-i\" viewBox=\"0 0 24 24\""), 'the kit\'s share icon');
+assert.ok(app.includes('data-ov-share-sheet'), 'the share attribute');
+
+// The channel page: one Share button, in the stream's own action row.
+const buttons = channel.match(/data-ov-share-sheet/g) || [];
+assert.strictEqual(buttons.length, 1, 'the channel page has one Share button');
+assert.ok(channel.includes('id="ch-btn-share"'), "it is the channel's Share button");
+assert.ok(channel.includes('id="ch-share-slot"'), "and it sits in the stream's own action row");
+assert.ok(channel.includes('class="ovk-btn ovk-btn--quiet"'), 'the kit\'s button');
+assert.ok(!appChannel.includes('shareStreamUrl'), 'the old copy-the-link button is gone');
+assert.ok(!read('public/css/features/channel.css').includes('ch-share-stream'), 'and so is its CSS');
+
+// What it shares: the channel's own public address (never an internal or an API URL).
+assert.ok(
+ appChannel.includes('b.dataset.url = recording || `${location.origin}${location.pathname}`;'),
+ "the button shares the channel page's own address",
+);
+assert.ok(appChannel.includes("b.dataset.timeFrom = '#video-element'"), 'a recording that plays can be shared from the moment it plays');
+assert.ok(appChannel.includes("videoHref('vod', dvr.vodId)"), 'a recording that plays is shared as that recording');
+
+// The recap page: one Share button, with the recap's own address.
+assert.strictEqual((recap.match(/shareButtonHtml\(/g) || []).length, 1, 'the recap page has one Share button');
+assert.ok(recap.includes('const shareUrl = `${location.origin}/recap/${id}`;'), "the recap's own address");
+assert.ok(!recap.includes('twitter.com/intent'), 'the old X link is gone');
+
+// The guide task that posted on X now offers the sheet.
+assert.strictEqual((guides.match(/shareButtonHtml\(/g) || []).length, 1, 'the guide offers the sheet');
+assert.ok(!guides.includes('twitter.com/intent'), 'the old X link is gone');
+
+// No page of the site keeps a link that shares the page by hand any more.
+for (const rel of ['public/js/recap.js', 'public/js/guides-live.js', 'public/js/app-channel.js', 'public/fragments/channel.html']) {
+ const src = read(rel);
+ for (const old of ['twitter.com/intent', 'x.com/intent', 'bsky.app/intent', 'reddit.com/submit', 'wa.me/?text', 't.me/share/url']) {
+ assert.ok(!src.includes(old), rel + ' still has ' + old);
+ }
+}
+
+console.log('share sheet: all checks passed');