openvibers/openvibe.live Commits

Hero buttons read as pressable; digest spells out hours; guide points at real dashboard controls

OP OpenVibers committed 16 Sept 2026 · 01623d75c2 · 4 files, +125 −34

- hero buttons: raised tinted surface with a bottom edge, inner highlight and real shadow, a
  press state, a chevron that appears on hover, a slow staggered sheen and a breathing icon so
  they are visibly interactive; responsive grid (auto-fit → 2 columns on phones, single row in
  landscape, 1 column under 360px)
- digest reads '4 hours live' instead of '4h live', matching the spelled-out units beside it
- guide → dashboard now targets stable element ids, opens whichever tab and sub-panel actually
  contains the control, and waits for it to be laid out before spotlighting. A hidden element
  has a zero-size rect, which is why the Offline Screen spotlight landed in the corner
  highlighting nothing; if it never becomes visible we say so instead of pointing at empty space

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex 2c26903a..71b7ffe5 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -10,33 +10,84 @@
background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
}
#page-home .hero-content { position: relative; z-index: 2; }
--/* ── Hero buttons: icon tile + label, brand colour per destination, one clean hover ── */
--#page-home .hero-cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
++/* ── Hero buttons: icon tile + label, brand colour per destination ──
++ These need to read as *pressable* at a glance on a phone, so they carry a raised surface,
++ a top highlight, a real shadow and a press state — plus a slow staggered sheen that sweeps
++ the row so the eye catches that they are interactive without anything shouting. */
++#page-home .hero-cta {
++ display: grid; gap: clamp(8px, 1.6vw, 12px); justify-content: center; margin-inline: auto;
++ grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 22vw, 168px), max-content));
++ max-width: min(920px, 96vw);
++}
.hb {
-- --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px; padding: 6px 18px 6px 6px; border-radius: 999px; min-height: 46px; letter-spacing: 0.01em;
-- font: inherit; font-weight: 700; font-size: 0.95rem; color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate;
-- background: color-mix(in srgb, var(--bg-card) 78%, transparent); border: 1px solid color-mix(in srgb, var(--c) 28%, var(--border));
-- box-shadow: 0 6px 18px rgba(0,0,0,0.22); transition: transform 0.22s cubic-bezier(.2,1.3,.3,1), border-color 0.22s, box-shadow 0.22s, color 0.22s;
++ --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;
++ padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;
++ border-radius: 999px; min-height: clamp(44px, 7.2vw, 52px); width: 100%;
++ font: inherit; font-weight: 700; font-size: clamp(0.88rem, 1.6vw, 0.98rem); letter-spacing: 0.01em;
++ color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate; overflow: hidden;
++ background:
++ linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0) 46%),
++ color-mix(in srgb, var(--c) 10%, var(--bg-card));
++ border: 1px solid color-mix(in srgb, var(--c) 42%, var(--border));
++ box-shadow: 0 2px 0 0 color-mix(in srgb, var(--c) 22%, transparent), 0 8px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09);
++ transition: transform 0.18s cubic-bezier(.2,1.3,.3,1), border-color 0.2s, box-shadow 0.2s, background 0.2s;
++}
++/* Slow sheen that crosses each button in turn — the idle "these are buttons" tell. */
++.hb::before {
++ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;
++ background: linear-gradient(105deg, transparent 38%, color-mix(in srgb, var(--c) 26%, transparent) 50%, transparent 62%);
++ background-size: 280% 100%; background-position: 220% 0;
++ animation: hbSheen 9s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.7s);
++}
++.hb::after {
++ content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
++ background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%);
++ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
++ -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s;
++}
++.hb-ico {
++ width: clamp(30px, 5vw, 34px); height: clamp(30px, 5vw, 34px); border-radius: 50%; flex: none;
++ display: grid; place-items: center; font-size: clamp(0.82rem, 1.5vw, 0.92rem); color: var(--c);
++ background: color-mix(in srgb, var(--c) 16%, var(--bg-tertiary));
++ border: 1px solid color-mix(in srgb, var(--c) 40%, transparent);
++ box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;
++ transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s;
++ animation: hbBreathe 4.5s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.35s);
}
--.hb::before { content: ''; position: absolute; inset: 0; border-radius: inherit; z-index: -1; background: linear-gradient(120deg, color-mix(in srgb, var(--c) 22%, transparent), transparent 60%); opacity: 0; transition: opacity 0.25s; }
--.hb::after { content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px; background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s; }
--.hb-ico { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 0.92rem; color: var(--c); background: color-mix(in srgb, var(--c) 12%, var(--bg-tertiary)); border: 1px solid color-mix(in srgb, var(--c) 32%, transparent); box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset; transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s; }
--.hb:hover, .hb:focus-visible { transform: translateY(-2px); border-color: color-mix(in srgb, var(--c) 70%, transparent); box-shadow: 0 12px 30px rgba(0,0,0,0.3), 0 0 24px color-mix(in srgb, var(--c) 30%, transparent); outline: none; }
--.hb:hover::before, .hb:focus-visible::before { opacity: 1; }
--.hb:hover::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }
--.hb:hover .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); }
--.hb:active { transform: translateY(0) scale(0.98); }
++.hb-label { flex: 1; text-align: left; }
++/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
++.hb-go { width: 0; opacity: 0; font-size: 0.72rem; color: var(--c); transition: width 0.22s, opacity 0.22s, transform 0.22s; transform: translateX(-4px); }
++.hb:hover, .hb:focus-visible {
++ transform: translateY(-3px);
++ border-color: color-mix(in srgb, var(--c) 78%, transparent);
++ box-shadow: 0 3px 0 0 color-mix(in srgb, var(--c) 40%, transparent), 0 16px 32px rgba(0,0,0,0.38), 0 0 26px color-mix(in srgb, var(--c) 32%, transparent), inset 0 1px 0 rgba(255,255,255,0.12);
++ outline: none;
++}
++.hb:hover::after, .hb:focus-visible::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }
++.hb:hover .hb-ico, .hb:focus-visible .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); animation: none; }
++.hb:hover .hb-go, .hb:focus-visible .hb-go { width: 10px; opacity: 1; transform: none; }
++.hb:active { transform: translateY(0) scale(0.975); box-shadow: 0 1px 0 0 color-mix(in srgb, var(--c) 30%, transparent), 0 4px 12px rgba(0,0,0,0.35), inset 0 2px 6px rgba(0,0,0,0.3); }
@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@keyframes hbOrbit { to { --a: 360deg; } }
--.hb-stream { --c: var(--accent); color: #fff; border-color: color-mix(in srgb, var(--accent) 55%, transparent);
-- background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 78%, #000) 0%, color-mix(in srgb, var(--accent) 55%, #000) 100%);
-- box-shadow: 0 8px 22px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.05) inset, 0 0 22px color-mix(in srgb, var(--accent) 22%, transparent); }
--.hb-stream::before { background: linear-gradient(180deg, rgba(255,255,255,0.14), transparent 55%); opacity: 1; }
--.hb-stream .hb-ico { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.28); color: #fff; }
--.hb-stream:hover { box-shadow: 0 14px 32px rgba(0,0,0,0.4), 0 0 30px color-mix(in srgb, var(--accent) 40%, transparent); }
--.hb-stream:hover .hb-ico { background: #fff; color: color-mix(in srgb, var(--accent) 70%, #000); box-shadow: 0 0 16px rgba(255,255,255,0.45); }
++@keyframes hbSheen { 0%, 62% { background-position: 220% 0; } 88%, 100% { background-position: -120% 0; } }
++@keyframes hbBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
++.hb-stream { --c: var(--accent); color: #fff;
++ background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0) 50%), linear-gradient(160deg, color-mix(in srgb, var(--accent) 80%, #000) 0%, color-mix(in srgb, var(--accent) 58%, #000) 100%);
++ border-color: color-mix(in srgb, var(--accent) 65%, transparent);
++ box-shadow: 0 2px 0 0 color-mix(in srgb, var(--accent) 45%, transparent), 0 10px 26px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.18); }
++.hb-stream .hb-ico { background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.32); color: #fff; }
++.hb-stream:hover .hb-ico, .hb-stream:focus-visible .hb-ico { background: #fff; color: color-mix(in srgb, var(--accent) 70%, #000); }
.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
--@media (max-width: 560px) { .hb { padding: 6px 13px 6px 6px; font-size: 0.88rem; } .hb-ico { width: 30px; height: 30px; font-size: 0.85rem; } }
++/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */
++@media (max-width: 560px) {
++ #page-home .hero-cta { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 420px; }
++ .hb { font-size: 0.86rem; padding-right: 12px; }
++}
++@media (max-width: 560px) and (orientation: landscape) {
++ #page-home .hero-cta { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); max-width: 96vw; }
++ .hb { min-height: 40px; }
++}
++@media (max-width: 360px) { #page-home .hero-cta { grid-template-columns: 1fr; max-width: 300px; } }
@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }
/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */
@@@ -59,12 +110,13 @@
@media (prefers-reduced-motion: reduce) {
.hfx-sparks, .hfx-spot { display: none; }
-- .hfx-shimmer, .hfx-glow, .hb::after, #page-home .section-header h2 .live-dot { animation: none !important; }
++ .hfx-shimmer, .hfx-glow, .hb::before, .hb::after, .hb-ico, #page-home .section-header h2 .live-dot { animation: none !important; }
.hfx-reveal { opacity: 1; transform: none; transition: none; }
}
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
++.rs-lite .hb::before { animation-duration: 14s; }
.rs-lite #page-home .stream-card { transition: none; }
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
ddiff --git a/public/index.html b/public/index.html
iindex 3030dd15..f958deb4 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -67,7 +67,7 @@
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
-- <link rel="stylesheet" href="/css/home-fx.css?v=6">
++ <link rel="stylesheet" href="/css/home-fx.css?v=7">
<link rel="stylesheet" href="/css/home-tour.css?v=2">
<link rel="stylesheet" href="/css/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@@ -280,12 +280,12 @@
</div>
</div>
<div class="hero-cta">
-- <button class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span></button>
-- <a href="https://openvibe.network" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span></a>
-- <a href="https://openvibe.games/game" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span></a>
-- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span></a>
-- <a href="https://discord.gg/M6MuRUaeJj" target="_blank" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span></a>
-- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span></a>
++ <button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
++ <a href="https://openvibe.network" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="https://openvibe.games/game" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<div class="home-tour-mount" id="home-tour-mount"></div>
@@@ -5089,7 +5089,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
--<script src="/js/app.js?v=141" defer></script>
++<script src="/js/app.js?v=142" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 2dbd7c74..390c849d 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1994,7 +1994,7 @@ async function loadHomeDigest() {
const st = d.stats || {};
const stats = [
st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',
-- st.hours >= 0.5 ? `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(Math.round(st.hours))}</b>h live</span>` : '',
++ st.hours >= 0.5 ? (() => { const hrs = Math.round(st.hours); return `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(hrs)}</b> ${hrs === 1 ? 'hour' : 'hours'} live</span>`; })() : '',
st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat lines</span>` : '',
st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex 7813b7f9..5ccb0283 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -31,7 +31,46 @@
async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }
async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }
const onBroadcast = () => location.pathname.startsWith('/broadcast');
-- const dashboard = (tab, heading) => { G.close(false); G.cfg.navigate('/dashboard'); setTimeout(() => { try { if (typeof switchDashTab === 'function' && tab) { const b = document.querySelector(`[data-dtab="${tab}"]`); switchDashTab(tab, b); } if (heading) { const h = [...document.querySelectorAll('h3, h4')].find(x => x.textContent.trim().toLowerCase().startsWith(heading.toLowerCase())); if (h) { h.scrollIntoView({ block: 'center', behavior: 'smooth' }); G.spotlight({ target: h.closest('.card, .dash-card, section, div') || h, title: heading, text: 'Here it is — set it up right here.' }); } } } catch { /* */ } }, 900); };
++ /**
++ * Send someone to a real control in the dashboard and point at it.
++ * Targets a stable element id rather than matching heading text, opens whichever tab and
++ * sub-panel actually contains it, and waits for it to be laid out before spotlighting —
++ * a hidden element has a zero-size rect, which is how the spotlight used to land in the
++ * top-left corner highlighting nothing.
++ */
++ async function dashboard(targetSel, label, hint) {
++ G.close(false);
++ if (!location.pathname.startsWith('/dashboard')) G.cfg.navigate('/dashboard');
++ const deadline = Date.now() + 12000;
++ let el = null;
++ while (Date.now() < deadline) {
++ el = document.querySelector(targetSel);
++ if (el) {
++ // Open the dashboard tab and sub-panel that own this element.
++ try {
++ const panel = el.closest('.dash-tab-panel[id^="dash-panel-"]');
++ if (panel && !panel.classList.contains('active') && typeof switchDashTab === 'function') {
++ const tab = panel.id.replace('dash-panel-', '');
++ switchDashTab(tab, document.querySelector(`[data-dtab="${tab}"]`));
++ await new Promise(r => setTimeout(r, 400));
++ }
++ const sub = el.closest('.dash-subpanel');
++ if (sub && !sub.classList.contains('active')) {
++ const btn = document.querySelector(`[data-ctab="${sub.id.replace(/^dash-[a-z]+-/, '')}"]`);
++ if (btn) { btn.click(); await new Promise(r => setTimeout(r, 400)); }
++ }
++ } catch { /* */ }
++ const r = el.getBoundingClientRect();
++ if (r.height > 8 && el.offsetParent !== null) break; // laid out and visible
++ }
++ await new Promise(r => setTimeout(r, 250));
++ el = null;
++ }
++ if (!el) { say(`${label} is in your dashboard — scroll down to find it.`, 'info'); return; }
++ try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { /* */ }
++ await new Promise(r => setTimeout(r, 450));
++ G.spotlight({ target: el, title: label, text: hint || 'Set it up right here — the guide is done, this is the real control.', actions: [{ label: 'Got it', primary: true }] });
++ }
// ── Journey: go live (stream → method → restream → go live) ─
const streamStep = {
@@@ -193,7 +232,7 @@
async () => { const cmd = ((document.querySelector('#ovg-sound-cmd') || {}).value || '').trim().replace(/^!+/, ''); const f = document.querySelector('#ovg-sound-file'); const file = f && f.files && f.files[0]; if (!cmd) return 'Name the command'; if (!file) return 'Choose an audio file'; try { await ui.upload('/sounds', { command: cmd }, file, 'sound'); say(`!${cmd} is ready`, 'success'); ui.confetti(); return true; } catch (e) { return (e && e.message) || 'Upload failed'; } }, 'Add sound');
one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,
(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },
-- async () => { dashboard('content', 'Offline Screen'); return true; }, 'Open the offline screen settings');
++ async () => { dashboard('#dash-card-offline', 'Offline Screen', 'Pick an image, a looping video or your own HTML — this is what visitors see when you are not live.'); return true; }, 'Open the offline screen settings');
one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b> → <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,