openvibers/openvibe.live Commits

Home: setup quest with a liquid fill, join panel for signed-out visitors, mobile effects fix

OP OpenVibers committed 16 Sept 2026 · de6b8828ba · 7 files, +269 −15

The "next up" button under Go live was a plain outline button that was easy to scroll past. It
is now a tank of water whose level is your setup progress: the crest bobs, bubbles rise, the
colour warms from accent through teal and green to gold as you finish tasks, and when you come
back having completed one the water surges up to the new level with the gain floating off. It
still names the next task and opens the setup hub.

Signed-out visitors used to get a single vague button. They now get the actual case for making
an account — six things an account gets you, each with an icon, staggered in as the panel
reaches the viewport — plus create, sign in, and a quieter "show me around first" that opens
the tour. Perks are visible by default; the entrance is a bonus, never the reason text renders.

Mobile effects fix: home-fx.js bailed out of the entire module on any phone, so the viewport
observer never bound and the hero button wave stayed frozen — the "doesn't work on mobile when
scrolling to it" report. Phones now run the cheap IntersectionObserver work (viewport gating,
scroll reveal, count-up) and skip only what is actually expensive: the particle canvas, the
pointer spotlight, card tilt and the headline shimmer. Scroll reveal got a failsafe pass so a
revealed element can never be left at opacity 0 if an observer callback does not land.

Copy: dropped "no ads" and "no trackers" claims across the site, taglines and SEO description.
We run open, public analytics on the home page and may need ad revenue later; promising
otherwise in permanent copy is a promise we should not be making.

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

Changes

