openvibers/openvibe.network Commits
The openvibe.network home shows more of the network: latest recordings, new jobs and coupon codes next to live streams, pastes and posts; real numbers beside the map; the sites grouped like the launcher; and a FAQ (#300)
Changes
@@@ -3,26 +3,29 @@<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">--<title>OpenVibe — One Account. All of OpenVibe.</title>++<title>OpenVibe: the open network for streaming, tools, chat and more</title><link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%22-32%20-32%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22ring%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%232563eb%22%2F%3E%3C%2FlinearGradient%3E%0A%3CradialGradient%20id%3D%22glow%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%22.34%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%0A%3CradialGradient%20id%3D%22bg%22%20cx%3D%22.5%22%20cy%3D%22.38%22%20r%3D%22.8%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2316223a%22%2F%3E%3Cstop%20offset%3D%22.6%22%20stop-color%3D%22%230a0f1c%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23060912%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20x%3D%22-32%22%20y%3D%22-32%22%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2215%22%20fill%3D%22url(%23bg)%22%2F%3E%3Cg%20transform%3D%22scale(1.22)%22%3E%0A%3Ccircle%20r%3D%2221%22%20fill%3D%22url(%23glow)%22%2F%3E%0A%3Ccircle%20r%3D%2218%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224%22%20opacity%3D%22.38%22%2F%3E%0A%3Cpath%20d%3D%22M0%2C-18%20A18%2C18%200%200%2C1%2017.4%2C4.6%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224.4%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M-9.5%2C-7%20L0%2C10%20L9.5%2C-7%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%224.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Ccircle%20cx%3D%2217.4%22%20cy%3D%224.6%22%20r%3D%223.2%22%20fill%3D%22%23ffffff%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"><meta name="theme-color" content="#0d131d"><meta name="color-scheme" content="dark light"><link rel="apple-touch-icon" href="/assets/logo-192.png"><link rel="manifest" href="/manifest.webmanifest">--<meta name="description" content="OpenVibe is an open, community-run network: live streaming, online tools, games, media, pastes and more — one account for all of it.">--<meta name="keywords" content="openvibe, openvibe network, live streaming, online tools, pastes, community, single sign-on">++<meta name="description" content="One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.">++<meta name="keywords" content="openvibe, openvibe network, go live from browser, online tools, open source platform, single sign-on, developer platform"><meta property="og:type" content="website"><meta property="og:url" content="https://openvibe.network/">--<meta property="og:title" content="OpenVibe — One Account. All of OpenVibe.">--<meta property="og:description" content="An open, community-run network: live streaming, tools, games, media and community — one account for everything.">--<meta property="og:image" content="https://openvibe.network/assets/logo-512.png">--<meta name="twitter:card" content="summary">--<meta name="twitter:title" content="OpenVibe — One Account. All of OpenVibe.">--<meta name="twitter:description" content="An open, community-run network: live streaming, tools, games, media and community — one account for everything.">--<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">++<meta property="og:title" content="OpenVibe: the open network for streaming, tools, chat and more">++<meta property="og:description" content="One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.">++<meta property="og:image" content="__OV_CARD__">++<meta property="og:image:width" content="1200">++<meta property="og:image:height" content="630">++<meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">++<meta name="twitter:card" content="summary_large_image">++<meta name="twitter:title" content="OpenVibe: the open network for streaming, tools, chat and more">++<meta name="twitter:description" content="One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.">++<meta name="twitter:image" content="__OV_CARD__"><meta property="og:site_name" content="OpenVibe"><link rel="canonical" href="https://openvibe.network/">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community","https://openvibe.space"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One account for the whole OpenVibe network: live streaming, tools, games, media, forums and community.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community","https://openvibe.space"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script><link rel="dns-prefetch" href="https://cdnjs.cloudflare.com"><!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.Paints the cached theme synchronously so the default palette never flashes. Reads@@@ -80,7 +83,7 @@ a{color:inherit}.btn-ghost{background:transparent;color:var(--text-primary);border-color:var(--border-light)}.btn-ghost:hover{background:var(--bg-hover);border-color:var(--accent)}#hero-account-cta{display:none}--.hero-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:28px;align-items:center;text-align:left}++.hero-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:28px;align-items:start;text-align:left}.hero-grid .hero-badge{margin-bottom:22px}.hero-grid h1{font-size:clamp(34px,4.3vw,52px)}.hero-grid p.sub{margin:18px 0 0}@@@ -101,7 +104,7 @@ a{color:inherit}.intent.dim{opacity:.38}.hero-account{margin-top:16px;font-size:13.5px;color:var(--text-secondary)}.hero-account a{color:var(--accent-light);font-weight:600;text-decoration:none}.hero-account a:hover{text-decoration:underline}--.hero-sky{position:relative}++.hero-sky{position:sticky;top:72px;padding-top:24px}.constellation{width:100%;height:auto;display:block;overflow:visible}.constellation .cs-orbit{fill:none;stroke:var(--border-light);stroke-dasharray:2 6;opacity:.7}.constellation .cs-far{opacity:.4}.constellation .cs-spoke{stroke:var(--accent);stroke-opacity:.18;stroke-width:1}@@@ -117,7 +120,7 @@ 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{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: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: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 */@@@ -1,26 +1,44 @@'use strict';--// What is happening on the network right now, for the front door (plan T11, D93): who is live on OpenVibe.Live, the--// newest public pastes on OpenVibe.Community and the newest posts on OpenVibe.Blog. Each comes from the service's own--// public API over loopback (fixed first-party addresses, never a URL anyone can choose), and the home page never waits--// for them: it renders what is known, and a read older than a minute starts one refresh in the background. A service--// that does not answer within two seconds simply contributes nothing this round.++// What is happening on the network right now, for the front door (plan T11, D93): who is live on OpenVibe.Live and its++// latest recordings (watched on OpenVibe.Video), the newest public pastes on OpenVibe.Community, the newest posts on++// OpenVibe.Blog, and the newest jobs and coupon codes OpenVibe.Search holds for OpenVibe.Work and OpenVibe.Coupons. Each++// comes from the service's own public API over loopback (fixed first-party addresses, never a URL anyone can choose), and++// the home page never waits for them: it renders what is known, and a read older than a minute starts one refresh in++// the background. A service that does not answer within two seconds simply contributes nothing this round.++//++// The same refresh reads two numbers Network knows itself (configure()): how many accounts exist and how many of the++// public services answered their last health check. Nothing here is estimated: a number nobody could read is left out.++const contracts = require('openvibe-contracts');++const TTL_MS = 60_000;const TIMEOUT_MS = 2000;++const origin = (id, fallback) => { const m = contracts.services.get(id); return (m && m.internalOrigin) || fallback; };const SOURCES = {live: process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000',community: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',blog: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810',++ search: process.env.OV_SEARCH_INTERNAL_URL || origin('search', 'http://127.0.0.1:4710'),};--const PUBLIC = { live: 'https://openvibe.live', community: 'https://openvibe.community', blog: 'https://openvibe.blog' };++const PUBLIC = { live: 'https://openvibe.live', video: 'https://openvibe.video', community: 'https://openvibe.community', blog: 'https://openvibe.blog', work: 'https://openvibe.work', coupons: 'https://openvibe.coupons' };--let state = { at: 0, version: 0, live: [], pastes: [], posts: [] };++const EMPTY = { live: [], liveCount: 0, recordings: [], pastes: [], posts: [], jobs: [], codes: [], counts: {} };++let state = { at: 0, version: 0, ...EMPTY };let inflight = null;// Tests switch the reads off; the page then shows the "nothing live" state.let enabled = process.env.HOME_ACTIVITY !== 'off' && process.env.NODE_ENV !== 'test';++// Network's own numbers (server/index.js): accounts() → a count, services() → { up, live }.++let readers = {};const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t; };const abs = (base, u) => { try { const x = new URL(String(u || ''), base); return /^https:$/.test(x.protocol) ? x.href : null; } catch { return null; } };++const onHost = (u, host) => { try { const x = new URL(String(u || '')); return x.protocol === 'https:' && x.hostname === host ? x.href : null; } catch { return null; } };++++// The front door is the network's most public page, so it is stricter than the services it quotes: an item whose words++// read as adult content is left out, and AI words that describe a person (looks, clothing, a guessed gender) are never++// repeated here (the owner's rule for public AI text). Such an item still lives on its own site; it is just not featured.++const ADULT = /(^|[^\w])18\s*\+|\b(adults?[- ]only|adult[- ](industry|content|work|entertainment)|nsfw|xxx|porn\w*|onlyfans|escorts?|webcam (models?|creators?|girls?)|cam ?girls?|sugar (daddy|baby)|nude\w*|sexy)\b/i;++const LOOKS = /\b(man|woman|men|women|girl|boy|guy|lady|ladies|gentleman|blond(e|es)?|brunette|redhead|beard(ed)?|hair\w*|wearing|dressed|outfit|shirtless|bikini|lingerie|cleavage|his|her|he|she)\b/i;async function getJson(url, fetchImpl) {try {@@@ -29,10 +47,13 @@ async function getJson(url, fetchImpl) {} catch { return null; }}--function liveStreams(body) {++function liveRows(body) {const rows = body && Array.isArray(body.streams) ? body.streams : [];-- return rows-- .filter((s) => s && s.is_live && !s.is_nsfw && s.username)++ return rows.filter((s) => s && s.is_live && !s.is_nsfw && s.username);++}++++function liveStreams(body) {++ return liveRows(body).map((s) => ({name: clip(s.display_name || s.username, 40),title: clip(s.ai_title || s.title || `${s.username} is live`, 90),@@@ -46,16 +67,55 @@ function liveStreams(body) {.slice(0, 4);}++/** "1h 53m", "28 min": how long a recording runs. */++function runtime(sec) {++ const s = Math.max(0, Math.round(Number(sec) || 0));++ if (s >= 3600) { const m = Math.floor((s % 3600) / 60); return `${Math.floor(s / 3600)}h${m ? ` ${m}m` : ''}`; }++ return `${Math.max(1, Math.round(s / 60))} min`;++}++++// The latest recordings worth opening: public, ready, at least ten minutes long, one per channel, so the row is a few++// different streams rather than one stream's parts. Each opens on OpenVibe.Video, where recordings are watched.++const MIN_RECORDING_S = 600;++function recordings(body) {++ const rows = body && Array.isArray(body.vods) ? body.vods : [];++ const seen = new Set();++ const out = [];++ for (const v of rows) {++ if (!v || !v.id || !v.username || v.status !== 'ready' || v.is_nsfw || v.visibility !== 'public' || v.is_public === false) continue;++ if ((Number(v.duration_seconds ?? v.duration) || 0) < MIN_RECORDING_S || seen.has(v.username)) continue;++ const title = clip(v.title || '', 80);++ if (!title || ADULT.test(title)) continue;++ seen.add(v.username);++ 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),++ });++ if (out.length === 4) break;++ }++ return out;++}++function publicPastes(body) {const rows = body && Array.isArray(body.pastes) ? body.pastes : [];return rows.filter((p) => p && p.visibility === 'public' && !p.is_nsfw && !p.burn_after_read && p.slug)-- .map((p) => ({-- title: clip(p.title || 'Untitled paste', 70),-- url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,-- kind: p.type === 'screenshot' ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),-- summary: clip(p.ai_summary || '', 110),-- }))++ .filter((p) => !ADULT.test(`${p.title || ''} ${p.ai_summary || ''}`) && !LOOKS.test(p.title || ''))++ .map((p) => {++ const shot = p.type === 'screenshot';++ // A screenshot's summary is an AI description of a picture, so it can describe a person: only a text or code++ // paste's summary is quoted, and only when it names nobody's looks.++ const summary = shot || LOOKS.test(p.ai_summary || '') ? '' : clip(p.ai_summary || '', 110);++ return {++ title: clip(p.title || 'Untitled paste', 70),++ url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,++ kind: shot ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),++ summary,++ };++ }).slice(0, 4);}@@@ -67,21 +127,70 @@ function blogPosts(body) {.slice(0, 3);}++// OpenVibe.Search documents (newest first when the query has no words): only public, indexable ones with a canonical++// address on the owning site.++function searchDocs(body, host) {++ const rows = body && Array.isArray(body.results) ? body.results : [];++ return rows.filter((d) => d && d.visibility === 'public' && d.indexable !== false && d.title && onHost(d.canonical_url, host));++}++++function jobs(body) {++ return searchDocs(body, 'openvibe.work')++ .filter((d) => !ADULT.test(`${d.title} ${d.summary || ''}`))++ .map((d) => {++ const f = d.facets || {};++ const where = clip(f.location || d.summary || '', 48);++ const board = d.provenance && d.provenance[0] && d.provenance[0].label ? clip(d.provenance[0].label, 30) : '';++ return { title: clip(d.title, 90), url: onHost(d.canonical_url, 'openvibe.work'), where: f.remote ? (where ? `Remote · ${where}` : 'Remote') : where, board };++ })++ .slice(0, 4);++}++++function coupons(body) {++ return searchDocs(body, 'openvibe.coupons')++ .filter((d) => !ADULT.test(d.title))++ .map((d) => {++ // Coupons titles read "CODE — what it takes off": the code is shown as a chip, the rest as the line.++ const m = /^(\S{2,32})\s+—\s+(.+)$/.exec(String(d.title).trim());++ return { code: m ? clip(m[1], 32) : '', title: clip(m ? m[2] : d.title, 90), url: onHost(d.canonical_url, 'openvibe.coupons') };++ })++ .slice(0, 4);++}++++async function readCount(fn) {++ if (typeof fn !== 'function') return null;++ try { const v = await fn(); return v == null ? null : v; } catch { return null; }++}++/** One refresh of every source; a source that fails keeps its last good list. */async function refresh({ fetch: fetchImpl = globalThis.fetch } = {}) {if (inflight) return inflight;inflight = (async () => {-- const [live, pastes, posts] = await Promise.all([++ const q = (owner, type) => `${SOURCES.search}/api/v1/search?owner=${owner}&type=${type}&limit=12`;++ const [live, vods, pastes, posts, work, codes, accounts, services] = await Promise.all([getJson(`${SOURCES.live}/api/streams`, fetchImpl),++ getJson(`${SOURCES.live}/api/vods?limit=40`, fetchImpl),getJson(`${SOURCES.community}/api/pastes?limit=12`, fetchImpl),getJson(`${SOURCES.blog}/feed.json`, fetchImpl),++ getJson(q('work', 'job'), fetchImpl),++ getJson(q('coupons', 'coupon'), fetchImpl),++ readCount(readers.accounts),++ readCount(readers.services),]);++ const counts = { ...state.counts };++ if (Number.isFinite(Number(accounts)) && Number(accounts) > 0) counts.accounts = Number(accounts);++ if (services && Number(services.live) > 0) counts.services = { up: Math.max(0, Number(services.up) || 0), live: Number(services.live) };state = {at: Date.now(),version: state.version + 1,live: live ? liveStreams(live) : state.live,++ liveCount: live ? liveRows(live).length : state.liveCount,++ recordings: vods ? recordings(vods) : state.recordings,pastes: pastes ? publicPastes(pastes) : state.pastes,posts: posts ? blogPosts(posts) : state.posts,++ jobs: work ? jobs(work) : state.jobs,++ codes: codes ? coupons(codes) : state.codes,++ counts,};return state;})().finally(() => { inflight = null; });@@@ -95,9 +204,11 @@ function peek() {}module.exports = {-- peek, refresh, liveStreams, publicPastes, blogPosts,++ peek, refresh, liveStreams, recordings, publicPastes, blogPosts, jobs, coupons, runtime, ADULT, LOOKS,++ /** server/index.js: Network's own numbers, read on each refresh ({ accounts: async () => n, services: () => ({ up, live }) }). */++ configure(r) { readers = { ...readers, ...(r || {}) }; },/** Tests: switch the background reads on or off. */setEnabled(on) { enabled = Boolean(on); },/** Tests: replace the snapshot. */-- _set(s) { state = { at: Date.now(), version: state.version + 1, live: [], pastes: [], posts: [], ...s }; },++ _set(s) { state = { at: Date.now(), version: state.version + 1, ...EMPTY, ...s }; },};@@@ -20,7 +20,9 @@ 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 frameSites = require('../frame/sites').SITES;++const { SITES: frameSites, GROUPS, groupOf, PRODUCTS } = 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}`);const OPEN = frameSites.filter((site) => site.status === 'open' && site.id !== 'network');const SOON = frameSites.filter((site) => site.status !== 'open');@@@ -99,21 +101,49 @@ ${openNodes}${soonNodes}</svg>`;}const fmtViewers = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));++const fmtCount = (n) => Number(n).toLocaleString('en-US');++// A picture that fails to load (a stream's first thumbnail is written a few seconds after it goes live) falls back to++// the avatar when there is one, else to an empty tile, never the browser's broken-image icon.++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) {const live = a.live.length-- ? a.live.map((s) => `<a class="now-stream" href="${esc(s.url)}">${s.thumb || s.avatar ? `<img src="${esc(s.thumb || s.avatar)}" alt="" loading="lazy" decoding="async" width="320" height="180"${s.thumb && s.avatar ? ` data-fallback="${esc(s.avatar)}" onerror="this.onerror=null;this.src=this.dataset.fallback"` : ''}>` : '<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.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>`;-- const list = (items, render, empty) => (items.length ? `<ul class="now-list">${items.map(render).join('')}</ul>` : `<p class="now-quiet">${empty}</p>`);++ 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.++ 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(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>--<div class="now-grid"><div class="now-col now-live"><h3>Live</h3><div class="now-streams">${live}</div><a class="now-more" href="https://openvibe.live/">All channels →</a></div>--<div class="now-col"><h3>New pastes</h3>${list(a.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>`, 'Nothing new in the last while. <a href="https://openvibe.community/new">Share one</a>.')}<a class="now-more" href="https://openvibe.community/">OpenVibe.Community →</a></div>--<div class="now-col"><h3>From the blog</h3>${list(a.posts, (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'No posts yet.')}<a class="now-more" href="https://openvibe.blog/">OpenVibe.Blog →</a></div></div></section>`;++<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>`;++}++++// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it++// could not read is left out rather than guessed.++function numbers(catalog, a) {++ const c = a.counts || {};++ const items = [++ c.services ? [fmtCount(c.services.up), 'services up', `of ${c.services.live} checked`, '/status'] : null,++ [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/'],++ ].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></section>`;}// The kit (openvibe-shared/showcase, /shared/showcase.css) owns the sections, cards and icon styles; this is only what// it does not draw: the "Right now" columns and the tool and "Opening next" chips.const CSS = `--#developers{margin-bottom:56px}#developers .sc-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}++#faq{margin-bottom:56px}#developers .sc-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.home-chips{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 0;padding:0;list-style:none}.home-chips>li>a{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));font-size:13.5px;font-weight:600;color:inherit;text-decoration:none}.home-chips>li>a:hover,.home-chips>li>a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}@@@ -121,7 +151,7 @@ const CSS = `.home-more{margin:14px 0 0;font-size:13.5px}.home-more a{font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}.now>h2{display:flex;align-items:center;gap:10px}.now-dot{width:10px;height:10px;border-radius:50%;background:var(--live-red,#ef4444);box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:now-pulse 2s infinite}@keyframes now-pulse{70%{box-shadow:0 0 0 10px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}--.now-grid{display:grid;gap:16px;grid-template-columns:repeat(3,minmax(0,1fr));margin-top:14px}++.now-grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));margin-top:14px}.now-col{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);min-width:0}.now-col h3{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0}.now-streams{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}@@@ -135,9 +165,23 @@ const CSS = `.now-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}.now-list a{display:block;color:inherit;text-decoration:none}.now-list b{display:block;font-size:14px;line-height:1.35}.now-list small{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--text-secondary,#a8b3c4);font-size:12.5px;line-height:1.4;margin-top:2px}--.now-quiet{color:var(--text-muted,#7d8aa0);font-size:13.5px;margin:0}.now-quiet a{color:var(--accent-light,#60a5fa)}.now-more{margin-top:auto;font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}--@media (max-width:860px){.now-grid{grid-template-columns:1fr}}++.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-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)}++.net-num h2{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0 0 10px}++.net-num ul{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(min(140px,100%),1fr))}++.net-num a{display:flex;flex-direction:column;gap:1px;padding:10px 12px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-primary,#0a0f1c);color:inherit;text-decoration:none;height:100%}++.net-num a:hover,.net-num a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}++.net-num b{font-size:24px;font-weight:800;letter-spacing:-.5px;line-height:1.15;color:var(--text-primary,#e6edf7)}++.net-num span{font-size:12.5px;color:var(--text-secondary,#a8b3c4);line-height:1.35}.net-num small{display:block;color:var(--text-muted,#7d8aa0);font-size:11.5px}++.home-group{margin:26px 0 10px;font-size:13px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}++.home-also{margin:10px 0 0;font-size:13.5px;color:var(--text-secondary,#a8b3c4)}.home-also a{color:var(--accent-light,#60a5fa);font-weight:600;text-decoration:none}.home-also a:hover{text-decoration:underline}++.faq-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));margin-top:4px}++.faq-item{padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}++.faq-item h3{font-size:15.5px;margin:0 0 6px;line-height:1.35}.faq-item p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:14px;line-height:1.55}@media (prefers-reduced-motion:reduce){.now-dot{animation:none}}${icons.CSS}`;@@@ -151,6 +195,20 @@ const asSentence = (t) => (/[.!?…]$/.test(String(t).trim()) ? String(t).trim()const inside = (section, extra) => (section && extra ? section.replace(/<\/section>$/, () => `${extra}</section>`) : section);const chips = (list, cls = '') => (list.length ? `<ul class="home-chips${cls ? ` ${cls}` : ''}">${list.map(([href, title, ic, name]) => `<li><a href="${esc(href)}" title="${esc(title)}">${icon(ic, 22)}${esc(name)}</a></li>`).join('')}</ul>` : '');++// The questions on the page, which its FAQPage JSON-LD repeats word for word: the network's own (its Contracts site++// block, the same answers OpenVibe.Help shows) after the few that only the front door answers.++const FRONT_FAQ = [++ { q: 'Do I need an account to use OpenVibe?', a: 'No. Watching streams, the tools, pastes, the wiki and the blog all work as a guest. An account keeps what should last: your history, themes, VODs and notifications.' },++ { q: 'Is OpenVibe open source?', a: 'Yes. Every OpenVibe service is open source under the OpenVibers organization on GitHub, and the network is run by the people who use it.' },++ { q: 'Can I build an app on OpenVibe?', a: 'Yes. OpenVibe.Services gives you projects, apps, keys and the API docs of every service, and your users sign in with their OpenVibe account through OAuth 2.0 or FedCM.' },++];++function faqPairs() {++ const m = contracts.services.get('network');++ const own = ((m && m.site && m.site.faq) || []).filter((f) => f && f.question && f.answer).map((f) => ({ q: f.question, a: f.answer }));++ const seen = new Set();++ return [FRONT_FAQ[0], ...own, ...FRONT_FAQ.slice(1)].filter((p) => !seen.has(p.q) && seen.add(p.q));++}++// Below the static hero (public/index.html: the intent picker and the constellation), every section is the shared// showcase kit, as on the other flagship homes (plan T11, D93); the data and every link are the ones this page had.function body(catalog, act) {@@@ -158,11 +216,14 @@ function body(catalog, act) {const popular = POPULAR.map((id) => byId.get(id)).filter(Boolean);const fams = catalog.families.filter((f) => f.url);const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;-- const sites = showcase.features({-- id: 'network', title: sitesHeading(),-- lede: 'Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.',-- items: OPEN.map((site) => ({ icon: `ov:${site.icon}`, title: siteName(site), text: site.tagline ? `${asSentence(site.tagline)} ${site.what}` : site.what, href: `https://${site.host}/` })),-- });++ // The service map: every open site under the launcher's groups (server/frame/sites.js GROUPS), so the 36 cards read++ // as six neighbourhoods, each with the products that live on another site's domain (openvibe.pics, …) beside it.++ const grid = (items) => showcase.features({ items }).replace(/^<section class="sc-sec">/, '').replace(/<\/section>$/, '');++ const groups = GROUPS.map((g) => ({ g, sites: OPEN.filter((site) => groupOf(site.id) === g.id), also: PRODUCTS.filter((p) => groupOf(p.id) === g.id) }))++ .filter((x) => x.sites.length)++ .map(({ g, sites: list, also }) => `<h3 class="home-group">${esc(g.name)}</h3>${grid(list.map((site) => ({ icon: `ov:${site.icon}`, title: siteName(site), text: site.tagline ? `${asSentence(site.tagline)} ${site.what}` : site.what, href: `https://${site.host}/` })))}${also.length ? `<p class="home-also">Also here: ${also.map((p) => `<a href="https://${esc(p.host)}/">${esc(p.host)}</a> (${esc(p.tagline.toLowerCase())})`).join(', ')}</p>` : ''}`).join('\n');++ const sites = `<section class="sc-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="sc-lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>++${groups}</section>`;const tools = inside(showcase.features({id: 'tools', title: catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open',lede: 'No installs and no sign-up wall. Every tool has its own short address, so yt.openvibe.tools or dns.openvibe.tools takes you straight there.',@@@ -187,13 +248,16 @@ ${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, si{ icon: 'fa-code-branch', title: 'Source on GitHub', text: 'Every OpenVibe service is open source under the OpenVibers organization.', href: 'https://github.com/OpenVibers' },],}), '<p class="home-more">Machine-readable: <a href="/llms.txt">/llms.txt</a> · <a href="/llms-full.txt">/llms-full.txt</a> · <a href="https://openvibe.tools/llms.txt">openvibe.tools/llms.txt</a></p>');++ const faq = `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions people ask</h2><p class="sc-lede">The short answers. Every OpenVibe site has its own page of answers on OpenVibe.Help.</p>++<div class="faq-grid">${faqPairs().map((p) => `<div class="faq-item"><h3>${esc(p.q)}</h3><p>${esc(p.a)}</p></div>`).join('')}</div><p class="home-more"><a href="https://openvibe.help/sites/openvibe.network">More answers about your account on OpenVibe.Help →</a></p></section>`;return `<style>${CSS}</style>${rightNow(act)}${sites}${tools}${account}${soon}--${dev}`;++${dev}++${faq}`;}// What an AI reader is told the page is (openvibe-shared/seo pageSummary): the meta, a WebPage JSON-LD tag and a@@@ -216,9 +280,10 @@ function render({ releaseId = '' } = {}) {const stat = fs.statSync(SHELL);const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}:${releaseId}`;if (pageCache.key === key) return pageCache;-- const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));++ 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()).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-->', () => 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>`);pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };return pageCache;}@@@ -739,7 +739,17 @@ app.get(['/login.html', '/admin.html'], (req, res) => {return res.status(404).end();});--// Landing page at the apex root++// Landing page at the apex root. Its numbers are Network's own, read with the rest of its activity once a minute:++// accounts (people, not guests or banned ones) and how many public services answered their last health check (a++// count is left out until at least half of them have been checked once).++require('./home/activity').configure({++ accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0').get()).cnt,++ services: () => {++ const live = require('./status/routes').rows(ecosystem).filter((r) => r.exposure && r.exposure.state === 'live');++ const known = live.filter((r) => r.status !== 'unknown');++ return known.length && known.length * 2 >= live.length ? { up: known.filter((r) => r.status === 'up').length, live: known.length } : null;++ },++});app.get(['/', '/index.html'], (req, res) => {if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');return require('./home/render').sendHome(req, res, { releaseId: release.release });@@@ -812,6 +822,14 @@ app.get('/openvibe-sw.js', (req, res) => {const sharedRev = new Map();function serveShared(req, res, next) {const name = req.path.slice(1);++ // The share cards (openvibe-shared/social, social/<service>.jpg): the home page's og:image is Network's own card.++ const social = require('openvibe-shared/social');++ if (social.isCard(name)) {++ const hashed = social.url(name.slice(7, -4), '').endsWith(`?v=${req.query.v}`);++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');++ res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed }));++ return res.sendFile(social.path(name));++ }if (!sharedFiles.isBrowserFile(name)) return next();if (!sharedRev.has(name)) sharedRev.set(name, require('crypto').createHash('sha256').update(fs.readFileSync(sharedFiles.path(name))).digest('hex').slice(0, 12));res.setHeader('Access-Control-Allow-Origin', '*');@@@ -34,6 +34,40 @@ assert.deepStrictEqual([pastes[0].kind, pastes[1].kind], ['js', 'Screenshot']);const posts = activity.blogPosts({ items: [{ url: 'https://openvibe.blog/@a/b', title: 'Post', summary: 'x' }, { url: 'http://evil.example/', title: 'bad' }] });assert.deepStrictEqual(posts.map((p) => p.url), ['https://openvibe.blog/@a/b'], 'only https links');++// Recordings: public, ready, ten minutes or more, one per channel, opened on OpenVibe.Video with Media's thumbnail.++const recs = activity.recordings({ vods: [++ { id: 9, username: 'gus', title: 'Long one', status: 'ready', visibility: 'public', duration_seconds: 4000, thumbnail_url: 'https://openvibe.media/t/vod-9.jpg' },++ { id: 8, username: 'gus', title: 'Same channel', status: 'ready', visibility: 'public', duration_seconds: 4000 },++ { id: 7, username: 'amy', title: 'Short', status: 'ready', visibility: 'public', duration_seconds: 120 },++ { id: 6, username: 'kim', title: 'Private', status: 'ready', visibility: 'private', duration_seconds: 4000 },++ { id: 5, username: 'lee', title: 'Elsewhere thumb', status: 'ready', visibility: 'public', duration_seconds: 700, thumbnail_url: 'https://evil.example/t.jpg' },++] });++assert.deepStrictEqual(recs.map((r) => r.url), ['https://openvibe.video/v/9', 'https://openvibe.video/v/5'], 'ten minutes or more, public, one per channel');++assert.strictEqual(recs[0].length, '1h 6m');++assert.strictEqual(recs[1].thumb, null, 'a thumbnail only from openvibe.media');++assert.strictEqual(activity.runtime(1805), '30 min');++++// The front door features no adult items and repeats no AI words about a person's looks.++const flagged = activity.publicPastes({ pastes: [++ { slug: 'a', title: 'Cam creator opportunity | 18+', visibility: 'public' },++ { slug: 'b', title: 'A man on a sofa', visibility: 'public', type: 'screenshot' },++ { slug: 'c', title: 'Game lobby', visibility: 'public', type: 'screenshot', ai_summary: 'A streamer sits in front of his screen' },++ { slug: 'd', title: 'Sort helper', visibility: 'public', type: 'code', language: 'js', ai_summary: 'Sorts an array by key' },++] });++assert.deepStrictEqual(flagged.map((p) => p.url), ['https://openvibe.community/p/c', 'https://openvibe.community/p/d'], 'no adult or looks titles');++assert.strictEqual(flagged[0].summary, '', "a screenshot's AI description is never quoted");++assert.strictEqual(flagged[1].summary, 'Sorts an array by key', "a code paste's summary is");++++// Jobs and coupon codes come from OpenVibe.Search, only public ones on their own site.++const job = activity.jobs({ results: [++ { visibility: 'public', title: 'Engineer at Acme', canonical_url: 'https://openvibe.work/jobs/1', facets: { location: 'Berlin', remote: true }, provenance: [{ label: 'Arbeitnow' }] },++ { visibility: 'restricted', title: 'Hidden', canonical_url: 'https://openvibe.work/jobs/2' },++ { visibility: 'public', title: 'Off site', canonical_url: 'https://example.com/jobs/3' },++] });++assert.deepStrictEqual(job, [{ title: 'Engineer at Acme', url: 'https://openvibe.work/jobs/1', where: 'Remote · Berlin', board: 'Arbeitnow' }]);++const code = activity.coupons({ results: [{ visibility: 'public', title: 'FALL10 — 10% off a watch', canonical_url: 'https://openvibe.coupons/c/1' }] });++assert.deepStrictEqual(code, [{ code: 'FALL10', title: '10% off a watch', url: 'https://openvibe.coupons/c/1' }]);++// The page with nothing live: the empty state invites a browser go-live.activity._set({ live: [], pastes: [], posts: [] });let { html } = home.render();@@@ -64,10 +98,24 @@ for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://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');++// The FAQ shown is the FAQPage JSON-LD, word for word, with the network's own questions from its Contracts site block.++const faqLd = JSON.parse(/<script type="application\/ld\+json">(\{"@context":"https:\/\/schema.org","@type":"FAQPage"[\s\S]*?)<\/script>/.exec(html)[1]);++assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');++faqLd.mainEntity.forEach((q) => assert.ok(html.includes(`<h3>${q.name.replace(/'/g, ''')}</h3>`), `the FAQ shows "${q.name}"`));++assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");++// The service map groups the sites the way the launcher does; numbers say only what was read.++assert.ok(/<h3 class="home-group">Watch & stream<\/h3>/.test(html) && /<h3 class="home-group">Build & automate<\/h3>/.test(html), 'the sites are grouped');++assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');++assert.ok(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');++assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');++assert.ok(/<meta property="og:image" content="https:\/\/openvibe\.network\/shared\/social\/network\.jpg\?v=/.test(html) && !html.includes('__OV_CARD__'), "the share card is the network's own");++// With activity: the items render, escaped.--activity._set({ live: [{ name: 'Al<b>ice', title: 'Hi & bye', url: 'https://openvibe.live/@alice', thumb: 'https://openvibe.live/t.jpg', avatar: 'https://openvibe.media/a', viewers: 1500, category: '' }], pastes, posts });++activity._set({ live: [{ name: 'Al<b>ice', title: 'Hi & bye', url: 'https://openvibe.live/@alice', thumb: 'https://openvibe.live/t.jpg', avatar: 'https://openvibe.media/a', viewers: 1500, category: '' }], liveCount: 1, pastes, posts, recordings: recs, jobs: job, codes: code, counts: { accounts: 1234, services: { up: 37, live: 38 } } });({ html } = home.render());assert.ok(html.includes('LIVE · 1.5k') && html.includes('Al<b>ice') && html.includes('Hi & bye'), 'viewers, names and titles render escaped');assert.ok(html.includes('data-fallback="https://openvibe.media/a"'), 'a broken thumbnail falls back to the avatar');assert.ok(html.includes('href="https://openvibe.community/p/one"') && html.includes('href="https://openvibe.blog/@a/b"'));++assert.ok(html.includes('href="https://openvibe.video/v/9"') && html.includes('<code class="now-code">FALL10</code>') && html.includes('Remote · Berlin · via Arbeitnow'), 'recordings, codes and jobs render');++assert.ok(/<b>1,234<\/b><span>accounts/.test(html) && /<b>37<\/b><span>services up <small>of 38 checked/.test(html) && /<b>1<\/b><span>stream live now/.test(html), 'the numbers read render');console.log('home front door: all checks passed');@@@ -12,8 +12,8 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 146, // measured 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: 28, // 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)++ htmlRawKB: 152, // 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: 30, // 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: 266, // 249.8 with boost.js (13.1); 228.3jsBrotliKB: 64, // 60.3 with boost.js (3.8); 53.9
@@ -3,26 +3,29 @@
@@ -3,26 +3,29 @@
<head>
<head>
<meta charset="utf-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>OpenVibe — One Account. All of OpenVibe.</title>
+<title>OpenVibe: the open network for streaming, tools, chat and more</title>
<link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%22-32%20-32%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22ring%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%232563eb%22%2F%3E%3C%2FlinearGradient%3E%0A%3CradialGradient%20id%3D%22glow%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%22.34%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%0A%3CradialGradient%20id%3D%22bg%22%20cx%3D%22.5%22%20cy%3D%22.38%22%20r%3D%22.8%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2316223a%22%2F%3E%3Cstop%20offset%3D%22.6%22%20stop-color%3D%22%230a0f1c%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23060912%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20x%3D%22-32%22%20y%3D%22-32%22%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2215%22%20fill%3D%22url(%23bg)%22%2F%3E%3Cg%20transform%3D%22scale(1.22)%22%3E%0A%3Ccircle%20r%3D%2221%22%20fill%3D%22url(%23glow)%22%2F%3E%0A%3Ccircle%20r%3D%2218%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224%22%20opacity%3D%22.38%22%2F%3E%0A%3Cpath%20d%3D%22M0%2C-18%20A18%2C18%200%200%2C1%2017.4%2C4.6%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224.4%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M-9.5%2C-7%20L0%2C10%20L9.5%2C-7%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%224.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Ccircle%20cx%3D%2217.4%22%20cy%3D%224.6%22%20r%3D%223.2%22%20fill%3D%22%23ffffff%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%22-32%20-32%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22ring%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%232563eb%22%2F%3E%3C%2FlinearGradient%3E%0A%3CradialGradient%20id%3D%22glow%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%22.34%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%0A%3CradialGradient%20id%3D%22bg%22%20cx%3D%22.5%22%20cy%3D%22.38%22%20r%3D%22.8%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2316223a%22%2F%3E%3Cstop%20offset%3D%22.6%22%20stop-color%3D%22%230a0f1c%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23060912%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20x%3D%22-32%22%20y%3D%22-32%22%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2215%22%20fill%3D%22url(%23bg)%22%2F%3E%3Cg%20transform%3D%22scale(1.22)%22%3E%0A%3Ccircle%20r%3D%2221%22%20fill%3D%22url(%23glow)%22%2F%3E%0A%3Ccircle%20r%3D%2218%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224%22%20opacity%3D%22.38%22%2F%3E%0A%3Cpath%20d%3D%22M0%2C-18%20A18%2C18%200%200%2C1%2017.4%2C4.6%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224.4%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M-9.5%2C-7%20L0%2C10%20L9.5%2C-7%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%224.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Ccircle%20cx%3D%2217.4%22%20cy%3D%224.6%22%20r%3D%223.2%22%20fill%3D%22%23ffffff%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<meta name="theme-color" content="#0d131d">
<meta name="theme-color" content="#0d131d">
<meta name="color-scheme" content="dark light">
<meta name="color-scheme" content="dark light">
<link rel="apple-touch-icon" href="/assets/logo-192.png">
<link rel="apple-touch-icon" href="/assets/logo-192.png">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="manifest" href="/manifest.webmanifest">
-<meta name="description" content="OpenVibe is an open, community-run network: live streaming, online tools, games, media, pastes and more — one account for all of it.">
-<meta name="keywords" content="openvibe, openvibe network, live streaming, online tools, pastes, community, single sign-on">
+<meta name="description" content="One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.">
+<meta name="keywords" content="openvibe, openvibe network, go live from browser, online tools, open source platform, single sign-on, developer platform">
<meta property="og:type" content="website">
<meta property="og:type" content="website">
<meta property="og:url" content="https://openvibe.network/">
<meta property="og:url" content="https://openvibe.network/">
-<meta property="og:title" content="OpenVibe — One Account. All of OpenVibe.">
-<meta property="og:description" content="An open, community-run network: live streaming, tools, games, media and community — one account for everything.">
-<meta property="og:image" content="https://openvibe.network/assets/logo-512.png">
-<meta name="twitter:card" content="summary">
-<meta name="twitter:title" content="OpenVibe — One Account. All of OpenVibe.">
-<meta name="twitter:description" content="An open, community-run network: live streaming, tools, games, media and community — one account for everything.">
-<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
+<meta property="og:title" content="OpenVibe: the open network for streaming, tools, chat and more">
+<meta property="og:description" content="One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.">
+<meta property="og:image" content="__OV_CARD__">
+<meta property="og:image:width" content="1200">
+<meta property="og:image:height" content="630">
+<meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="OpenVibe: the open network for streaming, tools, chat and more">
+<meta name="twitter:description" content="One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.">
+<meta name="twitter:image" content="__OV_CARD__">
<meta property="og:site_name" content="OpenVibe">
<meta property="og:site_name" content="OpenVibe">
<link rel="canonical" href="https://openvibe.network/">
<link rel="canonical" href="https://openvibe.network/">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community","https://openvibe.space"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One account for the whole OpenVibe network: live streaming, tools, games, media, forums and community.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community","https://openvibe.space"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
Paints the cached theme synchronously so the default palette never flashes. Reads
Paints the cached theme synchronously so the default palette never flashes. Reads
@@ -80,7 +83,7 @@ a{color:inherit}
@@ -80,7 +83,7 @@ a{color:inherit}
.btn-ghost{background:transparent;color:var(--text-primary);border-color:var(--border-light)}
.btn-ghost{background:transparent;color:var(--text-primary);border-color:var(--border-light)}
.btn-ghost:hover{background:var(--bg-hover);border-color:var(--accent)}
.btn-ghost:hover{background:var(--bg-hover);border-color:var(--accent)}
#hero-account-cta{display:none}
#hero-account-cta{display:none}
-.hero-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:28px;align-items:center;text-align:left}
+.hero-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:28px;align-items:start;text-align:left}
.hero-grid .hero-badge{margin-bottom:22px}
.hero-grid .hero-badge{margin-bottom:22px}
.hero-grid h1{font-size:clamp(34px,4.3vw,52px)}
.hero-grid h1{font-size:clamp(34px,4.3vw,52px)}
.hero-grid p.sub{margin:18px 0 0}
.hero-grid p.sub{margin:18px 0 0}
@@ -101,7 +104,7 @@ a{color:inherit}
@@ -101,7 +104,7 @@ a{color:inherit}
.intent.dim{opacity:.38}
.intent.dim{opacity:.38}
.hero-account{margin-top:16px;font-size:13.5px;color:var(--text-secondary)}
.hero-account{margin-top:16px;font-size:13.5px;color:var(--text-secondary)}
.hero-account a{color:var(--accent-light);font-weight:600;text-decoration:none}.hero-account a:hover{text-decoration:underline}
.hero-account a{color:var(--accent-light);font-weight:600;text-decoration:none}.hero-account a:hover{text-decoration:underline}
-.hero-sky{position:relative}
+.hero-sky{position:sticky;top:72px;padding-top:24px}
.constellation{width:100%;height:auto;display:block;overflow:visible}
.constellation{width:100%;height:auto;display:block;overflow:visible}
.constellation .cs-orbit{fill:none;stroke:var(--border-light);stroke-dasharray:2 6;opacity:.7}.constellation .cs-far{opacity:.4}
.constellation .cs-orbit{fill:none;stroke:var(--border-light);stroke-dasharray:2 6;opacity:.7}.constellation .cs-far{opacity:.4}
.constellation .cs-spoke{stroke:var(--accent);stroke-opacity:.18;stroke-width:1}
.constellation .cs-spoke{stroke:var(--accent);stroke-opacity:.18;stroke-width:1}
@@ -117,7 +120,7 @@ a{color:inherit}
@@ -117,7 +120,7 @@ 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{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: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: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 */
@@ -1,26 +1,44 @@
@@ -1,26 +1,44 @@
'use strict';
'use strict';
-// What is happening on the network right now, for the front door (plan T11, D93): who is live on OpenVibe.Live, the
-// newest public pastes on OpenVibe.Community and the newest posts on OpenVibe.Blog. Each comes from the service's own
-// public API over loopback (fixed first-party addresses, never a URL anyone can choose), and the home page never waits
-// for them: it renders what is known, and a read older than a minute starts one refresh in the background. A service
-// that does not answer within two seconds simply contributes nothing this round.
+// What is happening on the network right now, for the front door (plan T11, D93): who is live on OpenVibe.Live and its
+// latest recordings (watched on OpenVibe.Video), the newest public pastes on OpenVibe.Community, the newest posts on
+// OpenVibe.Blog, and the newest jobs and coupon codes OpenVibe.Search holds for OpenVibe.Work and OpenVibe.Coupons. Each
+// comes from the service's own public API over loopback (fixed first-party addresses, never a URL anyone can choose), and
+// the home page never waits for them: it renders what is known, and a read older than a minute starts one refresh in
+// the background. A service that does not answer within two seconds simply contributes nothing this round.
+//
+// The same refresh reads two numbers Network knows itself (configure()): how many accounts exist and how many of the
+// public services answered their last health check. Nothing here is estimated: a number nobody could read is left out.
+const contracts = require('openvibe-contracts');
+
const TTL_MS = 60_000;
const TTL_MS = 60_000;
const TIMEOUT_MS = 2000;
const TIMEOUT_MS = 2000;
+const origin = (id, fallback) => { const m = contracts.services.get(id); return (m && m.internalOrigin) || fallback; };
const SOURCES = {
const SOURCES = {
live: process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000',
live: process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000',
community: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',
community: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',
blog: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810',
blog: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810',
+ search: process.env.OV_SEARCH_INTERNAL_URL || origin('search', 'http://127.0.0.1:4710'),
};
};
-const PUBLIC = { live: 'https://openvibe.live', community: 'https://openvibe.community', blog: 'https://openvibe.blog' };
+const PUBLIC = { live: 'https://openvibe.live', video: 'https://openvibe.video', community: 'https://openvibe.community', blog: 'https://openvibe.blog', work: 'https://openvibe.work', coupons: 'https://openvibe.coupons' };
-let state = { at: 0, version: 0, live: [], pastes: [], posts: [] };
+const EMPTY = { live: [], liveCount: 0, recordings: [], pastes: [], posts: [], jobs: [], codes: [], counts: {} };
+let state = { at: 0, version: 0, ...EMPTY };
let inflight = null;
let inflight = null;
// Tests switch the reads off; the page then shows the "nothing live" state.
// Tests switch the reads off; the page then shows the "nothing live" state.
let enabled = process.env.HOME_ACTIVITY !== 'off' && process.env.NODE_ENV !== 'test';
let enabled = process.env.HOME_ACTIVITY !== 'off' && process.env.NODE_ENV !== 'test';
+// Network's own numbers (server/index.js): accounts() → a count, services() → { up, live }.
+let readers = {};
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t; };
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t; };
const abs = (base, u) => { try { const x = new URL(String(u || ''), base); return /^https:$/.test(x.protocol) ? x.href : null; } catch { return null; } };
const abs = (base, u) => { try { const x = new URL(String(u || ''), base); return /^https:$/.test(x.protocol) ? x.href : null; } catch { return null; } };
+const onHost = (u, host) => { try { const x = new URL(String(u || '')); return x.protocol === 'https:' && x.hostname === host ? x.href : null; } catch { return null; } };
+
+// The front door is the network's most public page, so it is stricter than the services it quotes: an item whose words
+// read as adult content is left out, and AI words that describe a person (looks, clothing, a guessed gender) are never
+// repeated here (the owner's rule for public AI text). Such an item still lives on its own site; it is just not featured.
+const ADULT = /(^|[^\w])18\s*\+|\b(adults?[- ]only|adult[- ](industry|content|work|entertainment)|nsfw|xxx|porn\w*|onlyfans|escorts?|webcam (models?|creators?|girls?)|cam ?girls?|sugar (daddy|baby)|nude\w*|sexy)\b/i;
+const LOOKS = /\b(man|woman|men|women|girl|boy|guy|lady|ladies|gentleman|blond(e|es)?|brunette|redhead|beard(ed)?|hair\w*|wearing|dressed|outfit|shirtless|bikini|lingerie|cleavage|his|her|he|she)\b/i;
async function getJson(url, fetchImpl) {
async function getJson(url, fetchImpl) {
try {
try {
@@ -29,10 +47,13 @@ async function getJson(url, fetchImpl) {
@@ -29,10 +47,13 @@ async function getJson(url, fetchImpl) {
} catch { return null; }
} catch { return null; }
}
}
-function liveStreams(body) {
+function liveRows(body) {
const rows = body && Array.isArray(body.streams) ? body.streams : [];
const rows = body && Array.isArray(body.streams) ? body.streams : [];
- return rows
- .filter((s) => s && s.is_live && !s.is_nsfw && s.username)
+ return rows.filter((s) => s && s.is_live && !s.is_nsfw && s.username);
+}
+
+function liveStreams(body) {
+ return liveRows(body)
.map((s) => ({
.map((s) => ({
name: clip(s.display_name || s.username, 40),
name: clip(s.display_name || s.username, 40),
title: clip(s.ai_title || s.title || `${s.username} is live`, 90),
title: clip(s.ai_title || s.title || `${s.username} is live`, 90),
@@ -46,16 +67,55 @@ function liveStreams(body) {
@@ -46,16 +67,55 @@ function liveStreams(body) {
.slice(0, 4);
.slice(0, 4);
}
}
+/** "1h 53m", "28 min": how long a recording runs. */
+function runtime(sec) {
+ const s = Math.max(0, Math.round(Number(sec) || 0));
+ if (s >= 3600) { const m = Math.floor((s % 3600) / 60); return `${Math.floor(s / 3600)}h${m ? ` ${m}m` : ''}`; }
+ return `${Math.max(1, Math.round(s / 60))} min`;
+}
+
+// The latest recordings worth opening: public, ready, at least ten minutes long, one per channel, so the row is a few
+// different streams rather than one stream's parts. Each opens on OpenVibe.Video, where recordings are watched.
+const MIN_RECORDING_S = 600;
+function recordings(body) {
+ const rows = body && Array.isArray(body.vods) ? body.vods : [];
+ const seen = new Set();
+ const out = [];
+ for (const v of rows) {
+ if (!v || !v.id || !v.username || v.status !== 'ready' || v.is_nsfw || v.visibility !== 'public' || v.is_public === false) continue;
+ if ((Number(v.duration_seconds ?? v.duration) || 0) < MIN_RECORDING_S || seen.has(v.username)) continue;
+ const title = clip(v.title || '', 80);
+ if (!title || ADULT.test(title)) continue;
+ seen.add(v.username);
+ 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),
+ });
+ if (out.length === 4) break;
+ }
+ return out;
+}
+
function publicPastes(body) {
function publicPastes(body) {
const rows = body && Array.isArray(body.pastes) ? body.pastes : [];
const rows = body && Array.isArray(body.pastes) ? body.pastes : [];
return rows
return rows
.filter((p) => p && p.visibility === 'public' && !p.is_nsfw && !p.burn_after_read && p.slug)
.filter((p) => p && p.visibility === 'public' && !p.is_nsfw && !p.burn_after_read && p.slug)
- .map((p) => ({
- title: clip(p.title || 'Untitled paste', 70),
- url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,
- kind: p.type === 'screenshot' ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),
- summary: clip(p.ai_summary || '', 110),
- }))
+ .filter((p) => !ADULT.test(`${p.title || ''} ${p.ai_summary || ''}`) && !LOOKS.test(p.title || ''))
+ .map((p) => {
+ const shot = p.type === 'screenshot';
+ // A screenshot's summary is an AI description of a picture, so it can describe a person: only a text or code
+ // paste's summary is quoted, and only when it names nobody's looks.
+ const summary = shot || LOOKS.test(p.ai_summary || '') ? '' : clip(p.ai_summary || '', 110);
+ return {
+ title: clip(p.title || 'Untitled paste', 70),
+ url: `${PUBLIC.community}/p/${encodeURIComponent(p.slug)}`,
+ kind: shot ? 'Screenshot' : (p.language && p.language !== 'text' ? p.language : 'Text'),
+ summary,
+ };
+ })
.slice(0, 4);
.slice(0, 4);
}
}
@@ -67,21 +127,70 @@ function blogPosts(body) {
@@ -67,21 +127,70 @@ function blogPosts(body) {
.slice(0, 3);
.slice(0, 3);
}
}
+// OpenVibe.Search documents (newest first when the query has no words): only public, indexable ones with a canonical
+// address on the owning site.
+function searchDocs(body, host) {
+ const rows = body && Array.isArray(body.results) ? body.results : [];
+ return rows.filter((d) => d && d.visibility === 'public' && d.indexable !== false && d.title && onHost(d.canonical_url, host));
+}
+
+function jobs(body) {
+ return searchDocs(body, 'openvibe.work')
+ .filter((d) => !ADULT.test(`${d.title} ${d.summary || ''}`))
+ .map((d) => {
+ const f = d.facets || {};
+ const where = clip(f.location || d.summary || '', 48);
+ const board = d.provenance && d.provenance[0] && d.provenance[0].label ? clip(d.provenance[0].label, 30) : '';
+ return { title: clip(d.title, 90), url: onHost(d.canonical_url, 'openvibe.work'), where: f.remote ? (where ? `Remote · ${where}` : 'Remote') : where, board };
+ })
+ .slice(0, 4);
+}
+
+function coupons(body) {
+ return searchDocs(body, 'openvibe.coupons')
+ .filter((d) => !ADULT.test(d.title))
+ .map((d) => {
+ // Coupons titles read "CODE — what it takes off": the code is shown as a chip, the rest as the line.
+ const m = /^(\S{2,32})\s+—\s+(.+)$/.exec(String(d.title).trim());
+ return { code: m ? clip(m[1], 32) : '', title: clip(m ? m[2] : d.title, 90), url: onHost(d.canonical_url, 'openvibe.coupons') };
+ })
+ .slice(0, 4);
+}
+
+async function readCount(fn) {
+ if (typeof fn !== 'function') return null;
+ try { const v = await fn(); return v == null ? null : v; } catch { return null; }
+}
+
/** One refresh of every source; a source that fails keeps its last good list. */
/** One refresh of every source; a source that fails keeps its last good list. */
async function refresh({ fetch: fetchImpl = globalThis.fetch } = {}) {
async function refresh({ fetch: fetchImpl = globalThis.fetch } = {}) {
if (inflight) return inflight;
if (inflight) return inflight;
inflight = (async () => {
inflight = (async () => {
- const [live, pastes, posts] = await Promise.all([
+ const q = (owner, type) => `${SOURCES.search}/api/v1/search?owner=${owner}&type=${type}&limit=12`;
+ const [live, vods, pastes, posts, work, codes, accounts, services] = await Promise.all([
getJson(`${SOURCES.live}/api/streams`, fetchImpl),
getJson(`${SOURCES.live}/api/streams`, fetchImpl),
+ getJson(`${SOURCES.live}/api/vods?limit=40`, fetchImpl),
getJson(`${SOURCES.community}/api/pastes?limit=12`, fetchImpl),
getJson(`${SOURCES.community}/api/pastes?limit=12`, fetchImpl),
getJson(`${SOURCES.blog}/feed.json`, fetchImpl),
getJson(`${SOURCES.blog}/feed.json`, fetchImpl),
+ getJson(q('work', 'job'), fetchImpl),
+ getJson(q('coupons', 'coupon'), fetchImpl),
+ readCount(readers.accounts),
+ readCount(readers.services),
]);
]);
+ const counts = { ...state.counts };
+ if (Number.isFinite(Number(accounts)) && Number(accounts) > 0) counts.accounts = Number(accounts);
+ if (services && Number(services.live) > 0) counts.services = { up: Math.max(0, Number(services.up) || 0), live: Number(services.live) };
state = {
state = {
at: Date.now(),
at: Date.now(),
version: state.version + 1,
version: state.version + 1,
live: live ? liveStreams(live) : state.live,
live: live ? liveStreams(live) : state.live,
+ liveCount: live ? liveRows(live).length : state.liveCount,
+ recordings: vods ? recordings(vods) : state.recordings,
pastes: pastes ? publicPastes(pastes) : state.pastes,
pastes: pastes ? publicPastes(pastes) : state.pastes,
posts: posts ? blogPosts(posts) : state.posts,
posts: posts ? blogPosts(posts) : state.posts,
+ jobs: work ? jobs(work) : state.jobs,
+ codes: codes ? coupons(codes) : state.codes,
+ counts,
};
};
return state;
return state;
})().finally(() => { inflight = null; });
})().finally(() => { inflight = null; });
@@ -95,9 +204,11 @@ function peek() {
@@ -95,9 +204,11 @@ function peek() {
}
}
module.exports = {
module.exports = {
- peek, refresh, liveStreams, publicPastes, blogPosts,
+ peek, refresh, liveStreams, recordings, publicPastes, blogPosts, jobs, coupons, runtime, ADULT, LOOKS,
+ /** server/index.js: Network's own numbers, read on each refresh ({ accounts: async () => n, services: () => ({ up, live }) }). */
+ configure(r) { readers = { ...readers, ...(r || {}) }; },
/** Tests: switch the background reads on or off. */
/** Tests: switch the background reads on or off. */
setEnabled(on) { enabled = Boolean(on); },
setEnabled(on) { enabled = Boolean(on); },
/** Tests: replace the snapshot. */
/** Tests: replace the snapshot. */
- _set(s) { state = { at: Date.now(), version: state.version + 1, live: [], pastes: [], posts: [], ...s }; },
+ _set(s) { state = { at: Date.now(), version: state.version + 1, ...EMPTY, ...s }; },
};
};
@@ -20,7 +20,9 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat
@@ -20,7 +20,9 @@ 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 frameSites = require('../frame/sites').SITES;
+const { SITES: frameSites, GROUPS, groupOf, PRODUCTS } = 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}`);
const siteName = (site) => (site.name.includes('.') ? site.name : `OpenVibe.${site.name}`);
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');
@@ -99,21 +101,49 @@ ${openNodes}${soonNodes}</svg>`;
@@ -99,21 +101,49 @@ ${openNodes}${soonNodes}</svg>`;
}
}
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));
+const fmtCount = (n) => Number(n).toLocaleString('en-US');
+// A picture that fails to load (a stream's first thumbnail is written a few seconds after it goes live) falls back to
+// the avatar when there is one, else to an empty tile, never the browser's broken-image icon.
+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) {
function rightNow(a) {
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 src="${esc(s.thumb || s.avatar)}" alt="" loading="lazy" decoding="async" width="320" height="180"${s.thumb && s.avatar ? ` data-fallback="${esc(s.avatar)}" onerror="this.onerror=null;this.src=this.dataset.fallback"` : ''}>` : '<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.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 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>`;
- const list = (items, render, empty) => (items.length ? `<ul class="now-list">${items.map(render).join('')}</ul>` : `<p class="now-quiet">${empty}</p>`);
+ 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.
+ 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(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>
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>
-<div class="now-grid"><div class="now-col now-live"><h3>Live</h3><div class="now-streams">${live}</div><a class="now-more" href="https://openvibe.live/">All channels →</a></div>
-<div class="now-col"><h3>New pastes</h3>${list(a.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>`, 'Nothing new in the last while. <a href="https://openvibe.community/new">Share one</a>.')}<a class="now-more" href="https://openvibe.community/">OpenVibe.Community →</a></div>
-<div class="now-col"><h3>From the blog</h3>${list(a.posts, (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'No posts yet.')}<a class="now-more" href="https://openvibe.blog/">OpenVibe.Blog →</a></div></div></section>`;
+<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>`;
+}
+
+// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it
+// could not read is left out rather than guessed.
+function numbers(catalog, a) {
+ const c = a.counts || {};
+ const items = [
+ c.services ? [fmtCount(c.services.up), 'services up', `of ${c.services.live} checked`, '/status'] : null,
+ [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/'],
+ ].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></section>`;
}
}
// The kit (openvibe-shared/showcase, /shared/showcase.css) owns the sections, cards and icon styles; this is only what
// The kit (openvibe-shared/showcase, /shared/showcase.css) owns the sections, cards and icon styles; this is only what
// it does not draw: the "Right now" columns and the tool and "Opening next" chips.
// it does not draw: the "Right now" columns and the tool and "Opening next" chips.
const CSS = `
const CSS = `
-#developers{margin-bottom:56px}#developers .sc-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
+#faq{margin-bottom:56px}#developers .sc-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
.home-chips{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 0;padding:0;list-style:none}
.home-chips{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 0;padding:0;list-style:none}
.home-chips>li>a{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));font-size:13.5px;font-weight:600;color:inherit;text-decoration:none}
.home-chips>li>a{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));font-size:13.5px;font-weight:600;color:inherit;text-decoration:none}
.home-chips>li>a:hover,.home-chips>li>a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}
.home-chips>li>a:hover,.home-chips>li>a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}
@@ -121,7 +151,7 @@ const CSS = `
@@ -121,7 +151,7 @@ const CSS = `
.home-more{margin:14px 0 0;font-size:13.5px}.home-more a{font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}
.home-more{margin:14px 0 0;font-size:13.5px}.home-more a{font-weight:600;color:var(--accent-light,#60a5fa);text-decoration:none}
.now>h2{display:flex;align-items:center;gap:10px}.now-dot{width:10px;height:10px;border-radius:50%;background:var(--live-red,#ef4444);box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:now-pulse 2s infinite}
.now>h2{display:flex;align-items:center;gap:10px}.now-dot{width:10px;height:10px;border-radius:50%;background:var(--live-red,#ef4444);box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:now-pulse 2s infinite}
@keyframes now-pulse{70%{box-shadow:0 0 0 10px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
@keyframes now-pulse{70%{box-shadow:0 0 0 10px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
-.now-grid{display:grid;gap:16px;grid-template-columns:repeat(3,minmax(0,1fr));margin-top:14px}
+.now-grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));margin-top:14px}
.now-col{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);min-width:0}
.now-col{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);min-width:0}
.now-col h3{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0}
.now-col h3{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0}
.now-streams{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
.now-streams{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
@@ -135,9 +165,23 @@ const CSS = `
@@ -135,9 +165,23 @@ const CSS = `
.now-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
.now-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
.now-list a{display:block;color:inherit;text-decoration:none}.now-list b{display:block;font-size:14px;line-height:1.35}
.now-list a{display:block;color:inherit;text-decoration:none}.now-list b{display:block;font-size:14px;line-height:1.35}
.now-list small{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--text-secondary,#a8b3c4);font-size:12.5px;line-height:1.4;margin-top:2px}
.now-list small{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--text-secondary,#a8b3c4);font-size:12.5px;line-height:1.4;margin-top:2px}
-.now-quiet{color:var(--text-muted,#7d8aa0);font-size:13.5px;margin:0}.now-quiet a{color:var(--accent-light,#60a5fa)}
.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}
-@media (max-width:860px){.now-grid{grid-template-columns:1fr}}
+.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-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)}
+.net-num h2{font-size:12px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin:0 0 10px}
+.net-num ul{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(min(140px,100%),1fr))}
+.net-num a{display:flex;flex-direction:column;gap:1px;padding:10px 12px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-primary,#0a0f1c);color:inherit;text-decoration:none;height:100%}
+.net-num a:hover,.net-num a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}
+.net-num b{font-size:24px;font-weight:800;letter-spacing:-.5px;line-height:1.15;color:var(--text-primary,#e6edf7)}
+.net-num span{font-size:12.5px;color:var(--text-secondary,#a8b3c4);line-height:1.35}.net-num small{display:block;color:var(--text-muted,#7d8aa0);font-size:11.5px}
+.home-group{margin:26px 0 10px;font-size:13px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}
+.home-also{margin:10px 0 0;font-size:13.5px;color:var(--text-secondary,#a8b3c4)}.home-also a{color:var(--accent-light,#60a5fa);font-weight:600;text-decoration:none}.home-also a:hover{text-decoration:underline}
+.faq-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));margin-top:4px}
+.faq-item{padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
+.faq-item h3{font-size:15.5px;margin:0 0 6px;line-height:1.35}.faq-item p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:14px;line-height:1.55}
@media (prefers-reduced-motion:reduce){.now-dot{animation:none}}
@media (prefers-reduced-motion:reduce){.now-dot{animation:none}}
${icons.CSS}`;
${icons.CSS}`;
@@ -151,6 +195,20 @@ const asSentence = (t) => (/[.!?…]$/.test(String(t).trim()) ? String(t).trim()
@@ -151,6 +195,20 @@ const asSentence = (t) => (/[.!?…]$/.test(String(t).trim()) ? String(t).trim()
const inside = (section, extra) => (section && extra ? section.replace(/<\/section>$/, () => `${extra}</section>`) : section);
const inside = (section, extra) => (section && extra ? section.replace(/<\/section>$/, () => `${extra}</section>`) : section);
const chips = (list, cls = '') => (list.length ? `<ul class="home-chips${cls ? ` ${cls}` : ''}">${list.map(([href, title, ic, name]) => `<li><a href="${esc(href)}" title="${esc(title)}">${icon(ic, 22)}${esc(name)}</a></li>`).join('')}</ul>` : '');
const chips = (list, cls = '') => (list.length ? `<ul class="home-chips${cls ? ` ${cls}` : ''}">${list.map(([href, title, ic, name]) => `<li><a href="${esc(href)}" title="${esc(title)}">${icon(ic, 22)}${esc(name)}</a></li>`).join('')}</ul>` : '');
+// The questions on the page, which its FAQPage JSON-LD repeats word for word: the network's own (its Contracts site
+// block, the same answers OpenVibe.Help shows) after the few that only the front door answers.
+const FRONT_FAQ = [
+ { q: 'Do I need an account to use OpenVibe?', a: 'No. Watching streams, the tools, pastes, the wiki and the blog all work as a guest. An account keeps what should last: your history, themes, VODs and notifications.' },
+ { q: 'Is OpenVibe open source?', a: 'Yes. Every OpenVibe service is open source under the OpenVibers organization on GitHub, and the network is run by the people who use it.' },
+ { q: 'Can I build an app on OpenVibe?', a: 'Yes. OpenVibe.Services gives you projects, apps, keys and the API docs of every service, and your users sign in with their OpenVibe account through OAuth 2.0 or FedCM.' },
+];
+function faqPairs() {
+ const m = contracts.services.get('network');
+ const own = ((m && m.site && m.site.faq) || []).filter((f) => f && f.question && f.answer).map((f) => ({ q: f.question, a: f.answer }));
+ const seen = new Set();
+ return [FRONT_FAQ[0], ...own, ...FRONT_FAQ.slice(1)].filter((p) => !seen.has(p.q) && seen.add(p.q));
+}
+
// Below the static hero (public/index.html: the intent picker and the constellation), every section is the shared
// Below the static hero (public/index.html: the intent picker and the constellation), every section is the shared
// showcase kit, as on the other flagship homes (plan T11, D93); the data and every link are the ones this page had.
// showcase kit, as on the other flagship homes (plan T11, D93); the data and every link are the ones this page had.
function body(catalog, act) {
function body(catalog, act) {
@@ -158,11 +216,14 @@ function body(catalog, act) {
@@ -158,11 +216,14 @@ function body(catalog, act) {
const popular = POPULAR.map((id) => byId.get(id)).filter(Boolean);
const popular = POPULAR.map((id) => byId.get(id)).filter(Boolean);
const fams = catalog.families.filter((f) => f.url);
const fams = catalog.families.filter((f) => f.url);
const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;
const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;
- const sites = showcase.features({
- id: 'network', title: sitesHeading(),
- lede: 'Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.',
- items: OPEN.map((site) => ({ icon: `ov:${site.icon}`, title: siteName(site), text: site.tagline ? `${asSentence(site.tagline)} ${site.what}` : site.what, href: `https://${site.host}/` })),
- });
+ // The service map: every open site under the launcher's groups (server/frame/sites.js GROUPS), so the 36 cards read
+ // as six neighbourhoods, each with the products that live on another site's domain (openvibe.pics, …) beside it.
+ const grid = (items) => showcase.features({ items }).replace(/^<section class="sc-sec">/, '').replace(/<\/section>$/, '');
+ const groups = GROUPS.map((g) => ({ g, sites: OPEN.filter((site) => groupOf(site.id) === g.id), also: PRODUCTS.filter((p) => groupOf(p.id) === g.id) }))
+ .filter((x) => x.sites.length)
+ .map(({ g, sites: list, also }) => `<h3 class="home-group">${esc(g.name)}</h3>${grid(list.map((site) => ({ icon: `ov:${site.icon}`, title: siteName(site), text: site.tagline ? `${asSentence(site.tagline)} ${site.what}` : site.what, href: `https://${site.host}/` })))}${also.length ? `<p class="home-also">Also here: ${also.map((p) => `<a href="https://${esc(p.host)}/">${esc(p.host)}</a> (${esc(p.tagline.toLowerCase())})`).join(', ')}</p>` : ''}`).join('\n');
+ const sites = `<section class="sc-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="sc-lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>
+${groups}</section>`;
const tools = inside(showcase.features({
const tools = inside(showcase.features({
id: 'tools', title: catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open',
id: 'tools', title: catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open',
lede: 'No installs and no sign-up wall. Every tool has its own short address, so yt.openvibe.tools or dns.openvibe.tools takes you straight there.',
lede: 'No installs and no sign-up wall. Every tool has its own short address, so yt.openvibe.tools or dns.openvibe.tools takes you straight there.',
@@ -187,13 +248,16 @@ ${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, si
@@ -187,13 +248,16 @@ ${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, si
{ icon: 'fa-code-branch', title: 'Source on GitHub', text: 'Every OpenVibe service is open source under the OpenVibers organization.', href: 'https://github.com/OpenVibers' },
{ icon: 'fa-code-branch', title: 'Source on GitHub', text: 'Every OpenVibe service is open source under the OpenVibers organization.', href: 'https://github.com/OpenVibers' },
],
],
}), '<p class="home-more">Machine-readable: <a href="/llms.txt">/llms.txt</a> · <a href="/llms-full.txt">/llms-full.txt</a> · <a href="https://openvibe.tools/llms.txt">openvibe.tools/llms.txt</a></p>');
}), '<p class="home-more">Machine-readable: <a href="/llms.txt">/llms.txt</a> · <a href="/llms-full.txt">/llms-full.txt</a> · <a href="https://openvibe.tools/llms.txt">openvibe.tools/llms.txt</a></p>');
+ const faq = `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions people ask</h2><p class="sc-lede">The short answers. Every OpenVibe site has its own page of answers on OpenVibe.Help.</p>
+<div class="faq-grid">${faqPairs().map((p) => `<div class="faq-item"><h3>${esc(p.q)}</h3><p>${esc(p.a)}</p></div>`).join('')}</div><p class="home-more"><a href="https://openvibe.help/sites/openvibe.network">More answers about your account on OpenVibe.Help →</a></p></section>`;
return `<style>${CSS}</style>
return `<style>${CSS}</style>
${rightNow(act)}
${rightNow(act)}
${sites}
${sites}
${tools}
${tools}
${account}
${account}
${soon}
${soon}
-${dev}`;
+${dev}
+${faq}`;
}
}
// What an AI reader is told the page is (openvibe-shared/seo pageSummary): the meta, a WebPage JSON-LD tag and a
// What an AI reader is told the page is (openvibe-shared/seo pageSummary): the meta, a WebPage JSON-LD tag and a
@@ -216,9 +280,10 @@ function render({ releaseId = '' } = {}) {
@@ -216,9 +280,10 @@ function render({ releaseId = '' } = {}) {
const stat = fs.statSync(SHELL);
const stat = fs.statSync(SHELL);
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}:${releaseId}`;
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}:${releaseId}`;
if (pageCache.key === key) return pageCache;
if (pageCache.key === key) return pageCache;
- const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));
+ 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 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()).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-->', () => 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>`);
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;
}
}
@@ -739,7 +739,17 @@ app.get(['/login.html', '/admin.html'], (req, res) => {
@@ -739,7 +739,17 @@ app.get(['/login.html', '/admin.html'], (req, res) => {
return res.status(404).end();
return res.status(404).end();
});
});
-// Landing page at the apex root
+// Landing page at the apex root. Its numbers are Network's own, read with the rest of its activity once a minute:
+// accounts (people, not guests or banned ones) and how many public services answered their last health check (a
+// count is left out until at least half of them have been checked once).
+require('./home/activity').configure({
+ accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0').get()).cnt,
+ services: () => {
+ const live = require('./status/routes').rows(ecosystem).filter((r) => r.exposure && r.exposure.state === 'live');
+ const known = live.filter((r) => r.status !== 'unknown');
+ return known.length && known.length * 2 >= live.length ? { up: known.filter((r) => r.status === 'up').length, live: known.length } : null;
+ },
+});
app.get(['/', '/index.html'], (req, res) => {
app.get(['/', '/index.html'], (req, res) => {
if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
return require('./home/render').sendHome(req, res, { releaseId: release.release });
return require('./home/render').sendHome(req, res, { releaseId: release.release });
@@ -812,6 +822,14 @@ app.get('/openvibe-sw.js', (req, res) => {
@@ -812,6 +822,14 @@ app.get('/openvibe-sw.js', (req, res) => {
const sharedRev = new Map();
const sharedRev = new Map();
function serveShared(req, res, next) {
function serveShared(req, res, next) {
const name = req.path.slice(1);
const name = req.path.slice(1);
+ // The share cards (openvibe-shared/social, social/<service>.jpg): the home page's og:image is Network's own card.
+ const social = require('openvibe-shared/social');
+ if (social.isCard(name)) {
+ const hashed = social.url(name.slice(7, -4), '').endsWith(`?v=${req.query.v}`);
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed }));
+ return res.sendFile(social.path(name));
+ }
if (!sharedFiles.isBrowserFile(name)) return next();
if (!sharedFiles.isBrowserFile(name)) return next();
if (!sharedRev.has(name)) sharedRev.set(name, require('crypto').createHash('sha256').update(fs.readFileSync(sharedFiles.path(name))).digest('hex').slice(0, 12));
if (!sharedRev.has(name)) sharedRev.set(name, require('crypto').createHash('sha256').update(fs.readFileSync(sharedFiles.path(name))).digest('hex').slice(0, 12));
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
@@ -34,6 +34,40 @@ assert.deepStrictEqual([pastes[0].kind, pastes[1].kind], ['js', 'Screenshot']);
@@ -34,6 +34,40 @@ assert.deepStrictEqual([pastes[0].kind, pastes[1].kind], ['js', 'Screenshot']);
const posts = activity.blogPosts({ items: [{ url: 'https://openvibe.blog/@a/b', title: 'Post', summary: 'x' }, { url: 'http://evil.example/', title: 'bad' }] });
const posts = activity.blogPosts({ items: [{ url: 'https://openvibe.blog/@a/b', title: 'Post', summary: 'x' }, { url: 'http://evil.example/', title: 'bad' }] });
assert.deepStrictEqual(posts.map((p) => p.url), ['https://openvibe.blog/@a/b'], 'only https links');
assert.deepStrictEqual(posts.map((p) => p.url), ['https://openvibe.blog/@a/b'], 'only https links');
+// Recordings: public, ready, ten minutes or more, one per channel, opened on OpenVibe.Video with Media's thumbnail.
+const recs = activity.recordings({ vods: [
+ { id: 9, username: 'gus', title: 'Long one', status: 'ready', visibility: 'public', duration_seconds: 4000, thumbnail_url: 'https://openvibe.media/t/vod-9.jpg' },
+ { id: 8, username: 'gus', title: 'Same channel', status: 'ready', visibility: 'public', duration_seconds: 4000 },
+ { id: 7, username: 'amy', title: 'Short', status: 'ready', visibility: 'public', duration_seconds: 120 },
+ { id: 6, username: 'kim', title: 'Private', status: 'ready', visibility: 'private', duration_seconds: 4000 },
+ { id: 5, username: 'lee', title: 'Elsewhere thumb', status: 'ready', visibility: 'public', duration_seconds: 700, thumbnail_url: 'https://evil.example/t.jpg' },
+] });
+assert.deepStrictEqual(recs.map((r) => r.url), ['https://openvibe.video/v/9', 'https://openvibe.video/v/5'], 'ten minutes or more, public, one per channel');
+assert.strictEqual(recs[0].length, '1h 6m');
+assert.strictEqual(recs[1].thumb, null, 'a thumbnail only from openvibe.media');
+assert.strictEqual(activity.runtime(1805), '30 min');
+
+// The front door features no adult items and repeats no AI words about a person's looks.
+const flagged = activity.publicPastes({ pastes: [
+ { slug: 'a', title: 'Cam creator opportunity | 18+', visibility: 'public' },
+ { slug: 'b', title: 'A man on a sofa', visibility: 'public', type: 'screenshot' },
+ { slug: 'c', title: 'Game lobby', visibility: 'public', type: 'screenshot', ai_summary: 'A streamer sits in front of his screen' },
+ { slug: 'd', title: 'Sort helper', visibility: 'public', type: 'code', language: 'js', ai_summary: 'Sorts an array by key' },
+] });
+assert.deepStrictEqual(flagged.map((p) => p.url), ['https://openvibe.community/p/c', 'https://openvibe.community/p/d'], 'no adult or looks titles');
+assert.strictEqual(flagged[0].summary, '', "a screenshot's AI description is never quoted");
+assert.strictEqual(flagged[1].summary, 'Sorts an array by key', "a code paste's summary is");
+
+// Jobs and coupon codes come from OpenVibe.Search, only public ones on their own site.
+const job = activity.jobs({ results: [
+ { visibility: 'public', title: 'Engineer at Acme', canonical_url: 'https://openvibe.work/jobs/1', facets: { location: 'Berlin', remote: true }, provenance: [{ label: 'Arbeitnow' }] },
+ { visibility: 'restricted', title: 'Hidden', canonical_url: 'https://openvibe.work/jobs/2' },
+ { visibility: 'public', title: 'Off site', canonical_url: 'https://example.com/jobs/3' },
+] });
+assert.deepStrictEqual(job, [{ title: 'Engineer at Acme', url: 'https://openvibe.work/jobs/1', where: 'Remote · Berlin', board: 'Arbeitnow' }]);
+const code = activity.coupons({ results: [{ visibility: 'public', title: 'FALL10 — 10% off a watch', canonical_url: 'https://openvibe.coupons/c/1' }] });
+assert.deepStrictEqual(code, [{ code: 'FALL10', title: '10% off a watch', url: 'https://openvibe.coupons/c/1' }]);
+
// The page with nothing live: the empty state invites a browser go-live.
// The page with nothing live: the empty state invites a browser go-live.
activity._set({ live: [], pastes: [], posts: [] });
activity._set({ live: [], pastes: [], posts: [] });
let { html } = home.render();
let { html } = home.render();
@@ -64,10 +98,24 @@ for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://
@@ -64,10 +98,24 @@ for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://
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');
+// The FAQ shown is the FAQPage JSON-LD, word for word, with the network's own questions from its Contracts site block.
+const faqLd = JSON.parse(/<script type="application\/ld\+json">(\{"@context":"https:\/\/schema.org","@type":"FAQPage"[\s\S]*?)<\/script>/.exec(html)[1]);
+assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');
+faqLd.mainEntity.forEach((q) => assert.ok(html.includes(`<h3>${q.name.replace(/'/g, ''')}</h3>`), `the FAQ shows "${q.name}"`));
+assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");
+// The service map groups the sites the way the launcher does; numbers say only what was read.
+assert.ok(/<h3 class="home-group">Watch & stream<\/h3>/.test(html) && /<h3 class="home-group">Build & automate<\/h3>/.test(html), 'the sites are grouped');
+assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');
+assert.ok(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');
+assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');
+assert.ok(/<meta property="og:image" content="https:\/\/openvibe\.network\/shared\/social\/network\.jpg\?v=/.test(html) && !html.includes('__OV_CARD__'), "the share card is the network's own");
+
// With activity: the items render, escaped.
// With activity: the items render, escaped.
-activity._set({ live: [{ name: 'Al<b>ice', title: 'Hi & bye', url: 'https://openvibe.live/@alice', thumb: 'https://openvibe.live/t.jpg', avatar: 'https://openvibe.media/a', viewers: 1500, category: '' }], pastes, posts });
+activity._set({ live: [{ name: 'Al<b>ice', title: 'Hi & bye', url: 'https://openvibe.live/@alice', thumb: 'https://openvibe.live/t.jpg', avatar: 'https://openvibe.media/a', viewers: 1500, category: '' }], liveCount: 1, pastes, posts, recordings: recs, jobs: job, codes: code, counts: { accounts: 1234, services: { up: 37, live: 38 } } });
({ html } = home.render());
({ html } = home.render());
assert.ok(html.includes('LIVE · 1.5k') && html.includes('Al<b>ice') && html.includes('Hi & bye'), 'viewers, names and titles render escaped');
assert.ok(html.includes('LIVE · 1.5k') && html.includes('Al<b>ice') && html.includes('Hi & bye'), 'viewers, names and titles render escaped');
assert.ok(html.includes('data-fallback="https://openvibe.media/a"'), 'a broken thumbnail falls back to the avatar');
assert.ok(html.includes('data-fallback="https://openvibe.media/a"'), 'a broken thumbnail falls back to the avatar');
assert.ok(html.includes('href="https://openvibe.community/p/one"') && html.includes('href="https://openvibe.blog/@a/b"'));
assert.ok(html.includes('href="https://openvibe.community/p/one"') && html.includes('href="https://openvibe.blog/@a/b"'));
+assert.ok(html.includes('href="https://openvibe.video/v/9"') && html.includes('<code class="now-code">FALL10</code>') && html.includes('Remote · Berlin · via Arbeitnow'), 'recordings, codes and jobs render');
+assert.ok(/<b>1,234<\/b><span>accounts/.test(html) && /<b>37<\/b><span>services up <small>of 38 checked/.test(html) && /<b>1<\/b><span>stream live now/.test(html), 'the numbers read render');
console.log('home front door: all checks passed');
console.log('home front door: all checks passed');
@@ -12,8 +12,8 @@ const { spawn } = require('child_process');
@@ -12,8 +12,8 @@ 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: 146, // measured 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: 28, // 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)
+ htmlRawKB: 152, // 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: 30, // 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: 266, // 249.8 with boost.js (13.1); 228.3
jsRawKB: 266, // 249.8 with boost.js (13.1); 228.3
jsBrotliKB: 64, // 60.3 with boost.js (3.8); 53.9
jsBrotliKB: 64, // 60.3 with boost.js (3.8); 53.9