openvibers/openvibe.live Commits

The one Share sheet on the channel page, the recap and the guide (#617)

OP OpenVibers committed 7 days ago · a9e8fc171c · 10 files, +142 −31

* The one Share sheet on the channel page, the recap and the guide

Replace Live's own share links with the shared sheet button (openvibe-shared 4.114.0): the recap's copy button and X link, the channel page's copy-the-stream-link button and the guide task's X link are gone.

- public/fragments/channel.html, public/js/app-channel.js: the channel page has one Share button in the stream's own action row (and in the header's actions when the channel is offline). It shares the channel's own public address; while a recording plays (the DVR) it shares that recording's page, so the sheet can offer the moment it plays
- public/js/recap.js: the recap page has one Share button, with the recap's own address
- public/js/guides-live.js: the first-follower task offers the sheet
- public/css/features/channel.css: the old button's CSS is gone
- test/share-sheet.test.js: the new markup is checked, and no page keeps a hand-made share link

* One Share button everywhere: the shared kit's

The Share button is now the shared kit's (openvibe-shared 4.114.0 kit.share): the same quiet kit button with the same share icon on every OpenVibe site. Live draws its pages in the browser, so this is the kit's markup, copied.

- public/js/app.js: shareButtonHtml(), the kit's button
- public/index.html: the shell loads /shared/kit.css, so the button looks the same here as everywhere else
- public/fragments/channel.html, public/js/recap.js, public/js/guides-live.js: the channel page, the recap page and the guide task carry it
- public/js/app-channel.js: the button keeps the kit's look wherever it is moved
- test/share-sheet.test.js: the button, its markup and the address it shares are checked

* The kit's stylesheet on the routes that share; the JS budget follows the kit's button

- public/features.json: the channel, recap and guide routes ask for /shared/kit.css (they carry a Share button); the shell does not carry it on every page
- scripts/perf/check-budgets.js: jsBrotliKB 301.2 -> 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js)

The home page's first response, measured: css 5 files 168.8 KB (33.9 br), js 26 files 1287.4 KB (301.6 br). The channel and recap routes carry kit.css instead.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 2a500558..07802a68 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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;
ddiff --git a/public/features.json b/public/features.json
iindex 9a6cba4c..75173b5d 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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"
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 25231c41..867167ee 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex a81de18c..ff09d063 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -851,6 +851,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.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, legacySessionI
document.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));
ddiff --git a/public/js/app.js b/public/js/app.js
iindex fca950b3..e357dc5a 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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.
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex 9e50189f..9ff983fb 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -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) ────────────────────
ddiff --git a/public/js/recap.js b/public/js/recap.js
iindex 23cd72db..d8d83b5b 100644
---- a/public/js/recap.js
++++ b/public/js/recap.js
@@@ -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 { /* */ }
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex ed07a093..e85d61d5 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 0b74d60f..7def0439 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -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)
};
ddiff --git a/test/share-sheet.test.js b/test/share-sheet.test.js
nnew file mode 100644
iindex 00000000..75a789ef
---- /dev/null
++++ b/test/share-sheet.test.js
@@@ -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');