ddiff --git a/docs/restream-branding.md b/docs/restream-branding.md
iindex 6edbd17b..1bf12962 100644
---- a/docs/restream-branding.md
++++ b/docs/restream-branding.md
@@@ -67,7 +67,7 @@ This is the most important panel — it should be the first one visitors see.
👉 Watch the FULL experience at:
https://openvibe.live/Goosely
--✅ No ads, no subs, no paywalls
++✅ Free to stream, free to watch
✅ Interactive chat with emotes & cosmetics
✅ Clips, VODs & AI highlights
✅ Open source — run your own instance
@@@ -111,7 +111,7 @@ The platform is 100% free, open source, and community-driven.
• Custom emotes, hats, name effects & particles
• Auto VODs, clips & AI highlights
• Scraplandia — the community browser game
--• No corporate algorithms, no ads
++• No corporate algorithms
• Built by the community, for the community
→ openvibe.live
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex 23e84315..f211b772 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -80,3 +80,168 @@
@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat { animation: none; } .tour-feat { opacity: 1; transform: none; } }
.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }
.rs-lite .tour-wire { animation: none; }
++
++/* ═══════════════════════════════════════════════════════════════
++ Setup quest + join panel (the slot under "Go live" in the tour card)
++
++ Two very different things live in one slot. Signed-in streamers get a tank of water that
++ fills as they finish setup: the level is their progress, the crest bobs, bubbles rise, and
++ when they come back after finishing a task the water surges up to the new level so the
++ progress is something they watch happen rather than a number that quietly changed.
++ Everyone else gets the case for making an account, because "Sign in" is not a reason.
++
++ All idle motion is gated on .fx-live, which home-fx.js adds when the slot scrolls into
++ view — nothing animates off-screen, and the effect restarts each time it is reached.
++ ═══════════════════════════════════════════════════════════════ */
++
++.tour-quest-slot { display: flex; justify-content: center; margin-top: 12px; }
++
++/* ── The quest ────────────────────────────────────────────── */
++.ovg-quest {
++ position: relative; overflow: hidden; isolation: isolate;
++ display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 12px;
++ width: min(560px, 100%); padding: 12px 15px; text-align: left; cursor: pointer;
++ font: inherit; color: var(--text-primary);
++ border-radius: 16px; border: 1px solid color-mix(in srgb, var(--q) 40%, var(--border));
++ background: linear-gradient(180deg, color-mix(in srgb, var(--q) 8%, var(--bg-card)), var(--bg-secondary));
++ box-shadow: 0 10px 26px rgba(0,0,0,0.30), inset 0 1px 0 rgba(255,255,255,0.05);
++ transition: transform 0.2s, box-shadow 0.25s, border-color 0.25s;
++}
++.ovg-quest:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--q) 70%, transparent);
++ box-shadow: 0 16px 38px rgba(0,0,0,0.42), 0 0 28px color-mix(in srgb, var(--q) 24%, transparent); }
++.ovg-quest:active { transform: translateY(0) scale(0.99); }
++.ovg-quest:focus-visible { outline: 2px solid var(--q); outline-offset: 3px; }
++
++/* The water itself. Height is the progress; the transition is what makes a finished task feel
++ like something happened. */
++.ovg-quest-liquid {
++ position: absolute; left: 0; right: 0; bottom: 0; z-index: 0; pointer-events: none;
++ height: calc(var(--pct, 0) * 1%);
++ transition: height 1.8s cubic-bezier(.32, 1, .38, 1);
++ background: linear-gradient(180deg, color-mix(in srgb, var(--q) 20%, transparent), color-mix(in srgb, var(--q) 9%, transparent));
++}
++/* A big, almost-round blob whose top edge sits just above the water line. Rotating it makes that
++ edge rise and fall — a crest, with no SVG and no script. */
++.ovg-quest-wave {
++ position: absolute; left: 50%; margin-left: -85%; top: -4px; width: 170%; aspect-ratio: 1;
++ border-radius: 43% 47% 44% 46%;
++ background: color-mix(in srgb, var(--q) 24%, transparent);
++ box-shadow: 0 -1px 0 color-mix(in srgb, var(--q) 55%, #fff 25%);
++ animation: questWave 9s linear infinite;
++}
++.ovg-quest-wave--b { top: -9px; opacity: 0.45; box-shadow: none; border-radius: 47% 42% 46% 44%; animation-duration: 13.5s; animation-direction: reverse; }
++@keyframes questWave { to { transform: rotate(360deg); } }
++.ovg-quest-bub {
++ position: absolute; left: var(--x); bottom: -6px; width: 5px; height: 5px; border-radius: 50%;
++ background: color-mix(in srgb, var(--q) 60%, #fff 25%); opacity: 0;
++ animation: questBub 4.6s ease-in infinite; animation-delay: calc(var(--i) * 0.72s);
++}
++@keyframes questBub { 0% { opacity: 0; transform: translateY(0) scale(0.55); } 18% { opacity: 0.8; } 100% { opacity: 0; transform: translateY(-44px) scale(1.15); } }
++
++.ovg-quest-sheen {
++ position: absolute; top: 0; bottom: 0; left: -40%; width: 32%; z-index: 1; pointer-events: none;
++ background: linear-gradient(100deg, transparent, rgba(255,255,255,0.10), transparent);
++ animation: questSheen 7s ease-in-out infinite;
++}
++@keyframes questSheen { 0%, 58% { transform: translateX(0); } 100% { transform: translateX(440%); } }
++
++.ovg-quest-ico, .ovg-quest-text, .ovg-quest-count, .ovg-quest-go, .ovg-quest-pop { position: relative; z-index: 2; }
++.ovg-quest-ico {
++ width: 34px; height: 34px; border-radius: 11px; display: grid; place-items: center; font-size: 0.88rem;
++ color: var(--q); background: color-mix(in srgb, var(--q) 20%, transparent);
++ border: 1px solid color-mix(in srgb, var(--q) 45%, transparent);
++ box-shadow: 0 0 14px color-mix(in srgb, var(--q) 25%, transparent);
++ animation: questBob 3.6s ease-in-out infinite;
++}
++@keyframes questBob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2.5px); } }
++.ovg-quest-text { min-width: 0; }
++.ovg-quest-text b { display: block; font-size: 0.94rem; font-weight: 800; letter-spacing: -0.01em; text-shadow: 0 1px 3px rgba(0,0,0,0.45); }
++.ovg-quest-text small { display: block; margin-top: 2px; font-size: 0.75rem; color: var(--text-secondary); text-shadow: 0 1px 3px rgba(0,0,0,0.5);
++ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.ovg-quest-count { text-align: right; line-height: 1.05; font-variant-numeric: tabular-nums; }
++.ovg-quest-count b { font-size: 1.06rem; font-weight: 900; color: var(--q); }
++.ovg-quest-count i { font-style: normal; font-size: 0.72rem; color: var(--text-secondary); text-shadow: 0 1px 3px rgba(0,0,0,0.45); }
++.ovg-quest-go { color: var(--q); opacity: 0.55; font-size: 0.8rem; transition: transform 0.2s, opacity 0.2s; }
++.ovg-quest:hover .ovg-quest-go { opacity: 1; transform: translateX(3px); }
++
++/* Came back with a task finished: the water climbs, the frame lights up, the gain floats off. */
++.ovg-quest-pop { position: absolute; right: 54px; top: 50%; font-size: 0.95rem; font-weight: 900; color: var(--q);
++ pointer-events: none; animation: questPop 2.6s ease-out forwards; }
++@keyframes questPop {
++ 0% { opacity: 0; transform: translateY(8px) scale(0.8); }
++ 20% { opacity: 1; transform: translateY(-6px) scale(1.18); }
++ 70% { opacity: 1; transform: translateY(-16px) scale(1); }
++ 100% { opacity: 0; transform: translateY(-30px) scale(0.95); }
++}
++.ovg-quest.is-surging { border-color: var(--q); box-shadow: 0 0 34px color-mix(in srgb, var(--q) 42%, transparent), 0 14px 34px rgba(0,0,0,0.40); }
++.ovg-quest.is-done .ovg-quest-text b { background: linear-gradient(95deg, #fbbf24, #fff 45%, #fbbf24);
++ -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
++
++/* Idle motion only while the slot is on screen. */
++.tour-quest-slot .ovg-quest-wave, .tour-quest-slot .ovg-quest-bub,
++.tour-quest-slot .ovg-quest-sheen, .tour-quest-slot .ovg-quest-ico { animation-play-state: paused; }
++.tour-quest-slot.fx-live .ovg-quest-wave, .tour-quest-slot.fx-live .ovg-quest-bub,
++.tour-quest-slot.fx-live .ovg-quest-sheen, .tour-quest-slot.fx-live .ovg-quest-ico { animation-play-state: running; }
++
++/* ── The join panel ───────────────────────────────────────── */
++.ovg-join {
++ position: relative; overflow: hidden; width: min(680px, 100%); text-align: left;
++ padding: 18px 18px 16px; border-radius: 20px;
++ border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
++ background: linear-gradient(165deg, color-mix(in srgb, var(--accent) 10%, var(--bg-card)), var(--bg-secondary) 62%);
++ box-shadow: 0 18px 44px rgba(0,0,0,0.34), inset 0 1px 0 rgba(255,255,255,0.05);
++}
++.ovg-join-glow {
++ position: absolute; inset: -40% -10% auto -10%; height: 200%; pointer-events: none;
++ background: radial-gradient(45% 40% at 20% 0%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%),
++ radial-gradient(40% 40% at 85% 8%, rgba(45,212,191,0.16), transparent 70%);
++ animation: joinDrift 16s ease-in-out infinite alternate; animation-play-state: paused;
++}
++.tour-quest-slot.fx-live .ovg-join-glow { animation-play-state: running; }
++@keyframes joinDrift { to { transform: translate3d(-5%, 3%, 0) scale(1.1); } }
++.ovg-join-head { position: relative; display: flex; gap: 12px; align-items: flex-start; }
++.ovg-join-mark { width: 40px; height: 40px; flex: none; border-radius: 13px; display: grid; place-items: center; color: #fff;
++ background: linear-gradient(135deg, var(--accent), #2dd4bf);
++ box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 45%, transparent); }
++.tour-quest-slot.fx-live .ovg-join-mark { animation: joinMark 5.5s ease-in-out infinite; }
++@keyframes joinMark { 0%, 100% { transform: translateY(0) rotate(0); } 50% { transform: translateY(-3px) rotate(-7deg); } }
++.ovg-join-head h3 { margin: 0; font-size: 1.02rem; font-weight: 900; letter-spacing: -0.015em; }
++.ovg-join-head p { margin: 3px 0 0; font-size: 0.82rem; color: var(--text-muted); }
++.ovg-join-perks { position: relative; list-style: none; margin: 14px 0 0; padding: 0;
++ display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 10px 18px; }
++/* Visible by default — the entrance is a bonus, never the reason anything is readable. */
++.ovg-join-perks li { display: flex; gap: 10px; align-items: flex-start; }
++.tour-quest-slot.fx-live .ovg-join-perks li { animation: joinPerk 0.6s cubic-bezier(.2, 1, .3, 1) both;
++ animation-delay: calc(var(--i) * 85ms + 100ms); }
++@keyframes joinPerk { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
++.ovg-join-ico { width: 28px; height: 28px; flex: none; border-radius: 9px; display: grid; place-items: center; font-size: 0.76rem;
++ color: var(--accent); background: color-mix(in srgb, var(--accent) 15%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent); }
++.ovg-join-perks b { display: block; font-size: 0.86rem; font-weight: 800; }
++.ovg-join-perks small { display: block; margin-top: 1px; font-size: 0.76rem; line-height: 1.45; color: var(--text-muted); }
++.ovg-join-cta { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; }
++.ovg-join-go { position: relative; overflow: hidden; }
++.ovg-join-go::after { content: ''; position: absolute; top: 0; bottom: 0; left: -45%; width: 35%;
++ background: linear-gradient(100deg, transparent, rgba(255,255,255,0.28), transparent); }
++.tour-quest-slot.fx-live .ovg-join-go::after { animation: questSheen 5.5s ease-in-out infinite; }
++.ovg-join-tour { background: none; border: 0; padding: 4px 2px; cursor: pointer; font: inherit;
++ font-size: 0.8rem; color: var(--text-muted); text-decoration: underline; text-underline-offset: 3px;
++ text-decoration-style: dotted; transition: color 0.2s; }
++.ovg-join-tour:hover { color: var(--accent); }
++
++@media (max-width: 620px) {
++ .ovg-quest { grid-template-columns: auto 1fr auto; gap: 10px; padding: 11px 12px; }
++ .ovg-quest-go { display: none; }
++ .ovg-quest-text b { font-size: 0.88rem; }
++ .ovg-quest-text small { font-size: 0.72rem; }
++ .ovg-join { padding: 15px 14px 14px; border-radius: 16px; }
++ .ovg-join-perks { grid-template-columns: 1fr; gap: 9px; }
++ .ovg-join-cta .btn { width: 100%; justify-content: center; }
++ .ovg-join-tour { width: 100%; text-align: center; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .ovg-quest-wave, .ovg-quest-bub, .ovg-quest-sheen, .ovg-quest-ico,
++ .ovg-join-glow, .ovg-join-mark, .ovg-join-go::after { animation: none !important; }
++ .ovg-quest-liquid { transition: none; }
++ .ovg-join-perks li { animation: none !important; }
++}
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 390c849d..7bb4ea21 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1214,7 +1214,7 @@ function startHeroRotation(words) {
// ── Hero quip rotator (funny AI-generated slogans) ──────────────
const HERO_FALLBACK_QUIPS = [
-- 'No ads. No investors. No suits. Just vibes.',
++ 'No investors. No suits. Just vibes.',
'Built by openvibes, for openvibes.',
"Corporate streaming? We don't know her.",
'Open source and proud of it.',
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex 5ccb0283..6e768ff1 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -283,17 +283,101 @@
window.startRestreamGuide = () => { if (onBroadcast()) return G.open('golive', { step: 'restream' }); G.cfg.navigate('/broadcast?guide=golive:restream'); };
window.openSetupHub = (focus) => G.open('setup-hub', { ctx: { focus } });
window.openSiteTour = () => G.open('tour');
++ // ── The home page quest / join panel ──────────────────────
++ //
++ // One slot in the tour card, two audiences. A signed-in streamer sees how far through setup
++ // they are as a tank of water that fills as they finish tasks — it names the next task, and
++ // the level visibly rises the first time they come back after finishing one. Everyone else
++ // sees what an account actually gets them, because "Sign in" on its own is not a reason.
++
++ const PERKS = [
++ ['fa-tower-broadcast', 'Go live for free', 'Browser, OBS or a robot. No approval queue, no invite, no waiting list.'],
++ ['fa-satellite-dish', 'Restream everywhere at once', 'Twitch, YouTube, Kick, RobotStreamer and any RTMP server from one stream.'],
++ ['fa-comments', 'One chat, every platform', '7TV, BTTV and FFZ emotes, sound commands and TTS, translated both ways.'],
++ ['fa-coins', 'Earn Vibes just for watching', 'Spend them on emotes, themes, cosmetics and sounds that play on stream.'],
++ ['fa-scissors', 'Clips and VODs cut for you', 'Every stream is recorded and the good bits are found automatically.'],
++ ['fa-circle-nodes', 'One account, whole network', 'The same login works across every OpenVibe site and tool.'],
++ ];
++
++ /** Water colour by progress — cool at the start, gold once it is done. */
++ function questTone(pct) {
++ if (pct >= 100) return { c: '#fbbf24', ico: 'fa-trophy' };
++ if (pct >= 66) return { c: '#4ade80', ico: 'fa-bolt' };
++ if (pct >= 33) return { c: '#2dd4bf', ico: 'fa-droplet' };
++ return { c: 'var(--accent)', ico: 'fa-droplet' };
++ }
++
++ const SEEN_KEY = 'ov_quest_seen_done';
++
++ function renderQuest(el, p) {
++ const pct = Math.max(0, Math.min(100, Math.round((p.done / p.total) * 100)));
++ const tone = questTone(pct);
++ const next = p.next;
++ const icon = next ? (TASK_ICON[next.id] || 'fa-arrow-right') : 'fa-trophy';
++ // Did they finish something since they last looked? Then let the water surge up to meet it.
++ let prev = null;
++ try { const v = localStorage.getItem(SEEN_KEY); if (v !== null) prev = parseInt(v, 10); } catch { /* */ }
++ const gained = Number.isFinite(prev) && p.done > prev ? p.done - prev : 0;
++ const startPct = gained ? Math.round((prev / p.total) * 100) : pct;
++
++ el.innerHTML = `
++ <button type="button" class="ovg-quest${pct >= 100 ? ' is-done' : ''}" style="--q:${tone.c};--pct:${startPct}"
++ aria-label="${next ? `Streamer setup, ${p.done} of ${p.total} done. Next up: ${esc(next.title)}` : 'Streamer setup complete'}">
++ <span class="ovg-quest-liquid" aria-hidden="true">
++ <span class="ovg-quest-wave"></span><span class="ovg-quest-wave ovg-quest-wave--b"></span>
++ ${[9, 27, 46, 63, 81].map((x, i) => `<span class="ovg-quest-bub" style="--x:${x}%;--i:${i}"></span>`).join('')}
++ </span>
++ <span class="ovg-quest-sheen" aria-hidden="true"></span>
++ <span class="ovg-quest-ico"><i class="fa-solid ${icon}"></i></span>
++ <span class="ovg-quest-text">
++ <b>${next ? `Next up: ${esc(next.title)}` : 'Streamer setup complete'}</b>
++ <small>${next ? esc(next.why) : 'Everything on the list is done. Come back when something new ships.'}</small>
++ </span>
++ <span class="ovg-quest-count"><b>${p.done}</b><i>/${p.total}</i></span>
++ <span class="ovg-quest-go" aria-hidden="true"><i class="fa-solid fa-chevron-right"></i></span>
++ ${gained ? `<span class="ovg-quest-pop">+${gained}</span>` : ''}
++ </button>`;
++
++ const btn = el.firstElementChild;
++ btn.addEventListener('click', () => openSetupHub(next ? next.id : null));
++ if (gained) {
++ // Fill from where they were to where they are, so the progress is something they watch happen.
++ requestAnimationFrame(() => setTimeout(() => { btn.style.setProperty('--pct', pct); btn.classList.add('is-surging'); }, 550));
++ setTimeout(() => btn.classList.remove('is-surging'), 3200);
++ }
++ try { localStorage.setItem(SEEN_KEY, String(p.done)); } catch { /* */ }
++ }
++
++ function renderJoin(el) {
++ el.innerHTML = `
++ <div class="ovg-join">
++ <span class="ovg-join-glow" aria-hidden="true"></span>
++ <div class="ovg-join-head">
++ <span class="ovg-join-mark" aria-hidden="true"><i class="fa-solid fa-circle-nodes"></i></span>
++ <div>
++ <h3>Make a free account and the whole thing opens up</h3>
++ <p>Free to make, works everywhere on the network, and takes about ten seconds.</p>
++ </div>
++ </div>
++ <ul class="ovg-join-perks">
++ ${PERKS.map(([ico, title, why], i) => `<li style="--i:${i}"><span class="ovg-join-ico"><i class="fa-solid ${ico}"></i></span><span><b>${title}</b><small>${why}</small></span></li>`).join('')}
++ </ul>
++ <div class="ovg-join-cta">
++ <a class="btn btn-primary btn-lg ovg-join-go" href="/api/auth/sso/login"><i class="fa-solid fa-user-plus"></i> Create your free account</a>
++ <a class="btn btn-outline ovg-join-in" href="/api/auth/sso/login"><i class="fa-solid fa-right-to-bracket"></i> I already have one</a>
++ <button type="button" class="ovg-join-tour"><i class="fa-solid fa-wand-magic-sparkles"></i> Just show me around first</button>
++ </div>
++ </div>`;
++ el.querySelector('.ovg-join-tour').addEventListener('click', () => openSiteTour());
++ }
++
window.setupNextUp = async function (el) {
-- // The home page button: names the next task for signed-in streamers, offers the tour otherwise.
++ // The home page slot: setup progress for signed-in streamers, the pitch for everyone else.
if (!el) return;
-- if (!me()) { el.innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i> See what you can do here'; el.onclick = (e) => { e.preventDefault(); openSiteTour(); }; return; }
++ if (!me()) { if (!el.querySelector('.ovg-join')) renderJoin(el); return; }
const p = await loadProgress();
-- if (!p) return;
-- const pct = Math.round((p.done / p.total) * 100);
-- const ring = `<span class="ovg-mini-ring" style="--pct:${pct}"></span>`;
-- el.innerHTML = p.next ? `${ring} Next up: ${esc(p.next.title)} <small>${p.done}/${p.total}</small>` : `${ring} Streamer setup complete <small>${p.done}/${p.total}</small>`;
-- el.onclick = (e) => { e.preventDefault(); openSetupHub(p.next ? p.next.id : null); };
-- el.classList.add('is-ready');
++ if (!p || !p.total) return;
++ renderQuest(el, p);
};
// First visit to Go Live with no slots → the go-live journey opens itself (until dismissed once).
(function autoFirstTime() {
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex 51837275..4c7c0660 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -63,7 +63,12 @@
<div class="tour-features">${FEATURES.map(([i, t], k) => `<span class="tour-feat" style="--i:${k}"><i class="${i.startsWith('fa-brands') ? i : 'fa-solid ' + i}"></i> ${t}</span>`).join('')}</div>
<div class="tour-actions">
<a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>
-- <a class="btn btn-outline btn-lg tour-next-up" id="tour-next-up" href="/broadcast?guide=golive:restream" onclick="event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a>
++ </div>
++ <!-- Filled by setupNextUp() in guides-live.js: the progress quest for signed-in
++ streamers, the join panel for everyone else. The link below is the no-JS
++ fallback and what a crawler sees. -->
++ <div class="tour-quest-slot" id="tour-next-up" data-fx-viewport>
++ <a class="btn btn-outline btn-lg" href="/broadcast?guide=golive:restream" onclick="event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a>
</div>
</section>`;
wire();
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 9f12ddc1..96392cc1 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -27,7 +27,7 @@ const FALLBACK_AUDIENCES = [
'keyboard warriors (literal)', 'off-grid dreamers', 'the beautifully unhinged',
];
const FALLBACK_QUIPS = [
-- 'No ads. No investors. No suits. Just vibes.',
++ 'No investors. No suits. Just vibes.',
'The stream site that pays rent in good vibes.',
'Open source and proud of it.',
'Built in the open, for everyone.',
@@@ -38,7 +38,7 @@ const FALLBACK_QUIPS = [
'Powered by caffeine and spite.',
'The internet hangout everyone forgot they wanted.',
'Clip it before it happens.',
-- 'Free forever, because we forgot to add a paywall.',
++ 'Free to start, because gatekeeping is boring.',
'Where the wifi is questionable and the community is not.',
'Stream from a van. We support that lifestyle.',
'One account. All of OpenVibe.',
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 6b187514..9dea3059 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -249,7 +249,7 @@ async function _homeMeta() {
{
'@context': 'https://schema.org', '@type': 'Organization', '@id': `${baseUrl()}/#org`,
name: 'OpenVibe', url: baseUrl(), logo: DEFAULT_OG_IMAGE,
-- description: 'A free, open-source network of live streaming, media and developer tools, run by its community with no ads and no investors.',
++ description: 'A free, open-source network of live streaming, media and developer tools, built and run by the people who use it.',
// sameAs is how a search engine learns these properties are one brand rather than
// unrelated sites that happen to share a name.
sameAs: [