openvibers/openvibe.network Commits
Give the Network home shared page and section headers (#707)
Changes
@@@ -1,4 +1,6 @@'use strict';++const kit = require('openvibe-shared/kit');++const sectionIcon = (name) => kit.icon(name, { cls: 'ovk-heading-icon' });const cache = require('openvibe-shared/cache-policy');// The openvibe.network home page: the static shell (public/index.html) with the network and the// tool catalog rendered into it on the server, so the page is complete without JavaScript and@@@ -97,7 +99,7 @@ function rankedIntents(popularity) {function intents(catalog, popularity = null) {const toolsLine = catalog.tools.length ? `Convert, compress, look up or download: ${catalog.tools.length} tools that just open.` : 'Convert, compress, look up or download: free tools that just open.';const ranked = rankedIntents(popularity);-- return `<ul class="intents" aria-label="What you can do here${popularity ? ', most visited this week first' : ''}">${ranked.map(({ it: [id, fa, verb, line, href, k], n }, pos) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small>${pos < 5 && n >= 10 ? `<em class="intent-n">${esc(fmtVisits(n))} visits this week</em>` : ''}</span></a></li>`).join('')}</ul>`;++ return `<ul class="intents" data-ovk-scroll aria-label="What you can do here${popularity ? ', most visited this week first' : ''}">${ranked.map(({ it: [id, fa, verb, line, href, k], n }, pos) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small>${pos < 5 && n >= 10 ? `<em class="intent-n">${esc(fmtVisits(n))} visits this week</em>` : ''}</span></a></li>`).join('')}</ul>`;}// The network as an orbit (the owner, 2026-10-28: the hero was "ugly af, boring… not easy to understand… nice animated@@@ -147,31 +149,31 @@ function rightNow(a) {const card = ({ url, thumb, badge, len, about, avatar, title, who, line, cls = '' }) => `<li><a class="vcard${cls}" href="${esc(url)}"><span class="vc-thumb">${thumb ? img(thumb, avatar, 320, 180) : '<span class="now-noimg"></span>'}${badge || ''}${len ? `<span class="vc-len">${esc(len)}</span>` : ''}<span class="vc-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>${about ? `<span class="vc-about">${esc(about)}</span>` : ''}</span><span class="vc-meta">${face(avatar)}<span class="vc-text"><b>${esc(title)}</b>${who ? `<small>${esc(who)}</small>` : ''}${line ? `<small>${esc(line)}</small>` : ''}</span></span></a></li>`;-- const rail = (title, href, more, cards, id = '') => (cards.length ? `<div class="now-block"${id ? ` id="${id}"` : ''}><div class="now-head"><h3>${title}</h3><a class="now-more" href="${href}">${more} →</a></div><ul class="now-rail">${cards.join('')}</ul></div>` : '');++ const rail = (title, href, more, cards, id = '') => (cards.length ? `<div class="now-block"${id ? ` id="${id}"` : ''}><div class="now-head"><h3 class="ovk-titled">${sectionIcon('grid')}${title}</h3>${kit.button({ href, label: more, icon: 'forward', kind: 'quiet' })}</div><ul class="now-rail" data-ovk-scroll>${cards.join('')}</ul></div>` : '');const live = a.live.map((s) => card({ url: s.url, thumb: s.thumb || s.avatar, badge: `<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span>`, avatar: s.avatar, title: s.title, who: s.name, line: s.category }));// Nobody live: one honest line and the way to start, not an empty 16:9 player (a phone saw a blank screen of it).-- const quiet = a.live.length ? '' : '<p class="now-quiet"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span><b>Nobody is live right now.</b> Be the first: <a href="https://openvibe.live/broadcast">go live from your browser</a>, no OBS and no follower minimum.</span></p>';++ const quiet = a.live.length ? '' : kit.empty({ icon: 'live', title: 'Nobody is live right now.', text: 'Go live from your browser, with no OBS and no follower minimum.', action: { href: 'https://openvibe.live/broadcast', label: 'Go live', icon: 'video' } });const recs = (a.recordings || []).map((r) => card({ url: r.url, thumb: r.thumb, len: r.length, about: r.about, avatar: r.avatar, title: r.title, who: r.channel, line: [views(r.views), ago(r.at)].filter(Boolean).join(' · ') }));const shots = (a.pastes || []).filter((p) => p.shot).slice(0, 4).map((p) => card({ url: p.url, thumb: p.shot, avatar: p.avatar, title: p.title, who: p.by || 'Screenshot', line: [views(p.views), p.likes ? `${p.likes} like${p.likes === 1 ? '' : 's'}` : '', ago(p.at)].filter(Boolean).join(' · '), cls: ' is-shot' }));const texts = (a.pastes || []).filter((p) => !p.shot);const list = (items, render) => `<ul class="now-list">${items.map(render).join('')}</ul>`;// A column with nothing to show is left out.-- const col = (items, title, render, href, more) => (items.length ? `<div class="now-col"><h3>${title}</h3>${list(items.slice(0, 3), render)}<a class="now-more" href="${href}">${more} →</a></div>` : '');++ const col = (items, title, render, href, more) => (items.length ? `<div class="now-col"><h3 class="ovk-titled">${sectionIcon('grid')}${title}</h3>${list(items.slice(0, 3), render)}${kit.button({ href, label: more, icon: 'forward', kind: 'quiet' })}</div>` : '');const cols = [col(texts, 'New pastes', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'https://openvibe.community/', 'OpenVibe.Community'),col(a.posts, 'From the blog', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'https://openvibe.blog/', 'OpenVibe.Blog'),col(a.jobs || [], 'New jobs', (j) => `<li><a href="${esc(j.url)}"><b>${esc(j.title)}</b><small>${[j.where, j.board ? `via ${j.board}` : ''].filter(Boolean).map(esc).join(' · ')}</small></a></li>`, 'https://openvibe.work/', 'Every job on OpenVibe.Work'),col(a.codes || [], 'Fresh coupon codes', (c) => `<li><a href="${esc(c.url)}">${c.code ? `<code class="now-code">${esc(c.code)}</code>` : ''}<b>${esc(c.title)}</b></a></li>`, 'https://openvibe.coupons/', 'OpenVibe.Coupons'),].filter(Boolean);-- return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>++ return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now" class="ovk-titled">${sectionIcon('members')}<span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2><p class="sc-lede">What people are streaming, sharing and posting across the network, read from each site within the last minute.</p>${live.length ? rail('Live now', 'https://openvibe.live/', 'All channels on OpenVibe.Live', live) : quiet}${nowParts([['recs', 'Recordings', rail('Latest recordings', 'https://openvibe.video/', 'Watch on OpenVibe.Video', recs, 'now-recs')],['shots', 'Screenshots', rail('New screenshots', 'https://openvibe.community/', 'OpenVibe.Community', shots, 'now-shots')],-- ['posts', 'Posts and jobs', cols.length ? `<div class="now-grid" id="now-posts">${cols.join('\n')}</div>` : ''],++ ['posts', 'Posts and jobs', cols.length ? `<div class="now-grid" data-ovk-scroll id="now-posts">${cols.join('\n')}</div>` : ''],])}</section>`;}@@@ -182,7 +184,7 @@ function nowParts(parts) {const shown = parts.filter(([, , html]) => html);if (shown.length < 2) return shown.map(([, , html]) => html).join('\n');const chips = shown.map(([id, label]) => `<a href="#now-${id}">${esc(label)}</a>`).join('');-- return `<nav class="now-pick" aria-label="New on OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<div class="(now-block|now-grid)"/, '<div class="$1" data-now-part')).join('\n')}`;++ return `<nav class="now-pick" data-ovk-scroll aria-label="New on OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<div class="(now-block|now-grid)"/, '<div class="$1" data-now-part')).join('\n')}`;}// The lower half (tools, what an account adds, what opens next, the questions, the developers' links): every section@@@ -192,7 +194,7 @@ function moreParts(parts) {const shown = parts.filter(([, , html]) => html);if (shown.length < 2) return shown.map(([, , html]) => html).join('\n');const chips = shown.map(([id, label]) => `<a href="#${id}">${esc(label)}</a>`).join('');-- return `<div class="more"><h2 class="more-pick-h">More about OpenVibe</h2><nav class="more-pick" aria-label="More about OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<section class="([^"]*)" id="([^"]*)"/, '<section class="$1" id="$2" data-more-part')).join('\n')}</div>`;++ return `<div class="more"><h2 class="more-pick-h">${sectionIcon('grid')}More about OpenVibe</h2><nav class="more-pick" data-ovk-scroll aria-label="More about OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<section class="([^"]*)" id="([^"]*)"/, '<section class="$1" id="$2" data-more-part')).join('\n')}</div>`;}// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it@@@ -206,7 +208,7 @@ function numbers(catalog, a) {c.accounts ? [fmtCount(c.accounts), 'accounts', '', '/login?tab=register'] : null,a.liveCount ? [fmtCount(a.liveCount), a.liveCount === 1 ? 'stream live now' : 'streams live now', '', 'https://openvibe.live/'] : null,].filter(Boolean);-- return `<section class="net-num" aria-labelledby="h-num"><h2 id="h-num">OpenVibe in numbers</h2><ul>${items.map(([n, label, note, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}${note ? ` <small>${esc(note)}</small>` : ''}</span></a></li>`).join('')}</ul><p class="net-num-more"><a href="/stats">Every site's numbers, open to everyone →</a></p></section>`;++ return `<section class="net-num" aria-labelledby="h-num"><h2 id="h-num" class="ovk-titled">${sectionIcon('grid')}OpenVibe in numbers</h2><ul>${items.map(([n, label, note, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}${note ? ` <small>${esc(note)}</small>` : ''}</span></a></li>`).join('')}</ul><p class="net-num-more"><a href="/stats">Every site's numbers, open to everyone →</a></p></section>`;}// The kit (openvibe-shared/showcase, /shared/showcase.css) owns the sections, cards and icon styles; this is only what@@@ -387,13 +389,13 @@ function body(catalog, act) {items: fams.length ? fams.map((f) => ({ icon: `ov:${f.icon}`, title: f.name, text: `${count(f)} ${count(f) === 1 ? 'tool' : 'tools'} · ${f.tagline || ''}`, href: f.path ? `https://openvibe.tools${f.path}` : f.url })): [{ icon: 'ov:tools', title: 'OpenVibe.Tools', text: 'Converters, downloaders, PDF and image tools, developer utilities and network diagnostics.', href: 'https://openvibe.tools/' }],}), '');-- const tools = `<section class="sc-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${esc(catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open')}</h2><p class="sc-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.</p>${chips(popular.map((t) => [t.url, t.tagline || '', t.icon, t.name]))}++ const tools = `<section class="sc-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools" class="ovk-titled">${sectionIcon('grid')}${esc(catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open')}</h2><p class="sc-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.</p>${chips(popular.map((t) => [t.url, t.tagline || '', t.icon, t.name]))}${fams.length ? `<details class="home-fold"><summary>Every kind of tool<span>${esc(fams.length)} families</span></summary>${families.replace(/<h2[^>]*>[\s\S]*?<\/h2>/, '').replace(/<p class="sc-lede">[\s\S]*?<\/p>/, '')}</details>` : ''}<p class="home-more"><a href="https://openvibe.tools/">Browse every tool at openvibe.tools →</a> · <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> · <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a></p></section>`;// What signing in adds: one list, a line each (cards made it two screens on a phone).-- const account = `<section class="sc-sec" id="account" aria-labelledby="h-account"><h2 id="h-account">What signing in adds</h2><p class="sc-lede">You can use almost everything as a guest. An account is for the things that need to remember you, and it works on every OpenVibe site the moment you arrive.</p>++ const account = `<section class="sc-sec" id="account" aria-labelledby="h-account"><h2 id="h-account" class="ovk-titled">${sectionIcon('members')}What signing in adds</h2><p class="sc-lede">You can use almost everything as a guest. An account is for the things that need to remember you, and it works on every OpenVibe site the moment you arrive.</p><ul class="home-rows">${ACCOUNT.map(([ic, title, text, href]) => `<li><a href="${esc(href)}">${icon(`ov:${ic}`, 22)}<span><b>${esc(title)}</b><small>${esc(text)}</small></span></a></li>`).join('')}</ul></section>`;// The sites still to open stay a quiet row of chips, not cards: they are addresses, not places to go yet.-- const soon = SOON.length ? `<section class="sc-sec" id="soon" aria-labelledby="h-soon"><h2 id="h-soon">Opening next</h2><p class="sc-lede">${esc(inWords(SOON.length))} more addresses are staked out. Each one opens when it is good enough to use daily, and your account will already work there.</p>++ const soon = SOON.length ? `<section class="sc-sec" id="soon" aria-labelledby="h-soon"><h2 id="h-soon" class="ovk-titled">${sectionIcon('grid')}Opening next</h2><p class="sc-lede">${esc(inWords(SOON.length))} more addresses are staked out. Each one opens when it is good enough to use daily, and your account will already work there.</p>${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, siteName(site)]), 'soon')}</section>` : '';const devCards = inside(showcase.features({id: 'developers', title: 'For developers and crawlers',@@@ -406,8 +408,8 @@ ${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, si],}), '<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 dev = `<section class="sc-sec home-dev" id="developers" aria-label="For developers"><details class="home-fold"><summary>For developers and crawlers<span>API, SDK, llms.txt, source</span></summary>${devCards.replace(/^<section[^>]*>/, '<div>').replace(/<\/section>$/, '</div>')}</details></section>`;-- 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, i) => `<details class="faq-item"${i === 0 ? ' open' : ''}><summary><h3>${esc(p.q)}</h3></summary><p>${esc(p.a)}</p></details>`).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>`;++ const faq = `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq" class="ovk-titled">${sectionIcon('help')}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, i) => `<details class="faq-item"${i === 0 ? ' open' : ''}><summary><h3 class="ovk-titled">${sectionIcon('help')}${esc(p.q)}</h3></summary><p>${esc(p.a)}</p></details>`).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)}${foldSites(sites)}@@@ -453,7 +455,7 @@ function render({ releaseId = '' } = {}) {})) + 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 = withSameAs(lookBoot.apply(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, act.popularity)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(SITE_COUNT)).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 = withSameAs(lookBoot.apply(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('<h1>Go live, chat, play<br><span class="grad">and get things done.</span></h1>', () => kit.pageHead({ title: 'Go live, chat, play and get things done.', icon: 'home', lede: 'Your places, people and projects across OpenVibe.' })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog, act.popularity)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(SITE_COUNT)).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<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}"><script src="${require('openvibe-shared/serve').url('kit-web.js')}" defer></script>\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;}@@@ -3,6 +3,7 @@// "Right now on OpenVibe" shows only public, safe-for-work items from each service's public API, with an honest empty// state. Rendered in-process; the activity reads are switched off and the snapshot is set directly.const assert = require('assert');++const { parseHTML } = require('linkedom');const activity = require('../server/home/activity');const home = require('../server/home/render');const sites = require('../server/frame/sites').SITES;@@@ -84,7 +85,7 @@ for (const [id, , verb, , href] of home.INTENTS) {assert.ok(html.includes(`<b>${verb}</b>`), `intent ${id} says "${verb}"`);}assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');--assert.ok(/Nobody is live right now/.test(html) && /go live from your browser/.test(html), 'an honest empty state');++assert.ok(/Nobody is live right now/.test(html) && /Go live from your browser/.test(html), 'an honest empty state');const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');// The hero's orbit (2026-10-28): the most used sites around the mark, the count of every site in its middle.assert.ok(html.includes('<div class="orbit" role="group"'), 'the orbit is drawn');@@@ -110,7 +111,7 @@ assert.strictEqual(siteLd.potentialAction['query-input'], 'required name=search_// A tagline that already ends a sentence ("What should we eat?") gets no second period; a family of one is "1 tool".assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');--assert.ok(/<h1>Go live, chat, play<br>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');++assert.ok(/<h1>Go live, chat, play and get things done\.<\/h1>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');const launcher = (/<section class="sc-sec sc-apps-sec" id="network"[\s\S]*?<\/section>/.exec(html) || [''])[0];for (const s of open) assert.ok(launcher.includes(`<a href="https://${s.host}/">`), `the sites launcher links ${s.host}`);// One plain link to the hub of every site (SEO-9), in the sites section the home already has, with the real count.@@@ -121,7 +122,7 @@ assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/// 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}"`));++faqLd.mainEntity.forEach((q) => assert.ok([...parseHTML(html).document.querySelectorAll('#faq h3')].some((el) => el.textContent === q.name), `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="sc-apps-group">Streams & places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make & build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');@@@ -153,16 +154,16 @@ assert.ok(html.includes('href="https://openvibe.video/v/9"') && html.includes('<// to one, and a wider screen shows no chips.{const chips = [...html.matchAll(/<a href="#now-([a-z]+)">([^<]+)<\/a>/g)];-- assert.ok(html.includes('<nav class="now-pick" aria-label="New on OpenVibe">') && chips.length >= 2, 'the chips render when there is more than one part');-- for (const [, id] of chips) assert.match(html, new RegExp(`<div class="now-(block|grid)" data-now-part id="now-${id}"`), `${id} is a part in the page`);++ assert.ok(html.includes('<nav class="now-pick" data-ovk-scroll aria-label="New on OpenVibe">') && chips.length >= 2, 'the chips render when there is more than one part');++ for (const [, id] of chips) assert.match(html, new RegExp(`<div class="now-(block|grid)" data-now-part[^>]* id="now-${id}"`), `${id} is a part in the page`);assert.ok(!/name="now-pick"|name="more-pick"/.test(html), 'no chip is a radio: nothing is picked');assert.match(html, /\.now-pick,\.more-pick,\.more-pick-h\{display:none\}\n@media \(max-width:600px\)\{/, 'chips only on a phone');assert.ok(!html.includes('[data-now-part]{display:none}') && !html.includes('[data-more-part]{display:none'), 'no part is ever hidden');}// The lower half: every section with its own heading; on a phone a row of chips under "More about OpenVibe" jumps to one.{-- const more = [...html.matchAll(/<nav class="more-pick" aria-label="More about OpenVibe">([\s\S]*?)<\/nav>/g)];-- assert.ok(html.includes('<div class="more"><h2 class="more-pick-h">More about OpenVibe</h2>') && more.length === 1, 'the More block and its chips');++ const more = [...html.matchAll(/<nav class="more-pick" data-ovk-scroll aria-label="More about OpenVibe">([\s\S]*?)<\/nav>/g)];++ assert.ok(parseHTML(html).document.querySelector('.more > .more-pick-h')?.textContent === 'More about OpenVibe' && more.length === 1, 'the More block and its chips');const ids = [...more[0][1].matchAll(/<a href="#([a-z]+)">/g)].map((m) => m[1]);assert.ok(ids.length >= 4 && ids[0] === 'tools', `a link per section, Tools first (${ids.join(',')})`);for (const id of ids) assert.match(html, new RegExp(`<section class="sc-sec[^"]*" id="${id}" data-more-part`), `${id} is a section in the page`);@@@ -12,11 +12,11 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 200, // 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network-- htmlBrotliKB: 38.5, // 38.1 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 37.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 37.8 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 36.1 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06); 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)++ htmlRawKB: 204, // 204: measured 203.5 KB with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network++ htmlBrotliKB: 39, // 39: measured 38.6 KB brotli with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 38.1 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 37.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 37.8 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 36.1 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06); 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)-- jsRawKB: 392.4, // 392.4: measured 384.4 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09-- jsBrotliKB: 94.9, // 94.9: measured 92.9 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09++ jsRawKB: 409, // 409: measured 408.1 KB raw / 99.0 KB brotli after merging main's Frame feed work (openvibe-shared 4.167.0), 2026-11-09; 406: measured 405.7 KB raw / 98.4 KB brotli with the shared page head's kit script on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 381.8: measured 375.8 KB raw / 91.1 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 373 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 372.3 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 369.6 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 368.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 366.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets; main's 4.117.0 navbar item and the account-safety helper included); about 363.8 with both (2026-11-06: openvibe-shared 4.117.0's featured navbar item, +1.7 KB, and the account-safety helper, +8.5 KB; merged, to be confirmed by CI's measure); 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3++ jsBrotliKB: 100, // 100: measured 408.1 KB raw / 99.0 KB brotli after merging main's Frame feed work (openvibe-shared 4.167.0), 2026-11-09; 99: measured 405.7 KB raw / 98.4 KB brotli with the shared page head's kit script on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 92.6: measured 375.8 KB raw / 91.1 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 90.5 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 90.3 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 89.8 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets; main's 4.117.0 navbar item and the account-safety helper included); about 88.8 with both (2026-11-06: 4.117.0's featured item and the account-safety helper); 86.6 with openvibe-shared 4.117.0 (2026-11-06: the featured navbar item's CSS and the phone-menu order); 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9cssFiles: 2, // 1 (the site's own; Font Awesome's stylesheet is gone since 2026-11-01)externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon};
@@ -1,4 +1,6 @@
@@ -1,4 +1,6 @@
'use strict';
'use strict';
+const kit = require('openvibe-shared/kit');
+const sectionIcon = (name) => kit.icon(name, { cls: 'ovk-heading-icon' });
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
// The openvibe.network home page: the static shell (public/index.html) with the network and the
// The openvibe.network home page: the static shell (public/index.html) with the network and the
// tool catalog rendered into it on the server, so the page is complete without JavaScript and
// tool catalog rendered into it on the server, so the page is complete without JavaScript and
@@ -97,7 +99,7 @@ function rankedIntents(popularity) {
@@ -97,7 +99,7 @@ function rankedIntents(popularity) {
function intents(catalog, popularity = null) {
function intents(catalog, popularity = null) {
const toolsLine = catalog.tools.length ? `Convert, compress, look up or download: ${catalog.tools.length} tools that just open.` : 'Convert, compress, look up or download: free tools that just open.';
const toolsLine = catalog.tools.length ? `Convert, compress, look up or download: ${catalog.tools.length} tools that just open.` : 'Convert, compress, look up or download: free tools that just open.';
const ranked = rankedIntents(popularity);
const ranked = rankedIntents(popularity);
- return `<ul class="intents" aria-label="What you can do here${popularity ? ', most visited this week first' : ''}">${ranked.map(({ it: [id, fa, verb, line, href, k], n }, pos) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small>${pos < 5 && n >= 10 ? `<em class="intent-n">${esc(fmtVisits(n))} visits this week</em>` : ''}</span></a></li>`).join('')}</ul>`;
+ return `<ul class="intents" data-ovk-scroll aria-label="What you can do here${popularity ? ', most visited this week first' : ''}">${ranked.map(({ it: [id, fa, verb, line, href, k], n }, pos) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small>${pos < 5 && n >= 10 ? `<em class="intent-n">${esc(fmtVisits(n))} visits this week</em>` : ''}</span></a></li>`).join('')}</ul>`;
}
}
// The network as an orbit (the owner, 2026-10-28: the hero was "ugly af, boring… not easy to understand… nice animated
// The network as an orbit (the owner, 2026-10-28: the hero was "ugly af, boring… not easy to understand… nice animated
@@ -147,31 +149,31 @@ function rightNow(a) {
@@ -147,31 +149,31 @@ function rightNow(a) {
const card = ({ url, thumb, badge, len, about, avatar, title, who, line, cls = '' }) => `<li><a class="vcard${cls}" href="${esc(url)}">
const card = ({ url, thumb, badge, len, about, avatar, title, who, line, cls = '' }) => `<li><a class="vcard${cls}" href="${esc(url)}">
<span class="vc-thumb">${thumb ? img(thumb, avatar, 320, 180) : '<span class="now-noimg"></span>'}${badge || ''}${len ? `<span class="vc-len">${esc(len)}</span>` : ''}<span class="vc-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>${about ? `<span class="vc-about">${esc(about)}</span>` : ''}</span>
<span class="vc-thumb">${thumb ? img(thumb, avatar, 320, 180) : '<span class="now-noimg"></span>'}${badge || ''}${len ? `<span class="vc-len">${esc(len)}</span>` : ''}<span class="vc-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>${about ? `<span class="vc-about">${esc(about)}</span>` : ''}</span>
<span class="vc-meta">${face(avatar)}<span class="vc-text"><b>${esc(title)}</b>${who ? `<small>${esc(who)}</small>` : ''}${line ? `<small>${esc(line)}</small>` : ''}</span></span></a></li>`;
<span class="vc-meta">${face(avatar)}<span class="vc-text"><b>${esc(title)}</b>${who ? `<small>${esc(who)}</small>` : ''}${line ? `<small>${esc(line)}</small>` : ''}</span></span></a></li>`;
- const rail = (title, href, more, cards, id = '') => (cards.length ? `<div class="now-block"${id ? ` id="${id}"` : ''}><div class="now-head"><h3>${title}</h3><a class="now-more" href="${href}">${more} →</a></div><ul class="now-rail">${cards.join('')}</ul></div>` : '');
+ const rail = (title, href, more, cards, id = '') => (cards.length ? `<div class="now-block"${id ? ` id="${id}"` : ''}><div class="now-head"><h3 class="ovk-titled">${sectionIcon('grid')}${title}</h3>${kit.button({ href, label: more, icon: 'forward', kind: 'quiet' })}</div><ul class="now-rail" data-ovk-scroll>${cards.join('')}</ul></div>` : '');
const live = a.live.map((s) => card({ url: s.url, thumb: s.thumb || s.avatar, badge: `<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span>`, avatar: s.avatar, title: s.title, who: s.name, line: s.category }));
const live = a.live.map((s) => card({ url: s.url, thumb: s.thumb || s.avatar, badge: `<span class="now-badge"><i></i>LIVE${s.viewers ? ` · ${esc(fmtViewers(s.viewers))}` : ''}</span>`, avatar: s.avatar, title: s.title, who: s.name, line: s.category }));
// Nobody live: one honest line and the way to start, not an empty 16:9 player (a phone saw a blank screen of it).
// Nobody live: one honest line and the way to start, not an empty 16:9 player (a phone saw a blank screen of it).
- const quiet = a.live.length ? '' : '<p class="now-quiet"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span><b>Nobody is live right now.</b> Be the first: <a href="https://openvibe.live/broadcast">go live from your browser</a>, no OBS and no follower minimum.</span></p>';
+ const quiet = a.live.length ? '' : kit.empty({ icon: 'live', title: 'Nobody is live right now.', text: 'Go live from your browser, with no OBS and no follower minimum.', action: { href: 'https://openvibe.live/broadcast', label: 'Go live', icon: 'video' } });
const recs = (a.recordings || []).map((r) => card({ url: r.url, thumb: r.thumb, len: r.length, about: r.about, avatar: r.avatar, title: r.title, who: r.channel, line: [views(r.views), ago(r.at)].filter(Boolean).join(' · ') }));
const recs = (a.recordings || []).map((r) => card({ url: r.url, thumb: r.thumb, len: r.length, about: r.about, avatar: r.avatar, title: r.title, who: r.channel, line: [views(r.views), ago(r.at)].filter(Boolean).join(' · ') }));
const shots = (a.pastes || []).filter((p) => p.shot).slice(0, 4).map((p) => card({ url: p.url, thumb: p.shot, avatar: p.avatar, title: p.title, who: p.by || 'Screenshot', line: [views(p.views), p.likes ? `${p.likes} like${p.likes === 1 ? '' : 's'}` : '', ago(p.at)].filter(Boolean).join(' · '), cls: ' is-shot' }));
const shots = (a.pastes || []).filter((p) => p.shot).slice(0, 4).map((p) => card({ url: p.url, thumb: p.shot, avatar: p.avatar, title: p.title, who: p.by || 'Screenshot', line: [views(p.views), p.likes ? `${p.likes} like${p.likes === 1 ? '' : 's'}` : '', ago(p.at)].filter(Boolean).join(' · '), cls: ' is-shot' }));
const texts = (a.pastes || []).filter((p) => !p.shot);
const texts = (a.pastes || []).filter((p) => !p.shot);
const list = (items, render) => `<ul class="now-list">${items.map(render).join('')}</ul>`;
const list = (items, render) => `<ul class="now-list">${items.map(render).join('')}</ul>`;
// A column with nothing to show is left out.
// A column with nothing to show is left out.
- const col = (items, title, render, href, more) => (items.length ? `<div class="now-col"><h3>${title}</h3>${list(items.slice(0, 3), render)}<a class="now-more" href="${href}">${more} →</a></div>` : '');
+ const col = (items, title, render, href, more) => (items.length ? `<div class="now-col"><h3 class="ovk-titled">${sectionIcon('grid')}${title}</h3>${list(items.slice(0, 3), render)}${kit.button({ href, label: more, icon: 'forward', kind: 'quiet' })}</div>` : '');
const cols = [
const cols = [
col(texts, 'New pastes', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'https://openvibe.community/', 'OpenVibe.Community'),
col(texts, 'New pastes', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'https://openvibe.community/', 'OpenVibe.Community'),
col(a.posts, 'From the blog', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'https://openvibe.blog/', 'OpenVibe.Blog'),
col(a.posts, 'From the blog', (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'https://openvibe.blog/', 'OpenVibe.Blog'),
col(a.jobs || [], 'New jobs', (j) => `<li><a href="${esc(j.url)}"><b>${esc(j.title)}</b><small>${[j.where, j.board ? `via ${j.board}` : ''].filter(Boolean).map(esc).join(' · ')}</small></a></li>`, 'https://openvibe.work/', 'Every job on OpenVibe.Work'),
col(a.jobs || [], 'New jobs', (j) => `<li><a href="${esc(j.url)}"><b>${esc(j.title)}</b><small>${[j.where, j.board ? `via ${j.board}` : ''].filter(Boolean).map(esc).join(' · ')}</small></a></li>`, 'https://openvibe.work/', 'Every job on OpenVibe.Work'),
col(a.codes || [], 'Fresh coupon codes', (c) => `<li><a href="${esc(c.url)}">${c.code ? `<code class="now-code">${esc(c.code)}</code>` : ''}<b>${esc(c.title)}</b></a></li>`, 'https://openvibe.coupons/', 'OpenVibe.Coupons'),
col(a.codes || [], 'Fresh coupon codes', (c) => `<li><a href="${esc(c.url)}">${c.code ? `<code class="now-code">${esc(c.code)}</code>` : ''}<b>${esc(c.title)}</b></a></li>`, 'https://openvibe.coupons/', 'OpenVibe.Coupons'),
].filter(Boolean);
].filter(Boolean);
- return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>
+ return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now" class="ovk-titled">${sectionIcon('members')}<span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>
<p class="sc-lede">What people are streaming, sharing and posting across the network, read from each site within the last minute.</p>
<p class="sc-lede">What people are streaming, sharing and posting across the network, read from each site within the last minute.</p>
${live.length ? rail('Live now', 'https://openvibe.live/', 'All channels on OpenVibe.Live', live) : quiet}
${live.length ? rail('Live now', 'https://openvibe.live/', 'All channels on OpenVibe.Live', live) : quiet}
${nowParts([
${nowParts([
['recs', 'Recordings', rail('Latest recordings', 'https://openvibe.video/', 'Watch on OpenVibe.Video', recs, 'now-recs')],
['recs', 'Recordings', rail('Latest recordings', 'https://openvibe.video/', 'Watch on OpenVibe.Video', recs, 'now-recs')],
['shots', 'Screenshots', rail('New screenshots', 'https://openvibe.community/', 'OpenVibe.Community', shots, 'now-shots')],
['shots', 'Screenshots', rail('New screenshots', 'https://openvibe.community/', 'OpenVibe.Community', shots, 'now-shots')],
- ['posts', 'Posts and jobs', cols.length ? `<div class="now-grid" id="now-posts">${cols.join('\n')}</div>` : ''],
+ ['posts', 'Posts and jobs', cols.length ? `<div class="now-grid" data-ovk-scroll id="now-posts">${cols.join('\n')}</div>` : ''],
])}</section>`;
])}</section>`;
}
}
@@ -182,7 +184,7 @@ function nowParts(parts) {
@@ -182,7 +184,7 @@ function nowParts(parts) {
const shown = parts.filter(([, , html]) => html);
const shown = parts.filter(([, , html]) => html);
if (shown.length < 2) return shown.map(([, , html]) => html).join('\n');
if (shown.length < 2) return shown.map(([, , html]) => html).join('\n');
const chips = shown.map(([id, label]) => `<a href="#now-${id}">${esc(label)}</a>`).join('');
const chips = shown.map(([id, label]) => `<a href="#now-${id}">${esc(label)}</a>`).join('');
- return `<nav class="now-pick" aria-label="New on OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<div class="(now-block|now-grid)"/, '<div class="$1" data-now-part')).join('\n')}`;
+ return `<nav class="now-pick" data-ovk-scroll aria-label="New on OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<div class="(now-block|now-grid)"/, '<div class="$1" data-now-part')).join('\n')}`;
}
}
// The lower half (tools, what an account adds, what opens next, the questions, the developers' links): every section
// The lower half (tools, what an account adds, what opens next, the questions, the developers' links): every section
@@ -192,7 +194,7 @@ function moreParts(parts) {
@@ -192,7 +194,7 @@ function moreParts(parts) {
const shown = parts.filter(([, , html]) => html);
const shown = parts.filter(([, , html]) => html);
if (shown.length < 2) return shown.map(([, , html]) => html).join('\n');
if (shown.length < 2) return shown.map(([, , html]) => html).join('\n');
const chips = shown.map(([id, label]) => `<a href="#${id}">${esc(label)}</a>`).join('');
const chips = shown.map(([id, label]) => `<a href="#${id}">${esc(label)}</a>`).join('');
- return `<div class="more"><h2 class="more-pick-h">More about OpenVibe</h2><nav class="more-pick" aria-label="More about OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<section class="([^"]*)" id="([^"]*)"/, '<section class="$1" id="$2" data-more-part')).join('\n')}</div>`;
+ return `<div class="more"><h2 class="more-pick-h">${sectionIcon('grid')}More about OpenVibe</h2><nav class="more-pick" data-ovk-scroll aria-label="More about OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<section class="([^"]*)" id="([^"]*)"/, '<section class="$1" id="$2" data-more-part')).join('\n')}</div>`;
}
}
// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it
// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it
@@ -206,7 +208,7 @@ function numbers(catalog, a) {
@@ -206,7 +208,7 @@ function numbers(catalog, a) {
c.accounts ? [fmtCount(c.accounts), 'accounts', '', '/login?tab=register'] : null,
c.accounts ? [fmtCount(c.accounts), 'accounts', '', '/login?tab=register'] : null,
a.liveCount ? [fmtCount(a.liveCount), a.liveCount === 1 ? 'stream live now' : 'streams live now', '', 'https://openvibe.live/'] : null,
a.liveCount ? [fmtCount(a.liveCount), a.liveCount === 1 ? 'stream live now' : 'streams live now', '', 'https://openvibe.live/'] : null,
].filter(Boolean);
].filter(Boolean);
- return `<section class="net-num" aria-labelledby="h-num"><h2 id="h-num">OpenVibe in numbers</h2><ul>${items.map(([n, label, note, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}${note ? ` <small>${esc(note)}</small>` : ''}</span></a></li>`).join('')}</ul><p class="net-num-more"><a href="/stats">Every site's numbers, open to everyone →</a></p></section>`;
+ return `<section class="net-num" aria-labelledby="h-num"><h2 id="h-num" class="ovk-titled">${sectionIcon('grid')}OpenVibe in numbers</h2><ul>${items.map(([n, label, note, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}${note ? ` <small>${esc(note)}</small>` : ''}</span></a></li>`).join('')}</ul><p class="net-num-more"><a href="/stats">Every site's numbers, open to everyone →</a></p></section>`;
}
}
// 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
@@ -387,13 +389,13 @@ function body(catalog, act) {
@@ -387,13 +389,13 @@ function body(catalog, act) {
items: fams.length ? fams.map((f) => ({ icon: `ov:${f.icon}`, title: f.name, text: `${count(f)} ${count(f) === 1 ? 'tool' : 'tools'} · ${f.tagline || ''}`, href: f.path ? `https://openvibe.tools${f.path}` : f.url }))
items: fams.length ? fams.map((f) => ({ icon: `ov:${f.icon}`, title: f.name, text: `${count(f)} ${count(f) === 1 ? 'tool' : 'tools'} · ${f.tagline || ''}`, href: f.path ? `https://openvibe.tools${f.path}` : f.url }))
: [{ icon: 'ov:tools', title: 'OpenVibe.Tools', text: 'Converters, downloaders, PDF and image tools, developer utilities and network diagnostics.', href: 'https://openvibe.tools/' }],
: [{ icon: 'ov:tools', title: 'OpenVibe.Tools', text: 'Converters, downloaders, PDF and image tools, developer utilities and network diagnostics.', href: 'https://openvibe.tools/' }],
}), '');
}), '');
- const tools = `<section class="sc-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${esc(catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open')}</h2><p class="sc-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.</p>${chips(popular.map((t) => [t.url, t.tagline || '', t.icon, t.name]))}
+ const tools = `<section class="sc-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools" class="ovk-titled">${sectionIcon('grid')}${esc(catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open')}</h2><p class="sc-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.</p>${chips(popular.map((t) => [t.url, t.tagline || '', t.icon, t.name]))}
${fams.length ? `<details class="home-fold"><summary>Every kind of tool<span>${esc(fams.length)} families</span></summary>${families.replace(/<h2[^>]*>[\s\S]*?<\/h2>/, '').replace(/<p class="sc-lede">[\s\S]*?<\/p>/, '')}</details>` : ''}<p class="home-more"><a href="https://openvibe.tools/">Browse every tool at openvibe.tools →</a> · <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> · <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a></p></section>`;
${fams.length ? `<details class="home-fold"><summary>Every kind of tool<span>${esc(fams.length)} families</span></summary>${families.replace(/<h2[^>]*>[\s\S]*?<\/h2>/, '').replace(/<p class="sc-lede">[\s\S]*?<\/p>/, '')}</details>` : ''}<p class="home-more"><a href="https://openvibe.tools/">Browse every tool at openvibe.tools →</a> · <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> · <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a></p></section>`;
// What signing in adds: one list, a line each (cards made it two screens on a phone).
// What signing in adds: one list, a line each (cards made it two screens on a phone).
- const account = `<section class="sc-sec" id="account" aria-labelledby="h-account"><h2 id="h-account">What signing in adds</h2><p class="sc-lede">You can use almost everything as a guest. An account is for the things that need to remember you, and it works on every OpenVibe site the moment you arrive.</p>
+ const account = `<section class="sc-sec" id="account" aria-labelledby="h-account"><h2 id="h-account" class="ovk-titled">${sectionIcon('members')}What signing in adds</h2><p class="sc-lede">You can use almost everything as a guest. An account is for the things that need to remember you, and it works on every OpenVibe site the moment you arrive.</p>
<ul class="home-rows">${ACCOUNT.map(([ic, title, text, href]) => `<li><a href="${esc(href)}">${icon(`ov:${ic}`, 22)}<span><b>${esc(title)}</b><small>${esc(text)}</small></span></a></li>`).join('')}</ul></section>`;
<ul class="home-rows">${ACCOUNT.map(([ic, title, text, href]) => `<li><a href="${esc(href)}">${icon(`ov:${ic}`, 22)}<span><b>${esc(title)}</b><small>${esc(text)}</small></span></a></li>`).join('')}</ul></section>`;
// The sites still to open stay a quiet row of chips, not cards: they are addresses, not places to go yet.
// The sites still to open stay a quiet row of chips, not cards: they are addresses, not places to go yet.
- const soon = SOON.length ? `<section class="sc-sec" id="soon" aria-labelledby="h-soon"><h2 id="h-soon">Opening next</h2><p class="sc-lede">${esc(inWords(SOON.length))} more addresses are staked out. Each one opens when it is good enough to use daily, and your account will already work there.</p>
+ const soon = SOON.length ? `<section class="sc-sec" id="soon" aria-labelledby="h-soon"><h2 id="h-soon" class="ovk-titled">${sectionIcon('grid')}Opening next</h2><p class="sc-lede">${esc(inWords(SOON.length))} more addresses are staked out. Each one opens when it is good enough to use daily, and your account will already work there.</p>
${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, siteName(site)]), 'soon')}</section>` : '';
${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, siteName(site)]), 'soon')}</section>` : '';
const devCards = inside(showcase.features({
const devCards = inside(showcase.features({
id: 'developers', title: 'For developers and crawlers',
id: 'developers', title: 'For developers and crawlers',
@@ -406,8 +408,8 @@ ${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, si
@@ -406,8 +408,8 @@ ${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, si
],
],
}), '<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 dev = `<section class="sc-sec home-dev" id="developers" aria-label="For developers"><details class="home-fold"><summary>For developers and crawlers<span>API, SDK, llms.txt, source</span></summary>${devCards.replace(/^<section[^>]*>/, '<div>').replace(/<\/section>$/, '</div>')}</details></section>`;
const dev = `<section class="sc-sec home-dev" id="developers" aria-label="For developers"><details class="home-fold"><summary>For developers and crawlers<span>API, SDK, llms.txt, source</span></summary>${devCards.replace(/^<section[^>]*>/, '<div>').replace(/<\/section>$/, '</div>')}</details></section>`;
- 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, i) => `<details class="faq-item"${i === 0 ? ' open' : ''}><summary><h3>${esc(p.q)}</h3></summary><p>${esc(p.a)}</p></details>`).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>`;
+ const faq = `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq" class="ovk-titled">${sectionIcon('help')}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, i) => `<details class="faq-item"${i === 0 ? ' open' : ''}><summary><h3 class="ovk-titled">${sectionIcon('help')}${esc(p.q)}</h3></summary><p>${esc(p.a)}</p></details>`).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)}
${foldSites(sites)}
${foldSites(sites)}
@@ -453,7 +455,7 @@ function render({ releaseId = '' } = {}) {
@@ -453,7 +455,7 @@ function render({ releaseId = '' } = {}) {
})) + 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()));
})) + seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what })))) + seo.jsonLdTag(seo.jsonLd.faq(faqPairs()));
const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const summary = summaryOf(catalog);
const summary = summaryOf(catalog);
- const html = withSameAs(lookBoot.apply(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, act.popularity)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(SITE_COUNT)).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 = withSameAs(lookBoot.apply(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('<h1>Go live, chat, play<br><span class="grad">and get things done.</span></h1>', () => kit.pageHead({ title: 'Go live, chat, play and get things done.', icon: 'home', lede: 'Your places, people and projects across OpenVibe.' })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog, act.popularity)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(SITE_COUNT)).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<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}"><script src="${require('openvibe-shared/serve').url('kit-web.js')}" defer></script>\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;
}
}
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
// "Right now on OpenVibe" shows only public, safe-for-work items from each service's public API, with an honest empty
// "Right now on OpenVibe" shows only public, safe-for-work items from each service's public API, with an honest empty
// state. Rendered in-process; the activity reads are switched off and the snapshot is set directly.
// state. Rendered in-process; the activity reads are switched off and the snapshot is set directly.
const assert = require('assert');
const assert = require('assert');
+const { parseHTML } = require('linkedom');
const activity = require('../server/home/activity');
const activity = require('../server/home/activity');
const home = require('../server/home/render');
const home = require('../server/home/render');
const sites = require('../server/frame/sites').SITES;
const sites = require('../server/frame/sites').SITES;
@@ -84,7 +85,7 @@ for (const [id, , verb, , href] of home.INTENTS) {
@@ -84,7 +85,7 @@ for (const [id, , verb, , href] of home.INTENTS) {
assert.ok(html.includes(`<b>${verb}</b>`), `intent ${id} says "${verb}"`);
assert.ok(html.includes(`<b>${verb}</b>`), `intent ${id} says "${verb}"`);
}
}
assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');
assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');
-assert.ok(/Nobody is live right now/.test(html) && /go live from your browser/.test(html), 'an honest empty state');
+assert.ok(/Nobody is live right now/.test(html) && /Go live from your browser/.test(html), 'an honest empty state');
const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');
const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');
// The hero's orbit (2026-10-28): the most used sites around the mark, the count of every site in its middle.
// The hero's orbit (2026-10-28): the most used sites around the mark, the count of every site in its middle.
assert.ok(html.includes('<div class="orbit" role="group"'), 'the orbit is drawn');
assert.ok(html.includes('<div class="orbit" role="group"'), 'the orbit is drawn');
@@ -110,7 +111,7 @@ assert.strictEqual(siteLd.potentialAction['query-input'], 'required name=search_
@@ -110,7 +111,7 @@ assert.strictEqual(siteLd.potentialAction['query-input'], 'required name=search_
// A tagline that already ends a sentence ("What should we eat?") gets no second period; a family of one is "1 tool".
// A tagline that already ends a sentence ("What should we eat?") gets no second period; a family of one is "1 tool".
assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');
assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');
assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');
assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');
-assert.ok(/<h1>Go live, chat, play<br>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');
+assert.ok(/<h1>Go live, chat, play and get things done\.<\/h1>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');
const launcher = (/<section class="sc-sec sc-apps-sec" id="network"[\s\S]*?<\/section>/.exec(html) || [''])[0];
const launcher = (/<section class="sc-sec sc-apps-sec" id="network"[\s\S]*?<\/section>/.exec(html) || [''])[0];
for (const s of open) assert.ok(launcher.includes(`<a href="https://${s.host}/">`), `the sites launcher links ${s.host}`);
for (const s of open) assert.ok(launcher.includes(`<a href="https://${s.host}/">`), `the sites launcher links ${s.host}`);
// One plain link to the hub of every site (SEO-9), in the sites section the home already has, with the real count.
// One plain link to the hub of every site (SEO-9), in the sites section the home already has, with the real count.
@@ -121,7 +122,7 @@ assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/
@@ -121,7 +122,7 @@ assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/
// The FAQ shown is the FAQPage JSON-LD, word for word, with the network's own questions from its Contracts site block.
// 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]);
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');
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}"`));
+faqLd.mainEntity.forEach((q) => assert.ok([...parseHTML(html).document.querySelectorAll('#faq h3')].some((el) => el.textContent === q.name), `the FAQ shows "${q.name}"`));
assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");
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.
// The service map groups the sites the way the launcher does; numbers say only what was read.
assert.ok(/<h3 class="sc-apps-group">Streams & places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make & build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');
assert.ok(/<h3 class="sc-apps-group">Streams & places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make & build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');
@@ -153,16 +154,16 @@ assert.ok(html.includes('href="https://openvibe.video/v/9"') && html.includes('<
@@ -153,16 +154,16 @@ assert.ok(html.includes('href="https://openvibe.video/v/9"') && html.includes('<
// to one, and a wider screen shows no chips.
// to one, and a wider screen shows no chips.
{
{
const chips = [...html.matchAll(/<a href="#now-([a-z]+)">([^<]+)<\/a>/g)];
const chips = [...html.matchAll(/<a href="#now-([a-z]+)">([^<]+)<\/a>/g)];
- assert.ok(html.includes('<nav class="now-pick" aria-label="New on OpenVibe">') && chips.length >= 2, 'the chips render when there is more than one part');
- for (const [, id] of chips) assert.match(html, new RegExp(`<div class="now-(block|grid)" data-now-part id="now-${id}"`), `${id} is a part in the page`);
+ assert.ok(html.includes('<nav class="now-pick" data-ovk-scroll aria-label="New on OpenVibe">') && chips.length >= 2, 'the chips render when there is more than one part');
+ for (const [, id] of chips) assert.match(html, new RegExp(`<div class="now-(block|grid)" data-now-part[^>]* id="now-${id}"`), `${id} is a part in the page`);
assert.ok(!/name="now-pick"|name="more-pick"/.test(html), 'no chip is a radio: nothing is picked');
assert.ok(!/name="now-pick"|name="more-pick"/.test(html), 'no chip is a radio: nothing is picked');
assert.match(html, /\.now-pick,\.more-pick,\.more-pick-h\{display:none\}\n@media \(max-width:600px\)\{/, 'chips only on a phone');
assert.match(html, /\.now-pick,\.more-pick,\.more-pick-h\{display:none\}\n@media \(max-width:600px\)\{/, 'chips only on a phone');
assert.ok(!html.includes('[data-now-part]{display:none}') && !html.includes('[data-more-part]{display:none'), 'no part is ever hidden');
assert.ok(!html.includes('[data-now-part]{display:none}') && !html.includes('[data-more-part]{display:none'), 'no part is ever hidden');
}
}
// The lower half: every section with its own heading; on a phone a row of chips under "More about OpenVibe" jumps to one.
// The lower half: every section with its own heading; on a phone a row of chips under "More about OpenVibe" jumps to one.
{
{
- const more = [...html.matchAll(/<nav class="more-pick" aria-label="More about OpenVibe">([\s\S]*?)<\/nav>/g)];
- assert.ok(html.includes('<div class="more"><h2 class="more-pick-h">More about OpenVibe</h2>') && more.length === 1, 'the More block and its chips');
+ const more = [...html.matchAll(/<nav class="more-pick" data-ovk-scroll aria-label="More about OpenVibe">([\s\S]*?)<\/nav>/g)];
+ assert.ok(parseHTML(html).document.querySelector('.more > .more-pick-h')?.textContent === 'More about OpenVibe' && more.length === 1, 'the More block and its chips');
const ids = [...more[0][1].matchAll(/<a href="#([a-z]+)">/g)].map((m) => m[1]);
const ids = [...more[0][1].matchAll(/<a href="#([a-z]+)">/g)].map((m) => m[1]);
assert.ok(ids.length >= 4 && ids[0] === 'tools', `a link per section, Tools first (${ids.join(',')})`);
assert.ok(ids.length >= 4 && ids[0] === 'tools', `a link per section, Tools first (${ids.join(',')})`);
for (const id of ids) assert.match(html, new RegExp(`<section class="sc-sec[^"]*" id="${id}" data-more-part`), `${id} is a section in the page`);
for (const id of ids) assert.match(html, new RegExp(`<section class="sc-sec[^"]*" id="${id}" data-more-part`), `${id} is a section in the page`);
@@ -12,11 +12,11 @@ const { spawn } = require('child_process');
@@ -12,11 +12,11 @@ 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: 200, // 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
- htmlBrotliKB: 38.5, // 38.1 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 37.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 37.8 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 36.1 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06); 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
+ htmlRawKB: 204, // 204: measured 203.5 KB with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
+ htmlBrotliKB: 39, // 39: measured 38.6 KB brotli with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 38.1 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 37.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 37.8 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 36.1 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06); 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
- jsRawKB: 392.4, // 392.4: measured 384.4 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
- jsBrotliKB: 94.9, // 94.9: measured 92.9 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
+ jsRawKB: 409, // 409: measured 408.1 KB raw / 99.0 KB brotli after merging main's Frame feed work (openvibe-shared 4.167.0), 2026-11-09; 406: measured 405.7 KB raw / 98.4 KB brotli with the shared page head's kit script on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 381.8: measured 375.8 KB raw / 91.1 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 373 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 372.3 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 369.6 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 368.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 366.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets; main's 4.117.0 navbar item and the account-safety helper included); about 363.8 with both (2026-11-06: openvibe-shared 4.117.0's featured navbar item, +1.7 KB, and the account-safety helper, +8.5 KB; merged, to be confirmed by CI's measure); 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
+ jsBrotliKB: 100, // 100: measured 408.1 KB raw / 99.0 KB brotli after merging main's Frame feed work (openvibe-shared 4.167.0), 2026-11-09; 99: measured 405.7 KB raw / 98.4 KB brotli with the shared page head's kit script on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 92.6: measured 375.8 KB raw / 91.1 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 90.5 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 90.3 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 89.8 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets; main's 4.117.0 navbar item and the account-safety helper included); about 88.8 with both (2026-11-06: 4.117.0's featured item and the account-safety helper); 86.6 with openvibe-shared 4.117.0 (2026-11-06: the featured navbar item's CSS and the phone-menu order); 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9
cssFiles: 2, // 1 (the site's own; Font Awesome's stylesheet is gone since 2026-11-01)
cssFiles: 2, // 1 (the site's own; Font Awesome's stylesheet is gone since 2026-11-01)
externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon
externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon
};
};