openvibers/openvibe.live Commits
Go Live: New slot stays in view, the card's facts are a grid, and the docked button travels (#759)
Changes
@@@ -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 !importanbackground: 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; }@@@ -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 slotworkspace and loads nothing else (_broadcastSignInGate). -->@@@ -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 hadif (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', () => {@@@ -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');@@@ -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');@@@ -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');
@@ -2447,9 +2447,18 @@
@@ -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-thumb { background: color-mix(in srgb, var(--accent) 38%, transparent); border-radius: 8px; }
.broadcast-main::-webkit-scrollbar-track { background: transparent; }
.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 { 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-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 {
.bc-ws-sidebar, .bc-ws-main {
border: 1px solid var(--border); border-radius: 14px;
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
@@ -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%);
background: linear-gradient(to bottom, transparent, var(--bg-primary, #0b0b10) 34%);
}
}
.bcs-golive[hidden] { display: none; }
.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="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="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; }
.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}
@@ -3215,9 +3232,17 @@ html[data-ov-motion=off] .bcs-gl-burst{display: none}
.gl-slot.is-on i { color: var(--accent); }
.gl-slot.is-on i { color: var(--accent); }
.gl-slot-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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-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 (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; }
.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; }
.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}
@@ -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-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-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: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 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; } }
@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. */
/* 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}
@@ -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-ws-quality > summary > i:first-child { width: 18px; }
.gl-fold .bc-sum-chev { flex: none; font-size: 0.75rem; }
.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));
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 { 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; }
.gl-suggest:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (pointer: coarse) { .gl-suggest { min-height: 44px; } }
@media (pointer: coarse) { .gl-suggest { min-height: 44px; } }
.gl-ideas { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
.gl-ideas { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
<!-- ═══ 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>
- <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
<!-- 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). -->
@@ -837,6 +837,40 @@
@@ -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
// 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.
// 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; }
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() {
function syncGoLiveDock() {
const ready = NARROW.matches && onBroadcastPage() && !!window._bcMyLoaded;
const ready = NARROW.matches && onBroadcastPage() && !!window._bcMyLoaded;
const liveMine = ready ? (window._bcMyLive || []) : [];
const liveMine = ready ? (window._bcMyLive || []) : [];
@@ -864,8 +898,8 @@
@@ -864,8 +898,8 @@
document.body.append(dock);
document.body.append(dock);
}
}
if (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') {
if (mode === 'live') {
st.startingSince = 0;
st.startingSince = 0;
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-stop';
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-stop';
@@ -900,6 +934,7 @@
@@ -900,6 +934,7 @@
}
}
}
}
document.body.classList.toggle('bc-golive-dock-on', !!mode);
document.body.classList.toggle('bc-golive-dock-on', !!mode);
+ liftDockOverFooter();
}
}
document.addEventListener('scroll', () => { if ($('bcs-golive') || goLiveButton()) syncGoLiveDock(); }, { capture: true, passive: true });
document.addEventListener('scroll', () => { if ($('bcs-golive') || goLiveButton()) syncGoLiveDock(); }, { capture: true, passive: true });
if (window.visualViewport) window.visualViewport.addEventListener('resize', () => {
if (window.visualViewport) window.visualViewport.addEventListener('resize', () => {
@@ -79,7 +79,7 @@ async function _wsLoadManagedStreams() {
@@ -79,7 +79,7 @@ async function _wsLoadManagedStreams() {
function _wsRenderSidebar() {
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()" 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
// 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).
// that did the same thing confused new streamers).
if (_wsState.managedStreams.length === 0) {
if (_wsState.managedStreams.length === 0) {
@@ -348,29 +348,26 @@ function _wsRenderPanel() {
@@ -348,29 +348,26 @@ function _wsRenderPanel() {
value="${esc(placeHandoff?.title || ms.title || '')}"
value="${esc(placeHandoff?.title || ms.title || '')}"
placeholder="What are you streaming?" maxlength="200"
placeholder="What are you streaming?" maxlength="200"
oninput="_wsMarkDirty()">
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">
<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>
</button>
+ <span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
</div>
</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)}">` : ''}
${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-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.">
<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()">
<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>
</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">
<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>
</button>
</div>
</div>
@@ -984,7 +981,6 @@ function _glSyncSummaries() {
@@ -984,7 +981,6 @@ function _glSyncSummaries() {
const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
set('gl-details-sum', _glDetailsSummary(ms));
set('gl-details-sum', _glDetailsSummary(ms));
set('gl-labels-sum', esc(_glLabelsSummary(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';
const method = ms.streaming_method || 'browser', mode = ms.browser_mode || _wsState.profile?.browserMode || 'camera';
set('gl-how-sum', esc(_glHowSummary(method, mode)));
set('gl-how-sum', esc(_glHowSummary(method, mode)));
const howIcon = document.querySelector('#gl-how > summary > i:first-child');
const howIcon = document.querySelector('#gl-how > summary > i:first-child');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'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.
* the AI's suggestions are offered to confirm, never applied by themselves; the save sends the creator's labels.
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -13,7 +13,7 @@ const panel = ws.slice(ws.indexOf('function _wsRenderPanel()'), ws.indexOf('\n}\
@@ -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)); };
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.
// 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('<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');
assert.ok(panel.includes('Labels are choices, not bans.'), 'it says what a label does, once');
@@ -76,4 +76,22 @@ assert.ok(!panel.includes('fa-spin') && !frag.slice(frag.indexOf('bc-ws-list'),
@@ -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(/@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');
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');
console.log('golive-setup: all passed');