openvibers/openvibe.shared Commits
4.87.0: share cards for every named domain; room kit pictures at their drawn size; footer contrast (#241)
Changes
@@@ -4,6 +4,25 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.87.0 — 2026-11-01++++- **Share cards for a service's other domains.**++ - openvibe.pics and openvibe.download (MediaHub), openvibe.homes (Rent) and openvibe.website (Space) previewed as++ their service: a link to openvibe.pics showed the OpenVibe.Video card.++ - `social.DOMAINS` names them (id, service, host, name, tagline, what); `social.domainOf(id)` and++ `social.domainForHost(host)` look them up.++ - The builders draw `social/<id>.jpg` like the service's card (the same mark, Pics in its pink) and write the++ domain's `icons/sites.json` entry.++ - `shell.page()` shares the domain's card on any page whose canonical URL is on that host, so the sites need no++ change.++- **The footer's "Nobody live right now" line reaches 4.5:1.** `.ovf-pulse-quiet` was the theme's muted text on the++ pulse's tint: 4.34:1 on Daylight, on every page with a footer (axe flagged 42 inner pages). It is now the++ secondary text.++- **Room kit pictures load at the size they are drawn.** `kit.mark` and `kit.card` put a Media object's original++ upload in the page: Report and Community drew a 3072 px, 3 MB avatar at 34 px, and Community a 2460 px picture in a++ 280 px card. They now ask Media's resize ladder for the width (`?w=96` or `160` for marks, `640` for card++ pictures); `kit.mediaW(url, width)` does the same for a site's own images.++## 4.86.0 — 2026-11-01- **Status colour names every site can use.**@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.86.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.87.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.86.0",++ "version": "4.87.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -435,7 +435,7 @@.ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important}.ovf-dot{width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ovfLive 1.8s ease-out infinite}@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}--.ovf-pulse-quiet{font-weight:500!important;color:var(--text-muted,#8b93ad)!important}++.ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important}@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}@media (max-width:560px){.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}@@@ -69,6 +69,11 @@"description": "Name it, price it, run it","icon": "network"},++ "download": {++ "name": "OpenVibe.Download",++ "description": "Your files, shared on your terms",++ "icon": "media"++ },"events": {"name": "OpenVibe.Events","description": "Events your services can count on.",@@@ -99,6 +104,11 @@"description": "Answers for every OpenVibe site","icon": "network"},++ "homes": {++ "name": "OpenVibe.Homes",++ "description": "Every home for sale, one search",++ "icon": "network"++ },"host": {"name": "OpenVibe.Host","description": "Hosting, compared honestly",@@@ -144,6 +154,11 @@"description": "Go live once, stream everywhere","icon": "network"},++ "pics": {++ "name": "OpenVibe.Pics",++ "description": "Share pictures. Fast, open, yours.",++ "icon": "media"++ },"play": {"name": "OpenVibe.Play","description": "Achievements, leaderboards and cloud saves for every game.",@@@ -239,6 +254,11 @@"description": "Know the moment it changes","icon": "network"},++ "website": {++ "name": "OpenVibe.Website",++ "description": "Websites that look finished",++ "icon": "space"++ },"wiki": {"name": "OpenVibe.Wiki","description": "Knowledge, with sources",@@@ -28,6 +28,7 @@* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })* kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)++ * kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,* body }) the left sidebar: vertical navigation, a drawer on phones*@@@ -49,6 +50,10 @@const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));/** A URL kept only when it is http(s), a same-site path, a query or a fragment; anything else becomes '#'. */const url = (u) => { const s = String(u == null ? '' : u).trim(); return /^(https?:\/\/|\/(?!\/)|\?|#)/i.test(s) ? s : '#'; };++ // A Media object (openvibe.media/o/med_…) at a width from Media's resize ladder (64, 96, 160, 320, 480, 640); any other++ // URL as url() gives it. Avatars and pictures are stored as uploaded: Report and Community drew a 3072 px, 3 MB++ // avatar at 34 px (4.87.0).++ const mediaW = (u, w) => { const s = url(u); return /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]+$/.test(s) ? `${s}?w=${Number(w) || 160}` : s; };const attrs = (o) => Object.keys(o || {}).filter((k) => o[k] != null && o[k] !== false && /^[a-z][a-z0-9-]*$/i.test(k)).map((k) => (o[k] === true ? ` ${k}` : ` ${k}="${esc(o[k])}"`)).join('');// ── Icons: 24-unit line drawings in the text's colour (stroke 2, round), one set for every site ──@@@ -142,7 +147,7 @@function mark({ name = '?', src = '', hue = null, square = false, size = null } = {}) {const cls = `ovk-mark${square ? ' ovk-mark--square' : ''}`;const st = size ? `--ovk-mark:${Number(size) || 40}px;` : '';-- if (src && url(src) !== '#') return `<img class="${cls}" src="${esc(url(src))}" alt="" loading="lazy" decoding="async"${st ? ` style="${st}"` : ''}>`;++ if (src && url(src) !== '#') return `<img class="${cls}" src="${esc(mediaW(src, (Number(size) || 40) > 48 ? 160 : 96))}" alt="" loading="lazy" decoding="async"${st ? ` style="${st}"` : ''}>`;const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);const letters = (words.length > 1 ? [...words[0]][0] + [...words[1]][0] : [...(words[0] || '?')].slice(0, 2).join('')).toUpperCase();return `<span class="${cls}" style="${st}--ovk-hue:${hue == null ? hueOf(name) : Number(hue) || 0}" aria-hidden="true">${esc(letters)}</span>`;@@@ -395,7 +400,7 @@const link = url(href);const first = String(preview || '').split('\n').slice(0, 9).join('\n').slice(0, 480).trimEnd();const pic = media(image)-- ? `<img src="${esc(image)}" alt="${esc(alt)}" loading="lazy" decoding="async">`++ ? `<img src="${esc(mediaW(image, 640))}" alt="${esc(alt)}" loading="lazy" decoding="async">`: first ? `<pre class="ovk-card-pre" aria-hidden="true">${esc(first)}</pre>`: `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';@@@ -837,7 +842,7 @@return {skeleton,-- esc, url, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,++ esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,enhance, VERSION: 2,@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.86.0",++ "version": "4.87.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -28,6 +28,9 @@ function serviceIds() {}function siteOf(id) {++ // A domain card (openvibe.pics, …) names itself; its icon is its service's (social.iconSite).++ const d = social.domainOf(id);++ if (d) return { id, name: d.name, tagline: d.tagline };const m = JSON.parse(fs.readFileSync(path.join(SERVICES, `${id}.json`), 'utf8'));if (!m.site) throw new Error(`${id}: no site block`);return { id, name: m.site.name, tagline: m.site.tagline || '' };@@@ -75,6 +75,13 @@ function sites() {if (only && !only.has(pg.id) && !only.has(pg.service)) continue;out.push({ ...site, id: pg.id, heading: pg.heading, tagline: pg.tagline, what: pg.what, domain: `${site.domain}${pg.path}`, iconId: site.id });}++ for (const d of social.DOMAINS) {++ const site = byId.get(d.service);++ if (!site) throw new Error(`domain card ${d.id}: no site ${d.service} in the manifests`);++ if (only && !only.has(d.id) && !only.has(d.service)) continue;++ out.push({ ...site, id: d.id, name: d.name, tagline: d.tagline, what: d.what, domain: d.host,++ accent: d.accent ? social.accentOf(d.id, d.accent) : site.accent, iconId: site.id });++ }return out;}@@@ -96,9 +96,14 @@ function serviceOf(o) {const siteId = (o) => { const s = String(serviceOf(o) || '').toLowerCase(); return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; };// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page--// has a canonical URL to make it absolute against. `image: null` opts out.++// has a canonical URL to make it absolute against; a page on one of a service's named domains (openvibe.pics) shares++// that domain's card. `image: null` opts out.function defaultImage(o) {if (!o.canonical) return null;++ let host = '';++ try { host = new URL(o.canonical).host; } catch (e) { /* a relative canonical: the service's card */ }++ const domain = social.domainForHost(host);++ if (domain && social.has(domain.id)) return social.url(domain.id);const service = serviceOf(o);return service ? social.url(service) : null;}@@@ -14,8 +14,10 @@// social.accentOf(service, accent?) the two accent stops the card uses (build script)// social.iconSite(service) the app-icon variant for the service's mark (build script)// social.pageOf(id) / social.PAGES the page cards: which page and site a card id names (null for a site's card)++// social.domainOf(id) / social.domainForHost(host) / social.DOMAINS a service's other named domains (openvibe.pics)//--// shell.page() uses social.url(o.service) for og:image when the page names no image of its own.++// shell.page() uses social.url(o.service) for og:image when the page names no image of its own, or the domain's card++// when the page's canonical URL is on one of DOMAINS' hosts.// ═══════════════════════════════════════════════════════════════const fs = require('fs');const path = require('path');@@@ -53,6 +55,28 @@ const PAGES = [/** The page a card id names ({ id, service, heading, … }), or null for a site's own card. */const pageOf = (id) => PAGES.find((pg) => pg.id === id) || null;++// Domain cards (social/<id>.jpg): a service's other domains that wear their own name on the navbar (openvibe.pics is++// MediaHub's, openvibe.homes is Rent's). Each gets a site card of its own, drawn like its service's (the same mark;++// its own accent when it has one), and shell.page() shares it on any page whose canonical URL is on that host. Before++// 4.86.1 a link to openvibe.pics previewed as OpenVibe.Video.++const DOMAINS = [++ { id: 'pics', service: 'media-hub', host: 'openvibe.pics', name: 'Pics', accent: '#f472b6', tagline: 'Share pictures. Fast, open, yours.',++ what: 'A link to share or embed any picture, in any size or format, with location and camera details removed.' },++ { id: 'download', service: 'media-hub', host: 'openvibe.download', name: 'Download', tagline: 'Your files, shared on your terms',++ what: 'A private drive with 25 GB free: share files with links you can take back, and back up a folder.' },++ { id: 'homes', service: 'rent', host: 'openvibe.homes', name: 'Homes', tagline: 'Every home for sale, one search',++ what: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist searched at once, plus HUD and bank homes.' },++ { id: 'website', service: 'space', host: 'openvibe.website', name: 'Website', tagline: 'Websites that look finished',++ what: 'Start from a real shop, portfolio, event, link page or launch page, or describe yours, and change anything from the first preview.' },++];++/** The domain card an id names ({ id, service, host, name, … }), or null. */++const domainOf = (id) => DOMAINS.find((d) => d.id === id) || null;++/** The domain card for a host (a www. prefix and a port ignored), or null. */++function domainForHost(host) {++ const h = String(host || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');++ return DOMAINS.find((d) => d.host === h) || null;++}++const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);function url(service, base = '/shared') {@@@ -63,7 +87,12 @@ function url(service, base = '/shared') {}// The mark's tint: app-icon has variants for a few sites; the rest wear the network's.--const iconSite = (service) => (Object.prototype.hasOwnProperty.call(icon.SITES, service) ? service : service === 'media-hub' ? 'media' : 'network');++// A domain card wears its service's mark.++function iconSite(service) {++ const d = domainOf(service);++ if (d) return iconSite(d.service);++ return Object.prototype.hasOwnProperty.call(icon.SITES, service) ? service : service === 'media-hub' ? 'media' : 'network';++}/** A deeper stop of the same hue (lightness down 16 points, saturation up 6), so a gradient never turns muddy. */function deepen(hex) {@@@ -82,4 +111,4 @@ function accentOf(service, accent) {return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(service)].accent;}--module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, DIR };++module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, domainOf, domainForHost, DOMAINS, DIR };nnew file mode 100644BBinary files /dev/null and b/social/download.jpg differnnew file mode 100644BBinary files /dev/null and b/social/homes.jpg differnnew file mode 100644BBinary files /dev/null and b/social/pics.jpg differnnew file mode 100644BBinary files /dev/null and b/social/website.jpg differnnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// kit.mark and kit.card ask Media for a resize width instead of the original upload (4.87.0): Report and Community drew a++// 3072 px, 3 MB avatar at 34 px, and Community a 2460 px picture in a 280 px card.++const assert = require('assert');++const kit = require('../kit');++++const obj = 'https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38';++assert.strictEqual(kit.mediaW(obj, 96), `${obj}?w=96`);++assert.strictEqual(kit.mediaW(`${obj}?w=64`, 96), `${obj}?w=64`, 'a URL that already has a query is left alone');++assert.strictEqual(kit.mediaW('https://openvibe.media/t/vod-1.jpg', 96), 'https://openvibe.media/t/vod-1.jpg', 'thumbnails are already small');++assert.strictEqual(kit.mediaW('https://example.com/a.png', 96), 'https://example.com/a.png');++assert.strictEqual(kit.mediaW('javascript:alert(1)', 96), '#', 'still sanitised like kit.url');++++assert.match(kit.mark({ name: 'a', src: obj }), new RegExp(`src="${obj}\\?w=96"`), 'a 40 px mark asks for 96');++assert.match(kit.mark({ name: 'a', src: obj, size: 64 }), new RegExp(`src="${obj}\\?w=160"`), 'a bigger mark asks for 160');++assert.match(kit.card({ title: 't', href: '/x', image: obj }), new RegExp(`<img src="${obj}\\?w=640"`), 'a card picture asks for 640');++console.log('kit media width: all checks passed');nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++// Domain cards (4.86.1): a service's other named domains have their own share card, and shell.page() shares it on any++// page whose canonical URL is on that host. Before, a link to openvibe.pics previewed as OpenVibe.Video, openvibe.homes++// as Rent and openvibe.website as Space.++const assert = require('assert');++const social = require('../social');++const shell = require('../shell');++const appIcons = require('../app-icons');++++const want = { pics: ['media-hub', 'openvibe.pics'], download: ['media-hub', 'openvibe.download'], homes: ['rent', 'openvibe.homes'],++ website: ['space', 'openvibe.website'] };++assert.deepStrictEqual(social.DOMAINS.map((d) => d.id).sort(), Object.keys(want).sort(), 'the four named domains');++Object.entries(want).forEach(([id, [service, host]]) => {++ const d = social.domainOf(id);++ assert.ok(d && d.service === service && d.host === host, `${id}: ${service} on ${host}`);++ assert.ok(d.name && d.tagline && d.what, `${id}: name, tagline and what`);++ assert.ok(d.what.length <= 130, `${id}: the description fits the card's two lines (${d.what.length})`);++ assert.ok(social.has(id), `${id}: social/${id}.jpg is drawn`);++ assert.strictEqual(social.iconSite(id), social.iconSite(service), `${id} wears ${service}'s mark`);++ assert.ok(appIcons.has(id) && appIcons.site(id).name === `OpenVibe.${d.name}`, `${id}: an icon entry named OpenVibe.${d.name}`);++ assert.strictEqual(social.domainForHost(host), d);++ assert.strictEqual(social.domainForHost(`www.${host}:443`), d, 'www. and a port are ignored');++});++assert.strictEqual(social.domainForHost('openvibe.video'), null, 'a service\'s own first domain keeps its card');++assert.strictEqual(social.domainForHost(''), null);++++// shell.page: the canonical's host picks the domain's card; the service's own domain keeps the service's card.++const og = (html) => (/og:image" content="([^"]+)"/.exec(html) || [])[1] || '';++assert.match(og(shell.page({ service: 'media-hub', canonical: 'https://openvibe.pics/a/abc', description: 'd' })), /^https:\/\/openvibe\.pics\/shared\/social\/pics\.jpg\?v=/);++assert.match(og(shell.page({ service: 'rent', canonical: 'https://openvibe.homes/', description: 'd' })), /\/shared\/social\/homes\.jpg\?v=/);++assert.match(og(shell.page({ service: 'media-hub', canonical: 'https://openvibe.video/v/1', description: 'd' })), /\/shared\/social\/media-hub\.jpg\?v=/);++assert.match(og(shell.page({ service: 'media-hub', canonical: 'https://openvibe.pics/', image: '/cover.png' })), /\/cover\.png$/, 'an explicit image still wins');++// The builders draw a card and write an icon entry for each domain (scripts run by hand; their output is committed).++const fs = require('fs');++const path = require('path');++const cards = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-social-cards.js'), 'utf8');++assert.match(cards, /for \(const d of social\.DOMAINS\)/);++assert.match(cards, /domain: d\.host,/);++const icons = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-icons.js'), 'utf8');++assert.match(icons, /const d = social\.domainOf\(id\);\s*if \(d\) return \{ id, name: d\.name, tagline: d\.tagline \};/);++console.log('social domains: all checks passed');
@@ -4,6 +4,25 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,25 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.87.0 — 2026-11-01
+
+- **Share cards for a service's other domains.**
+ - openvibe.pics and openvibe.download (MediaHub), openvibe.homes (Rent) and openvibe.website (Space) previewed as
+ their service: a link to openvibe.pics showed the OpenVibe.Video card.
+ - `social.DOMAINS` names them (id, service, host, name, tagline, what); `social.domainOf(id)` and
+ `social.domainForHost(host)` look them up.
+ - The builders draw `social/<id>.jpg` like the service's card (the same mark, Pics in its pink) and write the
+ domain's `icons/sites.json` entry.
+ - `shell.page()` shares the domain's card on any page whose canonical URL is on that host, so the sites need no
+ change.
+- **The footer's "Nobody live right now" line reaches 4.5:1.** `.ovf-pulse-quiet` was the theme's muted text on the
+ pulse's tint: 4.34:1 on Daylight, on every page with a footer (axe flagged 42 inner pages). It is now the
+ secondary text.
+- **Room kit pictures load at the size they are drawn.** `kit.mark` and `kit.card` put a Media object's original
+ upload in the page: Report and Community drew a 3072 px, 3 MB avatar at 34 px, and Community a 2460 px picture in a
+ 280 px card. They now ask Media's resize ladder for the width (`?w=96` or `160` for marks, `640` for card
+ pictures); `kit.mediaW(url, width)` does the same for a site's own images.
+
## 4.86.0 — 2026-11-01
## 4.86.0 — 2026-11-01
- **Status colour names every site can use.**
- **Status colour names every site can use.**
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.86.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.87.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.86.0",
+ "version": "4.87.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -435,7 +435,7 @@
@@ -435,7 +435,7 @@
.ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important}
.ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important}
.ovf-dot{width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ovfLive 1.8s ease-out infinite}
.ovf-dot{width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ovfLive 1.8s ease-out infinite}
@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
-.ovf-pulse-quiet{font-weight:500!important;color:var(--text-muted,#8b93ad)!important}
+.ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important}
@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
@media (max-width:560px){
@media (max-width:560px){
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
@@ -69,6 +69,11 @@
@@ -69,6 +69,11 @@
"description": "Name it, price it, run it",
"description": "Name it, price it, run it",
"icon": "network"
"icon": "network"
},
},
+ "download": {
+ "name": "OpenVibe.Download",
+ "description": "Your files, shared on your terms",
+ "icon": "media"
+ },
"events": {
"events": {
"name": "OpenVibe.Events",
"name": "OpenVibe.Events",
"description": "Events your services can count on.",
"description": "Events your services can count on.",
@@ -99,6 +104,11 @@
@@ -99,6 +104,11 @@
"description": "Answers for every OpenVibe site",
"description": "Answers for every OpenVibe site",
"icon": "network"
"icon": "network"
},
},
+ "homes": {
+ "name": "OpenVibe.Homes",
+ "description": "Every home for sale, one search",
+ "icon": "network"
+ },
"host": {
"host": {
"name": "OpenVibe.Host",
"name": "OpenVibe.Host",
"description": "Hosting, compared honestly",
"description": "Hosting, compared honestly",
@@ -144,6 +154,11 @@
@@ -144,6 +154,11 @@
"description": "Go live once, stream everywhere",
"description": "Go live once, stream everywhere",
"icon": "network"
"icon": "network"
},
},
+ "pics": {
+ "name": "OpenVibe.Pics",
+ "description": "Share pictures. Fast, open, yours.",
+ "icon": "media"
+ },
"play": {
"play": {
"name": "OpenVibe.Play",
"name": "OpenVibe.Play",
"description": "Achievements, leaderboards and cloud saves for every game.",
"description": "Achievements, leaderboards and cloud saves for every game.",
@@ -239,6 +254,11 @@
@@ -239,6 +254,11 @@
"description": "Know the moment it changes",
"description": "Know the moment it changes",
"icon": "network"
"icon": "network"
},
},
+ "website": {
+ "name": "OpenVibe.Website",
+ "description": "Websites that look finished",
+ "icon": "space"
+ },
"wiki": {
"wiki": {
"name": "OpenVibe.Wiki",
"name": "OpenVibe.Wiki",
"description": "Knowledge, with sources",
"description": "Knowledge, with sources",
@@ -28,6 +28,7 @@
@@ -28,6 +28,7 @@
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
* kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
+ * kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
* body }) the left sidebar: vertical navigation, a drawer on phones
* body }) the left sidebar: vertical navigation, a drawer on phones
*
*
@@ -49,6 +50,10 @@
@@ -49,6 +50,10 @@
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
/** A URL kept only when it is http(s), a same-site path, a query or a fragment; anything else becomes '#'. */
/** A URL kept only when it is http(s), a same-site path, a query or a fragment; anything else becomes '#'. */
const url = (u) => { const s = String(u == null ? '' : u).trim(); return /^(https?:\/\/|\/(?!\/)|\?|#)/i.test(s) ? s : '#'; };
const url = (u) => { const s = String(u == null ? '' : u).trim(); return /^(https?:\/\/|\/(?!\/)|\?|#)/i.test(s) ? s : '#'; };
+ // A Media object (openvibe.media/o/med_…) at a width from Media's resize ladder (64, 96, 160, 320, 480, 640); any other
+ // URL as url() gives it. Avatars and pictures are stored as uploaded: Report and Community drew a 3072 px, 3 MB
+ // avatar at 34 px (4.87.0).
+ const mediaW = (u, w) => { const s = url(u); return /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]+$/.test(s) ? `${s}?w=${Number(w) || 160}` : s; };
const attrs = (o) => Object.keys(o || {}).filter((k) => o[k] != null && o[k] !== false && /^[a-z][a-z0-9-]*$/i.test(k)).map((k) => (o[k] === true ? ` ${k}` : ` ${k}="${esc(o[k])}"`)).join('');
const attrs = (o) => Object.keys(o || {}).filter((k) => o[k] != null && o[k] !== false && /^[a-z][a-z0-9-]*$/i.test(k)).map((k) => (o[k] === true ? ` ${k}` : ` ${k}="${esc(o[k])}"`)).join('');
// ── Icons: 24-unit line drawings in the text's colour (stroke 2, round), one set for every site ──
// ── Icons: 24-unit line drawings in the text's colour (stroke 2, round), one set for every site ──
@@ -142,7 +147,7 @@
@@ -142,7 +147,7 @@
function mark({ name = '?', src = '', hue = null, square = false, size = null } = {}) {
function mark({ name = '?', src = '', hue = null, square = false, size = null } = {}) {
const cls = `ovk-mark${square ? ' ovk-mark--square' : ''}`;
const cls = `ovk-mark${square ? ' ovk-mark--square' : ''}`;
const st = size ? `--ovk-mark:${Number(size) || 40}px;` : '';
const st = size ? `--ovk-mark:${Number(size) || 40}px;` : '';
- if (src && url(src) !== '#') return `<img class="${cls}" src="${esc(url(src))}" alt="" loading="lazy" decoding="async"${st ? ` style="${st}"` : ''}>`;
+ if (src && url(src) !== '#') return `<img class="${cls}" src="${esc(mediaW(src, (Number(size) || 40) > 48 ? 160 : 96))}" alt="" loading="lazy" decoding="async"${st ? ` style="${st}"` : ''}>`;
const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
const letters = (words.length > 1 ? [...words[0]][0] + [...words[1]][0] : [...(words[0] || '?')].slice(0, 2).join('')).toUpperCase();
const letters = (words.length > 1 ? [...words[0]][0] + [...words[1]][0] : [...(words[0] || '?')].slice(0, 2).join('')).toUpperCase();
return `<span class="${cls}" style="${st}--ovk-hue:${hue == null ? hueOf(name) : Number(hue) || 0}" aria-hidden="true">${esc(letters)}</span>`;
return `<span class="${cls}" style="${st}--ovk-hue:${hue == null ? hueOf(name) : Number(hue) || 0}" aria-hidden="true">${esc(letters)}</span>`;
@@ -395,7 +400,7 @@
@@ -395,7 +400,7 @@
const link = url(href);
const link = url(href);
const first = String(preview || '').split('\n').slice(0, 9).join('\n').slice(0, 480).trimEnd();
const first = String(preview || '').split('\n').slice(0, 9).join('\n').slice(0, 480).trimEnd();
const pic = media(image)
const pic = media(image)
- ? `<img src="${esc(image)}" alt="${esc(alt)}" loading="lazy" decoding="async">`
+ ? `<img src="${esc(mediaW(image, 640))}" alt="${esc(alt)}" loading="lazy" decoding="async">`
: first ? `<pre class="ovk-card-pre" aria-hidden="true">${esc(first)}</pre>`
: first ? `<pre class="ovk-card-pre" aria-hidden="true">${esc(first)}</pre>`
: `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;
: `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;
const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';
const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';
@@ -837,7 +842,7 @@
@@ -837,7 +842,7 @@
return {
return {
skeleton,
skeleton,
- esc, url, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
+ esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
enhance, VERSION: 2,
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.86.0",
+ "version": "4.87.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -28,6 +28,9 @@ function serviceIds() {
@@ -28,6 +28,9 @@ function serviceIds() {
}
}
function siteOf(id) {
function siteOf(id) {
+ // A domain card (openvibe.pics, …) names itself; its icon is its service's (social.iconSite).
+ const d = social.domainOf(id);
+ if (d) return { id, name: d.name, tagline: d.tagline };
const m = JSON.parse(fs.readFileSync(path.join(SERVICES, `${id}.json`), 'utf8'));
const m = JSON.parse(fs.readFileSync(path.join(SERVICES, `${id}.json`), 'utf8'));
if (!m.site) throw new Error(`${id}: no site block`);
if (!m.site) throw new Error(`${id}: no site block`);
return { id, name: m.site.name, tagline: m.site.tagline || '' };
return { id, name: m.site.name, tagline: m.site.tagline || '' };
@@ -75,6 +75,13 @@ function sites() {
@@ -75,6 +75,13 @@ function sites() {
if (only && !only.has(pg.id) && !only.has(pg.service)) continue;
if (only && !only.has(pg.id) && !only.has(pg.service)) continue;
out.push({ ...site, id: pg.id, heading: pg.heading, tagline: pg.tagline, what: pg.what, domain: `${site.domain}${pg.path}`, iconId: site.id });
out.push({ ...site, id: pg.id, heading: pg.heading, tagline: pg.tagline, what: pg.what, domain: `${site.domain}${pg.path}`, iconId: site.id });
}
}
+ for (const d of social.DOMAINS) {
+ const site = byId.get(d.service);
+ if (!site) throw new Error(`domain card ${d.id}: no site ${d.service} in the manifests`);
+ if (only && !only.has(d.id) && !only.has(d.service)) continue;
+ out.push({ ...site, id: d.id, name: d.name, tagline: d.tagline, what: d.what, domain: d.host,
+ accent: d.accent ? social.accentOf(d.id, d.accent) : site.accent, iconId: site.id });
+ }
return out;
return out;
}
}
@@ -96,9 +96,14 @@ function serviceOf(o) {
@@ -96,9 +96,14 @@ function serviceOf(o) {
const siteId = (o) => { const s = String(serviceOf(o) || '').toLowerCase(); return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; };
const siteId = (o) => { const s = String(serviceOf(o) || '').toLowerCase(); return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; };
// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page
// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page
-// has a canonical URL to make it absolute against. `image: null` opts out.
+// has a canonical URL to make it absolute against; a page on one of a service's named domains (openvibe.pics) shares
+// that domain's card. `image: null` opts out.
function defaultImage(o) {
function defaultImage(o) {
if (!o.canonical) return null;
if (!o.canonical) return null;
+ let host = '';
+ try { host = new URL(o.canonical).host; } catch (e) { /* a relative canonical: the service's card */ }
+ const domain = social.domainForHost(host);
+ if (domain && social.has(domain.id)) return social.url(domain.id);
const service = serviceOf(o);
const service = serviceOf(o);
return service ? social.url(service) : null;
return service ? social.url(service) : null;
}
}
@@ -14,8 +14,10 @@
@@ -14,8 +14,10 @@
// social.accentOf(service, accent?) the two accent stops the card uses (build script)
// social.accentOf(service, accent?) the two accent stops the card uses (build script)
// social.iconSite(service) the app-icon variant for the service's mark (build script)
// social.iconSite(service) the app-icon variant for the service's mark (build script)
// social.pageOf(id) / social.PAGES the page cards: which page and site a card id names (null for a site's card)
// social.pageOf(id) / social.PAGES the page cards: which page and site a card id names (null for a site's card)
+// social.domainOf(id) / social.domainForHost(host) / social.DOMAINS a service's other named domains (openvibe.pics)
//
//
-// shell.page() uses social.url(o.service) for og:image when the page names no image of its own.
+// shell.page() uses social.url(o.service) for og:image when the page names no image of its own, or the domain's card
+// when the page's canonical URL is on one of DOMAINS' hosts.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -53,6 +55,28 @@ const PAGES = [
@@ -53,6 +55,28 @@ const PAGES = [
/** The page a card id names ({ id, service, heading, … }), or null for a site's own card. */
/** The page a card id names ({ id, service, heading, … }), or null for a site's own card. */
const pageOf = (id) => PAGES.find((pg) => pg.id === id) || null;
const pageOf = (id) => PAGES.find((pg) => pg.id === id) || null;
+// Domain cards (social/<id>.jpg): a service's other domains that wear their own name on the navbar (openvibe.pics is
+// MediaHub's, openvibe.homes is Rent's). Each gets a site card of its own, drawn like its service's (the same mark;
+// its own accent when it has one), and shell.page() shares it on any page whose canonical URL is on that host. Before
+// 4.86.1 a link to openvibe.pics previewed as OpenVibe.Video.
+const DOMAINS = [
+ { id: 'pics', service: 'media-hub', host: 'openvibe.pics', name: 'Pics', accent: '#f472b6', tagline: 'Share pictures. Fast, open, yours.',
+ what: 'A link to share or embed any picture, in any size or format, with location and camera details removed.' },
+ { id: 'download', service: 'media-hub', host: 'openvibe.download', name: 'Download', tagline: 'Your files, shared on your terms',
+ what: 'A private drive with 25 GB free: share files with links you can take back, and back up a folder.' },
+ { id: 'homes', service: 'rent', host: 'openvibe.homes', name: 'Homes', tagline: 'Every home for sale, one search',
+ what: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist searched at once, plus HUD and bank homes.' },
+ { id: 'website', service: 'space', host: 'openvibe.website', name: 'Website', tagline: 'Websites that look finished',
+ what: 'Start from a real shop, portfolio, event, link page or launch page, or describe yours, and change anything from the first preview.' },
+];
+/** The domain card an id names ({ id, service, host, name, … }), or null. */
+const domainOf = (id) => DOMAINS.find((d) => d.id === id) || null;
+/** The domain card for a host (a www. prefix and a port ignored), or null. */
+function domainForHost(host) {
+ const h = String(host || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
+ return DOMAINS.find((d) => d.host === h) || null;
+}
+
const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);
const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);
function url(service, base = '/shared') {
function url(service, base = '/shared') {
@@ -63,7 +87,12 @@ function url(service, base = '/shared') {
@@ -63,7 +87,12 @@ function url(service, base = '/shared') {
}
}
// The mark's tint: app-icon has variants for a few sites; the rest wear the network's.
// The mark's tint: app-icon has variants for a few sites; the rest wear the network's.
-const iconSite = (service) => (Object.prototype.hasOwnProperty.call(icon.SITES, service) ? service : service === 'media-hub' ? 'media' : 'network');
+// A domain card wears its service's mark.
+function iconSite(service) {
+ const d = domainOf(service);
+ if (d) return iconSite(d.service);
+ return Object.prototype.hasOwnProperty.call(icon.SITES, service) ? service : service === 'media-hub' ? 'media' : 'network';
+}
/** A deeper stop of the same hue (lightness down 16 points, saturation up 6), so a gradient never turns muddy. */
/** A deeper stop of the same hue (lightness down 16 points, saturation up 6), so a gradient never turns muddy. */
function deepen(hex) {
function deepen(hex) {
@@ -82,4 +111,4 @@ function accentOf(service, accent) {
@@ -82,4 +111,4 @@ function accentOf(service, accent) {
return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(service)].accent;
return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(service)].accent;
}
}
-module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, DIR };
+module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, domainOf, domainForHost, DOMAINS, DIR };
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/download.jpg differ
Binary files /dev/null and b/social/download.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/homes.jpg differ
Binary files /dev/null and b/social/homes.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/pics.jpg differ
Binary files /dev/null and b/social/pics.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/website.jpg differ
Binary files /dev/null and b/social/website.jpg differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// kit.mark and kit.card ask Media for a resize width instead of the original upload (4.87.0): Report and Community drew a
+// 3072 px, 3 MB avatar at 34 px, and Community a 2460 px picture in a 280 px card.
+const assert = require('assert');
+const kit = require('../kit');
+
+const obj = 'https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38';
+assert.strictEqual(kit.mediaW(obj, 96), `${obj}?w=96`);
+assert.strictEqual(kit.mediaW(`${obj}?w=64`, 96), `${obj}?w=64`, 'a URL that already has a query is left alone');
+assert.strictEqual(kit.mediaW('https://openvibe.media/t/vod-1.jpg', 96), 'https://openvibe.media/t/vod-1.jpg', 'thumbnails are already small');
+assert.strictEqual(kit.mediaW('https://example.com/a.png', 96), 'https://example.com/a.png');
+assert.strictEqual(kit.mediaW('javascript:alert(1)', 96), '#', 'still sanitised like kit.url');
+
+assert.match(kit.mark({ name: 'a', src: obj }), new RegExp(`src="${obj}\\?w=96"`), 'a 40 px mark asks for 96');
+assert.match(kit.mark({ name: 'a', src: obj, size: 64 }), new RegExp(`src="${obj}\\?w=160"`), 'a bigger mark asks for 160');
+assert.match(kit.card({ title: 't', href: '/x', image: obj }), new RegExp(`<img src="${obj}\\?w=640"`), 'a card picture asks for 640');
+console.log('kit media width: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+// Domain cards (4.86.1): a service's other named domains have their own share card, and shell.page() shares it on any
+// page whose canonical URL is on that host. Before, a link to openvibe.pics previewed as OpenVibe.Video, openvibe.homes
+// as Rent and openvibe.website as Space.
+const assert = require('assert');
+const social = require('../social');
+const shell = require('../shell');
+const appIcons = require('../app-icons');
+
+const want = { pics: ['media-hub', 'openvibe.pics'], download: ['media-hub', 'openvibe.download'], homes: ['rent', 'openvibe.homes'],
+ website: ['space', 'openvibe.website'] };
+assert.deepStrictEqual(social.DOMAINS.map((d) => d.id).sort(), Object.keys(want).sort(), 'the four named domains');
+Object.entries(want).forEach(([id, [service, host]]) => {
+ const d = social.domainOf(id);
+ assert.ok(d && d.service === service && d.host === host, `${id}: ${service} on ${host}`);
+ assert.ok(d.name && d.tagline && d.what, `${id}: name, tagline and what`);
+ assert.ok(d.what.length <= 130, `${id}: the description fits the card's two lines (${d.what.length})`);
+ assert.ok(social.has(id), `${id}: social/${id}.jpg is drawn`);
+ assert.strictEqual(social.iconSite(id), social.iconSite(service), `${id} wears ${service}'s mark`);
+ assert.ok(appIcons.has(id) && appIcons.site(id).name === `OpenVibe.${d.name}`, `${id}: an icon entry named OpenVibe.${d.name}`);
+ assert.strictEqual(social.domainForHost(host), d);
+ assert.strictEqual(social.domainForHost(`www.${host}:443`), d, 'www. and a port are ignored');
+});
+assert.strictEqual(social.domainForHost('openvibe.video'), null, 'a service\'s own first domain keeps its card');
+assert.strictEqual(social.domainForHost(''), null);
+
+// shell.page: the canonical's host picks the domain's card; the service's own domain keeps the service's card.
+const og = (html) => (/og:image" content="([^"]+)"/.exec(html) || [])[1] || '';
+assert.match(og(shell.page({ service: 'media-hub', canonical: 'https://openvibe.pics/a/abc', description: 'd' })), /^https:\/\/openvibe\.pics\/shared\/social\/pics\.jpg\?v=/);
+assert.match(og(shell.page({ service: 'rent', canonical: 'https://openvibe.homes/', description: 'd' })), /\/shared\/social\/homes\.jpg\?v=/);
+assert.match(og(shell.page({ service: 'media-hub', canonical: 'https://openvibe.video/v/1', description: 'd' })), /\/shared\/social\/media-hub\.jpg\?v=/);
+assert.match(og(shell.page({ service: 'media-hub', canonical: 'https://openvibe.pics/', image: '/cover.png' })), /\/cover\.png$/, 'an explicit image still wins');
+// The builders draw a card and write an icon entry for each domain (scripts run by hand; their output is committed).
+const fs = require('fs');
+const path = require('path');
+const cards = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-social-cards.js'), 'utf8');
+assert.match(cards, /for \(const d of social\.DOMAINS\)/);
+assert.match(cards, /domain: d\.host,/);
+const icons = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-icons.js'), 'utf8');
+assert.match(icons, /const d = social\.domainOf\(id\);\s*if \(d\) return \{ id, name: d\.name, tagline: d\.tagline \};/);
+console.log('social domains: all checks passed');