openvibers/openvibe.network Commits
The front door: a short hero with an animated orbit of the sites (no pill, no wall of text), YouTube-style cards for live streams, recordings and screenshots, no zero numbers, OpenRestream named right (#375)
Changes
@@@ -120,7 +120,31 @@ a{color:inherit}.constellation .cs-soon .cs-star{fill:var(--text-muted)}.constellation .cs-soon text{fill:var(--text-muted);font-size:10.5px;font-weight:500}@keyframes cs-twinkle{0%,100%{transform:scale(1);opacity:.16}50%{transform:scale(1.35);opacity:.3}}@media (prefers-reduced-motion:reduce){.constellation .cs-halo{animation:none}.intent{transition:none}}--@media (max-width:900px){.hero-grid{grid-template-columns:minmax(0,1fr);text-align:center}.hero-grid p.sub{margin:18px auto 0}.hero-sky{position:static;padding-top:0;text-align:left}.hero-sky .constellation{display:none}.intent{text-align:left}}++@media (max-width:900px){.hero-grid{grid-template-columns:minmax(0,1fr);text-align:center}.hero-grid p.sub{margin:14px auto 0}.hero-sky{position:static;padding-top:0}.intent{text-align:left}}++/* The orbit (server/home/render.js orbit()): rings turning in opposite directions, every icon upright. */++.orbit{position:relative;width:min(100%,460px);aspect-ratio:1;margin:0 auto;container-type:inline-size}++.orb-glow{position:absolute;inset:22%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent) 34%,transparent),transparent);filter:blur(6px);animation:orb-breathe 6s ease-in-out infinite}++.orb-ring{position:absolute;inset:0;border-radius:50%;animation:orb-spin 90s linear infinite}++.orb-ring::before{content:"";position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light) 30%,transparent)}++.orb-ring.r1{--R:31cqw}.orb-ring.r1::before{inset:19%}++.orb-ring.r2{--R:45cqw;animation-duration:140s;animation-direction:reverse}.orb-ring.r2::before{inset:5%;opacity:.55}++.orb-node{position:absolute;left:50%;top:50%;width:44px;height:44px;margin:-22px;transform:rotate(calc(360deg / var(--n) * var(--i))) translateY(calc(-1 * var(--R))) rotate(calc(-360deg / var(--n) * var(--i)));text-decoration:none;color:var(--text-primary)}++.orb-node.is-big{width:58px;height:58px;margin:-29px}++.orb-face{display:flex;flex-direction:column;align-items:center;gap:5px;width:100%;height:100%;animation:orb-spin 90s linear infinite reverse}++.r2 .orb-face{animation-duration:140s;animation-direction:normal}++.orb-face .ov-icon{display:grid;place-items:center;width:100%;height:100%;flex:none;border-radius:50%;background:color-mix(in srgb,var(--bg-secondary) 88%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent-light) 30%,transparent),0 8px 22px rgba(0,0,0,.35);transition:transform .3s cubic-bezier(.2,1.4,.3,1),box-shadow .3s}++.orb-name{font-size:11.5px;font-weight:700;color:var(--text-secondary);white-space:nowrap;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--bg-primary) 78%,transparent);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}++.orb-ring.r1{z-index:2}.orb-core{z-index:3}++.orb-node:hover .ov-icon,.orb-node:focus-visible .ov-icon{transform:scale(1.15);box-shadow:0 0 0 2px var(--accent-light),0 10px 28px rgba(0,0,0,.45)}++.orb-node:hover .orb-name,.orb-node:focus-visible .orb-name{color:var(--text-primary)}++.orb-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;gap:2px;text-decoration:none;color:var(--text-primary);text-align:center}++.orb-core b{font-size:15px;font-weight:800;margin-top:6px}.orb-core small{font-size:12px;color:var(--text-muted)}++@keyframes orb-spin{to{transform:rotate(360deg)}}++@keyframes orb-breathe{0%,100%{opacity:.7;transform:scale(.95)}50%{opacity:1;transform:scale(1.05)}}++@media (prefers-reduced-motion:reduce){.orb-ring,.orb-face,.orb-glow{animation:none}}++html[data-ov-motion=reduced] .orb-ring,html[data-ov-motion=reduced] .orb-face,html[data-ov-motion=reduced] .orb-glow{animation:none}++@media (max-width:900px){.hero-sky{order:-1;margin:-8px auto 4px}.hero-sky .orbit{width:min(72vw,270px)}.hero-sky .net-num{display:none}.orb-node.is-big{width:46px;height:46px;margin:-23px}.orb-node{width:34px;height:34px;margin:-17px}.orb-name{font-size:10.5px}.orb-core b{font-size:13px}}++@media (max-width:520px){.intents{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.intent small{display:none}.intent{align-items:center;gap:9px;padding:9px 10px}.intent>i{width:30px;height:30px;font-size:14px}.intent b{font-size:13.5px}.intent-bar button{padding:11px 14px}}/* Services */@@@ -181,7 +205,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}.foot-note{width:100%;font-size:12px;color:var(--text-muted)}@media (max-width:600px){-- .hero{padding-top:64px}++ .hero{padding-top:20px}}</style><script src="/shared/theme-loader.js"></script>@@@ -199,9 +223,8 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}<section class="hero"><div class="hero-grid"><div class="hero-main">-- <div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>-- <h1>The open network<br><span class="grad">for what you do online.</span></h1>-- <p class="sub">Live streaming from a browser tab, <!--OV:COUNT--> online tools, chat, games, a wiki and a blog; agents that do tasks for you; jobs, food help and places to rent; and the platform underneath, for anyone to build on. Open source, run by the people who use it, and open to everyone without an account. Sign in once and every site already knows you.</p>++ <h1>Go live, chat, play<br><span class="grad">and get things done.</span></h1>++ <p class="sub">Stream from a browser tab, no OBS. Chat, play and use <b><!--OV:COUNT--> online tools</b> with one free account across <b><!--OV:SITES--> sites</b>. Open source and community run.</p><form class="intent-bar" action="https://openvibe.tools/search" method="get" role="search"><label for="intent-q" class="sr-only">What do you want to do?</label><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>@@@ -89,12 +89,19 @@ function recordings(body) {const title = clip(v.title || '', 80);if (!title || ADULT.test(title)) continue;seen.add(v.username);++ // What happens in it (Live's AI overview), one line, only when it names nobody's looks (public AI text).++ const about = clip(String(v.ai_overview || '').split(/(?<=[.!?])\s/)[0] || '', 120);out.push({title,url: `${PUBLIC.video}/v/${encodeURIComponent(v.id)}`,thumb: onHost(v.thumbnail_url, 'openvibe.media'),channel: clip(v.display_name || v.username, 40),length: runtime(v.duration_seconds ?? v.duration),++ seconds: Math.round(Number(v.duration_seconds ?? v.duration) || 0),++ avatar: onHost(v.avatar_url, 'openvibe.media'),++ views: Math.max(0, Number(v.view_count) || 0),++ at: Date.parse(v.created_at) || null,++ about: about && !LOOKS.test(about) && !ADULT.test(about) ? about : '',});if (out.length === 4) break;}@@@ -116,6 +123,12 @@ function publicPastes(body) {url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,kind: shot ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),summary,++ shot: shot ? onHost(p.screenshot_url, 'openvibe.media') : null,++ by: clip(p.display_name || p.username || '', 40),++ avatar: onHost(p.avatar_url, 'openvibe.media'),++ views: Math.max(0, Number(p.views ?? p.unique_views) || 0),++ likes: Math.max(0, Number(p.likes) || 0),++ at: Date.parse(p.created_at) || null,};}).slice(0, 4);@@@ -20,10 +20,11 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat// The sites come from the frame's list (server/frame/sites.js): a site is open only when its service's// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and// "Opening next" always agree with what is actually running.--const { SITES: frameSites, GROUPS, groupOf, PRODUCTS } = require('../frame/sites');++const { SITES: frameSites, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');const contracts = require('openvibe-contracts');const social = require('openvibe-shared/social');--const siteName = (site) => (site.name.includes('.') ? site.name : `OpenVibe.${site.name}`);++// OpenRestream is its own name, never "OpenVibe.OpenRestream" (the owner, 2026-10-28).++const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));const OPEN = frameSites.filter((site) => site.status === 'open' && site.id !== 'network');const SOON = frameSites.filter((site) => site.status !== 'open');const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', 'Twenty'];@@@ -76,30 +77,20 @@ function intents(catalog) {return `<ul class="intents" aria-label="What you can do here">${INTENTS.map(([id, fa, verb, line, href, k]) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small></span></a></li>`).join('')}</ul>`;}--// The network as a constellation: open sites on the inner ring, the ones opening next faint on the outer ring.++// The network as an orbit (the owner, 2026-10-28: the hero was "ugly af, boring… not easy to understand… nice animated++// visuals"): the mark in the middle, the six most used sites on the inner ring and ten more on the outer, the rings++// turning slowly in opposite directions while each site's icon stays upright. Pure CSS transforms (cheap to draw), and++// still under reduced motion. Every open site is in the sites section below; this shows the shape of the network.const SHORT = (site) => site.name.replace(/^OpenVibe\./, '');--function constellation() {-- const ring = (list, r, start) => list.map((site, i) => {-- const a = start + (i / list.length) * Math.PI * 2;-- return { site, x: Math.round(Math.cos(a) * r), y: Math.round(Math.sin(a) * r) };-- });-- const inner = ring(OPEN, 118, -Math.PI / 2);-- const outer = ring(SOON, 176, -Math.PI / 2 + Math.PI / SOON.length);-- const lines = inner.map((n, i) => {-- const m = inner[(i + 1) % inner.length];-- return `<line class="cs-spoke" x1="0" y1="0" x2="${n.x}" y2="${n.y}"/><line class="cs-arc" x1="${n.x}" y1="${n.y}" x2="${m.x}" y2="${m.y}"/>`;-- }).join('');-- // Near the top and bottom the labels sit centred side by side and collide ("ZoneLiveTools"): every other one there-- // sits further out.-- const stagger = (n, i) => (Math.abs(n.x) / (Math.hypot(n.x, n.y) || 1) < 0.5 && i % 2 ? 14 : 0);-- const label = (n, r) => { const dx = n.x / (Math.hypot(n.x, n.y) || 1), dy = n.y / (Math.hypot(n.x, n.y) || 1); return { x: Math.round(n.x + dx * r), y: Math.round(n.y + dy * r) + 4, anchor: Math.abs(dx) < 0.25 ? 'middle' : (dx > 0 ? 'start' : 'end') }; };-- const openNodes = inner.map((n, i) => { const l = label(n, 17 + stagger(n, i)); return `<a href="https://${esc(n.site.host)}/" class="cs-node" style="--d:${(i * 0.37).toFixed(2)}s"><title>${esc(siteName(n.site))}: ${esc(n.site.tagline)}</title><circle class="cs-halo" cx="${n.x}" cy="${n.y}" r="11"/><circle class="cs-star" cx="${n.x}" cy="${n.y}" r="5"/><text x="${l.x}" y="${l.y}" text-anchor="${l.anchor}">${esc(SHORT(n.site))}</text></a>`; }).join('');-- const soonNodes = outer.map((n) => { const l = label(n, 9); return `<a href="https://${esc(n.site.host)}/" class="cs-node cs-soon"><title>${esc(siteName(n.site))} — opening next</title><circle class="cs-star" cx="${n.x}" cy="${n.y}" r="2.6"/><text x="${l.x}" y="${l.y}" text-anchor="${l.anchor}">${esc(SHORT(n.site))}</text></a>`; }).join('');-- return `<svg class="constellation" viewBox="-230 -215 460 430" role="group" aria-labelledby="cs-t"><title id="cs-t">The OpenVibe network: ${OPEN.length} open sites around one account, ${SOON.length} more opening next</title>--<defs><radialGradient id="cs-core"><stop offset="0" stop-color="var(--accent-light,#60a5fa)"/><stop offset="1" stop-color="var(--accent,#3b82f6)" stop-opacity="0"/></radialGradient></defs>--<circle class="cs-orbit" r="118"/><circle class="cs-orbit cs-far" r="176"/>${lines}--<circle r="46" fill="url(#cs-core)" opacity=".35"/><circle class="cs-core" r="24"/><text class="cs-core-t" y="4" text-anchor="middle">you</text>--${openNodes}${soonNodes}</svg>`;++function orbit() {++ const inner = OPEN.slice(0, 6), outer = OPEN.slice(6, 16);++ const node = (site, i, n, big) => `<a class="orb-node${big ? ' is-big' : ''}" href="https://${esc(site.host)}/" style="--i:${i};--n:${n}" title="${esc(siteName(site))}: ${esc(site.tagline || '')}" aria-label="${esc(siteName(site))}"><span class="orb-face">${icon(site.icon, big ? 26 : 20)}${big ? `<span class="orb-name">${esc(SHORT(site))}</span>` : ''}</span></a>`;++ return `<div class="orbit" role="group" aria-label="The OpenVibe network: ${OPEN.length + 1} sites around one account">++<div class="orb-glow" aria-hidden="true"></div>++<div class="orb-ring r2">${outer.map((s, i) => node(s, i, outer.length, false)).join('')}</div>++<div class="orb-ring r1">${inner.map((s, i) => node(s, i, inner.length, true)).join('')}</div>++<a class="orb-core" href="#network"><span class="ov-mark" data-size="64" data-variant="network" style="width:64px;height:64px"></span><b>${OPEN.length + 1} sites</b><small>one account</small></a>++</div>`;}const fmtViewers = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));@@@ -109,23 +100,45 @@ const fmtCount = (n) => Number(n).toLocaleString('en-US');const BLANK = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';const img = (src, fallback, w, h) => `<img src="${esc(src)}" alt="" loading="lazy" decoding="async" width="${w}" height="${h}" data-fallback="${esc(fallback || BLANK)}" onerror="this.onerror=null;this.src=this.dataset.fallback">`;function rightNow(a) {++ // Rich cards (the owner, 2026-10-28: "more compact, bigger, like YouTube… a preview… view count… the uploader and++ // avatar… rich content"): a 16:9 picture with its length or LIVE badge, a play button and what happens in it on hover,++ // then the channel's picture, the title, and views and when. Four across on a computer, two on a tablet, a row to swipe++ // on a phone.++ const ago = (ms) => {++ if (!ms) return '';++ const s = Math.max(0, (Date.now() - ms) / 1000);++ const [n, u] = s < 3600 ? [Math.max(1, Math.round(s / 60)), 'minute'] : s < 86400 ? [Math.round(s / 3600), 'hour'] : s < 86400 * 30 ? [Math.round(s / 86400), 'day'] : [Math.round(s / (86400 * 30)), 'month'];++ return `${n} ${u}${n === 1 ? '' : 's'} ago`;++ };++ const views = (n) => (n ? `${fmtViewers(n)} view${n === 1 ? '' : 's'}` : '');++ const face = (src) => (src ? img(src, null, 36, 36) : '<span class="vc-noface"></span>');++ const card = ({ url, thumb, badge, len, about, avatar, title, who, line, cls = '' }) => `<li><a class="vcard${cls}" href="${esc(url)}">++<span class="vc-thumb">${thumb ? img(thumb, avatar, 320, 180) : '<span class="now-noimg"></span>'}${badge || ''}${len ? `<span class="vc-len">${esc(len)}</span>` : ''}<span class="vc-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>${about ? `<span class="vc-about">${esc(about)}</span>` : ''}</span>++<span class="vc-meta">${face(avatar)}<span class="vc-text"><b>${esc(title)}</b>${who ? `<small>${esc(who)}</small>` : ''}${line ? `<small>${esc(line)}</small>` : ''}</span></span></a></li>`;++ const rail = (title, href, more, cards) => (cards.length ? `<div class="now-block"><div class="now-head"><h3>${title}</h3><a class="now-more" href="${href}">${more} →</a></div><ul class="now-rail">${cards.join('')}</ul></div>` : '');++const live = a.live.length-- ? a.live.map((s) => `<a class="now-stream" href="${esc(s.url)}">${s.thumb || s.avatar ? img(s.thumb || s.avatar, s.thumb ? s.avatar : null, 320, 180) : '<span class="now-noimg"></span>'}<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span><b>${esc(s.title)}</b><small>${esc(s.name)}${s.category ? ` · ${esc(s.category)}` : ''}</small></a>`).join('')-- : `<a class="now-stream now-empty" href="https://openvibe.live/broadcast"><span class="now-noimg"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i></span><b>Nobody is live right now</b><small>Be the first: go live from your browser, no OBS and no follower minimum.</small></a>`;++ ? a.live.map((s) => card({ url: s.url, thumb: s.thumb || s.avatar, badge: `<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span>`, avatar: s.avatar, title: s.title, who: s.name, line: s.category }))++ : [`<li><a class="vcard now-empty" href="https://openvibe.live/broadcast"><span class="vc-thumb"><span class="now-noimg"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i></span></span><span class="vc-meta"><span class="vc-text"><b>Nobody is live right now</b><small>Be the first: go live from your browser, no OBS and no follower minimum.</small></span></span></a></li>`];++ const recs = (a.recordings || []).map((r) => card({ url: r.url, thumb: r.thumb, len: r.length, about: r.about, avatar: r.avatar, title: r.title, who: r.channel, line: [views(r.views), ago(r.at)].filter(Boolean).join(' · ') }));++ const shots = (a.pastes || []).filter((p) => p.shot).slice(0, 4).map((p) => card({ url: p.url, thumb: p.shot, avatar: p.avatar, title: p.title, who: p.by || 'Screenshot', line: [views(p.views), p.likes ? `${p.likes} like${p.likes === 1 ? '' : 's'}` : '', ago(p.at)].filter(Boolean).join(' · '), cls: ' is-shot' }));++ const texts = (a.pastes || []).filter((p) => !p.shot);++const list = (items, render) => `<ul class="now-list">${items.map(render).join('')}</ul>`;-- // A column with nothing to show is left out; only Live always shows, since "nobody is live" is an invitation.++ // A column with nothing to show is left out.const col = (items, title, render, href, more) => (items.length ? `<div class="now-col"><h3>${title}</h3>${list(items, render)}<a class="now-more" href="${href}">${more} →</a></div>` : '');const cols = [-- `<div class="now-col now-live"><h3>Live now</h3><div class="now-streams">${live}</div><a class="now-more" href="https://openvibe.live/">All channels on OpenVibe.Live →</a></div>`,-- col(a.recordings || [], 'Latest recordings', (r) => `<li><a class="now-rec" href="${esc(r.url)}">${r.thumb ? img(r.thumb, null, 112, 63) : '<span class="now-noimg"></span>'}<span><b>${esc(r.title)}</b><small>${esc(r.channel)} · ${esc(r.length)}</small></span></a></li>`, 'https://openvibe.video/', 'Watch on OpenVibe.Video'),-- col(a.pastes, 'New pastes', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'https://openvibe.community/', 'OpenVibe.Community'),++ col(texts, 'New pastes', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'https://openvibe.community/', 'OpenVibe.Community'),col(a.posts, 'From the blog', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'https://openvibe.blog/', 'OpenVibe.Blog'),col(a.jobs || [], 'New jobs', (j) => `<li><a href="${esc(j.url)}"><b>${esc(j.title)}</b><small>${[j.where, j.board ? `via ${j.board}` : ''].filter(Boolean).map(esc).join(' · ')}</small></a></li>`, 'https://openvibe.work/', 'Every job on OpenVibe.Work'),col(a.codes || [], 'Fresh coupon codes', (c) => `<li><a href="${esc(c.url)}">${c.code ? `<code class="now-code">${esc(c.code)}</code>` : ''}<b>${esc(c.title)}</b></a></li>`, 'https://openvibe.coupons/', 'OpenVibe.Coupons'),].filter(Boolean);return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2><p class="sc-lede">What people are streaming, sharing and posting across the network, read from each site within the last minute.</p>--<div class="now-grid">${cols.join('\n')}</div></section>`;++${rail('Live now', 'https://openvibe.live/', 'All channels on OpenVibe.Live', live)}++${rail('Latest recordings', 'https://openvibe.video/', 'Watch on OpenVibe.Video', recs)}++${rail('New screenshots', 'https://openvibe.community/', 'OpenVibe.Community', shots)}++${cols.length ? `<div class="now-grid">${cols.join('\n')}</div>` : ''}</section>`;}// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it@@@ -137,7 +150,7 @@ function numbers(catalog, a) {[String(OPEN.length + 1), 'sites, one account', '', '#network'],catalog.tools.length ? [fmtCount(catalog.tools.length), 'online tools', '', 'https://openvibe.tools/'] : null,c.accounts ? [fmtCount(c.accounts), 'accounts', '', '/login?tab=register'] : null,-- [fmtCount(a.liveCount || 0), a.liveCount === 1 ? 'stream live now' : 'streams live now', '', 'https://openvibe.live/'],++ a.liveCount ? [fmtCount(a.liveCount), a.liveCount === 1 ? 'stream live now' : 'streams live now', '', 'https://openvibe.live/'] : null,].filter(Boolean);return `<section class="net-num" aria-labelledby="h-num"><h2 id="h-num">OpenVibe in numbers</h2><ul>${items.map(([n, label, note, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}${note ? ` <small>${esc(note)}</small>` : ''}</span></a></li>`).join('')}</ul><p class="net-num-more"><a href="/stats">Every site's numbers, open to everyone →</a></p></section>`;}@@@ -170,6 +183,29 @@ const CSS = `.now-more{margin-top:auto;font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}.now-rec{display:flex!important;gap:10px;align-items:flex-start}.now-rec img,.now-rec .now-noimg{flex:none;width:112px;height:63px;object-fit:cover;border-radius:8px;background:var(--bg-hover,#1c2a44);display:block}.now-rec>span{min-width:0}.now-rec b{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}++.now-block{margin-top:22px}.now-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}++.now-head h3{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0}.now-head .now-more{margin:0}++.now-rail{list-style:none;margin:0;padding:0;display:grid;gap:18px;grid-template-columns:repeat(4,minmax(0,1fr))}++@media (max-width:980px){.now-rail{grid-template-columns:repeat(2,minmax(0,1fr))}}++@media (max-width:600px){.now-rail{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:12px;margin:0 -16px;padding:2px 16px 8px;scrollbar-width:none;overscroll-behavior-x:contain}.now-rail::-webkit-scrollbar{display:none}.now-rail>li{flex:0 0 min(80%,320px);scroll-snap-align:start}}++.vcard{display:flex;flex-direction:column;gap:10px;color:inherit;text-decoration:none;min-width:0;border-radius:16px;outline-offset:4px}++.vc-thumb{position:relative;display:block;aspect-ratio:16/9;border-radius:14px;overflow:hidden;background:var(--bg-hover,#1c2a44);box-shadow:0 1px 0 rgba(255,255,255,.04) inset}++.vc-thumb img,.vc-thumb .now-noimg{display:block;width:100%;height:100%;object-fit:cover;transition:transform .6s cubic-bezier(.2,.7,.2,1);border-radius:0}++.vcard.is-shot .vc-thumb img{object-fit:cover;object-position:top}++.vc-len{position:absolute;right:8px;bottom:8px;padding:2px 7px;border-radius:6px;background:rgba(0,0,0,.78);color:#fff;font:700 12px/1.4 Inter,system-ui,sans-serif;transition:opacity .25s}++.vc-play{position:absolute;inset:0;display:grid;place-items:center;opacity:0;transition:opacity .25s;pointer-events:none}++.vc-play i{width:54px;height:54px;border-radius:50%;display:grid;place-items:center;background:rgba(0,0,0,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;font-size:18px;padding-left:3px;box-sizing:border-box;transform:scale(.75);transition:transform .35s cubic-bezier(.2,1.4,.3,1)}++.vc-about{position:absolute;left:0;right:0;bottom:0;padding:34px 12px 10px;background:linear-gradient(transparent,rgba(0,0,0,.88));color:#fff;font-size:12.5px;line-height:1.4;opacity:0;transform:translateY(10px);transition:opacity .3s,transform .35s cubic-bezier(.2,.7,.2,1);pointer-events:none}++@media (hover:hover){.vcard:hover .vc-thumb img,.vcard:focus-visible .vc-thumb img{transform:scale(1.06)}.vcard:hover .vc-play,.vcard:focus-visible .vc-play{opacity:1}.vcard:hover .vc-play i,.vcard:focus-visible .vc-play i{transform:none}.vcard:hover .vc-about,.vcard:focus-visible .vc-about{opacity:1;transform:none}.vcard:hover .vc-about~.vc-len,.vcard:hover .vc-len:has(~.vc-about){opacity:0}.vcard:hover b{color:var(--accent-light,#60a5fa)}}++.vc-meta{display:flex;gap:10px;align-items:flex-start;min-width:0}++.vc-meta img,.vc-noface{flex:none;width:36px;height:36px;border-radius:50%;object-fit:cover;background:var(--bg-hover,#1c2a44)}++.vc-text{display:flex;flex-direction:column;min-width:0;gap:1px}++.vc-text b{font-size:15px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;transition:color .2s}++.vc-text small{color:var(--text-secondary,#a8b3c4);font-size:13px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.vcard .now-badge{z-index:1}++.vcard.now-empty .vc-thumb{border:1px dashed var(--border-light,#2c3d5c);background:transparent}.vcard.now-empty .now-noimg{display:grid;place-items:center;font-size:28px;color:var(--accent-light,#60a5fa);background:transparent}++.now .now-grid{margin-top:26px}++@media (prefers-reduced-motion:reduce){.vc-thumb img,.vc-play,.vc-play i,.vc-about{transition:none}}.now-code{display:inline-block;margin:0 0 3px;padding:1px 7px;border-radius:6px;border:1px dashed var(--accent,#3b82f6);color:var(--accent-light,#60a5fa);font:700 12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.3px}.now-list b{overflow-wrap:anywhere}.net-num{margin-top:18px;padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:color-mix(in srgb,var(--bg-secondary,#101828) 85%,transparent)}@@@ -286,7 +322,7 @@ function render({ releaseId = '' } = {}) {const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what })))) + seo.jsonLdTag(seo.jsonLd.faq(faqPairs()));const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';const summary = summaryOf(catalog);-- const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', () => constellation() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);++ const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(OPEN.length + 1)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };return pageCache;}@@@ -82,8 +82,13 @@ for (const [id, , verb, , href] of home.INTENTS) {assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');assert.ok(/Nobody is live right now/.test(html) && /go live from your browser/.test(html), 'an honest empty state');const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');--assert.ok(html.includes('<svg class="constellation"'), 'the constellation is drawn');--for (const s of open) assert.ok(html.includes(`<a href="https://${s.host}/" class="cs-node"`), `the constellation links ${s.host}`);++// The hero's orbit (2026-10-28): the most used sites around the mark, the count of every site in its middle.++assert.ok(html.includes('<div class="orbit" role="group"'), 'the orbit is drawn');++for (const s of open.slice(0, 16)) assert.ok(html.includes(`href="https://${s.host}/" style="--i:`), `the orbit links ${s.host}`);++assert.ok(html.includes(`<b>${open.length + 1} sites</b><small>one account</small>`), 'the orbit says how many sites');++assert.ok(!/OpenVibe\.OpenRestream/.test(html), 'OpenRestream is its own name');++assert.ok(!/class="hero-badge"/.test(html), 'no pill above the headline (wasted space on a phone)');++assert.ok(!/streams live now/.test(html), 'a number that reads zero is left out');assert.ok(!html.includes('<!--OV:'), 'no placeholder left behind');// Below the hero every section is the shared showcase kit (plan T11), with its stylesheet, one h1 and an AI summary.assert.ok(html.includes('<link rel="stylesheet" href="/shared/showcase.css">'), 'the kit stylesheet is linked');@@@ -96,7 +101,7 @@ assert.ok(/"@type":"WebPage"/.test(html), 'the summary has its WebPage JSON-LD')// A tagline that already ends a sentence ("What should we eat?") gets no second period; a family of one is "1 tool".assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');--assert.ok(/Live streaming from a browser tab, /.test(html), 'the hero line still leads with going live from a browser');++assert.ok(/<h1>Go live, chat, play<br>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://${s.host}/">`), `the sites section links ${s.host}`);for (const href of ['https://openvibe.tools/api/catalog.json', '/llms.txt', 'https://openvibe.tools/llms.txt', 'https://github.com/OpenVibers', 'https://openvibe.codes/', 'https://yt.openvibe.tools/', 'https://dns.openvibe.tools/']) assert.ok(html.includes(`href="${href}"`), `the page still links ${href}`);assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/section>/g) || []).length, 'every section is closed once');@@@ -12,7 +12,7 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 157, // 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network++ htmlRawKB: 172, // 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the networkhtmlBrotliKB: 31, // 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)jsRawKB: 303, // 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
@@ -120,7 +120,31 @@ a{color:inherit}
@@ -120,7 +120,31 @@ a{color:inherit}
.constellation .cs-soon .cs-star{fill:var(--text-muted)}.constellation .cs-soon text{fill:var(--text-muted);font-size:10.5px;font-weight:500}
.constellation .cs-soon .cs-star{fill:var(--text-muted)}.constellation .cs-soon text{fill:var(--text-muted);font-size:10.5px;font-weight:500}
@keyframes cs-twinkle{0%,100%{transform:scale(1);opacity:.16}50%{transform:scale(1.35);opacity:.3}}
@keyframes cs-twinkle{0%,100%{transform:scale(1);opacity:.16}50%{transform:scale(1.35);opacity:.3}}
@media (prefers-reduced-motion:reduce){.constellation .cs-halo{animation:none}.intent{transition:none}}
@media (prefers-reduced-motion:reduce){.constellation .cs-halo{animation:none}.intent{transition:none}}
-@media (max-width:900px){.hero-grid{grid-template-columns:minmax(0,1fr);text-align:center}.hero-grid p.sub{margin:18px auto 0}.hero-sky{position:static;padding-top:0;text-align:left}.hero-sky .constellation{display:none}.intent{text-align:left}}
+@media (max-width:900px){.hero-grid{grid-template-columns:minmax(0,1fr);text-align:center}.hero-grid p.sub{margin:14px auto 0}.hero-sky{position:static;padding-top:0}.intent{text-align:left}}
+/* The orbit (server/home/render.js orbit()): rings turning in opposite directions, every icon upright. */
+.orbit{position:relative;width:min(100%,460px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
+.orb-glow{position:absolute;inset:22%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent) 34%,transparent),transparent);filter:blur(6px);animation:orb-breathe 6s ease-in-out infinite}
+.orb-ring{position:absolute;inset:0;border-radius:50%;animation:orb-spin 90s linear infinite}
+.orb-ring::before{content:"";position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light) 30%,transparent)}
+.orb-ring.r1{--R:31cqw}.orb-ring.r1::before{inset:19%}
+.orb-ring.r2{--R:45cqw;animation-duration:140s;animation-direction:reverse}.orb-ring.r2::before{inset:5%;opacity:.55}
+.orb-node{position:absolute;left:50%;top:50%;width:44px;height:44px;margin:-22px;transform:rotate(calc(360deg / var(--n) * var(--i))) translateY(calc(-1 * var(--R))) rotate(calc(-360deg / var(--n) * var(--i)));text-decoration:none;color:var(--text-primary)}
+.orb-node.is-big{width:58px;height:58px;margin:-29px}
+.orb-face{display:flex;flex-direction:column;align-items:center;gap:5px;width:100%;height:100%;animation:orb-spin 90s linear infinite reverse}
+.r2 .orb-face{animation-duration:140s;animation-direction:normal}
+.orb-face .ov-icon{display:grid;place-items:center;width:100%;height:100%;flex:none;border-radius:50%;background:color-mix(in srgb,var(--bg-secondary) 88%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent-light) 30%,transparent),0 8px 22px rgba(0,0,0,.35);transition:transform .3s cubic-bezier(.2,1.4,.3,1),box-shadow .3s}
+.orb-name{font-size:11.5px;font-weight:700;color:var(--text-secondary);white-space:nowrap;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--bg-primary) 78%,transparent);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}
+.orb-ring.r1{z-index:2}.orb-core{z-index:3}
+.orb-node:hover .ov-icon,.orb-node:focus-visible .ov-icon{transform:scale(1.15);box-shadow:0 0 0 2px var(--accent-light),0 10px 28px rgba(0,0,0,.45)}
+.orb-node:hover .orb-name,.orb-node:focus-visible .orb-name{color:var(--text-primary)}
+.orb-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;gap:2px;text-decoration:none;color:var(--text-primary);text-align:center}
+.orb-core b{font-size:15px;font-weight:800;margin-top:6px}.orb-core small{font-size:12px;color:var(--text-muted)}
+@keyframes orb-spin{to{transform:rotate(360deg)}}
+@keyframes orb-breathe{0%,100%{opacity:.7;transform:scale(.95)}50%{opacity:1;transform:scale(1.05)}}
+@media (prefers-reduced-motion:reduce){.orb-ring,.orb-face,.orb-glow{animation:none}}
+html[data-ov-motion=reduced] .orb-ring,html[data-ov-motion=reduced] .orb-face,html[data-ov-motion=reduced] .orb-glow{animation:none}
+@media (max-width:900px){.hero-sky{order:-1;margin:-8px auto 4px}.hero-sky .orbit{width:min(72vw,270px)}.hero-sky .net-num{display:none}.orb-node.is-big{width:46px;height:46px;margin:-23px}.orb-node{width:34px;height:34px;margin:-17px}.orb-name{font-size:10.5px}.orb-core b{font-size:13px}}
+
@media (max-width:520px){.intents{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.intent small{display:none}.intent{align-items:center;gap:9px;padding:9px 10px}.intent>i{width:30px;height:30px;font-size:14px}.intent b{font-size:13.5px}.intent-bar button{padding:11px 14px}}
@media (max-width:520px){.intents{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.intent small{display:none}.intent{align-items:center;gap:9px;padding:9px 10px}.intent>i{width:30px;height:30px;font-size:14px}.intent b{font-size:13.5px}.intent-bar button{padding:11px 14px}}
/* Services */
/* Services */
@@ -181,7 +205,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -181,7 +205,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
.foot-note{width:100%;font-size:12px;color:var(--text-muted)}
.foot-note{width:100%;font-size:12px;color:var(--text-muted)}
@media (max-width:600px){
@media (max-width:600px){
- .hero{padding-top:64px}
+ .hero{padding-top:20px}
}
}
</style>
</style>
<script src="/shared/theme-loader.js"></script>
<script src="/shared/theme-loader.js"></script>
@@ -199,9 +223,8 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -199,9 +223,8 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<section class="hero">
<section class="hero">
<div class="hero-grid">
<div class="hero-grid">
<div class="hero-main">
<div class="hero-main">
- <div class="hero-badge"><span class="ov-mark" data-size="20" data-variant="network"></span> The OpenVibe Network — open source, community run</div>
- <h1>The open network<br><span class="grad">for what you do online.</span></h1>
- <p class="sub">Live streaming from a browser tab, <!--OV:COUNT--> online tools, chat, games, a wiki and a blog; agents that do tasks for you; jobs, food help and places to rent; and the platform underneath, for anyone to build on. Open source, run by the people who use it, and open to everyone without an account. Sign in once and every site already knows you.</p>
+ <h1>Go live, chat, play<br><span class="grad">and get things done.</span></h1>
+ <p class="sub">Stream from a browser tab, no OBS. Chat, play and use <b><!--OV:COUNT--> online tools</b> with one free account across <b><!--OV:SITES--> sites</b>. Open source and community run.</p>
<form class="intent-bar" action="https://openvibe.tools/search" method="get" role="search">
<form class="intent-bar" action="https://openvibe.tools/search" method="get" role="search">
<label for="intent-q" class="sr-only">What do you want to do?</label>
<label for="intent-q" class="sr-only">What do you want to do?</label>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
@@ -89,12 +89,19 @@ function recordings(body) {
@@ -89,12 +89,19 @@ function recordings(body) {
const title = clip(v.title || '', 80);
const title = clip(v.title || '', 80);
if (!title || ADULT.test(title)) continue;
if (!title || ADULT.test(title)) continue;
seen.add(v.username);
seen.add(v.username);
+ // What happens in it (Live's AI overview), one line, only when it names nobody's looks (public AI text).
+ const about = clip(String(v.ai_overview || '').split(/(?<=[.!?])\s/)[0] || '', 120);
out.push({
out.push({
title,
title,
url: `${PUBLIC.video}/v/${encodeURIComponent(v.id)}`,
url: `${PUBLIC.video}/v/${encodeURIComponent(v.id)}`,
thumb: onHost(v.thumbnail_url, 'openvibe.media'),
thumb: onHost(v.thumbnail_url, 'openvibe.media'),
channel: clip(v.display_name || v.username, 40),
channel: clip(v.display_name || v.username, 40),
length: runtime(v.duration_seconds ?? v.duration),
length: runtime(v.duration_seconds ?? v.duration),
+ seconds: Math.round(Number(v.duration_seconds ?? v.duration) || 0),
+ avatar: onHost(v.avatar_url, 'openvibe.media'),
+ views: Math.max(0, Number(v.view_count) || 0),
+ at: Date.parse(v.created_at) || null,
+ about: about && !LOOKS.test(about) && !ADULT.test(about) ? about : '',
});
});
if (out.length === 4) break;
if (out.length === 4) break;
}
}
@@ -116,6 +123,12 @@ function publicPastes(body) {
@@ -116,6 +123,12 @@ function publicPastes(body) {
url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,
url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,
kind: shot ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),
kind: shot ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),
summary,
summary,
+ shot: shot ? onHost(p.screenshot_url, 'openvibe.media') : null,
+ by: clip(p.display_name || p.username || '', 40),
+ avatar: onHost(p.avatar_url, 'openvibe.media'),
+ views: Math.max(0, Number(p.views ?? p.unique_views) || 0),
+ likes: Math.max(0, Number(p.likes) || 0),
+ at: Date.parse(p.created_at) || null,
};
};
})
})
.slice(0, 4);
.slice(0, 4);
@@ -20,10 +20,11 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat
@@ -20,10 +20,11 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat
// The sites come from the frame's list (server/frame/sites.js): a site is open only when its service's
// The sites come from the frame's list (server/frame/sites.js): a site is open only when its service's
// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and
// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and
// "Opening next" always agree with what is actually running.
// "Opening next" always agree with what is actually running.
-const { SITES: frameSites, GROUPS, groupOf, PRODUCTS } = require('../frame/sites');
+const { SITES: frameSites, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const social = require('openvibe-shared/social');
const social = require('openvibe-shared/social');
-const siteName = (site) => (site.name.includes('.') ? site.name : `OpenVibe.${site.name}`);
+// OpenRestream is its own name, never "OpenVibe.OpenRestream" (the owner, 2026-10-28).
+const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));
const OPEN = frameSites.filter((site) => site.status === 'open' && site.id !== 'network');
const OPEN = frameSites.filter((site) => site.status === 'open' && site.id !== 'network');
const SOON = frameSites.filter((site) => site.status !== 'open');
const SOON = frameSites.filter((site) => site.status !== 'open');
const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', 'Twenty'];
const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', 'Twenty'];
@@ -76,30 +77,20 @@ function intents(catalog) {
@@ -76,30 +77,20 @@ function intents(catalog) {
return `<ul class="intents" aria-label="What you can do here">${INTENTS.map(([id, fa, verb, line, href, k]) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small></span></a></li>`).join('')}</ul>`;
return `<ul class="intents" aria-label="What you can do here">${INTENTS.map(([id, fa, verb, line, href, k]) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small></span></a></li>`).join('')}</ul>`;
}
}
-// The network as a constellation: open sites on the inner ring, the ones opening next faint on the outer ring.
+// The network as an orbit (the owner, 2026-10-28: the hero was "ugly af, boring… not easy to understand… nice animated
+// visuals"): the mark in the middle, the six most used sites on the inner ring and ten more on the outer, the rings
+// turning slowly in opposite directions while each site's icon stays upright. Pure CSS transforms (cheap to draw), and
+// still under reduced motion. Every open site is in the sites section below; this shows the shape of the network.
const SHORT = (site) => site.name.replace(/^OpenVibe\./, '');
const SHORT = (site) => site.name.replace(/^OpenVibe\./, '');
-function constellation() {
- const ring = (list, r, start) => list.map((site, i) => {
- const a = start + (i / list.length) * Math.PI * 2;
- return { site, x: Math.round(Math.cos(a) * r), y: Math.round(Math.sin(a) * r) };
- });
- const inner = ring(OPEN, 118, -Math.PI / 2);
- const outer = ring(SOON, 176, -Math.PI / 2 + Math.PI / SOON.length);
- const lines = inner.map((n, i) => {
- const m = inner[(i + 1) % inner.length];
- return `<line class="cs-spoke" x1="0" y1="0" x2="${n.x}" y2="${n.y}"/><line class="cs-arc" x1="${n.x}" y1="${n.y}" x2="${m.x}" y2="${m.y}"/>`;
- }).join('');
- // Near the top and bottom the labels sit centred side by side and collide ("ZoneLiveTools"): every other one there
- // sits further out.
- const stagger = (n, i) => (Math.abs(n.x) / (Math.hypot(n.x, n.y) || 1) < 0.5 && i % 2 ? 14 : 0);
- const label = (n, r) => { const dx = n.x / (Math.hypot(n.x, n.y) || 1), dy = n.y / (Math.hypot(n.x, n.y) || 1); return { x: Math.round(n.x + dx * r), y: Math.round(n.y + dy * r) + 4, anchor: Math.abs(dx) < 0.25 ? 'middle' : (dx > 0 ? 'start' : 'end') }; };
- const openNodes = inner.map((n, i) => { const l = label(n, 17 + stagger(n, i)); return `<a href="https://${esc(n.site.host)}/" class="cs-node" style="--d:${(i * 0.37).toFixed(2)}s"><title>${esc(siteName(n.site))}: ${esc(n.site.tagline)}</title><circle class="cs-halo" cx="${n.x}" cy="${n.y}" r="11"/><circle class="cs-star" cx="${n.x}" cy="${n.y}" r="5"/><text x="${l.x}" y="${l.y}" text-anchor="${l.anchor}">${esc(SHORT(n.site))}</text></a>`; }).join('');
- const soonNodes = outer.map((n) => { const l = label(n, 9); return `<a href="https://${esc(n.site.host)}/" class="cs-node cs-soon"><title>${esc(siteName(n.site))} — opening next</title><circle class="cs-star" cx="${n.x}" cy="${n.y}" r="2.6"/><text x="${l.x}" y="${l.y}" text-anchor="${l.anchor}">${esc(SHORT(n.site))}</text></a>`; }).join('');
- return `<svg class="constellation" viewBox="-230 -215 460 430" role="group" aria-labelledby="cs-t"><title id="cs-t">The OpenVibe network: ${OPEN.length} open sites around one account, ${SOON.length} more opening next</title>
-<defs><radialGradient id="cs-core"><stop offset="0" stop-color="var(--accent-light,#60a5fa)"/><stop offset="1" stop-color="var(--accent,#3b82f6)" stop-opacity="0"/></radialGradient></defs>
-<circle class="cs-orbit" r="118"/><circle class="cs-orbit cs-far" r="176"/>${lines}
-<circle r="46" fill="url(#cs-core)" opacity=".35"/><circle class="cs-core" r="24"/><text class="cs-core-t" y="4" text-anchor="middle">you</text>
-${openNodes}${soonNodes}</svg>`;
+function orbit() {
+ const inner = OPEN.slice(0, 6), outer = OPEN.slice(6, 16);
+ const node = (site, i, n, big) => `<a class="orb-node${big ? ' is-big' : ''}" href="https://${esc(site.host)}/" style="--i:${i};--n:${n}" title="${esc(siteName(site))}: ${esc(site.tagline || '')}" aria-label="${esc(siteName(site))}"><span class="orb-face">${icon(site.icon, big ? 26 : 20)}${big ? `<span class="orb-name">${esc(SHORT(site))}</span>` : ''}</span></a>`;
+ return `<div class="orbit" role="group" aria-label="The OpenVibe network: ${OPEN.length + 1} sites around one account">
+<div class="orb-glow" aria-hidden="true"></div>
+<div class="orb-ring r2">${outer.map((s, i) => node(s, i, outer.length, false)).join('')}</div>
+<div class="orb-ring r1">${inner.map((s, i) => node(s, i, inner.length, true)).join('')}</div>
+<a class="orb-core" href="#network"><span class="ov-mark" data-size="64" data-variant="network" style="width:64px;height:64px"></span><b>${OPEN.length + 1} sites</b><small>one account</small></a>
+</div>`;
}
}
const fmtViewers = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
const fmtViewers = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
@@ -109,23 +100,45 @@ const fmtCount = (n) => Number(n).toLocaleString('en-US');
@@ -109,23 +100,45 @@ const fmtCount = (n) => Number(n).toLocaleString('en-US');
const BLANK = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const BLANK = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const img = (src, fallback, w, h) => `<img src="${esc(src)}" alt="" loading="lazy" decoding="async" width="${w}" height="${h}" data-fallback="${esc(fallback || BLANK)}" onerror="this.onerror=null;this.src=this.dataset.fallback">`;
const img = (src, fallback, w, h) => `<img src="${esc(src)}" alt="" loading="lazy" decoding="async" width="${w}" height="${h}" data-fallback="${esc(fallback || BLANK)}" onerror="this.onerror=null;this.src=this.dataset.fallback">`;
function rightNow(a) {
function rightNow(a) {
+ // Rich cards (the owner, 2026-10-28: "more compact, bigger, like YouTube… a preview… view count… the uploader and
+ // avatar… rich content"): a 16:9 picture with its length or LIVE badge, a play button and what happens in it on hover,
+ // then the channel's picture, the title, and views and when. Four across on a computer, two on a tablet, a row to swipe
+ // on a phone.
+ const ago = (ms) => {
+ if (!ms) return '';
+ const s = Math.max(0, (Date.now() - ms) / 1000);
+ const [n, u] = s < 3600 ? [Math.max(1, Math.round(s / 60)), 'minute'] : s < 86400 ? [Math.round(s / 3600), 'hour'] : s < 86400 * 30 ? [Math.round(s / 86400), 'day'] : [Math.round(s / (86400 * 30)), 'month'];
+ return `${n} ${u}${n === 1 ? '' : 's'} ago`;
+ };
+ const views = (n) => (n ? `${fmtViewers(n)} view${n === 1 ? '' : 's'}` : '');
+ const face = (src) => (src ? img(src, null, 36, 36) : '<span class="vc-noface"></span>');
+ const card = ({ url, thumb, badge, len, about, avatar, title, who, line, cls = '' }) => `<li><a class="vcard${cls}" href="${esc(url)}">
+<span class="vc-thumb">${thumb ? img(thumb, avatar, 320, 180) : '<span class="now-noimg"></span>'}${badge || ''}${len ? `<span class="vc-len">${esc(len)}</span>` : ''}<span class="vc-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>${about ? `<span class="vc-about">${esc(about)}</span>` : ''}</span>
+<span class="vc-meta">${face(avatar)}<span class="vc-text"><b>${esc(title)}</b>${who ? `<small>${esc(who)}</small>` : ''}${line ? `<small>${esc(line)}</small>` : ''}</span></span></a></li>`;
+ const rail = (title, href, more, cards) => (cards.length ? `<div class="now-block"><div class="now-head"><h3>${title}</h3><a class="now-more" href="${href}">${more} →</a></div><ul class="now-rail">${cards.join('')}</ul></div>` : '');
+
const live = a.live.length
const live = a.live.length
- ? a.live.map((s) => `<a class="now-stream" href="${esc(s.url)}">${s.thumb || s.avatar ? img(s.thumb || s.avatar, s.thumb ? s.avatar : null, 320, 180) : '<span class="now-noimg"></span>'}<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span><b>${esc(s.title)}</b><small>${esc(s.name)}${s.category ? ` · ${esc(s.category)}` : ''}</small></a>`).join('')
- : `<a class="now-stream now-empty" href="https://openvibe.live/broadcast"><span class="now-noimg"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i></span><b>Nobody is live right now</b><small>Be the first: go live from your browser, no OBS and no follower minimum.</small></a>`;
+ ? a.live.map((s) => card({ url: s.url, thumb: s.thumb || s.avatar, badge: `<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span>`, avatar: s.avatar, title: s.title, who: s.name, line: s.category }))
+ : [`<li><a class="vcard now-empty" href="https://openvibe.live/broadcast"><span class="vc-thumb"><span class="now-noimg"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i></span></span><span class="vc-meta"><span class="vc-text"><b>Nobody is live right now</b><small>Be the first: go live from your browser, no OBS and no follower minimum.</small></span></span></a></li>`];
+ const recs = (a.recordings || []).map((r) => card({ url: r.url, thumb: r.thumb, len: r.length, about: r.about, avatar: r.avatar, title: r.title, who: r.channel, line: [views(r.views), ago(r.at)].filter(Boolean).join(' · ') }));
+ const shots = (a.pastes || []).filter((p) => p.shot).slice(0, 4).map((p) => card({ url: p.url, thumb: p.shot, avatar: p.avatar, title: p.title, who: p.by || 'Screenshot', line: [views(p.views), p.likes ? `${p.likes} like${p.likes === 1 ? '' : 's'}` : '', ago(p.at)].filter(Boolean).join(' · '), cls: ' is-shot' }));
+ const texts = (a.pastes || []).filter((p) => !p.shot);
+
const list = (items, render) => `<ul class="now-list">${items.map(render).join('')}</ul>`;
const list = (items, render) => `<ul class="now-list">${items.map(render).join('')}</ul>`;
- // A column with nothing to show is left out; only Live always shows, since "nobody is live" is an invitation.
+ // A column with nothing to show is left out.
const col = (items, title, render, href, more) => (items.length ? `<div class="now-col"><h3>${title}</h3>${list(items, render)}<a class="now-more" href="${href}">${more} →</a></div>` : '');
const col = (items, title, render, href, more) => (items.length ? `<div class="now-col"><h3>${title}</h3>${list(items, render)}<a class="now-more" href="${href}">${more} →</a></div>` : '');
const cols = [
const cols = [
- `<div class="now-col now-live"><h3>Live now</h3><div class="now-streams">${live}</div><a class="now-more" href="https://openvibe.live/">All channels on OpenVibe.Live →</a></div>`,
- col(a.recordings || [], 'Latest recordings', (r) => `<li><a class="now-rec" href="${esc(r.url)}">${r.thumb ? img(r.thumb, null, 112, 63) : '<span class="now-noimg"></span>'}<span><b>${esc(r.title)}</b><small>${esc(r.channel)} · ${esc(r.length)}</small></span></a></li>`, 'https://openvibe.video/', 'Watch on OpenVibe.Video'),
- col(a.pastes, 'New pastes', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'https://openvibe.community/', 'OpenVibe.Community'),
+ col(texts, 'New pastes', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'https://openvibe.community/', 'OpenVibe.Community'),
col(a.posts, 'From the blog', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'https://openvibe.blog/', 'OpenVibe.Blog'),
col(a.posts, 'From the blog', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'https://openvibe.blog/', 'OpenVibe.Blog'),
col(a.jobs || [], 'New jobs', (j) => `<li><a href="${esc(j.url)}"><b>${esc(j.title)}</b><small>${[j.where, j.board ? `via ${j.board}` : ''].filter(Boolean).map(esc).join(' · ')}</small></a></li>`, 'https://openvibe.work/', 'Every job on OpenVibe.Work'),
col(a.jobs || [], 'New jobs', (j) => `<li><a href="${esc(j.url)}"><b>${esc(j.title)}</b><small>${[j.where, j.board ? `via ${j.board}` : ''].filter(Boolean).map(esc).join(' · ')}</small></a></li>`, 'https://openvibe.work/', 'Every job on OpenVibe.Work'),
col(a.codes || [], 'Fresh coupon codes', (c) => `<li><a href="${esc(c.url)}">${c.code ? `<code class="now-code">${esc(c.code)}</code>` : ''}<b>${esc(c.title)}</b></a></li>`, 'https://openvibe.coupons/', 'OpenVibe.Coupons'),
col(a.codes || [], 'Fresh coupon codes', (c) => `<li><a href="${esc(c.url)}">${c.code ? `<code class="now-code">${esc(c.code)}</code>` : ''}<b>${esc(c.title)}</b></a></li>`, 'https://openvibe.coupons/', 'OpenVibe.Coupons'),
].filter(Boolean);
].filter(Boolean);
return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>
return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>
<p class="sc-lede">What people are streaming, sharing and posting across the network, read from each site within the last minute.</p>
<p class="sc-lede">What people are streaming, sharing and posting across the network, read from each site within the last minute.</p>
-<div class="now-grid">${cols.join('\n')}</div></section>`;
+${rail('Live now', 'https://openvibe.live/', 'All channels on OpenVibe.Live', live)}
+${rail('Latest recordings', 'https://openvibe.video/', 'Watch on OpenVibe.Video', recs)}
+${rail('New screenshots', 'https://openvibe.community/', 'OpenVibe.Community', shots)}
+${cols.length ? `<div class="now-grid">${cols.join('\n')}</div>` : ''}</section>`;
}
}
// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it
// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it
@@ -137,7 +150,7 @@ function numbers(catalog, a) {
@@ -137,7 +150,7 @@ function numbers(catalog, a) {
[String(OPEN.length + 1), 'sites, one account', '', '#network'],
[String(OPEN.length + 1), 'sites, one account', '', '#network'],
catalog.tools.length ? [fmtCount(catalog.tools.length), 'online tools', '', 'https://openvibe.tools/'] : null,
catalog.tools.length ? [fmtCount(catalog.tools.length), 'online tools', '', 'https://openvibe.tools/'] : null,
c.accounts ? [fmtCount(c.accounts), 'accounts', '', '/login?tab=register'] : null,
c.accounts ? [fmtCount(c.accounts), 'accounts', '', '/login?tab=register'] : null,
- [fmtCount(a.liveCount || 0), a.liveCount === 1 ? 'stream live now' : 'streams live now', '', 'https://openvibe.live/'],
+ a.liveCount ? [fmtCount(a.liveCount), a.liveCount === 1 ? 'stream live now' : 'streams live now', '', 'https://openvibe.live/'] : null,
].filter(Boolean);
].filter(Boolean);
return `<section class="net-num" aria-labelledby="h-num"><h2 id="h-num">OpenVibe in numbers</h2><ul>${items.map(([n, label, note, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}${note ? ` <small>${esc(note)}</small>` : ''}</span></a></li>`).join('')}</ul><p class="net-num-more"><a href="/stats">Every site's numbers, open to everyone →</a></p></section>`;
return `<section class="net-num" aria-labelledby="h-num"><h2 id="h-num">OpenVibe in numbers</h2><ul>${items.map(([n, label, note, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}${note ? ` <small>${esc(note)}</small>` : ''}</span></a></li>`).join('')}</ul><p class="net-num-more"><a href="/stats">Every site's numbers, open to everyone →</a></p></section>`;
}
}
@@ -170,6 +183,29 @@ const CSS = `
@@ -170,6 +183,29 @@ const CSS = `
.now-more{margin-top:auto;font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}
.now-more{margin-top:auto;font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}
.now-rec{display:flex!important;gap:10px;align-items:flex-start}.now-rec img,.now-rec .now-noimg{flex:none;width:112px;height:63px;object-fit:cover;border-radius:8px;background:var(--bg-hover,#1c2a44);display:block}
.now-rec{display:flex!important;gap:10px;align-items:flex-start}.now-rec img,.now-rec .now-noimg{flex:none;width:112px;height:63px;object-fit:cover;border-radius:8px;background:var(--bg-hover,#1c2a44);display:block}
.now-rec>span{min-width:0}.now-rec b{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
.now-rec>span{min-width:0}.now-rec b{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
+.now-block{margin-top:22px}.now-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}
+.now-head h3{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0}.now-head .now-more{margin:0}
+.now-rail{list-style:none;margin:0;padding:0;display:grid;gap:18px;grid-template-columns:repeat(4,minmax(0,1fr))}
+@media (max-width:980px){.now-rail{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media (max-width:600px){.now-rail{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:12px;margin:0 -16px;padding:2px 16px 8px;scrollbar-width:none;overscroll-behavior-x:contain}.now-rail::-webkit-scrollbar{display:none}.now-rail>li{flex:0 0 min(80%,320px);scroll-snap-align:start}}
+.vcard{display:flex;flex-direction:column;gap:10px;color:inherit;text-decoration:none;min-width:0;border-radius:16px;outline-offset:4px}
+.vc-thumb{position:relative;display:block;aspect-ratio:16/9;border-radius:14px;overflow:hidden;background:var(--bg-hover,#1c2a44);box-shadow:0 1px 0 rgba(255,255,255,.04) inset}
+.vc-thumb img,.vc-thumb .now-noimg{display:block;width:100%;height:100%;object-fit:cover;transition:transform .6s cubic-bezier(.2,.7,.2,1);border-radius:0}
+.vcard.is-shot .vc-thumb img{object-fit:cover;object-position:top}
+.vc-len{position:absolute;right:8px;bottom:8px;padding:2px 7px;border-radius:6px;background:rgba(0,0,0,.78);color:#fff;font:700 12px/1.4 Inter,system-ui,sans-serif;transition:opacity .25s}
+.vc-play{position:absolute;inset:0;display:grid;place-items:center;opacity:0;transition:opacity .25s;pointer-events:none}
+.vc-play i{width:54px;height:54px;border-radius:50%;display:grid;place-items:center;background:rgba(0,0,0,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;font-size:18px;padding-left:3px;box-sizing:border-box;transform:scale(.75);transition:transform .35s cubic-bezier(.2,1.4,.3,1)}
+.vc-about{position:absolute;left:0;right:0;bottom:0;padding:34px 12px 10px;background:linear-gradient(transparent,rgba(0,0,0,.88));color:#fff;font-size:12.5px;line-height:1.4;opacity:0;transform:translateY(10px);transition:opacity .3s,transform .35s cubic-bezier(.2,.7,.2,1);pointer-events:none}
+@media (hover:hover){.vcard:hover .vc-thumb img,.vcard:focus-visible .vc-thumb img{transform:scale(1.06)}.vcard:hover .vc-play,.vcard:focus-visible .vc-play{opacity:1}.vcard:hover .vc-play i,.vcard:focus-visible .vc-play i{transform:none}.vcard:hover .vc-about,.vcard:focus-visible .vc-about{opacity:1;transform:none}.vcard:hover .vc-about~.vc-len,.vcard:hover .vc-len:has(~.vc-about){opacity:0}.vcard:hover b{color:var(--accent-light,#60a5fa)}}
+.vc-meta{display:flex;gap:10px;align-items:flex-start;min-width:0}
+.vc-meta img,.vc-noface{flex:none;width:36px;height:36px;border-radius:50%;object-fit:cover;background:var(--bg-hover,#1c2a44)}
+.vc-text{display:flex;flex-direction:column;min-width:0;gap:1px}
+.vc-text b{font-size:15px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;transition:color .2s}
+.vc-text small{color:var(--text-secondary,#a8b3c4);font-size:13px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.vcard .now-badge{z-index:1}
+.vcard.now-empty .vc-thumb{border:1px dashed var(--border-light,#2c3d5c);background:transparent}.vcard.now-empty .now-noimg{display:grid;place-items:center;font-size:28px;color:var(--accent-light,#60a5fa);background:transparent}
+.now .now-grid{margin-top:26px}
+@media (prefers-reduced-motion:reduce){.vc-thumb img,.vc-play,.vc-play i,.vc-about{transition:none}}
.now-code{display:inline-block;margin:0 0 3px;padding:1px 7px;border-radius:6px;border:1px dashed var(--accent,#3b82f6);color:var(--accent-light,#60a5fa);font:700 12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.3px}
.now-code{display:inline-block;margin:0 0 3px;padding:1px 7px;border-radius:6px;border:1px dashed var(--accent,#3b82f6);color:var(--accent-light,#60a5fa);font:700 12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.3px}
.now-list b{overflow-wrap:anywhere}
.now-list b{overflow-wrap:anywhere}
.net-num{margin-top:18px;padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:color-mix(in srgb,var(--bg-secondary,#101828) 85%,transparent)}
.net-num{margin-top:18px;padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:color-mix(in srgb,var(--bg-secondary,#101828) 85%,transparent)}
@@ -286,7 +322,7 @@ function render({ releaseId = '' } = {}) {
@@ -286,7 +322,7 @@ function render({ releaseId = '' } = {}) {
const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what })))) + seo.jsonLdTag(seo.jsonLd.faq(faqPairs()));
const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what })))) + seo.jsonLdTag(seo.jsonLd.faq(faqPairs()));
const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const summary = summaryOf(catalog);
const summary = summaryOf(catalog);
- const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', () => constellation() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
+ const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(OPEN.length + 1)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return pageCache;
return pageCache;
}
}
@@ -82,8 +82,13 @@ for (const [id, , verb, , href] of home.INTENTS) {
@@ -82,8 +82,13 @@ for (const [id, , verb, , href] of home.INTENTS) {
assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');
assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');
assert.ok(/Nobody is live right now/.test(html) && /go live from your browser/.test(html), 'an honest empty state');
assert.ok(/Nobody is live right now/.test(html) && /go live from your browser/.test(html), 'an honest empty state');
const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');
const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');
-assert.ok(html.includes('<svg class="constellation"'), 'the constellation is drawn');
-for (const s of open) assert.ok(html.includes(`<a href="https://${s.host}/" class="cs-node"`), `the constellation links ${s.host}`);
+// The hero's orbit (2026-10-28): the most used sites around the mark, the count of every site in its middle.
+assert.ok(html.includes('<div class="orbit" role="group"'), 'the orbit is drawn');
+for (const s of open.slice(0, 16)) assert.ok(html.includes(`href="https://${s.host}/" style="--i:`), `the orbit links ${s.host}`);
+assert.ok(html.includes(`<b>${open.length + 1} sites</b><small>one account</small>`), 'the orbit says how many sites');
+assert.ok(!/OpenVibe\.OpenRestream/.test(html), 'OpenRestream is its own name');
+assert.ok(!/class="hero-badge"/.test(html), 'no pill above the headline (wasted space on a phone)');
+assert.ok(!/streams live now/.test(html), 'a number that reads zero is left out');
assert.ok(!html.includes('<!--OV:'), 'no placeholder left behind');
assert.ok(!html.includes('<!--OV:'), 'no placeholder left behind');
// Below the hero every section is the shared showcase kit (plan T11), with its stylesheet, one h1 and an AI summary.
// Below the hero every section is the shared showcase kit (plan T11), with its stylesheet, one h1 and an AI summary.
assert.ok(html.includes('<link rel="stylesheet" href="/shared/showcase.css">'), 'the kit stylesheet is linked');
assert.ok(html.includes('<link rel="stylesheet" href="/shared/showcase.css">'), 'the kit stylesheet is linked');
@@ -96,7 +101,7 @@ assert.ok(/"@type":"WebPage"/.test(html), 'the summary has its WebPage JSON-LD')
@@ -96,7 +101,7 @@ assert.ok(/"@type":"WebPage"/.test(html), 'the summary has its WebPage JSON-LD')
// A tagline that already ends a sentence ("What should we eat?") gets no second period; a family of one is "1 tool".
// A tagline that already ends a sentence ("What should we eat?") gets no second period; a family of one is "1 tool".
assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');
assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');
assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');
assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');
-assert.ok(/Live streaming from a browser tab, /.test(html), 'the hero line still leads with going live from a browser');
+assert.ok(/<h1>Go live, chat, play<br>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');
for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://${s.host}/">`), `the sites section links ${s.host}`);
for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://${s.host}/">`), `the sites section links ${s.host}`);
for (const href of ['https://openvibe.tools/api/catalog.json', '/llms.txt', 'https://openvibe.tools/llms.txt', 'https://github.com/OpenVibers', 'https://openvibe.codes/', 'https://yt.openvibe.tools/', 'https://dns.openvibe.tools/']) assert.ok(html.includes(`href="${href}"`), `the page still links ${href}`);
for (const href of ['https://openvibe.tools/api/catalog.json', '/llms.txt', 'https://openvibe.tools/llms.txt', 'https://github.com/OpenVibers', 'https://openvibe.codes/', 'https://yt.openvibe.tools/', 'https://dns.openvibe.tools/']) assert.ok(html.includes(`href="${href}"`), `the page still links ${href}`);
assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/section>/g) || []).length, 'every section is closed once');
assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/section>/g) || []).length, 'every section is closed once');
@@ -12,7 +12,7 @@ const { spawn } = require('child_process');
@@ -12,7 +12,7 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 157, // 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
+ htmlRawKB: 172, // 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
htmlBrotliKB: 31, // 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
htmlBrotliKB: 31, // 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsRawKB: 303, // 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
jsRawKB: 303, // 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3