openvibers/openvibe.live Commits

Footer rebuilt as a responsive link hub; Arena and home get fuller structured data

OP OpenVibers committed 16 Sept 2026 · dfdee1c6a4 · 4 files, +168 −35

Footer: the old single flex row with 8px padding wrapped Terms/Privacy/DMCA underneath the
floating chat buttons, where they were unreachable. Now a five-column grid (brand + tagline +
socials, Watch, Create, Network, Legal) that collapses to two columns then one, with vertical
clearance for the floating buttons on phones and horizontal clearance on desktop. The grouped
links also give crawlers real internal paths to every section with descriptive anchor text.

SEO: the home page now ships the WebSite and Organization entities its comment already promised,
with sameAs linking the network properties so search engines treat them as one brand. /arena is
indexable with its own title, description, canonical, CollectionPage/ItemList/breadcrumb data and
a crawlable snapshot — it was previously served the generic SPA head.

Hero buttons: one shared clock, so the sheen travels across the row as a single wave instead of
six independent cycles drifting apart, and it moves with transform rather than background-position,
which is what made it stutter. The icon pulse rides the same clock and offset.

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 71b7ffe5..1f9cf9ba 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -32,12 +32,15 @@
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. */
++/* One wave, not six blinks: every button shares --hb-wave, so they can never drift apart, and a
++ small per-button offset makes a single highlight travel across the row. */
++.hb { --hb-wave: 5s; --hb-step: 0.15s; }
.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);
++ content: ''; position: absolute; top: 0; bottom: 0; left: -65%; width: 55%; z-index: -1;
++ pointer-events: none; will-change: transform;
++ background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);
++ animation: hbWave var(--hb-wave) linear infinite;
++ animation-delay: calc(var(--i, 0) * var(--hb-step));
}
.hb::after {
content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
@@@ -52,7 +55,7 @@
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);
++ animation: hbPulse var(--hb-wave) ease-in-out infinite; animation-delay: calc(var(--i, 0) * var(--hb-step));
}
.hb-label { flex: 1; text-align: left; }
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
@@@ -69,8 +72,8 @@
.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; } }
--@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); } }
++@keyframes hbWave { 0% { transform: translateX(0); } 22%, 100% { transform: translateX(330%); } }
++@keyframes hbPulse { 0%, 100% { transform: scale(1); } 7% { transform: scale(1.11); } 20% { transform: scale(1); } }
.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);
@@@ -116,7 +119,7 @@
.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 .hb { --hb-wave: 8s; }
.rs-lite #page-home .stream-card { transition: none; }
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 361a4306..1d0ee8b5 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -6136,19 +6136,63 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border);
-- padding: 8px 16px;
++ /* The floating chat/DM buttons sit above the fold on phones — the old 8px footer put the
++ legal links underneath them, which is why Terms/Privacy/DMCA were cut off. */
++ padding: clamp(24px, 4vw, 40px) clamp(14px, 4vw, 32px) calc(clamp(20px, 3vw, 28px) + env(safe-area-inset-bottom, 0px));
++ margin-top: 40px;
++}
++.footer-inner { max-width: 1240px; margin: 0 auto; }
++.footer-cols {
++ display: grid; gap: clamp(20px, 3vw, 36px);
++ grid-template-columns: minmax(230px, 1.6fr) repeat(4, minmax(120px, 1fr));
++ align-items: start;
++}
++.footer-col { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
++.footer-col h3 {
++ margin: 0 0 2px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;
++ text-transform: uppercase; color: var(--text-muted);
++}
++.footer-col a {
++ color: var(--text-secondary); font-size: 0.86rem; text-decoration: none; line-height: 1.4;
++ transition: color 0.15s, transform 0.15s; width: fit-content;
++}
++.footer-col a:hover { color: var(--accent); transform: translateX(2px); }
++.footer-brand {
++ display: inline-flex; align-items: center; gap: 9px; color: var(--text-primary);
++ font-size: 1.05rem; font-weight: 700; text-decoration: none; margin-bottom: 2px;
++}
++.footer-brand strong { color: var(--accent); font-weight: 800; }
++.footer-brand:hover { color: var(--text-primary); }
++.footer-tagline { margin: 0; font-size: 0.82rem; line-height: 1.55; color: var(--text-muted); max-width: 46ch; }
++.footer-social { display: flex; gap: 8px; margin-top: 4px; }
++.footer-social a {
++ width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center;
++ color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border);
++ font-size: 0.95rem; transition: color 0.15s, border-color 0.15s, transform 0.15s;
++}
++.footer-social a:hover { color: var(--accent); border-color: var(--accent); transform: translateY(-2px); }
++.footer-bar {
++ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px;
++ margin-top: clamp(20px, 3vw, 30px); padding-top: 16px; border-top: 1px solid var(--border);
++ /* The floating DM/chat buttons are pinned to the bottom corners. Reserve their width until
++ the viewport is wide enough that the centred footer already clears them. */
++ padding-inline: clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px);
++}
++.footer-copy { color: var(--text-muted); font-size: 0.78rem; }
++.footer-legal-inline { display: flex; gap: 16px; }
++.footer-legal-inline a { color: var(--text-muted); font-size: 0.78rem; text-decoration: none; }
++.footer-legal-inline a:hover { color: var(--accent); }
++@media (max-width: 900px) {
++ .footer-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ .footer-col--brand { grid-column: 1 / -1; }
++}
++@media (max-width: 560px) {
++ /* Clear the floating chat buttons so nothing is hidden behind them. */
++ .footer { padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px)); }
++ .footer-cols { gap: 22px 16px; }
++ .footer-bar { justify-content: center; text-align: center; padding-inline: 0; }
++ .footer-legal-inline { justify-content: center; }
}
--.footer-content {
-- max-width: 1400px; margin: 0 auto;
-- display: flex; align-items: center; justify-content: center;
-- gap: 10px; flex-wrap: wrap; font-size: 0.8rem;
--}
--.footer-brand { color: var(--accent); font-weight: 600; }
--.footer-brand i { margin-right: 4px; }
--.footer-content a { color: var(--text-muted); font-size: 1rem; transition: color 0.15s; }
--.footer-content a:hover { color: var(--accent); }
--.footer-divider { color: var(--border-light); font-size: 0.7rem; }
--.footer-copy { color: var(--text-muted); font-size: 0.75rem; }
/* ═══════════════════════════════════════════════════════════════
RESPONSIVE
ddiff --git a/public/index.html b/public/index.html
iindex f958deb4..475899f7 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,12 +62,12 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=164">
++ <link rel="stylesheet" href="/css/style.css?v=166">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<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=7">
++ <link rel="stylesheet" href="/css/home-fx.css?v=8">
<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">
@@@ -5040,17 +5040,57 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- FOOTER -->
<!-- ═══════════════════════════════════════════════════════════ -->
<footer class="footer">
-- <div class="footer-content">
-- <span class="footer-brand"><span class="ov-mark" data-size="22"></span> OpenVibe.Live</span>
-- <span class="footer-divider">&middot;</span>
-- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank"><i class="fa-brands fa-github"></i></a>
-- <a href="https://discord.gg/M6MuRUaeJj" target="_blank"><i class="fa-brands fa-discord"></i></a>
-- <span class="footer-divider">&middot;</span>
-- <span class="footer-copy">Open Source &amp; Community Driven</span>
-- <span class="footer-divider">&middot;</span>
-- <a href="/tos">Terms</a>
-- <a href="/privacy">Privacy</a>
-- <a href="/dmca">DMCA</a>
++ <div class="footer-inner">
++ <div class="footer-cols">
++ <div class="footer-col footer-col--brand">
++ <a class="footer-brand" href="/" onclick="return handleLinkClick(event, '/')"><span class="ov-mark" data-size="26"></span> <span>OpenVibe<strong>.Live</strong></span></a>
++ <p class="footer-tagline">Free, open-source live streaming. Go live from a browser, OBS or a robot, restream everywhere, and keep your chat, emotes and clips in one place.</p>
++ <div class="footer-social">
++ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener" title="OpenVibe.Live on GitHub" aria-label="GitHub"><i class="fa-brands fa-github"></i></a>
++ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" rel="noopener noreferrer" title="Join the OpenVibe Discord" aria-label="Discord"><i class="fa-brands fa-discord"></i></a>
++ <a href="/sitemap.xml" title="Sitemap" aria-label="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
++ </div>
++ </div>
++ <nav class="footer-col" aria-label="Watch">
++ <h3>Watch</h3>
++ <a href="/" onclick="return handleLinkClick(event, '/')">Live streams</a>
++ <a href="/vods" onclick="return handleLinkClick(event, '/vods')">Stream VODs</a>
++ <a href="/clips" onclick="return handleLinkClick(event, '/clips')">Clips</a>
++ <a href="/arena" onclick="return handleLinkClick(event, '/arena')">The Arena</a>
++ <a href="/chat" onclick="return handleLinkClick(event, '/chat')">Global chat</a>
++ </nav>
++ <nav class="footer-col" aria-label="Create">
++ <h3>Create</h3>
++ <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Start streaming</a>
++ <a href="/broadcast?guide=golive:restream" onclick="event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide();">Set up restreams</a>
++ <a href="/dashboard" onclick="return handleLinkClick(event, '/dashboard')">Streamer dashboard</a>
++ <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')">Pastes</a>
++ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')">Documentation</a>
++ </nav>
++ <nav class="footer-col" aria-label="The OpenVibe network">
++ <h3>Network</h3>
++ <a href="https://openvibe.tools" target="_blank" rel="noopener">Free online tools</a>
++ <a href="https://dev.openvibe.tools" target="_blank" rel="noopener">Developer tools</a>
++ <a href="https://net.openvibe.tools" target="_blank" rel="noopener">Network diagnostics</a>
++ <a href="https://img.openvibe.tools" target="_blank" rel="noopener">Image converter</a>
++ <a href="https://openvibe.network" target="_blank" rel="noopener">Account &amp; themes</a>
++ </nav>
++ <nav class="footer-col" aria-label="Legal">
++ <h3>Legal</h3>
++ <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms of Service</a>
++ <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy Policy</a>
++ <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
++ <a href="/documentation#philosophy" onclick="return handleLinkClick(event, '/documentation')">What is this?</a>
++ </nav>
++ </div>
++ <div class="footer-bar">
++ <span class="footer-copy">Open source &amp; community driven &middot; no ads, no trackers, no investors</span>
++ <span class="footer-legal-inline">
++ <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms</a>
++ <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
++ <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
++ </span>
++ </div>
</div>
</footer>
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex f4866f2a..6b187514 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -117,6 +117,7 @@ async function _pageMeta(routePath) {
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);
if ((m = p.match(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(m[1]);
if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
++ if (p === '/arena') return _arenaMeta();
return null;
void bu;
@@@ -156,6 +157,33 @@ function _channelMeta(username) {
};
}
++// The Arena: a real content page (mic-judged trash talk), so give it real metadata.
++function _arenaMeta() {
++ let fighters = [], moments = [];
++ try { fighters = (require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }
++ try { moments = require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }
++ const nameOf = (id) => { try { return require('../arena/mic').nameOf(id); } catch { return null; } };
++ const names = fighters.slice(0, 8).map(nameOf).filter(Boolean);
++ const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';
++ const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);
++ const items = moments.slice(0, 10).map((mo, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(mo.text || 'Mic moment', 110) }));
++ const snapshot = _detailSnapshot({
++ title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,
++ desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,
++ transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',
++ });
++ return {
++ title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',
++ image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
++ jsonLd: [
++ { '@context': 'https://schema.org', '@type': 'CollectionPage', name: title, url: abs('/arena'), description: desc },
++ ...(items.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest mic moments', itemListElement: items }] : []),
++ _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),
++ ],
++ snapshot,
++ };
++}
++
// After-show report: /recap/:streamId
function _recapMeta(streamId) {
let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }
@@@ -213,6 +241,24 @@ async function _homeMeta() {
const listEls = [...liveItems, ...vodItems, ...clipItems].slice(0, 20)
.map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) }));
const jsonLd = [
++ {
++ '@context': 'https://schema.org', '@type': 'WebSite',
++ name: SITE_NAME, url: baseUrl(), description: clean(description, 300),
++ inLanguage: 'en', publisher: { '@id': `${baseUrl()}/#org` },
++ },
++ {
++ '@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.',
++ // sameAs is how a search engine learns these properties are one brand rather than
++ // unrelated sites that happen to share a name.
++ sameAs: [
++ 'https://github.com/OpenVibers',
++ 'https://discord.gg/M6MuRUaeJj',
++ 'https://openvibe.tools',
++ 'https://openvibe.network',
++ ],
++ },
{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },
];
return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot };
@@@ -425,7 +471,7 @@ function _cacheSet(key, html) {
_cache.set(key, { html, at: Date.now() });
}
--const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
++const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
async function middleware(req, res, next) {
if (req.method !== 'GET') return next();