openvibers/openvibe.live Commits
Live looks live: a live card wears its own frame, a LIVE pill and the ring (#792)
Changes
@@@ -771,12 +771,32 @@ body.ov-nav-overlay .navbar.ovnav-top {border-radius: var(--radius) var(--radius) 0 0;}.stream-card-live {-- position: absolute; top: 8px; left: 8px;++ position: absolute; top: 8px; left: 8px; z-index: 2;++ display: inline-flex; align-items: center; gap: 6px;background: var(--live-red); color: #fff;-- padding: 2px 8px; border-radius: var(--radius-sm);-- font-size: 0.7rem; font-weight: 700;++ padding: 4px 9px 4px 8px; border-radius: 8px;++ font-size: 0.7rem; font-weight: 800; letter-spacing: .06em; line-height: 1;text-transform: uppercase;--}++ box-shadow: 0 2px 10px color-mix(in srgb, var(--live-red) 45%, transparent);++}++.stream-card-live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: ov-live-beat 1.4s ease-in-out infinite; }++@keyframes ov-live-beat { 50% { opacity: .35; transform: scale(.75); } }++/* 286, "live looks live": a live card wears its own frame as a soft backdrop under its words (blurred, tinted by the++ theme's card colour, so text always sits on a scrim), a red hairline and glow, the LIVE pill, and the ring on the++ face. Hovered on a computer, or centred on a phone (app-home.js _liveCentreWatch), the frame drifts closer and the++ backdrop wakes. NSFW cards keep no backdrop (app-home.js leaves --live-bg out). */++.stream-card.is-live { position: relative; isolation: isolate; border-color: color-mix(in srgb, var(--live-red) 38%, var(--border)); box-shadow: var(--shadow-soft), 0 14px 36px -18px color-mix(in srgb, var(--thumb-tint, var(--live-red)) 70%, transparent); }++.stream-card.is-live::before { content: ""; position: absolute; inset: -20%; z-index: -2; background: var(--live-bg, none) center / cover no-repeat; filter: blur(30px) saturate(1.7); opacity: .6; transition: opacity .5s ease; }++.stream-card.is-live::after { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(180deg, color-mix(in srgb, var(--bg-card) 18%, transparent) 0%, color-mix(in srgb, var(--bg-card) 52%, transparent) 58%, color-mix(in srgb, var(--bg-card) 70%, transparent) 100%); }++.stream-card.is-live .stream-card-info { position: relative; background: none; }++.stream-card.is-live .stream-card-thumb img { transition: transform 7s cubic-bezier(.2, .6, .3, 1), opacity .4s; }++.stream-card.is-live:hover .stream-card-thumb img, .stream-card.is-live.is-centred .stream-card-thumb img { transform: scale(1.07); }++.stream-card.is-live:hover::before, .stream-card.is-live.is-centred::before { opacity: .85; }++.stream-card.is-live:hover { border-color: color-mix(in srgb, var(--live-red) 60%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--live-red) 40%, transparent), 0 18px 44px -16px color-mix(in srgb, var(--thumb-tint, var(--live-red)) 80%, transparent); }++.stream-card.is-live .stream-card-avatar { box-shadow: 0 0 0 2px var(--live-red), 0 0 0 4px var(--bg-card); }++html[data-ov-motion=off] .stream-card-live::before { animation: none; }++html[data-ov-motion=off] .stream-card.is-live .stream-card-thumb img { transition: none; transform: none; }++body.theme-light .stream-card.is-live::before { opacity: .35; }.stream-card-thumb .protocol-badge {position: absolute; bottom: 8px; left: 8px;}@@@ -1896,10 +1896,14 @@ function streamCardHTML(s, isLive) {const topics = isLive ? _streamTopics(s) : [];const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();const shown = topics.filter((t) => t !== cat).slice(0, 2);++ // 286 ("when they're live … sets the background based on … their thumbnail"): a live card wears its own frame as a++ // soft backdrop (style.css .stream-card.is-live), a LIVE pill, and the live ring on the face.++ const liveBg = isLive && !s.is_nsfw ? _liveBg(thumb) : '';return `-- <a class="stream-card"${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">++ <a class="stream-card${isLive ? ' is-live' : ''}"${liveBg ? ` style="--live-bg:${esc(liveBg)}"` : ''}${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')"><div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}++ ${isLive ? '<span class="stream-card-live">Live</span>' : ''}${streamTypeBadge(s.browser_mode, s.streaming_method)}${s.is_nsfw ? '<span class="stream-card-nsfw">18+</span>' : ''}${duration}@@@ -1958,6 +1962,25 @@ function renderStreamGrid(containerId, streams, isLive) {const empty = c.querySelector('.empty-state');c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');if (empty) { empty.style.display = 'none'; c.appendChild(empty); }++ if (isLive) _liveCentreWatch(c);++}++++/** A live card's backdrop: its own frame, only from our own addresses (a CSS url() never carries a stranger's). */++function _liveBg(u) {++ const v = String(u || '');++ if (!/^(\/[^/]|https:\/\/([a-z0-9-]+\.)*openvibe\.(live|media|video)\/)/i.test(v) || /["'()\\\s]/.test(v)) return '';++ return `url("${v}")`;++}++++/**++ * On a phone (no hover) the live card in the middle of the screen comes alive the way a hovered one does on a computer:++ * its frame drifts closer and its backdrop glows (style.css .stream-card.is-live.is-centred).++ */++let _liveCentreIO = null;++function _liveCentreWatch(root) {++ if (!root || typeof IntersectionObserver === 'undefined' || !window.matchMedia || !window.matchMedia('(hover: none)').matches) return;++ if (!_liveCentreIO) _liveCentreIO = new IntersectionObserver((entries) => entries.forEach((e) => e.target.classList.toggle('is-centred', e.isIntersecting)), { rootMargin: '-38% 0px -38% 0px' });++ root.querySelectorAll('.stream-card.is-live').forEach((el) => _liveCentreIO.observe(el));}/* ── Real-time home updates ─────────────────────────────────── */@@@ -2022,6 +2045,8 @@ function _updateLiveCard(card, s) {// Thumbnail: live cards render a placeholder icon (no <img>) until the first frame// is captured. Crossfade if an <img> exists, else swap the placeholder for one.if (s.thumbnail_url) {++ const bg = _liveBg(s.thumbnail_url);++ if (bg && !s.is_nsfw) card.style.setProperty('--live-bg', bg);const thumbBox = card.querySelector('.stream-card-thumb');const img = thumbBox && thumbBox.querySelector('img');if (img) {@@@ -2115,6 +2140,7 @@ async function refreshHomeLive() {if (el) {el.classList.add('stream-card-appear');grid.insertBefore(el, grid.children[idx] || null);++ _liveCentreWatch(el.parentNode);}}});@@@ -25,7 +25,7 @@ ctx.currentUser = { username: 'Carol' };assert.strictEqual(ovLabelsAttr(stream), '', 'the streamer\'s own stream is never marked for them');const home = read('public/js/app-home.js');--assert.ok(home.includes('<a class="stream-card"${ovLabelsAttr(s)}'), 'the home card is marked');++assert.match(home, /<a class="stream-card[^"]*"[^>\n]*\$\{ovLabelsAttr\(s\)\}/, 'the home card is marked (a live one also wears is-live and its frame, ask 286)');assert.ok(home.includes('style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>'), 'its picture is what a blur covers');assert.ok(home.includes('${ovLabelChips(s)}'), 'its chips under the title');const player = read('public/js/stream-player.js');nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++// Live looks live (plan ask 286, the owner: "when they're live … sets the background based on … their thumbnail …++// modern, stand out, unique"): a live card wears its own frame as a soft backdrop, a LIVE pill and the ring; an NSFW card++// keeps no backdrop; the backdrop is only ever our own address in a CSS url(); a phone's centred card comes alive like a++// hovered one. The functions are public/js/app-home.js's own, run here.++// node test/live-card.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');++const body = /function _liveBg\(u\) \{[\s\S]*?\n\}\n/.exec(src);++assert.ok(body, 'app-home.js has _liveBg');++const _liveBg = new Function(`${body[0]}; return _liveBg;`)();++++assert.strictEqual(_liveBg('/api/thumbnails/live/12.jpg?t=1'), 'url("/api/thumbnails/live/12.jpg?t=1")', 'our own path');++assert.strictEqual(_liveBg('https://openvibe.media/t/a.jpg'), 'url("https://openvibe.media/t/a.jpg")', 'Media');++assert.strictEqual(_liveBg('https://cdn.openvibe.video/a.jpg'), 'url("https://cdn.openvibe.video/a.jpg")', 'a subdomain of ours');++for (const bad of ['https://evil.example/x.jpg', '//evil.example/x.jpg', 'javascript:alert(1)', '/x") ;background:red', '/a b.jpg', "/a'.jpg", 'http://openvibe.live/a.jpg', '', null]) {++ assert.strictEqual(_liveBg(bad), '', `refused: ${JSON.stringify(bad)}`);++}++++assert.match(src, /const liveBg = isLive && !s\.is_nsfw \? _liveBg\(thumb\) : '';/, 'an NSFW card keeps no backdrop');++assert.match(src, /<a class="stream-card\$\{isLive \? ' is-live' : ''\}"\$\{liveBg \? ` style="--live-bg:\$\{esc\(liveBg\)\}"` : ''\}/, 'a live card is marked and wears its frame');++assert.match(src, /\$\{isLive \? '<span class="stream-card-live">Live<\/span>' : ''\}/, 'the LIVE pill');++assert.match(src, /if \(bg && !s\.is_nsfw\) card\.style\.setProperty\('--live-bg', bg\);/, 'the backdrop follows the newest frame');++assert.match(src, /if \(isLive\) _liveCentreWatch\(c\);/, 'a phone watches which live card is centred');++++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');++assert.match(css, /\.stream-card\.is-live::before \{[^}]*background: var\(--live-bg, none\)[^}]*filter: blur\(/, 'the frame, blurred, behind the card');++assert.match(css, /\.stream-card\.is-live::after \{[^}]*linear-gradient\([^}]*var\(--bg-card\)/, 'a scrim of the card colour, so words stay readable');++assert.match(css, /\.stream-card\.is-live:hover \.stream-card-thumb img, \.stream-card\.is-live\.is-centred \.stream-card-thumb img \{ transform: scale\(1\.07\); \}/, 'hovered or centred, the frame drifts closer');++assert.match(css, /\.stream-card\.is-live \.stream-card-avatar \{ box-shadow: 0 0 0 2px var\(--live-red\)/, 'the live ring on the face');++assert.match(css, /html\[data-ov-motion=off\] \.stream-card\.is-live \.stream-card-thumb img \{ transition: none; transform: none; \}/, 'Motion Off keeps it still');++console.log('live card: our-own backdrops only, NSFW without one, the pill, the ring, centred on a phone, Motion Off');
@@ -771,12 +771,32 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -771,12 +771,32 @@ body.ov-nav-overlay .navbar.ovnav-top {
border-radius: var(--radius) var(--radius) 0 0;
border-radius: var(--radius) var(--radius) 0 0;
}
}
.stream-card-live {
.stream-card-live {
- position: absolute; top: 8px; left: 8px;
+ position: absolute; top: 8px; left: 8px; z-index: 2;
+ display: inline-flex; align-items: center; gap: 6px;
background: var(--live-red); color: #fff;
background: var(--live-red); color: #fff;
- padding: 2px 8px; border-radius: var(--radius-sm);
- font-size: 0.7rem; font-weight: 700;
+ padding: 4px 9px 4px 8px; border-radius: 8px;
+ font-size: 0.7rem; font-weight: 800; letter-spacing: .06em; line-height: 1;
text-transform: uppercase;
text-transform: uppercase;
-}
+ box-shadow: 0 2px 10px color-mix(in srgb, var(--live-red) 45%, transparent);
+}
+.stream-card-live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: ov-live-beat 1.4s ease-in-out infinite; }
+@keyframes ov-live-beat { 50% { opacity: .35; transform: scale(.75); } }
+/* 286, "live looks live": a live card wears its own frame as a soft backdrop under its words (blurred, tinted by the
+ theme's card colour, so text always sits on a scrim), a red hairline and glow, the LIVE pill, and the ring on the
+ face. Hovered on a computer, or centred on a phone (app-home.js _liveCentreWatch), the frame drifts closer and the
+ backdrop wakes. NSFW cards keep no backdrop (app-home.js leaves --live-bg out). */
+.stream-card.is-live { position: relative; isolation: isolate; border-color: color-mix(in srgb, var(--live-red) 38%, var(--border)); box-shadow: var(--shadow-soft), 0 14px 36px -18px color-mix(in srgb, var(--thumb-tint, var(--live-red)) 70%, transparent); }
+.stream-card.is-live::before { content: ""; position: absolute; inset: -20%; z-index: -2; background: var(--live-bg, none) center / cover no-repeat; filter: blur(30px) saturate(1.7); opacity: .6; transition: opacity .5s ease; }
+.stream-card.is-live::after { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(180deg, color-mix(in srgb, var(--bg-card) 18%, transparent) 0%, color-mix(in srgb, var(--bg-card) 52%, transparent) 58%, color-mix(in srgb, var(--bg-card) 70%, transparent) 100%); }
+.stream-card.is-live .stream-card-info { position: relative; background: none; }
+.stream-card.is-live .stream-card-thumb img { transition: transform 7s cubic-bezier(.2, .6, .3, 1), opacity .4s; }
+.stream-card.is-live:hover .stream-card-thumb img, .stream-card.is-live.is-centred .stream-card-thumb img { transform: scale(1.07); }
+.stream-card.is-live:hover::before, .stream-card.is-live.is-centred::before { opacity: .85; }
+.stream-card.is-live:hover { border-color: color-mix(in srgb, var(--live-red) 60%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--live-red) 40%, transparent), 0 18px 44px -16px color-mix(in srgb, var(--thumb-tint, var(--live-red)) 80%, transparent); }
+.stream-card.is-live .stream-card-avatar { box-shadow: 0 0 0 2px var(--live-red), 0 0 0 4px var(--bg-card); }
+html[data-ov-motion=off] .stream-card-live::before { animation: none; }
+html[data-ov-motion=off] .stream-card.is-live .stream-card-thumb img { transition: none; transform: none; }
+body.theme-light .stream-card.is-live::before { opacity: .35; }
.stream-card-thumb .protocol-badge {
.stream-card-thumb .protocol-badge {
position: absolute; bottom: 8px; left: 8px;
position: absolute; bottom: 8px; left: 8px;
}
}
@@ -1896,10 +1896,14 @@ function streamCardHTML(s, isLive) {
@@ -1896,10 +1896,14 @@ function streamCardHTML(s, isLive) {
const topics = isLive ? _streamTopics(s) : [];
const topics = isLive ? _streamTopics(s) : [];
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const shown = topics.filter((t) => t !== cat).slice(0, 2);
const shown = topics.filter((t) => t !== cat).slice(0, 2);
+ // 286 ("when they're live … sets the background based on … their thumbnail"): a live card wears its own frame as a
+ // soft backdrop (style.css .stream-card.is-live), a LIVE pill, and the live ring on the face.
+ const liveBg = isLive && !s.is_nsfw ? _liveBg(thumb) : '';
return `
return `
- <a class="stream-card"${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
+ <a class="stream-card${isLive ? ' is-live' : ''}"${liveBg ? ` style="--live-bg:${esc(liveBg)}"` : ''}${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
+ ${isLive ? '<span class="stream-card-live">Live</span>' : ''}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
${s.is_nsfw ? '<span class="stream-card-nsfw">18+</span>' : ''}
${s.is_nsfw ? '<span class="stream-card-nsfw">18+</span>' : ''}
${duration}
${duration}
@@ -1958,6 +1962,25 @@ function renderStreamGrid(containerId, streams, isLive) {
@@ -1958,6 +1962,25 @@ function renderStreamGrid(containerId, streams, isLive) {
const empty = c.querySelector('.empty-state');
const empty = c.querySelector('.empty-state');
c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');
c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');
if (empty) { empty.style.display = 'none'; c.appendChild(empty); }
if (empty) { empty.style.display = 'none'; c.appendChild(empty); }
+ if (isLive) _liveCentreWatch(c);
+}
+
+/** A live card's backdrop: its own frame, only from our own addresses (a CSS url() never carries a stranger's). */
+function _liveBg(u) {
+ const v = String(u || '');
+ if (!/^(\/[^/]|https:\/\/([a-z0-9-]+\.)*openvibe\.(live|media|video)\/)/i.test(v) || /["'()\\\s]/.test(v)) return '';
+ return `url("${v}")`;
+}
+
+/**
+ * On a phone (no hover) the live card in the middle of the screen comes alive the way a hovered one does on a computer:
+ * its frame drifts closer and its backdrop glows (style.css .stream-card.is-live.is-centred).
+ */
+let _liveCentreIO = null;
+function _liveCentreWatch(root) {
+ if (!root || typeof IntersectionObserver === 'undefined' || !window.matchMedia || !window.matchMedia('(hover: none)').matches) return;
+ if (!_liveCentreIO) _liveCentreIO = new IntersectionObserver((entries) => entries.forEach((e) => e.target.classList.toggle('is-centred', e.isIntersecting)), { rootMargin: '-38% 0px -38% 0px' });
+ root.querySelectorAll('.stream-card.is-live').forEach((el) => _liveCentreIO.observe(el));
}
}
/* ── Real-time home updates ─────────────────────────────────── */
/* ── Real-time home updates ─────────────────────────────────── */
@@ -2022,6 +2045,8 @@ function _updateLiveCard(card, s) {
@@ -2022,6 +2045,8 @@ function _updateLiveCard(card, s) {
// Thumbnail: live cards render a placeholder icon (no <img>) until the first frame
// Thumbnail: live cards render a placeholder icon (no <img>) until the first frame
// is captured. Crossfade if an <img> exists, else swap the placeholder for one.
// is captured. Crossfade if an <img> exists, else swap the placeholder for one.
if (s.thumbnail_url) {
if (s.thumbnail_url) {
+ const bg = _liveBg(s.thumbnail_url);
+ if (bg && !s.is_nsfw) card.style.setProperty('--live-bg', bg);
const thumbBox = card.querySelector('.stream-card-thumb');
const thumbBox = card.querySelector('.stream-card-thumb');
const img = thumbBox && thumbBox.querySelector('img');
const img = thumbBox && thumbBox.querySelector('img');
if (img) {
if (img) {
@@ -2115,6 +2140,7 @@ async function refreshHomeLive() {
@@ -2115,6 +2140,7 @@ async function refreshHomeLive() {
if (el) {
if (el) {
el.classList.add('stream-card-appear');
el.classList.add('stream-card-appear');
grid.insertBefore(el, grid.children[idx] || null);
grid.insertBefore(el, grid.children[idx] || null);
+ _liveCentreWatch(el.parentNode);
}
}
}
}
});
});
@@ -25,7 +25,7 @@ ctx.currentUser = { username: 'Carol' };
@@ -25,7 +25,7 @@ ctx.currentUser = { username: 'Carol' };
assert.strictEqual(ovLabelsAttr(stream), '', 'the streamer\'s own stream is never marked for them');
assert.strictEqual(ovLabelsAttr(stream), '', 'the streamer\'s own stream is never marked for them');
const home = read('public/js/app-home.js');
const home = read('public/js/app-home.js');
-assert.ok(home.includes('<a class="stream-card"${ovLabelsAttr(s)}'), 'the home card is marked');
+assert.match(home, /<a class="stream-card[^"]*"[^>\n]*\$\{ovLabelsAttr\(s\)\}/, 'the home card is marked (a live one also wears is-live and its frame, ask 286)');
assert.ok(home.includes('style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>'), 'its picture is what a blur covers');
assert.ok(home.includes('style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>'), 'its picture is what a blur covers');
assert.ok(home.includes('${ovLabelChips(s)}'), 'its chips under the title');
assert.ok(home.includes('${ovLabelChips(s)}'), 'its chips under the title');
const player = read('public/js/stream-player.js');
const player = read('public/js/stream-player.js');
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+// Live looks live (plan ask 286, the owner: "when they're live … sets the background based on … their thumbnail …
+// modern, stand out, unique"): a live card wears its own frame as a soft backdrop, a LIVE pill and the ring; an NSFW card
+// keeps no backdrop; the backdrop is only ever our own address in a CSS url(); a phone's centred card comes alive like a
+// hovered one. The functions are public/js/app-home.js's own, run here.
+// node test/live-card.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
+const body = /function _liveBg\(u\) \{[\s\S]*?\n\}\n/.exec(src);
+assert.ok(body, 'app-home.js has _liveBg');
+const _liveBg = new Function(`${body[0]}; return _liveBg;`)();
+
+assert.strictEqual(_liveBg('/api/thumbnails/live/12.jpg?t=1'), 'url("/api/thumbnails/live/12.jpg?t=1")', 'our own path');
+assert.strictEqual(_liveBg('https://openvibe.media/t/a.jpg'), 'url("https://openvibe.media/t/a.jpg")', 'Media');
+assert.strictEqual(_liveBg('https://cdn.openvibe.video/a.jpg'), 'url("https://cdn.openvibe.video/a.jpg")', 'a subdomain of ours');
+for (const bad of ['https://evil.example/x.jpg', '//evil.example/x.jpg', 'javascript:alert(1)', '/x") ;background:red', '/a b.jpg', "/a'.jpg", 'http://openvibe.live/a.jpg', '', null]) {
+ assert.strictEqual(_liveBg(bad), '', `refused: ${JSON.stringify(bad)}`);
+}
+
+assert.match(src, /const liveBg = isLive && !s\.is_nsfw \? _liveBg\(thumb\) : '';/, 'an NSFW card keeps no backdrop');
+assert.match(src, /<a class="stream-card\$\{isLive \? ' is-live' : ''\}"\$\{liveBg \? ` style="--live-bg:\$\{esc\(liveBg\)\}"` : ''\}/, 'a live card is marked and wears its frame');
+assert.match(src, /\$\{isLive \? '<span class="stream-card-live">Live<\/span>' : ''\}/, 'the LIVE pill');
+assert.match(src, /if \(bg && !s\.is_nsfw\) card\.style\.setProperty\('--live-bg', bg\);/, 'the backdrop follows the newest frame');
+assert.match(src, /if \(isLive\) _liveCentreWatch\(c\);/, 'a phone watches which live card is centred');
+
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');
+assert.match(css, /\.stream-card\.is-live::before \{[^}]*background: var\(--live-bg, none\)[^}]*filter: blur\(/, 'the frame, blurred, behind the card');
+assert.match(css, /\.stream-card\.is-live::after \{[^}]*linear-gradient\([^}]*var\(--bg-card\)/, 'a scrim of the card colour, so words stay readable');
+assert.match(css, /\.stream-card\.is-live:hover \.stream-card-thumb img, \.stream-card\.is-live\.is-centred \.stream-card-thumb img \{ transform: scale\(1\.07\); \}/, 'hovered or centred, the frame drifts closer');
+assert.match(css, /\.stream-card\.is-live \.stream-card-avatar \{ box-shadow: 0 0 0 2px var\(--live-red\)/, 'the live ring on the face');
+assert.match(css, /html\[data-ov-motion=off\] \.stream-card\.is-live \.stream-card-thumb img \{ transition: none; transform: none; \}/, 'Motion Off keeps it still');
+console.log('live card: our-own backdrops only, NSFW without one, the pill, the ring, centred on a phone, Motion Off');