openvibers/openvibe.live Commits

Go Live: New slot stays in view, the card's facts are a grid, and the docked button travels (#759)

OP OpenVibers committed 3 days ago · 44f2688d2f · 6 files, +132 −44

The slot strip. New slot sat after the last slot, off the screen on a phone, and nothing said it was there. It is
pinned to the strip's end while the slots scroll under it (the plus alone where the strip is narrow).

The head. Streamer setup is a quiet pill at the row's end instead of a boxed button pressed against the page's name,
and the line under it is balanced, so no word is left alone on a last line.

The card. Suggest sits against the title (the title is as wide as its words) and wears the network's glyph instead
of an emoji. The facts are a grid of tiles, a name over its value, in place of pills of five widths: the address has
a row and copies on a tap, Mature and Chat share one, where the stream is sent too has the last. Labels leaves the
card: its row under the card has it.

The dock. The bar at the bottom of a phone appeared and vanished as the real button left and entered the screen. It
rises into place, and when the real button comes into view the bar's own button travels to it and hands over. Once
the footer is on screen the bar rides above it, so it never lies over the page's last lines. With Motion off it is
simply there or not.

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 8927da80..aacf7118 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2447,9 +2447,18 @@
.broadcast-main::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--accent) 38%, transparent); border-radius: 8px; }
.broadcast-main::-webkit-scrollbar-track { background: transparent; }
#bc-stream-manager { max-width: 1480px; padding: 4px clamp(0px, 1vw, 12px) 28px; flex: none; min-height: auto; overflow: visible; }
--#bc-stream-manager > h2 { margin: 0 0 2px; font-size: 1.22rem; flex-wrap: wrap; row-gap: 6px; }
--#bc-stream-manager > h2 .glw-open-btn { margin-left: 6px; }
--#bc-stream-manager > .bc-page-subtitle { margin: 0 0 10px; font-size: 0.86rem; }
++/* The head: the page's name, and Streamer setup as a quiet pill at the row's end (it was a boxed button pressed
++ against the name). The line under it never leaves one word on a line of its own. */
++#bc-stream-manager > h2 { margin: 0 0 4px; font-size: 1.22rem; gap: 8px; min-width: 0; }
++#bc-stream-manager > h2 .gl-head-t { flex: 0 1 auto; min-width: 0; }
++#bc-stream-manager > h2 .glw-open-btn { all: unset; box-sizing: border-box; flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 13px; border-radius: 999px; cursor: pointer;
++ font-size: 0.82rem; font-weight: 700; letter-spacing: 0; color: var(--text-secondary); border: 1px solid var(--border); background: color-mix(in srgb, var(--text-primary) 5%, transparent);
++ transition: color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }
++#bc-stream-manager > h2 .glw-open-btn i { font-size: 0.82rem; color: var(--accent); }
++#bc-stream-manager > h2 .glw-open-btn:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
++#bc-stream-manager > h2 .glw-open-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++@media (pointer: coarse) { #bc-stream-manager > h2 .glw-open-btn { min-height: 44px; } }
++#bc-stream-manager > .bc-page-subtitle { margin: 0 0 12px; font-size: 0.86rem; text-wrap: balance; }
.bc-workspace { flex: none; border: 0; border-radius: 0; background: transparent; overflow: visible; gap: 14px; align-items: flex-start; margin-top: 6px; }
.bc-ws-sidebar, .bc-ws-main {
border: 1px solid var(--border); border-radius: 14px;
@@@ -2836,7 +2845,15 @@ body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !importan
background: linear-gradient(to bottom, transparent, var(--bg-primary, #0b0b10) 34%);
}
.bcs-golive[hidden] { display: none; }
--.bcs-golive { bottom: max(var(--ovnav-appbar-h, 0px), var(--gl-keyboard, 0px), env(keyboard-inset-height, 0px)); }
++/* It rides above the footer once the footer is on screen (--bcs-lift, broadcast-studio.js), so it never lies over the
++ page's last lines; it rises into place; and when the real button comes into view its own button travels to it and
++ hands over, instead of appearing and vanishing. */
++.bcs-golive { bottom: calc(max(var(--ovnav-appbar-h, 0px), var(--gl-keyboard, 0px), env(keyboard-inset-height, 0px)) + var(--bcs-lift, 0px));
++ transition: opacity .22s ease, transform .28s cubic-bezier(.2, .8, .2, 1); }
++.bcs-golive.is-entering { opacity: 0; transform: translateY(calc(70% * var(--ov-move, 1))); }
++.bcs-golive.is-leaving { opacity: 0; pointer-events: none; }
++.bcs-golive .bcs-golive-btn { transition: transform .26s cubic-bezier(.2, .8, .2, 1), background-color var(--ov-dur-2, .15s); }
++.bcs-golive .bcs-golive-btn:active { transform: scale(.985); }
.bcs-golive[data-mode="live"] .bcs-golive-btn { background: #be123c; color: #fff; }
.bcs-golive[data-mode="connecting"] .bcs-golive-btn { background: var(--bg-card); color: var(--text-primary); }
.bcs-golive[data-mode="live"], .bcs-golive[data-mode="connecting"] { animation: unmute-pop var(--ov-dur-3, .3s) var(--ov-ease-out, ease-out) both; }
@@@ -3215,9 +3232,17 @@ html[data-ov-motion=off] .bcs-gl-burst{display: none}
.gl-slot.is-on i { color: var(--accent); }
.gl-slot-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gl-slot-live { flex: none; padding: 2px 7px; border-radius: 999px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: var(--live-red, #e11d48); color: #fff; }
--.gl-slot--add { color: var(--accent); border-style: dashed; background: transparent; }
--.gl-slot--add i { color: var(--accent); }
++/* New slot stays at the strip's end while the slots scroll under it: it was past the last slot, off the screen, and
++ nothing said it was there. Where the strip is narrow it is the plus alone. */
++.gl-slot--add { position: sticky; right: 0; z-index: 1; margin-left: auto; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
++ background: color-mix(in srgb, var(--accent) 14%, var(--bg-primary, #0b0b10)); box-shadow: -16px 0 14px -6px var(--bg-primary, #0b0b10); }
++.gl-slot--add i { color: var(--accent); transition: transform var(--ov-dur-3, .25s) var(--ov-ease-out, ease-out); }
++.gl-slot--add:hover i, .gl-slot--add:active i { transform: rotate(90deg); }
@media (pointer: coarse) { .gl-slot { min-height: 44px; } }
++@media (max-width: 640px) {
++ .gl-slot--add { width: 44px; min-width: 44px; padding: 0; justify-content: center; }
++ .gl-slot--add span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++}
.bc-workspace > .bc-ws-main { border: 0; border-radius: 0; background: none; box-shadow: none; }
.gl { display: grid; gap: 12px; align-items: start; min-width: 0; }
@@@ -3242,21 +3267,26 @@ html[data-ov-motion=off] .bcs-gl-burst{display: none}
.gl-title-row .bc-ws-save-status.bc-ws-save-error { flex: 0 1 50%; white-space: normal; color: var(--danger-text, var(--danger)); }
.gl-retry { font: inherit; font-weight: 700; color: var(--accent); background: none; border: 0; padding: 4px 2px; cursor: pointer; text-decoration: underline; }
--.gl-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 14px; min-width: 0; }
--.gl-chip { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; max-width: 100%; padding: 0 12px;
-- border-radius: 999px; cursor: pointer; font-size: 0.82rem; font-weight: 600; color: var(--text-secondary);
-- background: color-mix(in srgb, var(--text-primary) 5%, transparent); border: 1px solid var(--border);
-- transition: border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }
--.gl-chip i { flex: none; font-size: 0.8rem; color: var(--text-muted); }
--.gl-chip b { flex: none; font-weight: 750; color: var(--text-primary); }
--.gl-chip span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.gl-chip:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
++/* The card's facts: a grid of tiles, each a name over its value, in place of a pile of pills of five widths. The
++ address has the row to itself and copies on a tap; Mature and Chat share a row; where this stream is sent too has
++ the last (the card is one column of a wide screen, so the same grid serves both). */
++.gl-chips { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 14px 0; min-width: 0; }
++.gl-chip { all: unset; box-sizing: border-box; position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: auto auto; column-gap: 10px; align-items: center; min-height: 52px; min-width: 0; padding: 7px 12px;
++ border-radius: 14px; cursor: pointer; color: var(--text-secondary);
++ background: color-mix(in srgb, var(--text-primary) 4%, transparent); border: 1px solid var(--border);
++ transition: border-color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s), transform var(--ov-dur-1, .1s); }
++.gl-chip-i { grid-row: 1 / 3; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; font-size: 0.86rem; line-height: 1; color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
++.gl-chip-k { grid-column: 2; grid-row: 1; font-size: 0.72rem; font-weight: 650; line-height: 1.2; color: var(--text-muted); }
++.gl-chip-v { grid-column: 2; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; font-weight: 750; line-height: 1.25; color: var(--text-primary); }
++.gl-chip-end { grid-column: 3; grid-row: 1 / 3; font-size: 0.82rem; color: var(--text-muted); }
++.gl-chip--link, .gl-chip--wide { grid-column: 1 / -1; }
++.gl-chip:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
++.gl-chip:active { transform: scale(.985); }
.gl-chip:focus-visible, .gl-chip:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
.gl-chip--mature input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
--.gl-chip--mature:has(input:checked) { color: var(--danger-text, var(--danger)); border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); background: color-mix(in srgb, var(--danger) 9%, transparent); }
--.gl-chip--mature:has(input:checked) i, .gl-chip--mature:has(input:checked) b { color: var(--danger-text, var(--danger)); }
--.gl-chip--link { flex: 0 1 auto; min-width: 0; }
--.gl-chip--link i { color: var(--accent); }
++.gl-chip--mature:has(input:checked) { border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); background: color-mix(in srgb, var(--danger) 9%, transparent); }
++.gl-chip--mature:has(input:checked) .gl-chip-i { color: var(--danger-text, var(--danger)); background: color-mix(in srgb, var(--danger) 14%, transparent); }
++.gl-chip--mature:has(input:checked) .gl-chip-v { color: var(--danger-text, var(--danger)); }
@media (pointer: coarse) { .gl-chip { min-height: 44px; } }
/* The go-live block sits in the card without a frame of its own; the room for the phone's chat bubble moves to the end. */
@@@ -3304,11 +3334,20 @@ html[data-ov-motion=off] .bcs-gl-burst{display: none}
.gl-fold.bc-ws-quality > summary > i:first-child { width: 18px; }
.gl-fold .bc-sum-chev { flex: none; font-size: 0.75rem; }
--/* ✨ Suggest beside the title, and its three ideas under it: a tap puts one in the title. */
--.gl-suggest { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; padding: 0 12px; border-radius: 999px; cursor: pointer;
++/* Suggest sits against the title it writes for (the title is as wide as its words, so the button follows the last
++ of them), with the network's own glyph; its three ideas come under it and a tap puts one in the title. The glyph
++ turns a little under the pointer; while the ideas are being written the wait is the mark's own (OVMark.busy). */
++.gl-title-row { flex-wrap: wrap; row-gap: 6px; }
++.gl-title-row .gl-title { flex: 0 1 auto; width: auto; max-width: 100%; field-sizing: content; min-width: 6ch; }
++.gl-title-row .bc-ws-save-status { margin-left: auto; }
++.gl-suggest { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px 0 10px; border-radius: 999px; cursor: pointer;
font-size: 0.82rem; font-weight: 700; color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
-- background: color-mix(in srgb, var(--accent) 8%, transparent); transition: background-color var(--ov-dur-2, .15s); }
++ background: color-mix(in srgb, var(--accent) 8%, transparent); transition: background-color var(--ov-dur-2, .15s), transform var(--ov-dur-1, .1s); }
++.gl-suggest i { font-size: 0.86rem; transition: transform var(--ov-dur-3, .25s) var(--ov-ease-out, ease-out); }
.gl-suggest:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
++.gl-suggest:hover i { transform: rotate(-14deg) scale(1.12); }
++.gl-suggest:active { transform: scale(.96); }
++
.gl-suggest:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (pointer: coarse) { .gl-suggest { min-height: 44px; } }
.gl-ideas { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 08f3d4ed..7153dbd4 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -17,8 +17,8 @@
<!-- ═══ 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" id="bc-page-subtitle">Check the card, press Go live. Everything else is in the rows under it.</p>
++ <h2><i class="fa-solid fa-tower-broadcast"></i> <span class="gl-head-t">Go Live</span> <button type="button" class="glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check" aria-hidden="true"></i><span>Streamer setup</span></button></h2>
++ <p class="bc-page-subtitle" id="bc-page-subtitle">Check the card and 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
workspace and loads nothing else (_broadcastSignInGate). -->
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex cb032a23..61f24b34 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -837,6 +837,40 @@
// a moment on every load, and while a stream of theirs was already live (owner, 2026-10-27). With one live and this
// page not holding it, the bar resumes it instead of starting another.
function inView(n) { if (!n || !n.offsetParent) return false; const r = n.getBoundingClientRect(); return r.top < window.innerHeight - 8 && r.bottom > 0; }
++ // The bar does not appear and vanish: it rises into place, and when the real button comes into view the bar's
++ // button travels to it and hands over (the owner, 2026-11-10: "the button flashes in. It should … go back to
++ // where you want it on screen rather than flash"). With Motion off it is simply there or not.
++ function showDock(dock, on, real) {
++ if ((dock.dataset.on === '1') === on) return;
++ dock.dataset.on = on ? '1' : '0';
++ clearTimeout(dock._leave);
++ const btn = dock.querySelector('.bcs-golive-btn');
++ const still = document.documentElement.getAttribute('data-ov-motion') === 'off';
++ btn.style.transform = '';
++ if (on) {
++ dock.hidden = false;
++ dock.classList.remove('is-leaving');
++ if (still) return;
++ dock.classList.add('is-entering');
++ requestAnimationFrame(() => requestAnimationFrame(() => dock.classList.remove('is-entering')));
++ return;
++ }
++ if (still) { dock.hidden = true; return; }
++ if (real && inView(real)) {
++ const from = btn.getBoundingClientRect(), to = real.getBoundingClientRect();
++ if (from.width && from.height) btn.style.transform = `translate(${(to.left + to.width / 2) - (from.left + from.width / 2)}px, ${(to.top + to.height / 2) - (from.top + from.height / 2)}px) scale(${to.width / from.width}, ${to.height / from.height})`;
++ }
++ dock.classList.add('is-leaving');
++ dock._leave = setTimeout(() => { dock.hidden = true; dock.classList.remove('is-leaving'); btn.style.transform = ''; }, 280);
++ }
++ // The bar rides above the footer once the footer is on screen: it never lies over the page's last lines.
++ function liftDockOverFooter() {
++ const dock = $('bcs-golive'), foot = document.getElementById('ov-footer');
++ if (!dock || dock.hidden || !foot) return;
++ const top = foot.getBoundingClientRect().top;
++ const edge = window.innerHeight - (parseFloat(getComputedStyle(document.body).getPropertyValue('--ovnav-appbar-h')) || 0);
++ dock.style.setProperty('--bcs-lift', `${Math.max(0, Math.round(edge - top))}px`);
++ }
function syncGoLiveDock() {
const ready = NARROW.matches && onBroadcastPage() && !!window._bcMyLoaded;
const liveMine = ready ? (window._bcMyLive || []) : [];
@@@ -864,8 +898,8 @@
document.body.append(dock);
}
if (dock) {
-- dock.hidden = !mode;
-- dock.dataset.mode = mode || '';
++ showDock(dock, !!mode, real || goLiveButton());
++ if (mode) dock.dataset.mode = mode; // while it leaves it keeps the words it had
if (mode === 'live') {
st.startingSince = 0;
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-stop';
@@@ -900,6 +934,7 @@
}
}
document.body.classList.toggle('bc-golive-dock-on', !!mode);
++ liftDockOverFooter();
}
document.addEventListener('scroll', () => { if ($('bcs-golive') || goLiveButton()) syncGoLiveDock(); }, { capture: true, passive: true });
if (window.visualViewport) window.visualViewport.addEventListener('resize', () => {
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex a1aa1232..74e06908 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -79,7 +79,7 @@ async function _wsLoadManagedStreams() {
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>';
++ const add = '<button type="button" class="gl-slot gl-slot--add" onclick="showCreateManagedStreamModal()" title="New slot"><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) {
@@@ -348,29 +348,26 @@ function _wsRenderPanel() {
value="${esc(placeHandoff?.title || ms.title || '')}"
placeholder="What are you streaming?" maxlength="200"
oninput="_wsMarkDirty()">
-- <span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
<button type="button" class="gl-suggest" id="gl-suggest" onclick="_glSuggestTitles(this)" title="Three title ideas from your last streams on this slot">
-- <span aria-hidden="true">\u2728</span> Suggest
++ <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>
</button>
++ <span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
</div>
${placeHandoff ? `<p class="gl-place-handoff">From <a href="${esc(placeHandoff.url)}" rel="noopener">${esc(placeHandoff.name)} on OpenVibe.Tours</a>. Your stream will be linked to this place after you choose Go live.</p><input type="hidden" id="bc-place-name" value="${esc(placeHandoff.name)}"><input type="hidden" id="bc-place-url" value="${esc(placeHandoff.url)}">` : ''}
<div class="gl-ideas" id="gl-ideas" role="group" aria-label="Title ideas" hidden></div>
-- <div class="gl-chips" data-ovk-scroll>
++ <div class="gl-chips">
++ <button type="button" class="gl-chip gl-chip--link" onclick="_glCopyLink()" title="Copy your stream's address">
++ <span class="gl-chip-i" aria-hidden="true"><i class="fa-solid fa-link"></i></span><span class="gl-chip-k">Address</span><span class="gl-chip-v" id="gl-link">${esc(link)}</span><i class="fa-regular fa-copy gl-chip-end" aria-hidden="true"></i>
++ </button>
<label class="gl-chip gl-chip--mature" title="Mature (18+): viewers confirm their age before the stream plays. The category is set automatically from what is on stream.">
<input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()">
-- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i><span>Mature</span><b id="gl-mature-v">${ms.is_nsfw ? 'on' : 'off'}</b>
++ <span class="gl-chip-i" aria-hidden="true"><i class="fa-solid fa-triangle-exclamation"></i></span><span class="gl-chip-k">Mature</span><b class="gl-chip-v" id="gl-mature-v">${ms.is_nsfw ? 'on' : 'off'}</b>
</label>
-- <button type="button" class="gl-chip" onclick="_glOpenFold('gl-labels')" title="What this stream carries: each viewer decides to show, blur or hide it">
-- <i class="fa-solid fa-tag" aria-hidden="true"></i><span>Labels</span><b id="gl-labels-v">${esc(_glLabelsSummary(ms, true))}</b>
-- </button>
-- <button type="button" class="gl-chip gl-chip--link" onclick="_glCopyLink()" title="Copy your stream's address">
-- <i class="fa-solid fa-link" aria-hidden="true"></i><span id="gl-link">${esc(link)}</span>
-- </button>
-- <button type="button" class="gl-chip" onclick="_glOpenFold('bc-ws-restream-details')" title="Where this stream is sent too">
-- <i class="fa-solid fa-share-nodes" aria-hidden="true"></i><span>Restreams</span><b id="gl-rs-v">…</b>
-- </button>
<button type="button" class="gl-chip" onclick="_wsOpenPopoutChat()" title="Open your chat in its own window">
-- <i class="fa-solid fa-comment-dots" aria-hidden="true"></i><span>Chat</span>
++ <span class="gl-chip-i" aria-hidden="true"><i class="fa-solid fa-comment-dots"></i></span><span class="gl-chip-k">Chat</span><b class="gl-chip-v">Open</b>
++ </button>
++ <button type="button" class="gl-chip gl-chip--wide" onclick="_glOpenFold('bc-ws-restream-details')" title="Where this stream is sent too">
++ <span class="gl-chip-i" aria-hidden="true"><i class="fa-solid fa-share-nodes"></i></span><span class="gl-chip-k">Restreams</span><b class="gl-chip-v" id="gl-rs-v">…</b><i class="fa-solid fa-chevron-right gl-chip-end" aria-hidden="true"></i>
</button>
</div>
@@@ -984,7 +981,6 @@ function _glSyncSummaries() {
const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
set('gl-details-sum', _glDetailsSummary(ms));
set('gl-labels-sum', esc(_glLabelsSummary(ms)));
-- set('gl-labels-v', esc(_glLabelsSummary(ms, true)));
const method = ms.streaming_method || 'browser', mode = ms.browser_mode || _wsState.profile?.browserMode || 'camera';
set('gl-how-sum', esc(_glHowSummary(method, mode)));
const howIcon = document.querySelector('#gl-how > summary > i:first-child');
ddiff --git a/test/golive-labels.test.js b/test/golive-labels.test.js
iindex 2388bb01..b5705284 100644
---- a/test/golive-labels.test.js
++++ b/test/golive-labels.test.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * Labels on the go-live card (L1, with Live #477's API): a chip in the row and a fold with the six labels as toggles;
++ * Labels on the go-live screen (L1, with Live #477's API): a fold under the card with the six labels as toggles;
* the AI's suggestions are offered to confirm, never applied by themselves; the save sends the creator's labels.
*/
const assert = require('assert');
@@@ -13,7 +13,7 @@ const panel = ws.slice(ws.indexOf('function _wsRenderPanel()'), ws.indexOf('\n}\
const fn = (name) => { const i = ws.indexOf(`function ${name}(`); assert.ok(i > 0, name); return ws.slice(i, ws.indexOf('\n}\n', i)); };
// The chip and the fold.
--assert.ok(panel.includes(`onclick="_glOpenFold('gl-labels')"`) && panel.includes('id="gl-labels-v"'), 'a Labels chip that opens its fold');
++assert.ok(!panel.includes('id="gl-labels-v"'), 'no Labels tile on the card (the owner, 2026-11-10: "the labels, you don\'t really need"): its row under the card has it');
assert.ok(panel.includes('<details class="gl-fold" id="gl-labels">') && panel.includes('id="gl-labels-sum"'), 'a Labels fold whose line says what is set');
assert.ok(panel.includes('Labels are choices, not bans.'), 'it says what a label does, once');
ddiff --git a/test/golive-setup.test.js b/test/golive-setup.test.js
iindex c370264c..28a81f03 100644
---- a/test/golive-setup.test.js
++++ b/test/golive-setup.test.js
@@@ -76,4 +76,22 @@ assert.ok(!panel.includes('fa-spin') && !frag.slice(frag.indexOf('bc-ws-list'),
assert.ok(/@container bcws \(min-width: 900px\) \{\s*\.gl \{ grid-template-columns/.test(css), 'two columns on a wide screen');
assert.ok(/@media \(pointer: coarse\) \{ \.gl-chip \{ min-height: 44px; \} \}/.test(css), 'chips are thumb-sized on touch');
++// The owner's pass of 2026-11-10. New slot stays in view while the slots scroll under it.
++assert.ok(/\.gl-slot--add \{ position: sticky; right: 0;/.test(css), 'New slot is pinned to the end of the strip');
++// The card's facts are a grid of tiles (a name over its value), not pills of five widths; the address copies.
++assert.ok(/\.gl-chips \{ display: grid; grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/.test(css), 'the facts are a grid');
++assert.ok(panel.includes('<span class="gl-chip-k">Address</span><span class="gl-chip-v" id="gl-link">') && panel.includes('class="gl-chip gl-chip--link" onclick="_glCopyLink()"'), 'the address tile copies');
++assert.ok((panel.match(/class="gl-chip-i" aria-hidden="true"/g) || []).length === 4, 'each tile has its icon in a tile of its own');
++// Suggest sits against the title with the network's glyph, not an emoji.
++assert.ok(panel.includes('<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>') && !/gl-suggest[\s\S]{0,200}\\u2728/.test(panel), 'Suggest wears the network glyph');
++assert.ok(/\.gl-title-row \.gl-title \{ flex: 0 1 auto; width: auto; max-width: 100%; field-sizing: content;/.test(css), 'the title is as wide as its words, so Suggest follows them');
++// The head: Streamer setup at the row's end, and the line under it never leaves one word alone.
++assert.ok(frag.includes('<span class="gl-head-t">Go Live</span> <button type="button" class="glw-open-btn" onclick="openSetupHub()"'), 'the head');
++assert.ok(/#bc-stream-manager > \.bc-page-subtitle \{[^}]*text-wrap: balance;/.test(css), 'no word left alone on the last line');
++// The docked button travels to and from its place, and rides above the footer.
++const studio = fs.readFileSync(path.join(__dirname, '..', 'public/js/broadcast-studio.js'), 'utf8');
++assert.ok(studio.includes('function showDock(dock, on, real)') && studio.includes("dock.classList.add('is-entering')") && studio.includes("dock.classList.add('is-leaving')"), 'the dock rises and hands over, it does not flash');
++assert.ok(studio.includes("dock.style.setProperty('--bcs-lift'") && /\.bcs-golive \{ bottom: calc\(max\([^;]*\+ var\(--bcs-lift, 0px\)\);/.test(css), 'the dock clears the footer');
++assert.ok(/const still = document\.documentElement\.getAttribute\('data-ov-motion'\) === 'off';/.test(studio), 'with Motion off it is simply there or not');
++
console.log('golive-setup: all passed');