openvibers/openvibe.network Commits

The front door's sections are the shared showcase kit, with an AI summary (#50)

OP OpenVibers committed 5 Oct 2026 · 73b00a0038 · 5 files, +84 −35

* The front door's sections are the shared showcase kit, with an AI summary (plan T11, D93)

Below the static hero (the intent picker, the search and the constellation,
unchanged), every section of openvibe.network's home is now
openvibe-shared/showcase, like the other flagship homes: the open sites, the
tools, what signing in adds, opening next and the developer row. The data
and links are the ones the page had.

- Kit cards for sites, tool families, account and developers; "Opening
  next" stays a quiet row of dashed chips (addresses, not places yet), and
  the popular-tool chips plus a "Browse every tool" link sit inside the
  tools section. An empty catalog still shows no empty rows and links
  openvibe.tools.
- The network's ov icons fill the kit's 40 px icon tile (they drew a 22 px
  ring inside it), and the developer row is three cards.
- The private CSS keeps only what the kit does not draw (Right now, chips,
  icons); /shared/showcase.css is linked from public/index.html.
- seo.pageSummary: <meta name="ai-summary">, a WebPage JSON-LD tag and a
  noscript facts block (open sites, opening next, the tool count only when
  known; dated by the catalog).
- Tests: kit sections, the stylesheet, one h1, the summary meta/JSON-LD,
  every open site linked; page-checks reads the sites heading from the kit
  section so the ItemList still names a visible heading.

* Front door review fixes: every old link kept, site taglines back, $-safe section insert, four-up developer row

Changes

