openvibers/openvibe.network Commits

One site list: the owner's eight groups, everywhere the network shows them (ask 274) (#796)

OP OpenVibers committed 2 days ago · 3302de78a2 · 15 files, +169 −111

* One site list: the owner's eight groups, everywhere the network shows them (ask 274)

sites.js GROUPS is now watch, talk, play, read, shop, tools, make and build, in that order, at most seven tiles each. Media is Video's section, Homes Rent's and Bench Report's: their domains keep answering but they have no tile. Pics and Download stay list entries beside Video. The menu, the home service map, /sites, pulse.sites and online.sites all count the same 55 open places and say what they count. Contracts is pinned to 0.400.0, whose site.neighbourhood carries the same ids. A new test checks every manifest site is in one group or a named merge, no group is over seven, and the merged hosts still resolve.

* org sameAs: count the listed places, not the pre-merge sites

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 01e5e2d6..30e10b71 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"otpauth": "9.6.0",
@@@ -2679,9 +2679,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.399.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
-- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
++ "version": "0.400.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",
++ "integrity": "sha512-37gMtbK0gQy1P3TdD0KauSDvV/p2J0fepd1qfsPhj8SWSZXHwry9zdwzJWDKiKsqKWnG5jvy9hLf4bH/1zIrpg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex fcdd8dc1..5a6a43fa 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"otpauth": "9.6.0",
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 0779412e..264b1b0d 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -28,12 +28,15 @@
const { isExtensionOrigin } = require('../auth/extension-origin');
const express = require('express');
const crypto = require('crypto');
--const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
++const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { createMenuCards } = require('./menu-cards');
const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
++// One number for "how many places the network lists": the menu, its tile count, pulse.sites and online.sites.
++// 53 open sites (Network the front door included) plus Pics and Download, the two domains Video serves as entries.
++const LISTED_TOTAL = LISTED.filter((s) => s.status === 'open').length;
const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
const BASE_WEIGHT = { live: 7, tools: 6, space: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only
const MENU_BEACON = 'https://openvibe.network/api/frame/menu-click';
@@@ -75,7 +78,7 @@ function siteForDestination(hostname) {
if (!h) return null;
const site = siteForHost(h);
if (site) return site;
-- const product = PRODUCTS.find((p) => h === p.host || h.endsWith('.' + p.host));
++ const product = PRODUCTS.concat(MERGED_PRODUCTS).find((p) => h === p.host || h.endsWith('.' + p.host));
return product ? SITES.find((s) => s.id === product.of) || null : null;
}
/** A user_history row's Frame site id: service alias first, the site id itself, then the row's URL host. */
@@@ -177,14 +180,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
// frame_cache across restarts. A failed site keeps its last good card for 30 minutes; GET /api/frame carries
// at most twelve, Live first while anyone is live, then the Frame's own ranking.
const menuCards = createMenuCards({
-- sites: () => SITES.filter((s) => s.status === 'open'),
++ sites: () => LISTED_SITES.filter((s) => s.status === 'open'),
rankOf: () => orderedOpen().map((s) => s.id),
load: () => load('menu-cards'),
save: (v) => save('menu-cards', v),
onChange: () => { version = Date.now(); },
});
await menuCards.init();
-- const openIds = () => new Set(SITES.filter((s) => s.status === 'open').map((s) => s.id));
++ const openIds = () => new Set(LISTED_SITES.filter((s) => s.status === 'open').map((s) => s.id));
/** Only open sites, never the site itself, at most six; drops duplicates and anything from an older seed. */
function cleanNext(map) {
const ids = openIds(); const out = {};
@@@ -203,7 +206,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
* the first move is counted, so the menu's "Next from here" is never empty on a cold network. */
function seedNext() {
const ids = openIds(); const map = {};
-- for (const site of SITES.filter((s) => s.status === 'open')) {
++ for (const site of LISTED_SITES.filter((s) => s.status === 'open')) {
const out = []; const seen = new Set([site.id]);
const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
for (const id of NEXT_SEED[site.id] || []) add(id);
@@@ -292,7 +295,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
byFrom.get(pair.from).push(pair);
}
const seed = seedNext(); const map = {};
-- for (const site of SITES.filter((s) => s.status === 'open')) {
++ for (const site of LISTED_SITES.filter((s) => s.status === 'open')) {
const out = []; const seen = new Set([site.id]);
const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
const moved = (byFrom.get(site.id) || []).sort((a, b) => b.people.size - a.people.size || b.moves - a.moves || a.to.localeCompare(b.to));
@@@ -347,7 +350,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
function orderedOpen() {
-- const open = SITES.filter(s => s.status === 'open');
++ const open = LISTED_SITES.filter(s => s.status === 'open');
// Real use decides, compared raw: normalising by one service's own analytics scale made Live's tens of
// thousands of page views flatten every other site to nearly zero, so the cold-start BASE_WEIGHT decided the
// order and Video (openvibe.video) sat under Tools, Community and Space despite 2,687 hits in 7 days (11-07).
@@@ -359,7 +362,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
function linkPool() {
const { catalog } = toolsCatalog.peek();
const pool = new Map();
-- for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: fullName(s), url: s.url || `https://${s.host}/`, about: s.tagline });
++ for (const s of LISTED_SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: fullName(s), url: s.url || `https://${s.host}/`, about: s.tagline });
for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });
for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
return pool;
@@@ -376,7 +379,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const pool = linkPool();
const popular = new Set(popularTools(12).map(t => t.url));
const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));
-- const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: fullName(s), what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
++ const body = { sites: LISTED_SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: fullName(s), what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
let j = null;
try { j = await fromAi(body); } catch { j = null; }
if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
@@@ -403,7 +406,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const discoverIds = discover && Array.isArray(discover.ids) && discover.ids.length ? discover.ids.filter(id => open.some(s => s.id === id)) : open.map(s => s.id);
return {
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
-- // Every open site, most used first, each followed by the products it serves on their own domains (`of`).
++ // Every listed place, most used first, each followed by the products it serves on their own domains (`of`).
++ // Media, Homes and Bench are merged sections, not entries; Pics and Download are entries after Video.
nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: MENU_NAME[s.id] || s.name, url: s.url || `https://${s.host}/`, icon: ICON_ALIAS[s.icon] || s.icon, tagline: MENU_LINE[s.id] || s.tagline, group: groupOf(s.id), ...(s.of ? { of: s.of } : {}) })),
// The menu's data: where people go next from each open site (totals only, no person), the fair discovery
// order, and the click beacon (Shared sends it only when this object is present).
@@@ -414,7 +418,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
cards: menuCards.cards(),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
-- soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: s.url || `https://${s.host}/`, icon: s.icon, state: s.state })),
++ soon: LISTED.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: s.url || `https://${s.host}/`, icon: s.icon, state: s.state })),
footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover: footerDiscover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
};
}
@@@ -475,7 +479,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
return {
live_now: live ? live.channels : null,
watching: live ? live.watching : null,
-- sites: orderedOpen().length,
++ sites: LISTED_TOTAL,
views_7d: views > 0 ? Math.round(views) : null,
live: live ? live.list : null,
updated: new Date().toISOString(),
@@@ -487,7 +491,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
await pulseInflight;
}
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });
-- res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, live: null, updated: null }));
++ res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: LISTED_TOTAL, views_7d: null, live: null, updated: null }));
});
// ── GET /api/frame/online — who is on the network, and a few counts (owner, 2026-11-10) ────────────────────────
@@@ -555,7 +559,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
visible,
counts: await countsOf(nowMs),
live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },
-- sites: orderedOpen().length,
++ sites: LISTED_TOTAL,
views_7d: views > 0 ? Math.round(views) : null,
updated: new Date(nowMs).toISOString(),
};
@@@ -604,7 +608,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
}
}
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });
-- res.send(e ? e.body : JSON.stringify({ updated: null, members: null, guests: null, total: null, people: [], more: 0, live: null, counts: [], items: [], sites: orderedOpen().length, views_7d: null }));
++ res.send(e ? e.body : JSON.stringify({ updated: null, members: null, guests: null, total: null, people: [], more: 0, live: null, counts: [], items: [], sites: LISTED_TOTAL, views_7d: null }));
});
// GET /api/frame/feed?kind=&site=&q=&following=1&cursor=&limit= and GET /api/frame/wall?limit=&after=&for= — the menu's Feed and wall
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
iindex 2446d8f1..35f42660 100644
---- a/server/frame/sites.js
++++ b/server/frame/sites.js
@@@ -55,60 +55,74 @@ const fullName = (s) => (/^Open[A-Z]/.test(String(s && s.name)) ? s.name : `Open
// The launcher (openvibe-shared navbar) lists every open site under one of these groups, most used first within
// each. A site missing here lands in the last group, so a new manifest is never left out of the menu.
--// The six neighbourhoods of the 2026-10-28 plan (§3.1): each label is two words, so it never collides with a site's own
--// name, and they follow what a person came to do. Rooms not open yet (Cam, Tours, Surf, Fun, Meme, Casino, Today, Report,
--// Vision, Website, Design, Domains, open.shoes) are named here so they land in their neighbourhood the day
--// their manifest opens. The building blocks stay last, "For developers" (the tail of Make & build).
++// The owner's regrouping (2026-11-10, plan ask 274): eight groups named for what a person came to do, in the order
++// the menu, its folders, the footer and the home pages show them. Seven tiles a group at most; Video's two domains
++// (Pics, Download) are list entries of their own, while Media, Homes and Bench are sections of Video, Rent and
++// Report and keep their domains without a tile. Contracts' `site.neighbourhood` repeats these ids for every site.
const GROUPS = [
-- { id: 'streams', name: 'Streams & places', sites: ['live', 'media-hub', 'stream', 'pics', 'download', 'cam', 'tours'] },
-- { id: 'games', name: 'Games & laughs', sites: ['games', 'surf', 'fun', 'meme', 'casino', 'quest'] },
-- { id: 'answers', name: 'News & answers', sites: ['today', 'report', 'bench', 'news', 'search', 'wiki', 'blog', 'reviews', 'vision'] },
-- { id: 'make', name: 'Make & build', sites: ['website', 'connect', 'design', 'image', 'domains', 'computer', 'codes', 'space', 'tools', 'host', 'bot'] },
-- { id: 'shop', name: 'Shop & swap', sites: ['deals', 'coupons', 'shoes', 'rent', 'homes', 'work', 'food', 'trade'] },
-- { id: 'people', name: 'People & you', sites: ['chat', 'mail', 'community', 'tips', 'vip', 'help', 'network', 'inventory'] },
++ { id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'pics', 'download', 'stream', 'cam', 'tours'] },
++ { id: 'talk', name: 'Talk & belong', sites: ['community', 'chat', 'tips', 'vip', 'mail', 'help', 'network'] },
++ { id: 'play', name: 'Play', sites: ['games', 'quest', 'surf', 'fun', 'meme', 'casino', 'inventory'] },
++ { id: 'read', name: 'Read & ask', sites: ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision'] },
++ { id: 'shop', name: 'Shop & money', sites: ['deals', 'coupons', 'shoes', 'reviews', 'trade', 'rent', 'work'] },
++ { id: 'tools', name: 'Free tools', sites: ['tools', 'design', 'domains', 'host', 'image', 'lock', 'food'] },
// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second
-- // place to chat with an agent (plan L39, 2026-10-31).
-- { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'actor', 'run', 'zone', 'events', 'media', 'watch'] },
++ // place to chat with an agent (plan L39, 2026-10-31). It makes things with AI, beside the places that do.
++ { id: 'make', name: 'Make with AI', sites: ['space', 'codes', 'bot', 'actor', 'computer', 'connect'] },
++ { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'run', 'zone', 'events', 'watch', 'git'] },
];
// What each site is, in a few words, for the menus (the launcher's rows are two to a line on a phone: about 22
// characters fit). The manifests' taglines stay the sites' own sentences everywhere else; a site missing here shows
// its tagline.
const MENU_LINE = {
live: 'Go live, watch streams', 'media-hub': 'Clips and past streams', stream: 'Stream everywhere', pics: 'Share pictures',
-- download: 'Your files', tips: 'Support streamers', vip: 'Back your creators', computer: 'Your computer, your way', image: 'Edit photos in your browser', lock: 'Files only you can open',
-- chat: 'Chat with anyone', mail: 'Masks and inboxes', community: 'Posts and pastes', blog: 'Write long-form', news: 'Sourced, not spun', wiki: 'Facts with sources',
-- games: 'Play in your browser', quest: 'Earn badges', fun: 'Something to do',
-- codes: 'Code with any agent', actor: 'The agent engine', space: 'Host a site or app', tools: 'Every online tool', bot: 'Drive a robot',
-- deals: 'Deals people share', coupons: 'Codes that work', reviews: 'Honest reviews', work: 'Find a job', rent: 'Places to rent',
-- homes: 'Homes for sale', food: 'Food near you', help: 'Help nearby', trade: 'Watch the markets', watch: 'Know when it changes',
-- search: 'Search everything', host: 'Compare hosting', report: 'Your numbers, honest', vision: 'Ask a picture',
-- exchange: 'AI models, one account', connect: 'Share a port with Link',
-- network: 'Your account', inventory: 'Your items',
-- services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API',
++ download: 'Your files', cam: 'Your camera, in private', tours: 'Cities, routes, stays',
++ community: 'Posts and pastes', chat: 'Chat with anyone', tips: 'Support streamers', vip: 'Back your creators', mail: 'Masks and inboxes', help: 'Help nearby', network: 'Your account',
++ games: 'Play in your browser', quest: 'Earn badges', surf: 'Surf maps and the web', fun: 'Something to do', meme: 'Memes with receipts', casino: 'Odds shown, chips free', inventory: 'Your items',
++ news: 'Sourced, not spun', today: 'Your schedule', report: 'Your numbers, honest', search: 'Search everything', wiki: 'Facts with sources', blog: 'Write long-form', vision: 'Ask a picture',
++ deals: 'Deals people share', coupons: 'Codes that work', shoes: 'Every shoe, every price', reviews: 'Honest reviews', trade: 'Watch the markets', rent: 'Places to rent', work: 'Find a job',
++ tools: 'Every online tool', design: 'Try themes live', domains: 'Names and prices', host: 'Compare hosting', image: 'Edit photos in your browser', lock: 'Files only you can open', food: 'Food near you',
++ space: 'Host a site or app', codes: 'Code with any agent', bot: 'Drive a robot', actor: 'The agent engine', computer: 'Your computer, your way', connect: 'Share a port with Link',
++ services: 'APIs, SDK and keys', exchange: 'AI models, one account', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', watch: 'Know when it changes', git: 'Host and browse code',
};
// A site's name in the menus, where the rows are short words (Pics, Download, VIP, Tips): the product keeps its full name
// everywhere else (its own site, page titles, sign-in). The owner, 2026-10-28: "label it simply Restream… it does look
// a little weird" next to the others.
--const MENU_NAME = { stream: 'Restream', bench: 'AI' };
++const MENU_NAME = { stream: 'Restream' };
const groupOf = (id) => (GROUPS.find(g => g.sites.includes(id)) || GROUPS[GROUPS.length - 1]).id;
++// Sites whose own domain keeps answering but that are a section of another site: in the menu, the footer, the home
++// pages and /sites they are not a tile of their own (owner, 2026-11-10). siteForHost still knows them.
++// media → Video (openvibe.media stays the API and the embed host)
++const MERGED_SITES = new Map([['media', 'media-hub']]);
++
// Products with a domain of their own that another service serves (its manifest lists the domain): in the menu
--// while that service's site is open, right after it. They are not sites of their own (no ranking, no legal profile).
++// while that service's site is open, right after it. They are not sites of their own (no ranking, no legal profile),
++// but they are list entries: Pics and Download are what Video's drive shows on their own domains (owner, 2026-11-10).
const PRODUCTS = [
{ id: 'pics', name: 'Pics', host: 'openvibe.pics', icon: 'pics', of: 'media-hub', tagline: 'Share pictures, fast and open' },
{ id: 'download', name: 'Download', host: 'openvibe.download', icon: 'download', of: 'media-hub', tagline: 'Your files, shared on your terms' },
-- { id: 'homes', name: 'Homes', host: 'openvibe.homes', icon: 'homes', of: 'rent', tagline: 'Every home-for-sale site, one search' },
-- // The move (2026-11-08): Bench's public pages are Report's AI section. The product keeps the old host for
-- // redirects and links straight at the Report page; Report's manifest will name the host in the next Contracts
-- // release, so the explicit `listed` flag is the bridge until then.
-- { id: 'bench', name: 'AI', host: 'bench.openvibe.services', icon: 'bench', of: 'report', url: 'https://openvibe.report/ai', tagline: 'Which AI is best, and what it costs', listed: true },
].filter((p) => {
-- if (p.listed) return true;
const s = SITES.find(x => x.id === p.of); const m = s && contracts.services.get(s.service);
return m && (m.domains || []).includes(p.host);
});
++// Domains of a merged section (no tile): Homes is Rent's for-sale search, Bench is Report's AI section (its host
++// already redirects). They stay in the host map so a page or a sign-in return keeps naming its own place.
++const MERGED_PRODUCTS = [
++ { id: 'homes', name: 'Homes', host: 'openvibe.homes', icon: 'homes', of: 'rent' },
++ { id: 'bench', name: 'AI', host: 'bench.openvibe.services', icon: 'bench', of: 'report' },
++];
++
++// The list the menu, the footer, the home pages and /sites show: every site except a merged section, plus the
++// products above with a domain of their own (Pics, Download). Open sites in this list today: 55.
++const LISTED_SITES = SITES.filter((s) => !MERGED_SITES.has(s.id));
++const listed = (p) => {
++ const owner = SITES.find((s) => s.id === p.of);
++ return { ...p, service: p.of, what: p.tagline, status: owner ? owner.status : 'soon', state: owner ? owner.state : 'placeholder' };
++};
++const LISTED = LISTED_SITES.concat(PRODUCTS.map(listed));
// Manifest icon names the shared icon set draws differently.
const ICON_ALIAS = { 'media-hub': 'video' };
@@@ -120,10 +134,10 @@ function siteForHost(hostname) {
if (byHost.has(h)) return byHost.get(h);
// A product on its own domain belongs to the site that serves it (Bench's host is Report's AI section now).
// Before the subdomain pass, because services.openvibe.services would otherwise claim bench.openvibe.services.
-- const product = PRODUCTS.find((p) => p.host === h || h.endsWith('.' + p.host));
++ const product = PRODUCTS.concat(MERGED_PRODUCTS).find((p) => p.host === h || h.endsWith('.' + p.host));
if (product) return SITES.find((s) => s.id === product.of) || null;
for (const s of SITES) if (h.endsWith('.' + s.host)) return s;
return null;
}
--module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName };
++module.exports = { SITES, LISTED_SITES, LISTED, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_SITES, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName };
ddiff --git a/server/home/render.js b/server/home/render.js
iindex b8d74050..8dafb827 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -26,18 +26,20 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat
// The sites come from the frame's list (server/frame/sites.js): a site is open only when its service's
// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and
// "Opening next" always agree with what is actually running.
--const { SITES: frameSites, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');
++const { LISTED, GROUPS, groupOf, fullName } = require('../frame/sites');
const contracts = require('openvibe-contracts');
const social = require('openvibe-shared/social');
// OpenRestream is its own name, never "OpenVibe.OpenRestream" (the owner, 2026-10-28).
const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));
--const OPEN = frameSites.filter((site) => site.status === 'open' && site.id !== 'network');
--// How many sites there are: every open one, this front door included, the same count /sites shows (server/sites/page.js).
++const OPEN = LISTED.filter((site) => site.status === 'open' && site.id !== 'network');
++// How many places the network lists: every open entry, this front door included, the same count the menu, /sites and
++// the Frame's pulse/online answer (server/sites/page.js updates with it). Pics and Download count; Media, Homes and
++// Bench are merged sections, not entries.
// One number for the whole page: until 2026-11-06 the sites heading counted without the front door and the link under it
// with it, so a phone showed "51 sites, one front door" above "All 52 sites" (the owner: "why isn't this count just
// dynamic"). It always was computed; it now has one definition.
--const SITE_COUNT = frameSites.filter((site) => site.status === 'open').length;
--const SOON = frameSites.filter((site) => site.status !== 'open');
++const SITE_COUNT = LISTED.filter((site) => site.status === 'open').length;
++const SOON = LISTED.filter((site) => site.status !== 'open');
const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', 'Twenty'];
const inWords = (n) => WORDS[n] || String(n);
const ACCOUNT = [
@@@ -370,18 +372,12 @@ function body(catalog, act) {
const popular = POPULAR.map((id) => byId.get(id)).filter(Boolean);
const fams = catalog.families.filter((f) => f.url);
const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;
-- // The service map: every open site under the launcher's groups (server/frame/sites.js GROUPS), so the 36 cards read
-- // as six neighbourhoods, each with the products that live on another site's domain (openvibe.pics, …) beside it.
-- // Every open site in its neighbourhood (server/frame/sites.js GROUPS), as a launcher under its short name (the domain
-- // word: "Live", "Reviews"; the full name leads its one line): a phone shows the icons three or
-- // four to a row, the way its home screen does; wider screens add each site's one line. The products that live on
-- // another site's domain (openvibe.pics, …) are tiles of their neighbourhood too. /sites has the long descriptions.
++ // The service map: every open place under the launcher's groups (server/frame/sites.js GROUPS), so the cards read
++ // as the same eight groups, in the same order, as the menu. Pics and Download are list entries beside Video;
++ // Media, Homes and Bench are merged sections and have no tile. /sites has the long descriptions.
const groups = GROUPS.map((g) => ({
name: g.name,
-- items: [
-- ...OPEN.filter((site) => groupOf(site.id) === g.id).map((site) => ({ icon: `ov:${site.icon}`, name: site.name, text: `${siteName(site)}: ${site.tagline || site.what}`, href: `https://${site.host}/` })),
-- ...PRODUCTS.filter((x) => groupOf(x.id) === g.id).map((x) => ({ icon: `ov:${x.icon}`, name: x.name, text: x.tagline, href: x.url || `https://${x.host}/` })),
-- ],
++ items: OPEN.filter((site) => groupOf(site.id) === g.id).map((site) => ({ icon: `ov:${site.icon}`, name: site.name, text: `${siteName(site)}: ${site.tagline || site.what}`, href: `https://${site.host}/` })),
})).filter((g) => g.items.length);
const sites = showcase.apps({
id: 'network', title: sitesHeading(),
ddiff --git a/server/home/try-next.js b/server/home/try-next.js
iindex 1d339539..6e16b3f7 100644
---- a/server/home/try-next.js
++++ b/server/home/try-next.js
@@@ -10,10 +10,10 @@
* sites, still excluding what the person uses; when Report does not answer at all, it is the fixed starter list.
* The answer says which it is: { source, basis, generated_at, items: [...] }.
*/
--const { SITES, fullName } = require('../frame/sites');
++const { LISTED_SITES, fullName } = require('../frame/sites');
const DAY = 86_400_000;
--const OPEN = SITES.filter((site) => site.status === 'open' && site.id !== 'network');
++const OPEN = LISTED_SITES.filter((site) => site.status === 'open' && site.id !== 'network');
const BY_SERVICE = new Map();
for (const site of OPEN) { BY_SERVICE.set(site.service, site); BY_SERVICE.set(site.id, site); }
// History's host-derived service name for a product whose site has another id.
ddiff --git a/server/sites/page.js b/server/sites/page.js
iindex bfccb5e9..94ad8122 100644
---- a/server/sites/page.js
++++ b/server/sites/page.js
@@@ -1,28 +1,28 @@
'use strict';
--// /sites: the front door to the whole network, for people and for crawlers (SEO-9). Every open site is listed under
--// the same six neighbourhoods the home page uses (server/frame/sites.js GROUPS), with the site's own tagline, and
++// /sites: the front door to the whole network, for people and for crawlers (SEO-9). Every open entry is listed under
++// the same eight groups the home page and the menu use (server/frame/sites.js GROUPS), with its own tagline, and
// each site is anchored at its registry id (/sites#live) so it can be linked directly. One source: the registry the
// home reads, so the hub cannot disagree with the home, and a site that is not open is never listed.
const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/ov-icons');
--const { SITES, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');
++const { LISTED, GROUPS, groupOf, fullName } = require('../frame/sites');
const esc = seo.esc;
const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));
// The one-line purpose, in the home's own words: the tagline it renders on every card (the llms.txt and ItemList
// descriptions use `what`, the longer text).
const siteLine = (site) => site.tagline || site.what;
--const openSites = (list = SITES) => list.filter((site) => site.status === 'open');
++const openSites = (list = LISTED) => list.filter((site) => site.status === 'open');
// The home counts the front door itself in "N sites, one account"; the hub lists it too (it is an open site, with the
// account and themes behind every other one).
--const groupsOf = (sites) => GROUPS.map((group) => ({ group, sites: sites.filter((site) => groupOf(site.id) === group.id), also: PRODUCTS.filter((p) => groupOf(p.id) === group.id) })).filter((x) => x.sites.length);
++const groupsOf = (sites) => GROUPS.map((group) => ({ group, sites: sites.filter((site) => groupOf(site.id) === group.id) })).filter((x) => x.sites.length);
// The network's own share card, the same one the home pages carry (an indexable page without og:image is the one
// thing the SEO audit still finds on this page).
const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const card = (site) => `<a class="sc-card" id="${esc(site.id)}" href="https://${esc(site.host)}/"><span class="sc-ic sc-ic-ov"><span class="ov-icon sc-ovi" data-icon="${esc(site.icon)}" style="--ovi-size:40px" aria-hidden="true">${icons.svg(site.icon)}</span></span><span><b>${esc(siteName(site))}</b><small>${esc(siteLine(site))}</small></span></a>`;
--function render({ releaseId = '', sites = SITES } = {}) {
++function render({ releaseId = '', sites = LISTED } = {}) {
const open = openSites(sites);
const groups = groupsOf(open);
const count = open.length;
@@@ -30,9 +30,9 @@ function render({ releaseId = '', sites = SITES } = {}) {
const desc = `Every open OpenVibe site in one place, grouped by what people come to do, each with a link to its own home. One free account signs you in on all of them.`;
const intro = 'Every open OpenVibe site, grouped by what people come to do; signing in once carries your name, theme and notifications to all of them.';
const headings = new Map(groups.map(({ group }) => [group.id, `h-${group.id}`]));
-- // The group anchors are prefixed: a group id ("games") can be a site id too, and one page cannot hold both.
++ // The group anchors are prefixed: a group id can match a site id ("tools"), and one page cannot hold both.
const jump = `<nav class="sites-jump" aria-labelledby="h-jump"><h2 id="h-jump">The groups</h2><ol>${groups.map(({ group, sites: list }) => `<li><a href="#group-${esc(group.id)}">${esc(group.name)}<small>${list.length}</small></a></li>`).join('')}</ol></nav>`;
-- const body = groups.map(({ group, sites: list, also }) => `<section class="sc-sec" id="group-${esc(group.id)}" aria-labelledby="${esc(headings.get(group.id))}"><h2 id="${esc(headings.get(group.id))}">${esc(group.name)}</h2><div class="sc-grid">${list.map(card).join('')}</div>${also.length ? `<p class="sites-also">Also here: ${also.map((p) => `<a href="${esc(p.url || `https://${p.host}/`)}">${esc(p.name || p.host)}</a> (${esc(p.tagline.toLowerCase())})`).join(', ')}</p>` : ''}</section>`).join('\n');
++ const body = groups.map(({ group, sites: list }) => `<section class="sc-sec" id="group-${esc(group.id)}" aria-labelledby="${esc(headings.get(group.id))}"><h2 id="${esc(headings.get(group.id))}">${esc(group.name)}</h2><div class="sc-grid">${list.map(card).join('')}</div></section>`).join('\n');
const ld = {
'@context': 'https://schema.org', '@type': 'CollectionPage',
name: title, description: desc, url: 'https://openvibe.network/sites',
@@@ -108,9 +108,6 @@ const CSS = `
.sites-jump small{color:var(--text-muted);font-weight:700;font-size:12px;font-variant-numeric:tabular-nums}
.sites .sc-sec:first-of-type{margin-top:40px}
.sc-card[id]{scroll-margin-top:84px}
--.sites-also{margin:12px 0 0;font-size:13.5px;color:var(--text-secondary)}
--.sites-also a{color:var(--accent-light);font-weight:600;text-decoration:none}
--.sites-also a:hover{text-decoration:underline}
`;
module.exports = { render, llmsText, siteLine, openSites, CSS };
ddiff --git a/test/frame-menu.test.js b/test/frame-menu.test.js
iindex 37cf4888..496a9d3a 100644
---- a/test/frame-menu.test.js
++++ b/test/frame-menu.test.js
@@@ -8,14 +8,14 @@ const express = require('express');
const http = require('http');
const { getDb } = require('../server/db/database');
const { createFrameService } = require('../server/frame/service');
--const { SITES, siteForHost } = require('../server/frame/sites');
++const { LISTED_SITES, siteForHost } = require('../server/frame/sites');
const listen = (app) => new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const url = (srv, path) => `http://127.0.0.1:${srv.address().port}${path}`;
(async () => {
const db = getDb();
-- const openIds = SITES.filter((s) => s.status === 'open').map((s) => s.id);
++ const openIds = LISTED_SITES.filter((s) => s.status === 'open').map((s) => s.id);
for (let i = 1; i <= 5; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `menu${i}`, 'x');
const ins = db.prepare("INSERT INTO user_history (user_id, service, type, title, url) VALUES (?, ?, 'page', 't', ?)");
const insOld = db.prepare("INSERT INTO user_history (user_id, service, type, title, url, created_at) VALUES (?, ?, 'page', 't', ?, datetime('now', '-31 days'))");
ddiff --git a/test/frame-ranking.test.js b/test/frame-ranking.test.js
iindex 2a744167..1ce2e1eb 100644
---- a/test/frame-ranking.test.js
++++ b/test/frame-ranking.test.js
@@@ -10,7 +10,7 @@ const { createFrameService } = require('../server/frame/service');
(async () => {
const db = getDb();
const svc = await createFrameService(db, { services: {} }, null, { selfToken: () => 'test-token' });
-- assert.deepStrictEqual(svc.ranking().order.slice(0, 7), ['live', 'tools', 'space', 'community', 'games', 'media', 'network'], 'cold start: the hand-set base order');
++ assert.deepStrictEqual(svc.ranking().order.slice(0, 6), ['live', 'tools', 'space', 'community', 'games', 'network'], 'cold start: the hand-set base order (Media is Video\'s section, not a tile)');
for (let i = 1; i <= 4; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `rank${i}`, 'x');
const day = "substring(ov_now(), 1, 10)";
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 6377d031..a2ee273f 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -5,9 +5,7 @@ const http = require('http');
const contracts = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { createFrameService, BANNED } = require('../server/frame/service');
--const { siteForHost, GROUPS } = require('../server/frame/sites');
--const HAS_MAIL = !!contracts.services.get('mail');
--const HAS_GIT = !!contracts.services.get('git');
++const { siteForHost, GROUPS, LISTED } = require('../server/frame/sites');
(async () => {
const db = getDb();
@@@ -41,26 +39,25 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
let p = svc.payloadFor('openvibe.media');
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// the test still follows what the installed manifests say.
--const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image'];
--if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');
--if (HAS_GIT) COLD_ORDER.splice(COLD_ORDER.indexOf('events') + 1, 0, 'git');
++const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'network', 'chat', 'mail', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'git', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image'];
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');
--// The launcher: every open site in one of the groups, and the products other services serve on their own domains
--// right after their site (Pics and Download after Video, Homes after Rent).
--assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');
--assert.strictEqual(p.groups.find(g => g.id === 'build').name, 'For developers');
--assert.deepStrictEqual(GROUPS.find(g => g.id === 'people').sites.slice(0, 3), ['chat', 'mail', 'community'], 'Email sits with Chat in the talk group, before Community');
++assert.ok(!p.nav.some(n => ['media', 'homes', 'bench', 'website'].includes(n.id)), 'merged sections and the domain-only Website are not tiles');
++// The launcher: every open place in one of the eight groups (owner, 2026-11-10), and the products other services
++// serve on their own domains right after their site (Pics and Download after Video; Homes and Bench are sections).
++assert.deepStrictEqual(p.groups.map(g => g.name), ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'], 'the eight groups in the owner\'s order');
++assert.deepStrictEqual(p.groups.map(g => g.id), ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build']);
++assert.deepStrictEqual(GROUPS.find(g => g.id === 'talk').sites, ['community', 'chat', 'tips', 'vip', 'mail', 'help', 'network'], 'Email sits with Chat in the talk group');
assert.ok(p.nav.every(n => p.groups.some(g => g.id === n.group)), 'every entry has a group');
--assert.deepStrictEqual(p.nav.filter(n => n.of).map(n => [n.id, n.of, n.url]), [['pics', 'media-hub', 'https://openvibe.pics/'], ['download', 'media-hub', 'https://openvibe.download/'], ['homes', 'rent', 'https://openvibe.homes/'], ['bench', 'report', 'https://openvibe.report/ai']]);
++assert.deepStrictEqual(p.nav.filter(n => n.of).map(n => [n.id, n.of, n.url]), [['pics', 'media-hub', 'https://openvibe.pics/'], ['download', 'media-hub', 'https://openvibe.download/']]);
const at = (id) => p.nav.findIndex(n => n.id === id);
--assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 1 && at('bench') === at('report') + 1, 'a product follows its site');
--assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('homes')].group, p.nav[at('tools')].group], ['video', 'streams', 'shop', 'make'], 'Video draws the video icon (there is no media-hub icon)');
--assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.nav[at(id)].group), ['build', 'make', 'build', 'build', 'make'], 'robots and coding agents for people; the agent engine, compute and storage for developers (plan L39: Actor is the engine Codes, Vibe and the Dock call)');
--// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
--[['games', 'games'], ['connect', 'make'], ['image', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']]
++assert.ok(at('pics') === at('media-hub') + 1 && at('download') === at('media-hub') + 2, 'Pics and Download follow their site');
++assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('tools')].group], ['video', 'watch', 'tools'], 'Video draws the video icon (there is no media-hub icon)');
++assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.nav[at(id)].group), ['make', 'make', 'build', 'build', 'make'], 'robots and coding agents make with AI; compute and storage are for developers');
++// Every tile is in exactly one group, each group has at most seven tiles, and the merged sections still answer.
++[['games', 'play'], ['connect', 'make'], ['image', 'tools'], ['quest', 'play'], ['inventory', 'play'], ['watch', 'build'], ['download', 'watch'], ['search', 'read'], ['chat', 'talk'], ['mail', 'talk'], ['domains', 'tools'], ['report', 'read'], ['design', 'tools'], ['today', 'read'], ['surf', 'play'], ['meme', 'play'], ['vision', 'read'], ['exchange', 'build'], ['fun', 'play'], ['cam', 'watch'], ['casino', 'play'], ['tours', 'watch'], ['shoes', 'shop']]
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
--assert.strictEqual(p.nav.length, 56 + (HAS_MAIL ? 1 : 0) + (HAS_GIT ? 1 : 0)); // Computer and Locker since Contracts 0.347.0, Studio since 0.348.0 (2026-11-08).
++assert.strictEqual(p.nav.length, 55, '53 open sites plus Pics and Download');
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
@@@ -157,7 +154,7 @@ for (let i = 0; i < 6; i++) await ins.run(1, 'tools', 'dns');
assert.equal(r.headers.get('access-control-allow-origin'), '*');
assert.match(r.headers.get('cache-control'), /max-age=30/);
const j = await r.json();
-- assert.deepStrictEqual([j.live_now, j.watching, j.sites], [2, 7, pulseSvc.ranking().order.length]);
++ assert.deepStrictEqual([j.live_now, j.watching, j.sites], [2, 7, LISTED.filter((s) => s.status === 'open').length], 'the one open-place count (sites plus Pics and Download)');
assert.ok(j.views_7d > 0 && j.updated, 'last week\'s views from the ranking');
liveList.close();
const down = await (await fetch(`http://127.0.0.1:${srv.address().port}/api/frame/pulse`)).json();
ddiff --git a/test/home-front-door.test.js b/test/home-front-door.test.js
iindex 8a111b67..6af044e9 100644
---- a/test/home-front-door.test.js
++++ b/test/home-front-door.test.js
@@@ -6,7 +6,7 @@ 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;
++const sites = require('../server/frame/sites').LISTED;
activity.setEnabled(false);
@@@ -128,7 +128,7 @@ assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');
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 &amp; places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make &amp; build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');
++assert.ok(/<h3 class="sc-apps-group">Watch &amp; stream<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make with AI<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');
assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');
assert.ok(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');
assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');
ddiff --git a/test/home-site-count.test.js b/test/home-site-count.test.js
iindex 9dffd2ab..20849288 100644
---- a/test/home-site-count.test.js
++++ b/test/home-site-count.test.js
@@@ -6,10 +6,10 @@ const assert = require('assert');
const activity = require('../server/home/activity');
const home = require('../server/home/render');
const sitesPage = require('../server/sites/page');
--const { SITES } = require('../server/frame/sites');
++const { LISTED } = require('../server/frame/sites');
activity.setEnabled(false);
--const expected = SITES.filter((s) => s.status === 'open').length;
++const expected = LISTED.filter((s) => s.status === 'open').length;
const { html } = home.render();
const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
const WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'];
ddiff --git a/test/org-same-as.test.js b/test/org-same-as.test.js
iindex d6145877..5421f032 100644
---- a/test/org-same-as.test.js
++++ b/test/org-same-as.test.js
@@@ -5,9 +5,9 @@ const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { SAME_AS, withSameAs } = require('../server/home/render');
--const { SITES } = require('../server/frame/sites');
++const { LISTED } = require('../server/frame/sites');
--const open = SITES.filter((s) => s.status === 'open' && s.id !== 'network');
++const open = LISTED.filter((s) => s.status === 'open' && s.id !== 'network');
assert.strictEqual(SAME_AS.length, open.length + 2, 'GitHub, Discord and every open site');
assert.ok(SAME_AS.includes('https://github.com/OpenVibers'));
const missing = open.filter((s) => !SAME_AS.includes(`https://${s.host}`)).map((s) => s.host);
ddiff --git a/test/seo-routes.test.js b/test/seo-routes.test.js
iindex 9369e4df..eb86bcaf 100644
---- a/test/seo-routes.test.js
++++ b/test/seo-routes.test.js
@@@ -93,8 +93,8 @@ const cache = require('openvibe-shared/cache-policy');
// ── /sites: the hub of the whole network (SEO-9) ──
const sitesPage = require('../server/sites/page');
-- const { SITES } = require('../server/frame/sites');
-- const openSites = SITES.filter((site) => site.status === 'open');
++ const { LISTED } = require('../server/frame/sites');
++ const openSites = LISTED.filter((site) => site.status === 'open');
const hub = await get('/sites');
assert.strictEqual(hub.status, 200, '/sites answers 200 (it was a 404 before SEO-9)');
assert.ok(hub.type.startsWith('text/html'), '/sites is HTML');
@@@ -130,7 +130,7 @@ const cache = require('openvibe-shared/cache-policy');
assert.deepStrictEqual(pageIds.filter((id, i) => pageIds.indexOf(id) !== i), [], 'no id repeats on the page');
// Only `open`: an entry that is not open never reaches the page or its ItemList.
const one = openSites[0];
-- const closed = sitesPage.render({ sites: SITES.map((site) => (site.id === one.id ? { ...site, status: 'soon' } : site)) });
++ const closed = sitesPage.render({ sites: LISTED.map((site) => (site.id === one.id ? { ...site, status: 'soon' } : site)) });
assert.ok(!closed.includes(`id="${one.id}"`), 'a site that is not open is not anchored on the page');
assert.strictEqual(JSON.parse(closed.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]).mainEntity.numberOfItems, openSites.length - 1, 'and not in the ItemList');
// Findable: the sitemap lists it, and llms-full.txt carries its list.
ddiff --git a/test/site-groups.test.js b/test/site-groups.test.js
nnew file mode 100644
iindex 00000000..d470a09a
---- /dev/null
++++ b/test/site-groups.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++// The owner's one site list (2026-11-10, plan ask 274): eight groups, in this order, at most seven tiles each,
++// every site in exactly one of them (or a named merge with a domain that keeps answering). The same list feeds the
++// menu, its You-tab folders, the footer's Network column and the home pages' "More on OpenVibe".
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const {
++ SITES, LISTED, LISTED_SITES, GROUPS, groupOf, PRODUCTS, MERGED_SITES, MERGED_PRODUCTS,
++ MENU_LINE, MENU_NAME, siteForHost,
++} = require('../server/frame/sites');
++
++const LABELS = ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'];
++assert.deepStrictEqual(GROUPS.map((g) => g.name), LABELS, 'the eight labels, in the order the menu, footer and homes show');
++assert.deepStrictEqual(GROUPS.map((g) => g.id), ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build'], 'the ids Contracts `site.neighbourhood` and Shared folders use');
++
++// At most seven tiles a group, and every listed place in exactly one group.
++for (const g of GROUPS) assert.ok(g.sites.length <= 7, `${g.name}: ${g.sites.length} tiles`);
++for (const s of LISTED) assert.strictEqual(GROUPS.filter((g) => g.sites.includes(s.id)).length, 1, `${s.id}: exactly one group`);
++assert.deepStrictEqual(GROUPS.map((g) => g.sites.length), [7, 7, 7, 7, 7, 7, 6, 7]);
++assert.strictEqual(LISTED.filter((s) => s.status === 'open').length, 55, 'the one number: 53 open sites plus Pics and Download');
++assert.deepStrictEqual(PRODUCTS.map((p) => LISTED.find((s) => s.id === p.id).status), ['open', 'open'], 'Pics and Download are open entries');
++
++// Every manifest site is in one group, or is one of the named merges: Media into Video, Bench into Report,
++// Homes into Rent (no manifest of its own) and Play into Games. `website` has no site and is in no list.
++const named = new Set([...MERGED_SITES.keys(), ...MERGED_PRODUCTS.map((p) => p.id), 'play']);
++const manifestSites = contracts.services.manifests.filter((m) => m.site);
++for (const m of manifestSites) {
++ const id = m.site.icon;
++ const grouped = GROUPS.some((g) => g.sites.includes(id));
++ assert.ok(grouped || named.has(id), `${id}: in a group or a named merge`);
++ assert.ok(!grouped || groupOf(id) === m.site.neighbourhood, `${id}: manifest neighbourhood ${m.site.neighbourhood} is its group`);
++}
++assert.ok(!GROUPS.some((g) => g.sites.includes('website')), 'Website (Space\'s second domain, no site) is in no list');
++assert.ok(!['media', 'homes', 'bench'].some((id) => GROUPS.some((g) => g.sites.includes(id))), 'the merged sections have no tile');
++
++// Names everywhere: Email, Restream, Studio and Locker; never the id or the old product name.
++const nameOf = (id) => MENU_NAME[id] || (LISTED.find((s) => s.id === id) || {}).name;
++assert.deepStrictEqual(['mail', 'stream', 'image', 'lock'].map(nameOf), ['Email', 'Restream', 'Studio', 'Locker']);
++assert.deepStrictEqual(['media', 'homes', 'bench', 'website'].filter((id) => LISTED.some((s) => s.id === id)), [], 'merged sections and Website are not listed');
++
++// Every tile keeps its one menu line.
++for (const s of LISTED) assert.ok(MENU_LINE[s.id] && MENU_LINE[s.id].length <= 28, `${s.id}: one line (${MENU_LINE[s.id]})`);
++
++// A merged section keeps its own domain answering; the frame gives it the section's home.
++assert.strictEqual(siteForHost('openvibe.homes').id, 'rent', 'Homes belongs to Rent');
++assert.strictEqual(siteForHost('bench.openvibe.services').id, 'report', 'Bench belongs to Report');
++assert.strictEqual(siteForHost('openvibe.media').id, 'media', 'Media still answers; it is Video\'s API, not a tile');
++assert.ok(SITES.some((s) => s.id === 'media'), 'Media stays a known host');
++
++console.log(`site groups: ${LISTED.filter((s) => s.status === 'open').length} open places in ${GROUPS.length} groups, at most seven a group, every manifest site placed`);