'use strict';
/**
* openvibe-shared/showcase — the sections a product's home page is built from (plan T11, D92), rendered on the
* server as plain HTML (complete without JavaScript, readable by crawlers) and styled from the network's theme
* tokens, so every product's home reads as one family:
*
* const sc = require('openvibe-shared/showcase');
* `` (cached; carries the ring icons' CSS too) or
* ``, then sc.hero({ eyebrow, title, accent, lede, actions, note, aside }) (an eyebrow that is
* only the site's name is left out: the navbar shows it; { icon, text } gives it an icon)
* + sc.features({ id, title, lede, items: [{ icon, title, text, href }] })
* + sc.steps({ title, lede, items: [{ title, text, href }] })
* + sc.code({ title, lede, samples: [{ label, lang, code }] })
* + sc.demo({ title, lede, iframe: { src, title, height } }) (or html: trusted markup)
* + sc.compare({ title, lede, columns: ['OpenVibe', 'Elsewhere'], rows: [{ label, values: [true, 'paid add-on'] }] })
* + sc.pricing({ title, lede, tiers: [{ name, price, per, items, action, highlight }], note })
* + sc.limits({ title, lede, columns, rows: [{ label, values }], source }) pricing that equals the API's limits
* + sc.stories({ title, items: [{ who, text, outcome }] })
* + sc.stats({ title, items: [{ value, label, href, icon }] }) a strip of real numbers (three across on a phone)
* + sc.cards({ title, lede, items: [{ href, title, text, image, badge, meta, live }], layout: 'grid'|'rail',
* ratio: '16/9'|'4/3'|'1/1', more: { label, href } }) rich content: videos, streams, posts
* + sc.spotlight({ title, lede, items: [{ title, text, image, video, alt, href, actions }] }) features, shown
* + sc.faq({ title, items: [[question, answer]] }) an accordion, with FAQPage data (faqJsonLd)
* + sc.network({ service, title, lede, items }) "Part of OpenVibe": related products
* + sc.cta({ title, text, actions })
* sc.jsonLd(obj) is a `;
const faqJsonLd = (items) => jsonLd({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]).map(([q, a]) => ({ '@type': 'Question', name: String(q), acceptedAnswer: { '@type': 'Answer', text: String(a) } })) });
/**
* Where each home page leads next on the network: the other places in the same menu group, in the group's own order
* (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of 2026-11-10), so "More on OpenVibe" shows the
* same neighbourhood the launcher and the footer show. Up to four. [to, what, href]; `to` is the name and icon the
* card shows (Email is `email`, OpenRestream `openre`, Studio `studio`, Locker `locker`).
*/
const GROUPS = {
watch: [
['live', 'Go live, watch streams', 'https://openvibe.live/'],
['video', 'Clips and past streams', 'https://openvibe.video/'],
['pics', 'Share pictures', 'https://openvibe.pics/'],
['download', 'Your files', 'https://openvibe.download/'],
['openre', 'Stream everywhere', 'https://openre.stream/'],
['cam', 'Your camera, in private', 'https://openvibe.cam/'],
['tours', 'Cities, routes, stays', 'https://openvibe.tours/'],
],
talk: [
['community', 'Posts and pastes', 'https://openvibe.community/'],
['chat', 'Chat with anyone', 'https://openvibe.chat/'],
['tips', 'Support streamers', 'https://openvibe.tips/'],
['vip', 'Back your creators', 'https://openvibe.vip/'],
['email', 'Masks and inboxes', 'https://openvibe.email/'],
['help', 'Help nearby', 'https://openvibe.help/'],
['network', 'Your account', 'https://openvibe.network/'],
],
play: [
['games', 'Play in your browser', 'https://openvibe.games/'],
['quest', 'Earn badges', 'https://openvibe.quest/'],
['surf', 'Surf maps and the web', 'https://openvibe.surf/'],
['fun', 'Something to do', 'https://openvibe.fun/'],
['meme', 'Memes with receipts', 'https://openvibe.meme/'],
['casino', 'Odds shown, chips free', 'https://openvibe.casino/'],
['inventory', 'Your items', 'https://openvibe.my/inventory'],
],
read: [
['news', 'Sourced, not spun', 'https://openvibe.news/'],
['today', 'Your schedule', 'https://openvibe.today/'],
['report', 'Your numbers, honest', 'https://openvibe.report/'],
['search', 'Search everything', 'https://search.openvibe.network/'],
['wiki', 'Facts with sources', 'https://openvibe.wiki/'],
['blog', 'Write long-form', 'https://openvibe.blog/'],
['vision', 'Ask a picture', 'https://openvibe.vision/'],
],
shop: [
['deals', 'Deals people share', 'https://openvibe.deals/'],
['coupons', 'Codes that work', 'https://openvibe.coupons/'],
['shoes', 'Every shoe, every price', 'https://open.shoes/'],
['reviews', 'Honest reviews', 'https://openvibe.reviews/'],
['trade', 'Watch the markets', 'https://openvibe.trade/'],
['rent', 'Places to rent', 'https://openvibe.rent/'],
['work', 'Find a job', 'https://openvibe.work/'],
],
tools: [
['tools', 'Every online tool', 'https://openvibe.tools/'],
['design', 'Try themes live', 'https://openvibe.design/'],
['domains', 'Names and prices', 'https://openvibe.domains/'],
['host', 'Compare hosting', 'https://openvibe.host/'],
['studio', 'Edit photos in your browser', 'https://openvibe.studio/'],
['locker', 'Files only you can open', 'https://openvibe.locker/'],
['food', 'Food near you', 'https://openvibe.food/'],
],
make: [
['space', 'Host a site or app', 'https://openvibe.space/'],
['codes', 'Code with any agent', 'https://openvibe.codes/'],
['bot', 'Drive a robot', 'https://openvibe.bot/'],
['actor', 'The agent engine', 'https://openvibe.actor/'],
['computer', 'Your computer, your way', 'https://openvibe.computer/'],
['connect', 'Share a port with Link', 'https://connect.openvibe.link/'],
],
build: [
['services', 'APIs, SDK and keys', 'https://openvibe.services/'],
['exchange', 'AI models, one account', 'https://openvibe.exchange/'],
['run', 'Run jobs anywhere', 'https://openvibe.run/'],
['zone', 'S3 storage', 'https://openvibe.zone/'],
['events', 'Event streams', 'https://openvibe.events/'],
['watch', 'Know when it changes', 'https://openvibe.watch/'],
['git', 'Host and browse code', 'https://git.openvibe.codes/'],
],
};
// The service names home pages pass that are not the card's own name (Video is media-hub, Media is its API,
// Restream's service is openre, Email's mail, Studio's image, Locker's lock, Homes is Rent's section, Bench Report's).
const RELATED_ALIAS = {
media: 'video', 'media-hub': 'video', video: 'video', stream: 'openre', openre: 'openre', restream: 'openre',
mail: 'email', email: 'email', image: 'studio', studio: 'studio', lock: 'locker', locker: 'locker',
homes: 'rent', bench: 'report',
};
const RELATED_ALL = Object.entries(GROUPS).flatMap(([group, members]) => members.map(([to, what, href]) => ({ group, to, what, href })));
const RELATED_BY_ID = new Map(RELATED_ALL.map((x) => [x.to, x]));
function relatedFor(key) {
const id = RELATED_ALIAS[key] || key;
const here = RELATED_BY_ID.get(id);
if (!here) return null;
return RELATED_ALL.filter((x) => x.group === here.group && x.to !== id).slice(0, 4).map((x) => [x.to, x.what, x.href]);
}
const RELATED = {};
for (const x of RELATED_ALL) RELATED[x.to] = relatedFor(x.to);
for (const [alias, id] of Object.entries(RELATED_ALIAS)) if (RELATED_BY_ID.has(id)) RELATED[alias] = relatedFor(id);
// A product without its own list shows one place from each of the first four groups, in the groups' order.
const DEFAULT_RELATED = ['live', 'community', 'games', 'news'].map((id) => {
const x = RELATED_BY_ID.get(id);
return [x.to, x.what, x.href];
});
/**
* "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same
* [to, what, href] or { to, what, href } shape), each a ring icon, its name and why to go there.
*/
function network({ id: sid, service, title = 'More on OpenVibe', lede = 'One account across every OpenVibe site.', items, icon: secIcon } = {}) {
const list = (items || RELATED[String(service || '').toLowerCase()] || DEFAULT_RELATED)
.map((x) => (Array.isArray(x) ? { to: x[0], what: x[1], href: x[2] } : x))
.filter((x) => x && x.what && safeHref(x.href) && x.to !== service).slice(0, 4);
if (!list.length) return '';
const h = `sc-${slug(title)}`;
return `${head(title, lede, h, secIcon || 'spark')}
`;
}
/**
* apps({ id, title, lede, groups: [{ name, items }], items, more: { href, label } }) — a set of apps as a launcher, the
* way a phone's home screen shows them: each item { icon ('ov:live' or 'fa-…'), name, text, href }. Phones see icon
* tiles three or four to a row with the name under the icon (the one-line text is for screen readers and wider
* screens); from 600 px each tile is a row: icon, name, the text in one line. groups put a small heading over each
* set (the network's neighbourhoods); items without groups is one grid. A set of two to four, or of eight, twelve…,
* gets a column count that fills its rows (four tiles: four across on a phone, two by two from 600 px, four from
* 960 px), so no tile sits alone on a row. Empty → ''.
*/
function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null, icon: secIcon } = {}) {
const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
if (!sets.length) return '';
const h = `sc-${slug(title || 'apps')}`;
const glyph = (x) => { const k = String(x.icon || 'ov:spark'); return k.startsWith('ov:') ? appIcon(k.slice(3), 56) : `${icon(k, 22)}`; };
const tile = (x) => `
`;
// Six or nine are three a row (4.46.1): four columns left one tile alone on the last row.
const cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : n % 3 === 0 ? 3 : 0);
const body = sets.map((g) => `${g.name ? `
${esc(g.name)}
` : ''}
${g.items.map(tile).join('')}
`).join('');
const end = more && safeHref(more.href) ? `
` : '';
return `${head(title, lede, h, secIcon || 'grid')}${body}${end}`;
}
/** The closing call to action. */
function cta({ title, text, actions: acts } = {}) {
if (!title) return '';
return `
${esc(title)}
${text ? `
${esc(text)}
` : ''}${actions(acts)}`;
}
// ── Rooms (plan 2026-10-28 §3.3): a room's front door, in the order every room shares ──
let roomsLib = null, kitLib = null;
const fmtNum = (v) => (typeof v === 'number' ? v.toLocaleString('en-US') : String(v));
/** The room's motif when it brings none: its icon in an aura of its accent, two slow orbits and their dots. */
function roomMotif(r) {
kitLib = kitLib || require('./kit');
return `
`
+ `${kitLib.icon(r ? r.icon : 'spark')}
`;
}
/**
* room({ room, hero, proof, rails, scenes, explore, dev, faq, network, html }) — a room's front door, in the order every
* room shares: hero → live proof → rails (Right now, For you…) → feature scenes → explore → for developers → questions →
* the rest of OpenVibe. Wrapped in .ov-room with the room's accent (rooms.js); what has nothing to show is left out.
* room: 'surf' (rooms.js id or host; an unknown one keeps the theme's accent)
* hero: { title (≤ 8 words), lede, search: { action, name, placeholder, hints: [typed in turn], label },
* start: { href, label } (when there is no box), chips: [{ label, href }] (≤ 5), motif (trusted HTML) }
* proof: [{ value, label, live: true } | { value, label, asOf: 'Oct 28' }] — real numbers only; 0 or none is left out
* rails: [{ id, title, more, items: [kit.card(...)], size }] — kit.rail
* scenes: [{ title, text, html (trusted: the room's own component in a demo state), action: { href, label } }]
* explore: { title, items: [{ href, title, text, icon }] } — sc.features · dev: sc.code's arguments, with links
* faq: { title, items: [[q, a]] } · network: sc.network's arguments · html: trusted markup after the scenes
* Needs kit.css, room.css (cards, rails, the room's accent) and showcase.css; motion.js makes the numbers tick and the box type.
*/
function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq: q, network: net, html } = {}) {
roomsLib = roomsLib || require('./rooms');
kitLib = kitLib || require('./kit');
const r = roomsLib.get(key);
const chips = (h.chips || []).filter((c) => c && c.label && safeHref(c.href)).slice(0, 5);
const box = h.search && h.search.action ? `` : '';
const start = !box && h.start && safeHref(h.start.href) ? `