openvibers/openvibe.live Commits
Go Live for a new streamer: one card, going live from the browser first (#708)
Changes
@@@ -216,6 +216,31 @@padding: 48px 32px;text-align: center;}++/* First run: one card, the browser first (the headline: no OBS, no download, no follower minimum), apps second. */++.bc-first {++ max-width: 520px; width: 100%;++ padding: 28px 20px;++ border: 1px solid var(--border);++ border-radius: var(--radius-lg, 12px);++ background: var(--bg-secondary);++}++.bc-first-mark {++ display: inline-flex; align-items: center; justify-content: center;++ width: 52px; height: 52px; margin-bottom: 12px; border-radius: 50%;++ font-size: 1.35rem; color: var(--live-red, var(--accent));++ background: color-mix(in srgb, var(--live-red, var(--accent)) 14%, transparent);++}++.bc-first-title { margin: 0 0 8px; font-size: 1.25rem; }++.bc-first-lede { margin: 0 0 18px; color: var(--text-secondary); line-height: 1.5; }++.bc-first-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }++.bc-first-actions .btn { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }++.bc-first-go { flex: 1 1 220px; }++.bc-first-app { flex: 0 1 auto; }++.bc-first-fine { margin: 14px 0 0; font-size: 0.8rem; color: var(--text-muted); }++@media (max-width: 480px) {++ .bc-ws-empty { padding: 20px 0; }++ .bc-first-actions .btn { flex: 1 1 100%; }++}.bc-ws-panel-hd {display: flex;align-items: flex-start;@@@ -18,7 +18,7 @@<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ --><div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live"><h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</button></h2>-- <p class="bc-page-subtitle">Check the card, press Go live. Everything else is in the rows under it.</p>++ <p class="bc-page-subtitle" id="bc-page-subtitle">Check the card, press Go live. Everything else is in the rows under it.</p><!-- Guests: going live needs an account. broadcast.js shows this in place of the slotworkspace and loads nothing else (_broadcastSignInGate). -->@@@ -59,13 +59,19 @@<!-- Right main panel --><div class="bc-ws-main">-- <!-- Empty state: no stream selected -->++ <!-- First run (no streams yet): one press makes a browser stream and opens its card, where++ Go live is; OBS and other apps get the full form. broadcast-workspace.js _wsShowEmpty. --><div class="bc-ws-empty" id="bc-ws-empty">-- <i class="fa-solid fa-tower-broadcast" style="font-size:2.5rem;opacity:0.2;margin-bottom:16px"></i>-- <p class="muted" style="margin:0 0 16px">A slot is one stream of yours: its title, its link, how it goes out and where else it is sent. Make one to go live.</p>-- <button class="btn btn-outline" onclick="showCreateManagedStreamModal()">-- <i class="fa-solid fa-plus"></i> Create stream slot-- </button>++ <div class="bc-first" id="bc-first">++ <span class="bc-first-mark" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>++ <h3 class="bc-first-title">Your first stream</h3>++ <p class="bc-first-lede">Go live from this browser with your camera, your screen or both: no OBS, no download, no follower minimum.</p>++ <div class="bc-first-actions">++ <button type="button" class="btn btn-primary bc-first-go" id="bc-first-go" onclick="wsFirstBrowserStream(this)"><i class="fa-solid fa-video" aria-hidden="true"></i> Go live from this browser</button>++ <button type="button" class="btn btn-outline bc-first-app" onclick="wsFirstAppStream()"><i class="fa-solid fa-server" aria-hidden="true"></i> Use OBS or another app</button>++ </div>++ <p class="bc-first-fine">Each stream you make keeps its own title, link and key. Make more any time.</p>++ </div></div><!-- Profile editor + Go Live + History (populated by broadcast-workspace.js) --><div class="bc-ws-panel" id="bc-ws-panel" style="display:none"></div>@@@ -80,8 +80,10 @@ function _wsRenderSidebar() {const list = document.getElementById('bc-ws-list');if (!list) return;const add = '<button type="button" class="gl-slot gl-slot--add" onclick="showCreateManagedStreamModal()"><i class="fa-solid fa-plus" aria-hidden="true"></i><span>New slot</span></button>';++ // No streams yet: the first-run card below has the ways to start, so the row stays empty (two "make one" buttons++ // that did the same thing confused new streamers).if (_wsState.managedStreams.length === 0) {-- list.innerHTML = add;++ list.innerHTML = '';return;}list.innerHTML = _wsState.managedStreams.map(ms => {@@@ -182,6 +184,40 @@ function _wsShowEmpty() {const panel = document.getElementById('bc-ws-panel');if (empty) empty.style.display = '';if (panel) panel.style.display = 'none';++ // "Check the card, press Go live" points at a card that does not exist yet.++ _wsSubtitle(_wsState.managedStreams.length ? null : 'Make your first stream, then press Go live on its card.');++}++++/** The page's line under Go Live: `text`, or (null) the line for when a stream's card is shown. */++function _wsSubtitle(text) {++ const sub = document.getElementById('bc-page-subtitle');++ if (!sub) return;++ if (sub.dataset.withCard === undefined) sub.dataset.withCard = sub.textContent;++ sub.textContent = text || sub.dataset.withCard;++}++++/** First run: make a browser stream in one press and open its card, where Go live is. */++async function wsFirstBrowserStream(btn) {++ if (!currentUser) { if (typeof _broadcastSignInGate === 'function') _broadcastSignInGate(false); return; }++ if (btn) btn.disabled = true;++ try {++ const name = String(currentUser.display_name || currentUser.username || '').trim();++ const data = await api('/streams/managed', {++ method: 'POST',++ body: { title: name ? `${name}'s stream`.slice(0, 140) : 'My stream', category: '', protocol: 'webrtc', streaming_method: 'browser' },++ });++ if (data && data.managed_stream) await onManagedStreamCreated(data.managed_stream.id);++ } catch (err) {++ toast('Your stream could not be made: ' + (err?.message || 'the server did not answer'), 'error');++ } finally {++ if (btn) btn.disabled = false;++ }++}++++/** First run with OBS, an encoder or FFmpeg: the full form, with RTMP picked. */++function wsFirstAppStream() {++ showCreateManagedStreamModal();++ if (typeof _cmsSelectMethod === 'function') _cmsSelectMethod('rtmp');}/** A Tours link may fill the studio. The streamer still presses Go live and can edit the title. */@@@ -204,6 +240,7 @@ function _wsRenderPanel() {if (!ms) { _wsShowEmpty(); return; }if (empty) empty.style.display = 'none';++ _wsSubtitle(null);panel.style.display = '';const p = _wsState.profile;nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++/**++ * Go Live with no streams yet (a new streamer's first visit). It used to say "Check the card, press Go live" with no++ * card on the page, offered two buttons that did the same thing ("New slot" and "Create stream slot") and explained the++ * word "slot" instead of going live (signed-in sweep, 2026-11-09). Now one card leads with going live from the browser++ * (one press makes a browser stream and opens its card), OBS and other apps get the full form with RTMP picked, the++ * slot row stays empty, and the line under Go Live says what to do first.++ *++ * node test/golive-first-run.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const root = path.join(__dirname, '..');++const html = fs.readFileSync(path.join(root, 'public/fragments/broadcast.html'), 'utf8');++const ws = fs.readFileSync(path.join(root, 'public/js/broadcast-workspace.js'), 'utf8');++const css = fs.readFileSync(path.join(root, 'public/css/features/broadcast.css'), 'utf8');++++// The card: going live from the browser first, apps second; no "slot" lecture, no second create button.++const empty = html.slice(html.indexOf('id="bc-ws-empty"'), html.indexOf('id="bc-ws-panel"'));++assert.ok(empty.includes('Your first stream'), 'the first-run card names what it is');++assert.ok(/onclick="wsFirstBrowserStream\(this\)"[^>]*>[\s\S]*?Go live from this browser/.test(empty), 'the browser is the main action');++assert.ok(/onclick="wsFirstAppStream\(\)"[^>]*>[\s\S]*?Use OBS or another app/.test(empty), 'apps are the second action');++assert.ok(empty.indexOf('wsFirstBrowserStream') < empty.indexOf('wsFirstAppStream'), 'the browser comes first');++assert.ok(!/Create stream slot|A slot is one stream/.test(empty), 'no second create button and no slot lecture');++assert.ok(/no OBS, no download, no follower minimum/.test(empty), 'the headline promise is on the card');++assert.ok(html.includes('id="bc-page-subtitle"'), 'the line under Go Live can change with the state');++++// One press: a browser stream (WebRTC), then its card opens (onManagedStreamCreated selects it).++const first = ws.slice(ws.indexOf('async function wsFirstBrowserStream'), ws.indexOf('function wsFirstAppStream'));++assert.ok(/streaming_method: 'browser'/.test(first) && /protocol: 'webrtc'/.test(first), 'a browser stream is made');++assert.ok(/onManagedStreamCreated\(data\.managed_stream\.id\)/.test(first), 'and its card opens');++assert.ok(/if \(!currentUser\)/.test(first), 'a guest gets the sign-in gate, not a 401');++assert.ok(/btn\.disabled = true/.test(first) && /btn\.disabled = false/.test(first), 'no double press makes two streams');++const app = ws.slice(ws.indexOf('function wsFirstAppStream'), ws.indexOf('function wsFirstAppStream') + 300);++assert.ok(/showCreateManagedStreamModal\(\)/.test(app) && /_cmsSelectMethod\('rtmp'\)/.test(app), 'apps open the form with RTMP picked');++++// No streams: the slot row is empty (the card has the ways to start); with streams, New slot is at the end of the row.++const side = ws.slice(ws.indexOf('function _wsRenderSidebar'), ws.indexOf('function _wsRenderSidebar') + 1800);++assert.ok(/managedStreams\.length === 0\) \{\s*list\.innerHTML = '';/.test(side), 'no New slot pill beside the first-run card');++assert.ok(/\}\)\.join\(''\) \+ add;/.test(side), 'New slot still ends the row once streams exist');++++// The subtitle: what to do first while there is no card, the card line back once a card shows.++const showEmpty = ws.slice(ws.indexOf('function _wsShowEmpty'), ws.indexOf('function _wsSubtitle'));++assert.ok(/Make your first stream, then press Go live on its card\./.test(showEmpty), 'the first-run line');++const renderPanel = ws.slice(ws.indexOf('function _wsRenderPanel'), ws.indexOf('function _wsRenderPanel') + 600);++assert.ok(/_wsSubtitle\(null\)/.test(renderPanel), 'a shown card restores the card line');++++// Phone: both buttons full width and 44 px tall.++assert.ok(/\.bc-first-actions \.btn \{ min-height: 44px;/.test(css), '44 px buttons');++assert.ok(/@media \(max-width: 480px\) \{[^}]*\.bc-ws-empty[^}]*\}[^}]*\.bc-first-actions \.btn \{ flex: 1 1 100%; \}/.test(css), 'full width on a phone');++++console.log('go live first run: one card, the browser first, apps second, no duplicate create button');
@@ -216,6 +216,31 @@
@@ -216,6 +216,31 @@
padding: 48px 32px;
padding: 48px 32px;
text-align: center;
text-align: center;
}
}
+/* First run: one card, the browser first (the headline: no OBS, no download, no follower minimum), apps second. */
+.bc-first {
+ max-width: 520px; width: 100%;
+ padding: 28px 20px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg, 12px);
+ background: var(--bg-secondary);
+}
+.bc-first-mark {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 52px; height: 52px; margin-bottom: 12px; border-radius: 50%;
+ font-size: 1.35rem; color: var(--live-red, var(--accent));
+ background: color-mix(in srgb, var(--live-red, var(--accent)) 14%, transparent);
+}
+.bc-first-title { margin: 0 0 8px; font-size: 1.25rem; }
+.bc-first-lede { margin: 0 0 18px; color: var(--text-secondary); line-height: 1.5; }
+.bc-first-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
+.bc-first-actions .btn { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
+.bc-first-go { flex: 1 1 220px; }
+.bc-first-app { flex: 0 1 auto; }
+.bc-first-fine { margin: 14px 0 0; font-size: 0.8rem; color: var(--text-muted); }
+@media (max-width: 480px) {
+ .bc-ws-empty { padding: 20px 0; }
+ .bc-first-actions .btn { flex: 1 1 100%; }
+}
.bc-ws-panel-hd {
.bc-ws-panel-hd {
display: flex;
display: flex;
align-items: flex-start;
align-items: flex-start;
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ -->
<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ -->
<div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live">
<div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live">
<h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</button></h2>
<h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</button></h2>
- <p class="bc-page-subtitle">Check the card, press Go live. Everything else is in the rows under it.</p>
+ <p class="bc-page-subtitle" id="bc-page-subtitle">Check the card, press Go live. Everything else is in the rows under it.</p>
<!-- Guests: going live needs an account. broadcast.js shows this in place of the slot
<!-- Guests: going live needs an account. broadcast.js shows this in place of the slot
workspace and loads nothing else (_broadcastSignInGate). -->
workspace and loads nothing else (_broadcastSignInGate). -->
@@ -59,13 +59,19 @@
@@ -59,13 +59,19 @@
<!-- Right main panel -->
<!-- Right main panel -->
<div class="bc-ws-main">
<div class="bc-ws-main">
- <!-- Empty state: no stream selected -->
+ <!-- First run (no streams yet): one press makes a browser stream and opens its card, where
+ Go live is; OBS and other apps get the full form. broadcast-workspace.js _wsShowEmpty. -->
<div class="bc-ws-empty" id="bc-ws-empty">
<div class="bc-ws-empty" id="bc-ws-empty">
- <i class="fa-solid fa-tower-broadcast" style="font-size:2.5rem;opacity:0.2;margin-bottom:16px"></i>
- <p class="muted" style="margin:0 0 16px">A slot is one stream of yours: its title, its link, how it goes out and where else it is sent. Make one to go live.</p>
- <button class="btn btn-outline" onclick="showCreateManagedStreamModal()">
- <i class="fa-solid fa-plus"></i> Create stream slot
- </button>
+ <div class="bc-first" id="bc-first">
+ <span class="bc-first-mark" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
+ <h3 class="bc-first-title">Your first stream</h3>
+ <p class="bc-first-lede">Go live from this browser with your camera, your screen or both: no OBS, no download, no follower minimum.</p>
+ <div class="bc-first-actions">
+ <button type="button" class="btn btn-primary bc-first-go" id="bc-first-go" onclick="wsFirstBrowserStream(this)"><i class="fa-solid fa-video" aria-hidden="true"></i> Go live from this browser</button>
+ <button type="button" class="btn btn-outline bc-first-app" onclick="wsFirstAppStream()"><i class="fa-solid fa-server" aria-hidden="true"></i> Use OBS or another app</button>
+ </div>
+ <p class="bc-first-fine">Each stream you make keeps its own title, link and key. Make more any time.</p>
+ </div>
</div>
</div>
<!-- Profile editor + Go Live + History (populated by broadcast-workspace.js) -->
<!-- Profile editor + Go Live + History (populated by broadcast-workspace.js) -->
<div class="bc-ws-panel" id="bc-ws-panel" style="display:none"></div>
<div class="bc-ws-panel" id="bc-ws-panel" style="display:none"></div>
@@ -80,8 +80,10 @@ function _wsRenderSidebar() {
@@ -80,8 +80,10 @@ function _wsRenderSidebar() {
const list = document.getElementById('bc-ws-list');
const list = document.getElementById('bc-ws-list');
if (!list) return;
if (!list) return;
const add = '<button type="button" class="gl-slot gl-slot--add" onclick="showCreateManagedStreamModal()"><i class="fa-solid fa-plus" aria-hidden="true"></i><span>New slot</span></button>';
const add = '<button type="button" class="gl-slot gl-slot--add" onclick="showCreateManagedStreamModal()"><i class="fa-solid fa-plus" aria-hidden="true"></i><span>New slot</span></button>';
+ // No streams yet: the first-run card below has the ways to start, so the row stays empty (two "make one" buttons
+ // that did the same thing confused new streamers).
if (_wsState.managedStreams.length === 0) {
if (_wsState.managedStreams.length === 0) {
- list.innerHTML = add;
+ list.innerHTML = '';
return;
return;
}
}
list.innerHTML = _wsState.managedStreams.map(ms => {
list.innerHTML = _wsState.managedStreams.map(ms => {
@@ -182,6 +184,40 @@ function _wsShowEmpty() {
@@ -182,6 +184,40 @@ function _wsShowEmpty() {
const panel = document.getElementById('bc-ws-panel');
const panel = document.getElementById('bc-ws-panel');
if (empty) empty.style.display = '';
if (empty) empty.style.display = '';
if (panel) panel.style.display = 'none';
if (panel) panel.style.display = 'none';
+ // "Check the card, press Go live" points at a card that does not exist yet.
+ _wsSubtitle(_wsState.managedStreams.length ? null : 'Make your first stream, then press Go live on its card.');
+}
+
+/** The page's line under Go Live: `text`, or (null) the line for when a stream's card is shown. */
+function _wsSubtitle(text) {
+ const sub = document.getElementById('bc-page-subtitle');
+ if (!sub) return;
+ if (sub.dataset.withCard === undefined) sub.dataset.withCard = sub.textContent;
+ sub.textContent = text || sub.dataset.withCard;
+}
+
+/** First run: make a browser stream in one press and open its card, where Go live is. */
+async function wsFirstBrowserStream(btn) {
+ if (!currentUser) { if (typeof _broadcastSignInGate === 'function') _broadcastSignInGate(false); return; }
+ if (btn) btn.disabled = true;
+ try {
+ const name = String(currentUser.display_name || currentUser.username || '').trim();
+ const data = await api('/streams/managed', {
+ method: 'POST',
+ body: { title: name ? `${name}'s stream`.slice(0, 140) : 'My stream', category: '', protocol: 'webrtc', streaming_method: 'browser' },
+ });
+ if (data && data.managed_stream) await onManagedStreamCreated(data.managed_stream.id);
+ } catch (err) {
+ toast('Your stream could not be made: ' + (err?.message || 'the server did not answer'), 'error');
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+}
+
+/** First run with OBS, an encoder or FFmpeg: the full form, with RTMP picked. */
+function wsFirstAppStream() {
+ showCreateManagedStreamModal();
+ if (typeof _cmsSelectMethod === 'function') _cmsSelectMethod('rtmp');
}
}
/** A Tours link may fill the studio. The streamer still presses Go live and can edit the title. */
/** A Tours link may fill the studio. The streamer still presses Go live and can edit the title. */
@@ -204,6 +240,7 @@ function _wsRenderPanel() {
@@ -204,6 +240,7 @@ function _wsRenderPanel() {
if (!ms) { _wsShowEmpty(); return; }
if (!ms) { _wsShowEmpty(); return; }
if (empty) empty.style.display = 'none';
if (empty) empty.style.display = 'none';
+ _wsSubtitle(null);
panel.style.display = '';
panel.style.display = '';
const p = _wsState.profile;
const p = _wsState.profile;
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+/**
+ * Go Live with no streams yet (a new streamer's first visit). It used to say "Check the card, press Go live" with no
+ * card on the page, offered two buttons that did the same thing ("New slot" and "Create stream slot") and explained the
+ * word "slot" instead of going live (signed-in sweep, 2026-11-09). Now one card leads with going live from the browser
+ * (one press makes a browser stream and opens its card), OBS and other apps get the full form with RTMP picked, the
+ * slot row stays empty, and the line under Go Live says what to do first.
+ *
+ * node test/golive-first-run.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const html = fs.readFileSync(path.join(root, 'public/fragments/broadcast.html'), 'utf8');
+const ws = fs.readFileSync(path.join(root, 'public/js/broadcast-workspace.js'), 'utf8');
+const css = fs.readFileSync(path.join(root, 'public/css/features/broadcast.css'), 'utf8');
+
+// The card: going live from the browser first, apps second; no "slot" lecture, no second create button.
+const empty = html.slice(html.indexOf('id="bc-ws-empty"'), html.indexOf('id="bc-ws-panel"'));
+assert.ok(empty.includes('Your first stream'), 'the first-run card names what it is');
+assert.ok(/onclick="wsFirstBrowserStream\(this\)"[^>]*>[\s\S]*?Go live from this browser/.test(empty), 'the browser is the main action');
+assert.ok(/onclick="wsFirstAppStream\(\)"[^>]*>[\s\S]*?Use OBS or another app/.test(empty), 'apps are the second action');
+assert.ok(empty.indexOf('wsFirstBrowserStream') < empty.indexOf('wsFirstAppStream'), 'the browser comes first');
+assert.ok(!/Create stream slot|A slot is one stream/.test(empty), 'no second create button and no slot lecture');
+assert.ok(/no OBS, no download, no follower minimum/.test(empty), 'the headline promise is on the card');
+assert.ok(html.includes('id="bc-page-subtitle"'), 'the line under Go Live can change with the state');
+
+// One press: a browser stream (WebRTC), then its card opens (onManagedStreamCreated selects it).
+const first = ws.slice(ws.indexOf('async function wsFirstBrowserStream'), ws.indexOf('function wsFirstAppStream'));
+assert.ok(/streaming_method: 'browser'/.test(first) && /protocol: 'webrtc'/.test(first), 'a browser stream is made');
+assert.ok(/onManagedStreamCreated\(data\.managed_stream\.id\)/.test(first), 'and its card opens');
+assert.ok(/if \(!currentUser\)/.test(first), 'a guest gets the sign-in gate, not a 401');
+assert.ok(/btn\.disabled = true/.test(first) && /btn\.disabled = false/.test(first), 'no double press makes two streams');
+const app = ws.slice(ws.indexOf('function wsFirstAppStream'), ws.indexOf('function wsFirstAppStream') + 300);
+assert.ok(/showCreateManagedStreamModal\(\)/.test(app) && /_cmsSelectMethod\('rtmp'\)/.test(app), 'apps open the form with RTMP picked');
+
+// No streams: the slot row is empty (the card has the ways to start); with streams, New slot is at the end of the row.
+const side = ws.slice(ws.indexOf('function _wsRenderSidebar'), ws.indexOf('function _wsRenderSidebar') + 1800);
+assert.ok(/managedStreams\.length === 0\) \{\s*list\.innerHTML = '';/.test(side), 'no New slot pill beside the first-run card');
+assert.ok(/\}\)\.join\(''\) \+ add;/.test(side), 'New slot still ends the row once streams exist');
+
+// The subtitle: what to do first while there is no card, the card line back once a card shows.
+const showEmpty = ws.slice(ws.indexOf('function _wsShowEmpty'), ws.indexOf('function _wsSubtitle'));
+assert.ok(/Make your first stream, then press Go live on its card\./.test(showEmpty), 'the first-run line');
+const renderPanel = ws.slice(ws.indexOf('function _wsRenderPanel'), ws.indexOf('function _wsRenderPanel') + 600);
+assert.ok(/_wsSubtitle\(null\)/.test(renderPanel), 'a shown card restores the card line');
+
+// Phone: both buttons full width and 44 px tall.
+assert.ok(/\.bc-first-actions \.btn \{ min-height: 44px;/.test(css), '44 px buttons');
+assert.ok(/@media \(max-width: 480px\) \{[^}]*\.bc-ws-empty[^}]*\}[^}]*\.bc-first-actions \.btn \{ flex: 1 1 100%; \}/.test(css), 'full width on a phone');
+
+console.log('go live first run: one card, the browser first, apps second, no duplicate create button');