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)

OP OpenVibers committed 22 days ago · eb42e08060 · 6 files, +300 −55

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 2e51f4f1..d2a34c80 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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 */
ddiff --git a/server/home/activity.js b/server/home/activity.js
iindex 9d024f0c..a02be752 100644
---- a/server/home/activity.js
++++ b/server/home/activity.js
@@@ -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 }; },
};
ddiff --git a/server/home/render.js b/server/home/render.js
iindex c1bd927d..6061e42f 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -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;
}
ddiff --git a/server/index.js b/server/index.js
iindex a5dc3d6c..ce54a5ab 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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', '*');
ddiff --git a/test/home-front-door.test.js b/test/home-front-door.test.js
iindex 3236c336..c02f8adc 100644
---- a/test/home-front-door.test.js
++++ b/test/home-front-door.test.js
@@@ -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, '&#39;')}</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 &amp; stream<\/h3>/.test(html) && /<h3 class="home-group">Build &amp; 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&lt;b&gt;ice') && html.includes('Hi &amp; 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');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex f8d796a4..2718d0cc 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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.3
jsBrotliKB: 64, // 60.3 with boost.js (3.8); 53.9