openvibers/openvibe.network Commits
The front door's 47 sites as a launcher, not 47 feature cards (21 phone screens -> 10) (#415)
Changes
@@@ -782,7 +782,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:MIT (`license` in [package.json](package.json)).<!-- versions:start -->--- openvibe-contracts: v0.232.0++- openvibe-contracts: v0.238.0- openvibe-sdk: v0.67.0--- openvibe-shared: v4.33.0++- openvibe-shared: v4.38.0<!-- versions:end -->@@@ -6,9 +6,9 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.232.0",++ "contracts": "openvibe-contracts v0.238.0","sdk": "openvibe-sdk v0.67.0",-- "shared": "openvibe-shared v4.33.0",++ "shared": "openvibe-shared v4.38.0","capabilities": ["identity.subject.resolve","mods.grant.manage",@@@ -48,5 +48,5 @@"per-actor limits (openvibe-sdk/limits)","universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)"],-- "updated": "2026-10-28"++ "updated": "2026-10-29"}@@@ -22,7 +22,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0","pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -1639,9 +1639,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.34.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",-- "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",++ "version": "4.38.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",++ "integrity": "sha512-4sEQ78EQaw+cWWJs+AlPRhxObzHT1nwTMz6yHVBL20Mr+raAhhP10DtL1OTnbuMCDVsYhVwpd3QGgmyC6391EA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -26,7 +26,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0","pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -216,8 +216,6 @@ const CSS = `.net-num a:hover,.net-num a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}.net-num b{font-size:24px;font-weight:800;letter-spacing:-.5px;line-height:1.15;color:var(--text-primary,#e6edf7)}.net-num span{font-size:12.5px;color:var(--text-secondary,#a8b3c4);line-height:1.35}.net-num small{display:block;color:var(--text-muted,#7d8aa0);font-size:11.5px}--.home-group{margin:26px 0 10px;font-size:13px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}--.home-also{margin:10px 0 0;font-size:13.5px;color:var(--text-secondary,#a8b3c4)}.home-also a{color:var(--accent-light,#60a5fa);font-weight:600;text-decoration:none}.home-also a:hover{text-decoration:underline}.faq-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));margin-top:4px}.faq-item{padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.faq-item h3{font-size:15.5px;margin:0 0 6px;line-height:1.35}.faq-item p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:14px;line-height:1.55}@@@ -257,13 +255,22 @@ function body(catalog, act) {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.-- const grid = (items) => showcase.features({ items }).replace(/^<section class="sc-sec">/, '').replace(/<\/section>$/, '');-- const groups = GROUPS.map((g) => ({ g, sites: OPEN.filter((site) => groupOf(site.id) === g.id), also: PRODUCTS.filter((p) => groupOf(p.id) === g.id) }))-- .filter((x) => x.sites.length)-- .map(({ g, sites: list, also }) => `<h3 class="home-group">${esc(g.name)}</h3>${grid(list.map((site) => ({ icon: `ov:${site.icon}`, title: siteName(site), text: site.tagline ? `${asSentence(site.tagline)} ${site.what}` : site.what, href: `https://${site.host}/` })))}${also.length ? `<p class="home-also">Also here: ${also.map((p) => `<a href="https://${esc(p.host)}/">${esc(p.host)}</a> (${esc(p.tagline.toLowerCase())})`).join(', ')}</p>` : ''}`).join('\n');-- const sites = `<section class="sc-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="sc-lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>--${groups}--<p class="home-more"><a href="/sites">All ${OPEN.length + 1} sites, grouped by what you came to do →</a></p></section>`;++ // 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.++ 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: `https://${x.host}/` })),++ ],++ })).filter((g) => g.items.length);++ const sites = showcase.apps({++ 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.',++ groups, more: { href: '/sites', label: `All ${OPEN.length + 1} sites, grouped by what you came to do` },++ });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.',@@@ -92,7 +92,7 @@ assert.ok(!/streams live now/.test(html), 'a number that reads zero is left out'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', 'developers']) assert.ok(html.includes(`<section class="sc-sec" id="${id}"`), `the ${id} section is a kit section`);++for (const id of ['network', 'tools', 'account', 'developers']) assert.ok(new RegExp(`<section class="sc-sec[^"]*" id="${id}"`).test(html), `the ${id} section is a showcase section`);assert.strictEqual(html.includes('<section class="sc-sec" id="soon"'), sites.some((s) => s.status === 'soon'), 'an "Opening next" section only while a site is still to open (none since Tips and VIP, contracts 0.200.0)');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');@@@ -107,9 +107,10 @@ assert.strictEqual(siteLd.potentialAction['query-input'], 'required name=search_assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');assert.ok(/<h1>Go live, chat, play<br>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');--for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://${s.host}/">`), `the sites section links ${s.host}`);++const launcher = (/<section class="sc-sec sc-apps-sec" id="network"[\s\S]*?<\/section>/.exec(html) || [''])[0];++for (const s of open) assert.ok(launcher.includes(`<a href="https://${s.host}/">`), `the sites launcher links ${s.host}`);// One plain link to the hub of every site (SEO-9), in the sites section the home already has, with the real count.--assert.ok(html.includes(`<p class="home-more"><a href="/sites">All ${open.length + 1} sites, grouped by what you came to do →</a></p>`), 'the home links /sites with the real count');++assert.ok(html.includes(`<p class="sc-apps-more"><a href="/sites">All ${open.length + 1} sites, grouped by what you came to do →</a></p>`), 'the home links /sites with the real count');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');@@@ -119,7 +120,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">Streams & places<\/h3>/.test(html) && /<h3 class="home-group">For developers<\/h3>/.test(html) && /<h3 class="home-group">Make & build<\/h3>/.test(html), 'the sites are grouped');++assert.ok(/<h3 class="sc-apps-group">Streams & places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make & build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');assert.ok(/<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');@@@ -31,7 +31,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(/<section class="sc-sec" id="network" aria-labelledby="([^"]+)"><h2 id="\1">([^<]*)<\/h2>/)[2];++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);
@@ -782,7 +782,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -782,7 +782,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.232.0
+- openvibe-contracts: v0.238.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.33.0
+- openvibe-shared: v4.38.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,9 +6,9 @@
@@ -6,9 +6,9 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.232.0",
+ "contracts": "openvibe-contracts v0.238.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.33.0",
+ "shared": "openvibe-shared v4.38.0",
"capabilities": [
"capabilities": [
"identity.subject.resolve",
"identity.subject.resolve",
"mods.grant.manage",
"mods.grant.manage",
@@ -48,5 +48,5 @@
@@ -48,5 +48,5 @@
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
"universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)"
"universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)"
],
],
- "updated": "2026-10-28"
+ "updated": "2026-10-29"
}
}
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1639,9 +1639,9 @@
@@ -1639,9 +1639,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.34.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
- "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
+ "version": "4.38.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
+ "integrity": "sha512-4sEQ78EQaw+cWWJs+AlPRhxObzHT1nwTMz6yHVBL20Mr+raAhhP10DtL1OTnbuMCDVsYhVwpd3QGgmyC6391EA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -216,8 +216,6 @@ const CSS = `
@@ -216,8 +216,6 @@ const CSS = `
.net-num a:hover,.net-num a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}
.net-num a:hover,.net-num a:focus-visible{border-color:var(--accent,#3b82f6);outline:0}
.net-num b{font-size:24px;font-weight:800;letter-spacing:-.5px;line-height:1.15;color:var(--text-primary,#e6edf7)}
.net-num b{font-size:24px;font-weight:800;letter-spacing:-.5px;line-height:1.15;color:var(--text-primary,#e6edf7)}
.net-num span{font-size:12.5px;color:var(--text-secondary,#a8b3c4);line-height:1.35}.net-num small{display:block;color:var(--text-muted,#7d8aa0);font-size:11.5px}
.net-num span{font-size:12.5px;color:var(--text-secondary,#a8b3c4);line-height:1.35}.net-num small{display:block;color:var(--text-muted,#7d8aa0);font-size:11.5px}
-.home-group{margin:26px 0 10px;font-size:13px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}
-.home-also{margin:10px 0 0;font-size:13.5px;color:var(--text-secondary,#a8b3c4)}.home-also a{color:var(--accent-light,#60a5fa);font-weight:600;text-decoration:none}.home-also a:hover{text-decoration:underline}
.faq-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));margin-top:4px}
.faq-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));margin-top:4px}
.faq-item{padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.faq-item{padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.faq-item h3{font-size:15.5px;margin:0 0 6px;line-height:1.35}.faq-item p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:14px;line-height:1.55}
.faq-item h3{font-size:15.5px;margin:0 0 6px;line-height:1.35}.faq-item p{margin:0;color:var(--text-secondary,#a8b3c4);font-size:14px;line-height:1.55}
@@ -257,13 +255,22 @@ function body(catalog, act) {
@@ -257,13 +255,22 @@ function body(catalog, act) {
const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;
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
// 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.
// as six neighbourhoods, each with the products that live on another site's domain (openvibe.pics, …) beside it.
- const grid = (items) => showcase.features({ items }).replace(/^<section class="sc-sec">/, '').replace(/<\/section>$/, '');
- const groups = GROUPS.map((g) => ({ g, sites: OPEN.filter((site) => groupOf(site.id) === g.id), also: PRODUCTS.filter((p) => groupOf(p.id) === g.id) }))
- .filter((x) => x.sites.length)
- .map(({ g, sites: list, also }) => `<h3 class="home-group">${esc(g.name)}</h3>${grid(list.map((site) => ({ icon: `ov:${site.icon}`, title: siteName(site), text: site.tagline ? `${asSentence(site.tagline)} ${site.what}` : site.what, href: `https://${site.host}/` })))}${also.length ? `<p class="home-also">Also here: ${also.map((p) => `<a href="https://${esc(p.host)}/">${esc(p.host)}</a> (${esc(p.tagline.toLowerCase())})`).join(', ')}</p>` : ''}`).join('\n');
- const sites = `<section class="sc-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="sc-lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>
-${groups}
-<p class="home-more"><a href="/sites">All ${OPEN.length + 1} sites, grouped by what you came to do →</a></p></section>`;
+ // 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.
+ 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: `https://${x.host}/` })),
+ ],
+ })).filter((g) => g.items.length);
+ const sites = showcase.apps({
+ 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.',
+ groups, more: { href: '/sites', label: `All ${OPEN.length + 1} sites, grouped by what you came to do` },
+ });
const tools = inside(showcase.features({
const tools = inside(showcase.features({
id: 'tools', title: catalog.tools.length ? `${catalog.tools.length} tools that just open` : 'Tools that just open',
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.',
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.',
@@ -92,7 +92,7 @@ assert.ok(!/streams live now/.test(html), 'a number that reads zero is left out'
@@ -92,7 +92,7 @@ assert.ok(!/streams live now/.test(html), 'a number that reads zero is left out'
assert.ok(!html.includes('<!--OV:'), 'no placeholder left behind');
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.
// 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');
assert.ok(html.includes('<link rel="stylesheet" href="/shared/showcase.css">'), 'the kit stylesheet is linked');
-for (const id of ['network', 'tools', 'account', 'developers']) assert.ok(html.includes(`<section class="sc-sec" id="${id}"`), `the ${id} section is a kit section`);
+for (const id of ['network', 'tools', 'account', 'developers']) assert.ok(new RegExp(`<section class="sc-sec[^"]*" id="${id}"`).test(html), `the ${id} section is a showcase section`);
assert.strictEqual(html.includes('<section class="sc-sec" id="soon"'), sites.some((s) => s.status === 'soon'), 'an "Opening next" section only while a site is still to open (none since Tips and VIP, contracts 0.200.0)');
assert.strictEqual(html.includes('<section class="sc-sec" id="soon"'), sites.some((s) => s.status === 'soon'), 'an "Opening next" section only while a site is still to open (none since Tips and VIP, contracts 0.200.0)');
assert.ok(!/class="home-(sec|card|grid|fams|fam|dev)\b/.test(html), 'no hand-built section markup is left');
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.strictEqual((html.match(/<h1[\s>]/g) || []).length, 1, 'one h1: the hero');
@@ -107,9 +107,10 @@ assert.strictEqual(siteLd.potentialAction['query-input'], 'required name=search_
@@ -107,9 +107,10 @@ assert.strictEqual(siteLd.potentialAction['query-input'], 'required name=search_
assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');
assert.ok(!/[.?!]\. /.test(html.replace(/<[^>]+>/g, ' ')), 'no doubled sentence end after a site tagline');
assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');
assert.ok(!/\b1 tools\b/.test(html), 'one tool is "1 tool"');
assert.ok(/<h1>Go live, chat, play<br>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');
assert.ok(/<h1>Go live, chat, play<br>/.test(html) && /Stream from a browser tab, no OBS\./.test(html), 'the hero still leads with going live from a browser');
-for (const s of open) assert.ok(html.includes(`<a class="sc-card" href="https://${s.host}/">`), `the sites section links ${s.host}`);
+const launcher = (/<section class="sc-sec sc-apps-sec" id="network"[\s\S]*?<\/section>/.exec(html) || [''])[0];
+for (const s of open) assert.ok(launcher.includes(`<a href="https://${s.host}/">`), `the sites launcher links ${s.host}`);
// One plain link to the hub of every site (SEO-9), in the sites section the home already has, with the real count.
// One plain link to the hub of every site (SEO-9), in the sites section the home already has, with the real count.
-assert.ok(html.includes(`<p class="home-more"><a href="/sites">All ${open.length + 1} sites, grouped by what you came to do →</a></p>`), 'the home links /sites with the real count');
+assert.ok(html.includes(`<p class="sc-apps-more"><a href="/sites">All ${open.length + 1} sites, grouped by what you came to do →</a></p>`), 'the home links /sites with the real count');
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}`);
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');
assert.strictEqual((html.match(/<section[\s>]/g) || []).length, (html.match(/<\/section>/g) || []).length, 'every section is closed once');
@@ -119,7 +120,7 @@ assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');
@@ -119,7 +120,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">Streams & places<\/h3>/.test(html) && /<h3 class="home-group">For developers<\/h3>/.test(html) && /<h3 class="home-group">Make & build<\/h3>/.test(html), 'the sites are grouped');
+assert.ok(/<h3 class="sc-apps-group">Streams & places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make & build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');
assert.ok(/<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');
@@ -31,7 +31,7 @@ const updates = fs.readFileSync(path.join(__dirname, '..', 'server', 'updates',
@@ -31,7 +31,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\)\}/);
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 home = require('../server/home/render').render().html;
-const heading = home.match(/<section class="sc-sec" id="network" aria-labelledby="([^"]+)"><h2 id="\1">([^<]*)<\/h2>/)[2];
+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}"`);
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');
console.log('page checks: login, updates filter and home JSON-LD ok');
process.exit(0);
process.exit(0);