openvibers/openvibe.network Commits
The all-sites menu, regrouped by what people came to do (Watch & stream first, Games, Build with AI, Everyday, Your account, For developers) with short menu labels that fit a phone (#334)
Changes
@@@ -19,7 +19,7 @@// ═══════════════════════════════════════════════════════════════const express = require('express');const crypto = require('crypto');--const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS } = require('./sites');++const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE } = require('./sites');const toolsCatalog = require('../domains/catalog');const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;@@@ -164,7 +164,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isreturn {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`).-- nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: ICON_ALIAS[s.icon] || s.icon, tagline: s.tagline, group: groupOf(s.id), ...(s.of ? { of: s.of } : {}) })),++ nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: s.name, 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 } : {}) })),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: `https://${s.host}/`, icon: s.icon, state: s.state })),@@@ -31,17 +31,36 @@ const byHost = new Map(SITES.map(s => [s.host, s]));// 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.--// Plan track K (the owner, 2026-10-24: consumer-friendly products, the developer side on Services): what people use--// first, agents and robots as their own group, and the building blocks last under "For developers".++// Grouped by what a person came to do (the owner, 2026-10-25: "reorganize this whole menu and make it more clean and++// clear and organized and easier to understand"): watching first (Live is the flagship), then talking, playing,++// building with AI, everyday errands, their own account and items, and the building blocks last "For developers"++// (plan track K). Alerts (watch) is an everyday tool; Download sits with Video and Pics; Inventory with the account,++// so a person's items are one tap away on every site.const GROUPS = [-- { id: 'talk', name: 'Chat & community', sites: ['chat', 'community', 'wiki', 'news', 'blog'] },-- { id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'pics', 'stream', 'tips', 'vip'] },-- { id: 'agents', name: 'Agents & robots', sites: ['actor', 'bot', 'codes', 'watch'] },-- { id: 'everyday', name: 'Shop & everyday', sites: ['deals', 'coupons', 'reviews', 'food', 'rent', 'homes', 'work', 'trade', 'host'] },-- { id: 'create', name: 'Create & play', sites: ['games', 'space', 'tools', 'download', 'quest'] },-- { id: 'you', name: 'Your OpenVibe', sites: ['network', 'search', 'help', 'inventory'] },++ { id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'stream', 'pics', 'download', 'tips', 'vip'] },++ { id: 'talk', name: 'Chat & community', sites: ['chat', 'community', 'blog', 'news', 'wiki'] },++ { id: 'play', name: 'Games', sites: ['games', 'quest'] },++ { id: 'make', name: 'Build with AI', sites: ['codes', 'actor', 'space', 'tools', 'bot'] },++ { id: 'everyday', name: 'Everyday', sites: ['deals', 'coupons', 'reviews', 'work', 'rent', 'homes', 'food', 'help', 'trade', 'watch', 'search', 'host'] },++ { id: 'you', name: 'Your account', sites: ['network', 'inventory'] },{ id: 'build', name: 'For developers', sites: ['services', 'ai', 'run', 'zone', 'events', 'media', 'play'] },];++// 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',++ chat: 'Chat with anyone', community: 'Posts and pastes', blog: 'Write long-form', news: 'Sourced, not spun', wiki: 'Facts with sources',++ games: 'Play in your browser', quest: 'Earn badges',++ codes: 'Build apps with AI', actor: 'Agents do it for you', 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',++ network: 'Your account', inventory: 'Your items',++ services: 'APIs and SDK', ai: 'AI models API', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API', play: 'Game services',++};++const groupOf = (id) => (GROUPS.find(g => g.sites.includes(id)) || GROUPS[GROUPS.length - 1]).id;// Products with a domain of their own that another service serves (its manifest lists the domain): in the menu@@@ -62,4 +81,4 @@ function siteForHost(hostname) {return null;}--module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS };++module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE };@@@ -17,14 +17,17 @@ let p = svc.payloadFor('openvibe.media');assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['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', 'ai', 'search', 'run', 'play', 'zone'], 'cold start follows the base order');// 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.id), ['talk', 'watch', 'agents', 'everyday', 'create', 'you', 'build'], 'what people use first, the building blocks last (plan track K)');++assert.deepStrictEqual(p.groups.map(g => g.name), ['Watch & stream', 'Chat & community', 'Games', 'Build with AI', 'Everyday', 'Your account', 'For developers'], 'by what a person came to do, the building blocks last (the owner, 2026-10-25)');assert.strictEqual(p.groups.find(g => g.id === 'build').name, 'For developers');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/']]);const at = (id) => p.nav.findIndex(n => n.id === id);assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 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', 'watch', 'everyday', 'create'], 'Video draws the video icon (there is no media-hub icon)');--assert.deepStrictEqual(['actor', 'bot', 'run', 'zone'].map((id) => p.nav[at(id)].group), ['agents', 'agents', 'build', 'build'], 'agents and robots for people, compute and storage for developers');++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', 'watch', 'everyday', 'make'], 'Video draws the video icon (there is no media-hub icon)');++assert.deepStrictEqual(['actor', 'bot', 'run', 'zone'].map((id) => p.nav[at(id)].group), ['make', 'make', 'build', 'build'], 'agents and robots for people, compute and storage for developers');++// Games together, items with the account, Alerts as an everyday tool, files beside Video (each when its site is open).++[['games', 'play'], ['quest', 'play'], ['inventory', 'you'], ['watch', 'everyday'], ['download', 'watch']]++ .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, 40);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');@@@ -107,7 +107,7 @@ assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');faqLd.mainEntity.forEach((q) => assert.ok(html.includes(`<h3>${q.name.replace(/'/g, ''')}</h3>`), `the FAQ shows "${q.name}"`));assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");// The service map groups the sites the way the launcher does; numbers say only what was read.--assert.ok(/<h3 class="home-group">Watch & stream<\/h3>/.test(html) && /<h3 class="home-group">For developers<\/h3>/.test(html) && /<h3 class="home-group">Agents & robots<\/h3>/.test(html), 'the sites are grouped');++assert.ok(/<h3 class="home-group">Watch & stream<\/h3>/.test(html) && /<h3 class="home-group">For developers<\/h3>/.test(html) && /<h3 class="home-group">Build with AI<\/h3>/.test(html), 'the sites are grouped');assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');assert.ok(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
-const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS } = require('./sites');
+const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE } = require('./sites');
const toolsCatalog = require('../domains/catalog');
const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
@@ -164,7 +164,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -164,7 +164,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
return {
return {
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
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 open site, most used first, each followed by the products it serves on their own domains (`of`).
- nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: ICON_ALIAS[s.icon] || s.icon, tagline: s.tagline, group: groupOf(s.id), ...(s.of ? { of: s.of } : {}) })),
+ nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: s.name, 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 } : {}) })),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
// 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: `https://${s.host}/`, icon: s.icon, state: s.state })),
soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
@@ -31,17 +31,36 @@ const byHost = new Map(SITES.map(s => [s.host, s]));
@@ -31,17 +31,36 @@ const byHost = new Map(SITES.map(s => [s.host, s]));
// The launcher (openvibe-shared navbar) lists every open site under one of these groups, most used first within
// 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.
// each. A site missing here lands in the last group, so a new manifest is never left out of the menu.
-// Plan track K (the owner, 2026-10-24: consumer-friendly products, the developer side on Services): what people use
-// first, agents and robots as their own group, and the building blocks last under "For developers".
+// Grouped by what a person came to do (the owner, 2026-10-25: "reorganize this whole menu and make it more clean and
+// clear and organized and easier to understand"): watching first (Live is the flagship), then talking, playing,
+// building with AI, everyday errands, their own account and items, and the building blocks last "For developers"
+// (plan track K). Alerts (watch) is an everyday tool; Download sits with Video and Pics; Inventory with the account,
+// so a person's items are one tap away on every site.
const GROUPS = [
const GROUPS = [
- { id: 'talk', name: 'Chat & community', sites: ['chat', 'community', 'wiki', 'news', 'blog'] },
- { id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'pics', 'stream', 'tips', 'vip'] },
- { id: 'agents', name: 'Agents & robots', sites: ['actor', 'bot', 'codes', 'watch'] },
- { id: 'everyday', name: 'Shop & everyday', sites: ['deals', 'coupons', 'reviews', 'food', 'rent', 'homes', 'work', 'trade', 'host'] },
- { id: 'create', name: 'Create & play', sites: ['games', 'space', 'tools', 'download', 'quest'] },
- { id: 'you', name: 'Your OpenVibe', sites: ['network', 'search', 'help', 'inventory'] },
+ { id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'stream', 'pics', 'download', 'tips', 'vip'] },
+ { id: 'talk', name: 'Chat & community', sites: ['chat', 'community', 'blog', 'news', 'wiki'] },
+ { id: 'play', name: 'Games', sites: ['games', 'quest'] },
+ { id: 'make', name: 'Build with AI', sites: ['codes', 'actor', 'space', 'tools', 'bot'] },
+ { id: 'everyday', name: 'Everyday', sites: ['deals', 'coupons', 'reviews', 'work', 'rent', 'homes', 'food', 'help', 'trade', 'watch', 'search', 'host'] },
+ { id: 'you', name: 'Your account', sites: ['network', 'inventory'] },
{ id: 'build', name: 'For developers', sites: ['services', 'ai', 'run', 'zone', 'events', 'media', 'play'] },
{ id: 'build', name: 'For developers', sites: ['services', 'ai', 'run', 'zone', 'events', 'media', 'play'] },
];
];
+// 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',
+ chat: 'Chat with anyone', community: 'Posts and pastes', blog: 'Write long-form', news: 'Sourced, not spun', wiki: 'Facts with sources',
+ games: 'Play in your browser', quest: 'Earn badges',
+ codes: 'Build apps with AI', actor: 'Agents do it for you', 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',
+ network: 'Your account', inventory: 'Your items',
+ services: 'APIs and SDK', ai: 'AI models API', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API', play: 'Game services',
+};
+
const groupOf = (id) => (GROUPS.find(g => g.sites.includes(id)) || GROUPS[GROUPS.length - 1]).id;
const groupOf = (id) => (GROUPS.find(g => g.sites.includes(id)) || GROUPS[GROUPS.length - 1]).id;
// Products with a domain of their own that another service serves (its manifest lists the domain): in the menu
// Products with a domain of their own that another service serves (its manifest lists the domain): in the menu
@@ -62,4 +81,4 @@ function siteForHost(hostname) {
@@ -62,4 +81,4 @@ function siteForHost(hostname) {
return null;
return null;
}
}
-module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS };
+module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE };
@@ -17,14 +17,17 @@ let p = svc.payloadFor('openvibe.media');
@@ -17,14 +17,17 @@ let p = svc.payloadFor('openvibe.media');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['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', 'ai', 'search', 'run', 'play', 'zone'], 'cold start follows the base order');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['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', 'ai', 'search', 'run', 'play', 'zone'], 'cold start follows the base order');
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
// 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).
// right after their site (Pics and Download after Video, Homes after Rent).
-assert.deepStrictEqual(p.groups.map(g => g.id), ['talk', 'watch', 'agents', 'everyday', 'create', 'you', 'build'], 'what people use first, the building blocks last (plan track K)');
+assert.deepStrictEqual(p.groups.map(g => g.name), ['Watch & stream', 'Chat & community', 'Games', 'Build with AI', 'Everyday', 'Your account', 'For developers'], 'by what a person came to do, the building blocks last (the owner, 2026-10-25)');
assert.strictEqual(p.groups.find(g => g.id === 'build').name, 'For developers');
assert.strictEqual(p.groups.find(g => g.id === 'build').name, 'For developers');
assert.ok(p.nav.every(n => p.groups.some(g => g.id === n.group)), 'every entry has a 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/']]);
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/']]);
const at = (id) => p.nav.findIndex(n => n.id === id);
const at = (id) => p.nav.findIndex(n => n.id === id);
assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 1, 'a product follows its site');
assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 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', 'watch', 'everyday', 'create'], 'Video draws the video icon (there is no media-hub icon)');
-assert.deepStrictEqual(['actor', 'bot', 'run', 'zone'].map((id) => p.nav[at(id)].group), ['agents', 'agents', 'build', 'build'], 'agents and robots for people, compute and storage for developers');
+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', 'watch', 'everyday', 'make'], 'Video draws the video icon (there is no media-hub icon)');
+assert.deepStrictEqual(['actor', 'bot', 'run', 'zone'].map((id) => p.nav[at(id)].group), ['make', 'make', 'build', 'build'], 'agents and robots for people, compute and storage for developers');
+// Games together, items with the account, Alerts as an everyday tool, files beside Video (each when its site is open).
+[['games', 'play'], ['quest', 'play'], ['inventory', 'you'], ['watch', 'everyday'], ['download', 'watch']]
+ .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, 40);
assert.strictEqual(p.nav.length, 40);
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 === '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');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
@@ -107,7 +107,7 @@ assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');
@@ -107,7 +107,7 @@ 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(html.includes(`<h3>${q.name.replace(/'/g, ''')}</h3>`), `the FAQ shows "${q.name}"`));
assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");
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="home-group">Watch & stream<\/h3>/.test(html) && /<h3 class="home-group">For developers<\/h3>/.test(html) && /<h3 class="home-group">Agents & robots<\/h3>/.test(html), 'the sites are grouped');
+assert.ok(/<h3 class="home-group">Watch & stream<\/h3>/.test(html) && /<h3 class="home-group">For developers<\/h3>/.test(html) && /<h3 class="home-group">Build with AI<\/h3>/.test(html), 'the sites are grouped');
assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');
assert.ok(/<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(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');
assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');
assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');