ddiff --git a/public/index.html b/public/index.html
iindex ae60b086..217ecf05 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -32,6 +32,7 @@
(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++<link rel="stylesheet" href="/shared/showcase.css">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 50e06cbf..fb35f72a 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -8,6 +8,7 @@ const path = require('path');
const crypto = require('crypto');
const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/icons');
++const showcase = require('openvibe-shared/showcase');
const toolsCatalog = require('../domains/catalog');
const activity = require('./activity');
@@@ -81,27 +82,22 @@ function rightNow(a) {
? 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 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>`);
-- return `<section class="home-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>
++ return `<section class="sc-sec now" aria-labelledby="h-now"><h2 id="h-now"><span class="now-dot" aria-hidden="true"></span>Right now on OpenVibe</h2>
<div class="now-grid"><div class="now-col now-live"><h3>Live</h3><div class="now-streams">${live}</div><a class="now-more" href="https://openvibe.live/">All channels →</a></div>
<div class="now-col"><h3>New pastes</h3>${list(a.pastes, (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b><small>${esc(p.kind)}${p.summary ? ` · ${esc(p.summary)}` : ''}</small></a></li>`, 'Nothing new in the last while. <a href="https://openvibe.community/new">Share one</a>.')}<a class="now-more" href="https://openvibe.community/">OpenVibe.Community →</a></div>
<div class="now-col"><h3>From the blog</h3>${list(a.posts, (p) => `<li><a href="${esc(p.url)}"><b>${esc(p.title)}</b>${p.summary ? `<small>${esc(p.summary)}</small>` : ''}</a></li>`, 'No posts yet.')}<a class="now-more" href="https://openvibe.blog/">OpenVibe.Blog →</a></div></div></section>`;
}
++// The kit (openvibe-shared/showcase, /shared/showcase.css) owns the sections and cards; this is only what it does not
++// draw: the "Right now" columns, the tool and "Opening next" chips, and the shared icon styles.
const CSS = `
--.home-sec{max-width:1080px;margin:56px auto 0;padding:0 24px}.home-sec>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.home-sec>p.lede{color:var(--text-secondary,#a8b3c4);margin:0 0 18px;max-width:760px}
--.home-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
--.home-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
--.home-card:hover,.home-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
--.home-card b{display:block;font-size:16px}.home-card em{display:block;font-style:normal;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa));margin:1px 0 4px}
--.home-card small{display:block;color:var(--text-secondary,#a8b3c4);font-size:13.5px;line-height:1.45}
--.home-fams{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
--.home-fam{display:flex;gap:12px;align-items:center;padding:12px 14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none}
--.home-fam:hover{border-color:var(--accent,#3b82f6)}.home-fam b{display:block;font-size:14.5px}.home-fam small{color:var(--text-muted,#7d8aa0);font-size:12.5px}
--.home-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;padding:0;list-style:none}
++.sc-ic:has(.sc-ovi){background:none}.sc-ic .sc-ovi{--ovi-size:40px!important}
++#developers{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{border-color:var(--accent,#3b82f6)}
--.home-dev{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
--.home-dev div{padding:16px;border-radius:16px;border:1px dashed var(--border,rgba(255,255,255,.14))}.home-dev b{display:block;margin-bottom:4px}.home-dev p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:13.5px}.home-dev code{font-size:12.5px}
++.home-chips>li>a:hover,.home-chips>li>a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}
++.home-chips.soon>li>a{color:var(--text-secondary,#a8b3c4);border-style:dashed}
++.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}
@@@ -121,33 +117,72 @@ const CSS = `
.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}}
--@media (prefers-reduced-motion:reduce){.home-card{transition:none}.now-dot{animation:none}}
++@media (prefers-reduced-motion:reduce){.now-dot{animation:none}}
${icons.CSS}`;
// The sites section's heading; the JSON-LD ItemList of the same sites carries it as its name, so the
// structured data names something a visitor can see (browser check, WS-Q task 3).
const sitesHeading = () => `${inWords(OPEN.length)} sites, one front door`;
++// Markup inside a kit section after its grid (the kit closes the section itself); an empty section stays empty.
++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>` : '');
++
++// 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) {
-- const byId = new Map(catalog.tools.map(t => [t.id, t]));
-- 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 byId = new Map(catalog.tools.map((t) => [t.id, t]));
++ 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 ? `${site.tagline}. ${site.what}` : site.what, href: `https://${site.host}/` })),
++ });
++ 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.',
++ items: fams.length ? fams.map((f) => ({ icon: `ov:${f.icon}`, title: f.name, text: `${count(f)} 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/' }],
++ }), `${chips(popular.map((t) => [t.url, t.tagline || '', t.icon, t.name]))}<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>`);
++ const account = showcase.features({
++ id: 'account', title: 'What signing in adds',
++ 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.',
++ items: ACCOUNT.map(([ic, title, text, href]) => ({ icon: `ov:${ic}`, title, text, href })),
++ });
++ // 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>
++${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, siteName(site)]), 'soon')}</section>` : '';
++ const dev = inside(showcase.features({
++ id: 'developers', title: 'For developers and crawlers',
++ lede: 'OpenVibe is open source and community-run. Everything public is meant to be read by machines too.',
++ items: [
++ { icon: 'ov:codes', title: 'Build on OpenVibe', text: 'Sign in with OpenVibe (OAuth 2.0 and FedCM), the API, SDK and webhooks: all on OpenVibe.Codes.', href: 'https://openvibe.codes/' },
++ { icon: 'ov:tools', title: 'Tool catalog', text: 'Every tool with its description, keywords and addresses as JSON.', href: 'https://openvibe.tools/api/catalog.json' },
++ { icon: 'ov:blog', title: 'llms.txt', text: 'A plain-text map of the network for AI assistants, with the full text beside it.', href: '/llms.txt' },
++ { 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>');
return `<style>${CSS}</style>
${rightNow(act)}
--<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="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>
--<div class="home-grid">${OPEN.map((site) => `<a class="home-card" href="https://${esc(site.host)}/">${icon(site.icon, 48)}<span><b>${esc(siteName(site))}</b><em>${esc(site.tagline)}</em><small>${esc(site.what)}</small></span></a>`).join('')}</div></section>
--<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length ? `${catalog.tools.length} tools` : 'Tools'} that just open</h2><p class="lede">No installs and no sign-up wall. Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p>
--${fams.length ? `<div class="home-fams">${fams.map(f => `<a class="home-fam" href="${esc(f.path ? 'https://openvibe.tools' + f.path : f.url)}">${icon(f.icon, 38)}<span><b>${esc(f.name)}</b><small>${count(f)} tools · ${esc(f.tagline || '')}</small></span></a>`).join('')}</div>` : ''}
--${popular.length ? `<ul class="home-chips">${popular.map(t => `<li><a href="${esc(t.url)}" title="${esc(t.tagline || '')}">${icon(t.icon, 22)}${esc(t.name)}</a></li>`).join('')}</ul>` : ''}</section>
--<section class="home-sec" aria-labelledby="h-acct"><h2 id="h-acct">What signing in adds</h2><p class="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>
--<div class="home-grid">${ACCOUNT.map(([ic, n, d, u]) => `<a class="home-card" href="${u}">${icon(ic, 44)}<span><b>${n}</b><small>${d}</small></span></a>`).join('')}</div></section>
--${SOON.length ? `<section class="home-sec" aria-labelledby="h-soon"><h2 id="h-soon">Opening next</h2><p class="lede">${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>
--<ul class="home-chips">${SOON.map((site) => `<li><a href="https://${esc(site.host)}/" title="${esc(site.tagline)}">${icon(site.icon, 22)}${esc(siteName(site))}</a></li>`).join('')}</ul></section>` : ''}
--<section class="home-sec" aria-labelledby="h-dev"><h2 id="h-dev">For developers and crawlers</h2><p class="lede">OpenVibe is open source and community-run. Everything public is meant to be read by machines too.</p>
--<div class="home-dev"><div><b>Tool catalog</b><p>Every tool with its description, keywords and addresses as JSON: <a href="https://openvibe.tools/api/catalog.json"><code>openvibe.tools/api/catalog.json</code></a></p></div>
--<div><b>llms.txt</b><p>A plain-text map of the network for AI assistants: <a href="/llms.txt"><code>/llms.txt</code></a> here and on <a href="https://openvibe.tools/llms.txt">openvibe.tools</a>.</p></div>
--<div><b>Sign in with OpenVibe</b><p>OAuth 2.0 and FedCM for sites on the network. Source on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a>.</p></div></div></section>`;
++${sites}
++${tools}
++${account}
++${soon}
++${dev}`;
++}
++
++// What an AI reader is told the page is (openvibe-shared/seo pageSummary): the meta, a WebPage JSON-LD tag and a
++// noscript facts block. Only what is true right now: the open sites and the catalog's own count and date.
++function summaryOf(catalog) {
++ return seo.pageSummary({
++ title: 'OpenVibe.Network',
++ summary: `One account for ${OPEN.length} open sites (${OPEN.map((site) => SHORT(site).toLowerCase()).join(', ')}), free, open source and community run.`,
++ url: 'https://openvibe.network/',
++ facts: [['Open sites', OPEN.length], ['Opening next', SOON.length], ...(catalog.tools.length ? [['Online tools', catalog.tools.length]] : [])],
++ updated: catalog.tools.length ? String(catalog.updated).slice(0, 10) : undefined,
++ });
}
let pageCache = { key: '', html: '', etag: '' };
@@@ -158,7 +193,8 @@ function render() {
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}`;
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 html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + 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</head>`);
++ 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</head>`);
pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return pageCache;
}
ddiff --git a/test/home-empty-catalog.test.js b/test/home-empty-catalog.test.js
iindex bb4e041a..59b97d3f 100644
---- a/test/home-empty-catalog.test.js
++++ b/test/home-empty-catalog.test.js
@@@ -12,7 +12,9 @@ assert.ok(!/\b0 tools\b/.test(html), 'no "0 tools" heading');
assert.ok(!/\b0 online tools\b/.test(html), 'no "0 online tools" in the hero');
assert.ok(/Tools that just open/.test(html), 'the section still says what Tools is');
assert.ok(/free online tools/.test(html), 'the hero reads "free online tools"');
--assert.ok(!/<div class="home-fams"><\/div>/.test(html) && !/<ul class="home-chips"><\/ul>/.test(html), 'no empty rows');
++assert.ok(!/<div class="sc-grid"><\/div>/.test(html) && !/<ul class="home-chips[^"]*"><\/ul>/.test(html), 'no empty rows');
++assert.ok(/<section class="sc-sec" id="tools"[^>]*>[\s\S]*?href="https:\/\/openvibe\.tools\/"/.test(html), 'the tools section still links openvibe.tools');
++assert.ok(!/<li>Online tools: /.test(html), 'the AI summary facts leave out a tool count it does not know');
const txt = home.llmsTxt();
assert.ok(!/Tool families/.test(txt), 'llms.txt leaves out the empty Tool families section');
assert.ok(/Sites/.test(txt) && /Machine-readable/.test(txt));
ddiff --git a/test/home-front-door.test.js b/test/home-front-door.test.js
iindex 47290c34..ed4b0df4 100644
---- a/test/home-front-door.test.js
++++ b/test/home-front-door.test.js
@@@ -48,6 +48,16 @@ const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');
assert.ok(html.includes('<svg class="constellation"'), 'the constellation is drawn');
for (const s of open) assert.ok(html.includes(`<a href="https://${s.host}/" class="cs-node"`), `the constellation links ${s.host}`);
assert.ok(!html.includes('<!--OV:'), 'no placeholder left behind');
++// Below the hero every section is the shared showcase kit (plan T11), with its stylesheet, one h1 and an AI summary.
++assert.ok(html.includes('<link rel="stylesheet" href="/shared/showcase.css">'), 'the kit stylesheet is linked');
++for (const id of ['network', 'tools', 'account', 'soon', 'developers']) assert.ok(html.includes(`<section class="sc-sec" id="${id}"`), `the ${id} section is a kit section`);
++assert.ok(!/class="home-(sec|card|grid|fams|fam|dev)\b/.test(html), 'no hand-built section markup is left');
++assert.strictEqual((html.match(/<h1[\s>]/g) || []).length, 1, 'one h1: the hero');
++assert.match(html, /<meta name="ai-summary" content="One account for \d+ open sites \(live, /);
++assert.ok(/"@type":"WebPage"/.test(html), 'the summary has its WebPage JSON-LD');
++for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://${s.host}/">`), `the sites section links ${s.host}`);
++for (const href of ['https://openvibe.tools/api/catalog.json', '/llms.txt', 'https://openvibe.tools/llms.txt', 'https://github.com/OpenVibers', 'https://openvibe.codes/', 'https://yt.openvibe.tools/', 'https://dns.openvibe.tools/']) assert.ok(html.includes(`href="${href}"`), `the page still links ${href}`);
++assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/section>/g) || []).length, 'every section is closed once');
// 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 });
ddiff --git a/test/page-checks.test.js b/test/page-checks.test.js
iindex 17bc39eb..32ca740b 100644
---- a/test/page-checks.test.js
++++ b/test/page-checks.test.js
@@@ -29,7 +29,7 @@ const updates = fs.readFileSync(path.join(__dirname, '..', 'server', 'updates',
assert.match(updates, /\.up-sites a\[aria-current\]\{background:var\(--accent-strong,#1d4ed8\);border-color:var\(--accent-strong,#1d4ed8\);color:var\(--on-accent-strong,#fff\)\}/);
const home = require('../server/home/render').render().html;
--const heading = home.match(/<h2 id="h-sites">([^<]*)<\/h2>/)[1];
++const heading = home.match(/<section class="sc-sec" id="network" aria-labelledby="([^"]+)"><h2 id="\1">([^<]*)<\/h2>/)[2];
assert.ok(home.includes(`"@type":"ItemList","name":${JSON.stringify(heading)}`), `the ItemList is named "${heading}"`);
console.log('page checks: login, updates filter and home JSON-LD ok');
process.exit(0);