openvibers/openvibe.shared Commits
Home page kit: stats, cards/rails, spotlight, FAQ, related products, JSON-LD, motion (#108)
Changes
@@@ -6,6 +6,22 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## 4.13.0 — unreleased++- **Richer home pages for every site** (`showcase.js`, `showcase.css`, `docs/home-pages.md`). The owner, 2026-10-25:++ "more rich content and different types… shared code so you can make nice home pages across all our different sites…++ and other people can reuse too". About 30 sites build their homes from showcase.++ - New sections:++ - `stats`: a strip of real numbers.++ - `cards`: rich content in a grid or a swipeable rail, with lazy images that hold their ratio, a LIVE badge and++ "See all".++ - `spotlight`: features shown with a picture or a short muted loop, alternating sides.++ - `faq`: an accordion that works without JavaScript, plus `faqJsonLd()`.++ - `network`: "Part of OpenVibe", up to four related products from the curated `RELATED` map, with ring icons++ and the `ov_from` tag, so the network links together in a readable, crawlable way.++ - `jsonLd()` writes structured data that cannot close its script early.++ - The hero gets a soft glow from the theme's accent. Sections rise in on scroll (CSS scroll-driven animations, only++ without reduced motion).++ - The same safe-link and escaping rules as the other sections.++- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up anddown button on mobile will actually unmute… those little color sample type s*** so that it loads that then thethumbnail… then the actual video… that background effect… the mute button looks a little weird".@@@ -142,7 +142,8 @@ async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, hea'--mute-audio', '--disk-cache-size=16777216', '--window-size=1280,900', '--disable-features=BackForwardCache', ...args, 'about:blank',], { stdio: 'ignore', detached: true }); // its own process group: close() stops Chrome and its helpers togetherconst portFile = path.join(profile, 'DevToolsActivePort');-- for (let i = 0; i < 200 && !wsUrl; i++) {++ // Up to 45 s: several Chromes starting at once on a two-core CI runner took longer than 20 s (2026-10-25).++ for (let i = 0; i < 450 && !wsUrl; i++) {if (proc.exitCode != null) break;try {const [port, p] = fs.readFileSync(portFile, 'utf8').split('\n');nnew file mode 100644@@@ -0,0 +1,64 @@++# Home pages on the OpenVibe network++++Every OpenVibe product builds its home page from the same sections, `openvibe-shared/showcase`. So do sites that++other people build on the network. The sections are rendered on the server as plain HTML: complete without++JavaScript, readable by search engines and AI crawlers, and styled from the network's theme tokens, so a page follows++the visitor's theme and light or dark mode.++++```js++const sc = require('openvibe-shared/showcase');++const serve = require('openvibe-shared/serve');++++const page = `<!doctype html><html lang="en"><head>++<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">++<title>My product: what it does</title>++<link rel="stylesheet" href="${serve.url('showcase.css')}">++<script src="${serve.url('theme-loader.js')}" defer></script>++${sc.faqJsonLd(FAQ)}++</head><body>++<div id="navbar-mount"></div>++<main>++${sc.hero({ eyebrow: 'My product', title: 'Do the thing.', accent: 'In seconds.', lede: 'One sentence a person understands.', actions: [{ label: 'Start', href: '/start', primary: true }] })}++${sc.stats({ items: [{ value: '1,284', label: 'things made this week' }] })}++${sc.cards({ title: 'New today', layout: 'rail', more: { label: 'See all', href: '/all' }, items })}++${sc.spotlight({ title: 'How it works', items: [{ title: 'Step one', text: '…', image: '/shot.png' }] })}++${sc.faq({ items: FAQ })}++${sc.network({ service: 'my-product' })}++${sc.cta({ title: 'Ready?', actions: [{ label: 'Start', href: '/start', primary: true }] })}++</main>++<script src="${serve.url('navbar.js')}"></script>++</body></html>`;++```++++## Sections++++| Section | What it shows | Notes |++| --- | --- | --- |++| `hero` | What the product is, in one line, and where to start | Has a soft glow from the theme's accent |++| `stats` | A strip of real numbers | Only values the page measured; a link per number is optional |++| `cards` | Rich content: streams, videos, posts, products, listings | `layout: 'rail'` is one swipeable row. Images are lazy and hold their space (`ratio`). `live: true` shows the LIVE badge |++| `spotlight` | Features shown with a picture or a short muted loop, alternating sides | Stacks on phones |++| `features`, `steps` | A grid of what it does; a numbered path to done | |++| `code`, `demo` | Copyable samples; the product in a frame | |++| `compare`, `pricing`, `limits` | Feature tables and plans | `limits` names the URL its numbers come from |++| `stories` | Real uses, in people's words | Never invented |++| `faq` | An accordion that works without JavaScript | Put `faqJsonLd(items)` in the head for FAQPage data |++| `network` | "Part of OpenVibe": up to four related products | Curated per product in `RELATED`. Links carry `ov_from=<service>` |++| `nextSteps` | What to do next on other products after finishing something | |++| `cta` | The closing call to action | |++++## Rules the kit enforces++++- **Escaping:** every string is escaped. Only a field named `html` passes markup through, and it must be the caller's own.++- **Safe links:** links must be `https://` or a path on the same site. `javascript:`, protocol-relative and++ backslash tricks are left out, and so are `http://` images.++- **No placeholders:** a section with nothing to show renders nothing, never an empty shell or "coming soon".++- **Motion:** the scroll reveal uses CSS scroll-driven animations and runs only for people who have not asked for++ reduced motion. Browsers without the feature show everything at once.++++## Linking the network together++++`network({ service })` gives each product a short, relevant set of neighbours (Live leads to OpenRestream, Video,++Chat and Tips; Tools to Codes, Services, Actor and Space). That way readers and search engines see how the sites fit++together, rather than every page linking every domain. Pass `items` to choose your own: `[[to, what, href]]`, where++`to` is a service id that picks the ring icon and the "OpenVibe.X" name.@@@ -66,10 +66,51 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}++.sc-head-row{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap}.sc-head-row>div{min-width:0}.sc-head-row h2{margin:0 0 6px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em}++.sc-more{font-weight:700;font-size:14px;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;white-space:nowrap;margin-bottom:18px}.sc-more:hover{text-decoration:underline}++.sc-stats{display:grid;gap:10px;margin:0;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}++.sc-stat{display:flex;flex-direction:column-reverse;gap:2px;padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none}++.sc-stat dd{margin:0;font-size:clamp(24px,3vw,32px);font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums}.sc-stat dt{font-size:13.5px;color:var(--text-secondary,#96a7c2)}++a.sc-stat:hover{border-color:var(--accent,#3b82f6)}++.sc-cards{list-style:none;margin:0;padding:0;display:grid;gap:14px}++.sc-cards.sc-wrap{grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}++.sc-cards.sc-rail{grid-auto-flow:column;grid-auto-columns:minmax(240px,300px);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:8px;scrollbar-width:thin}++.sc-cards.sc-rail>li{scroll-snap-align:start}++.sc-tile{display:flex;flex-direction:column;height:100%;border-radius:16px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .2s,box-shadow .2s}++.sc-tile:hover,.sc-tile:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-3px);box-shadow:0 12px 30px rgba(0,0,0,.28);outline:0}++.sc-thumb{position:relative;display:block;aspect-ratio:var(--sc-ar,16/9);background:linear-gradient(135deg,color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--bg-tertiary,#172238)),var(--bg-tertiary,#172238))}++.sc-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .35s ease}.sc-tile:hover .sc-thumb img{transform:scale(1.04)}++.sc-badge{position:absolute;left:10px;top:10px;padding:3px 8px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#fff;background:rgba(0,0,0,.66)}++.sc-badge.sc-live{background:var(--live-red,#ef4444)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}++.sc-tile-body{display:block;padding:12px 14px 14px}.sc-tile-body b{display:block;font-size:15px;line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}++.sc-tile-body small{display:block;margin-top:4px;font-size:12.5px;color:var(--text-muted,#7386a3)}.sc-tile-text{display:block;margin-top:6px;font-size:13.5px;line-height:1.45;color:var(--text-secondary,#96a7c2)}++.sc-spots{display:grid;gap:28px}++.sc-spot{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:28px;align-items:center}++.sc-spot:nth-child(even) .sc-spot-media{order:2}++.sc-spot-media{border-radius:18px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);box-shadow:0 18px 50px color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}++.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}++.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}++.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}++.sc-faq{display:grid;gap:8px;max-width:820px}++.sc-faq details{border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}++.sc-faq summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}++.sc-faq summary::-webkit-details-marker{display:none}++.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}++.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}++.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}++.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}++.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}++.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}++.sc-net b{display:block;font-size:15px}.sc-net small{display:block;color:var(--text-secondary,#96a7c2);font-size:13px;line-height:1.4}++.sc-hero{position:relative;isolation:isolate}++.sc-hero::before{content:'';position:absolute;z-index:-1;inset:-40px 0 auto;height:340px;pointer-events:none;background:radial-gradient(ellipse at 30% 20%,color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent),transparent 62%),radial-gradient(ellipse at 80% 0%,color-mix(in srgb,var(--secondary,#22d3ee) 14%,transparent),transparent 58%);filter:blur(10px);opacity:.9}++@keyframes scPulse{50%{opacity:.35}}++@supports (animation-timeline:view()){@media (prefers-reduced-motion:no-preference){.sc-sec{animation:scRise linear both;animation-timeline:view();animation-range:entry 0% cover 18%}}}++@keyframes scRise{from{opacity:.001;transform:translateY(18px)}to{opacity:1;transform:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}--@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}++@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}--@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}++@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}@@@ -15,7 +15,14 @@* + 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 }] }) a strip of real numbers++ * + 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 <script type="application/ld+json"> that cannot end early ('<' escaped).** Every string is escaped; the only markup that passes through is a field named `html` (trusted, the caller's own).* `icon` is a Font Awesome class ('fa-code') or an openvibe-shared/icons name ('ov:tools'). `actions` are@@@ -162,6 +169,115 @@ function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {}).join('')}</ol></section>`;}++/** A strip of real numbers ("12,400 streams", "184 tools"). The page passes what it measured; nothing is invented here. */++function stats({ id: sid, title, items } = {}) {++ const usable = (items || []).filter((x) => x && x.value != null && x.label);++ if (!usable.length) return '';++ const h = `sc-${slug(title || 'numbers')}`;++ return `<section class="sc-sec sc-stats-sec"${id(sid)}${labelled(title, h)}>${title ? `<h2 id="${h}" class="sr-only">${esc(title)}</h2>` : ''}<dl class="sc-stats">${usable.map((x) => {++ const inner = `<dt>${esc(x.label)}</dt><dd>${esc(x.value)}</dd>`;++ const href = safeHref(x.href);++ return href ? `<a class="sc-stat" href="${esc(href)}">${inner}</a>` : `<div class="sc-stat">${inner}</div>`;++ }).join('')}</dl></section>`;++}++++const RATIOS = { '16/9': '16 / 9', '4/3': '4 / 3', '1/1': '1 / 1', '3/4': '3 / 4', '9/16': '9 / 16' };++/**++ * Rich content: live streams, videos, posts, products, listings. `layout: 'rail'` is one swipeable row (scroll snap) for++ * the top of a page; 'grid' (the default) wraps. Images are lazy and hold their space (`ratio`), so nothing jumps.++ * `live: true` adds the red LIVE badge; `badge` is any short label, `meta` a quiet line under the title.++ */++function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', more } = {}) {++ const usable = (items || []).filter((c) => c && c.title && safeHref(c.href));++ if (!usable.length) return '';++ const h = `sc-${slug(title)}`;++ const ar = RATIOS[ratio] || RATIOS['16/9'];++ const moreHref = more && safeHref(more.href);++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${title || moreHref ? `<div class="sc-head-row"><div>${head(title, lede, h)}</div>${moreHref ? `<a class="sc-more" href="${esc(moreHref)}">${esc(more.label || 'See all')} <span aria-hidden="true">→</span></a>` : ''}</div>` : ''}<ul class="sc-cards sc-${layout === 'rail' ? 'rail' : 'wrap'}" style="--sc-ar:${ar}">${usable.map((c) => {++ const img = /^(https:\/\/|\/(?!\/))/.test(c.image || '') ? `<img src="${esc(c.image)}" alt="${esc(c.alt || '')}" loading="lazy" decoding="async">` : '';++ const badge = c.live ? '<span class="sc-badge sc-live">LIVE</span>' : c.badge ? `<span class="sc-badge">${esc(c.badge)}</span>` : '';++ return `<li><a class="sc-tile" href="${esc(safeHref(c.href))}"><span class="sc-thumb">${img}${badge}</span><span class="sc-tile-body"><b>${esc(c.title)}</b>${c.meta ? `<small>${esc(c.meta)}</small>` : ''}${c.text ? `<span class="sc-tile-text">${esc(c.text)}</span>` : ''}</span></a></li>`;++ }).join('')}</ul></section>`;++}++++/** Features, shown rather than listed: a picture or a short muted loop beside each, alternating sides. */++function spotlight({ id: sid, title, lede, items } = {}) {++ const usable = (items || []).filter((x) => x && x.title);++ if (!usable.length) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-spots">${usable.map((x) => {++ const video = /^(https:\/\/|\/(?!\/))/.test(x.video || '') ? `<video src="${esc(x.video)}" muted loop playsinline autoplay preload="none"${x.image ? ` poster="${esc(x.image)}"` : ''} aria-label="${esc(x.alt || x.title)}"></video>` : '';++ const img = !video && /^(https:\/\/|\/(?!\/))/.test(x.image || '') ? `<img src="${esc(x.image)}" alt="${esc(x.alt || '')}" loading="lazy" decoding="async">` : '';++ const href = safeHref(x.href);++ return `<article class="sc-spot">${video || img ? `<div class="sc-spot-media">${video || img}</div>` : ''}<div class="sc-spot-body"><h3>${href ? `<a href="${esc(href)}">${esc(x.title)}</a>` : esc(x.title)}</h3>${x.text ? `<p>${esc(x.text)}</p>` : ''}${actions(x.actions, 'sc-actions sc-spot-act')}</div></article>`;++ }).join('')}</div></section>`;++}++++/** Questions people ask, as an accordion that works without JavaScript. Pair it with faqJsonLd(items) in the head. */++function faq({ id: sid, title = 'Questions', lede, items } = {}) {++ const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);++ if (!usable.length) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;++}++/** A JSON-LD block that cannot close its script early. */++const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;++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 product leads next on the network: real, crawlable links with descriptive text, chosen per product so a++ * reader (and a search engine) sees how the sites fit together, not one long list of every domain. [to, what, href].++ */++const RELATED = {++ live: [['openre', 'Stream to every platform at once', 'https://openre.stream/'], ['video', 'Watch past streams and clips', 'https://openvibe.video/'], ['chat', 'Chat rooms and DMs', 'https://openvibe.chat/'], ['tips', 'Tips and alerts for your stream', 'https://openvibe.tips/']],++ openre: [['live', 'Go live from your browser', 'https://openvibe.live/'], ['video', 'Keep your broadcasts', 'https://openvibe.video/'], ['chat', 'One chat for every platform', 'https://openvibe.chat/']],++ video: [['live', 'Live streams now', 'https://openvibe.live/'], ['pics', 'Pictures, fast and open', 'https://openvibe.pics/'], ['tools', 'Convert and trim media', 'https://openvibe.tools/video-tools']],++ media: [['video', 'Videos, clips and VODs', 'https://openvibe.video/'], ['pics', 'Share pictures', 'https://openvibe.pics/'], ['download', 'Your files, anywhere', 'https://openvibe.download/']],++ chat: [['live', 'Live streams with chat', 'https://openvibe.live/'], ['actor', 'Agents that work while you are away', 'https://openvibe.actor/'], ['community', 'Posts and pastes', 'https://openvibe.community/']],++ tools: [['codes', 'Build apps with AI', 'https://openvibe.codes/'], ['services', 'APIs and SDK for developers', 'https://openvibe.services/'], ['actor', 'Hand a task to an agent', 'https://openvibe.actor/'], ['space', 'Host a site or an app', 'https://openvibe.space/']],++ codes: [['run', 'Run jobs on your own machine', 'https://openvibe.run/'], ['services', 'The SDK and APIs', 'https://openvibe.services/'], ['space', 'Publish what you build', 'https://openvibe.space/']],++ actor: [['tools', 'Hundreds of free tools', 'https://openvibe.tools/'], ['watch', 'Alerts when something changes', 'https://openvibe.watch/'], ['bot', 'Drive a robot from your browser', 'https://openvibe.bot/'], ['codes', 'Coding agents', 'https://openvibe.codes/']],++ services: [['tools', 'Tools with open APIs', 'https://openvibe.tools/developers'], ['space', 'Host apps on the network', 'https://openvibe.space/'], ['network', 'Sign-in for your app', 'https://openvibe.network/']],++ space: [['codes', 'Build it with AI', 'https://openvibe.codes/'], ['services', 'The SDK your app calls', 'https://openvibe.services/'], ['host', 'Hosting, compared', 'https://openvibe.host/']],++ run: [['codes', 'Coding agents', 'https://openvibe.codes/'], ['bot', 'Robots and machines', 'https://openvibe.bot/'], ['actor', 'Agents', 'https://openvibe.actor/']],++ watch: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['work', 'Job alerts', 'https://openvibe.work/'], ['actor', 'An agent to act on alerts', 'https://openvibe.actor/']],++ bot: [['live', 'Stream your robot', 'https://openvibe.live/'], ['actor', 'Let an agent drive', 'https://openvibe.actor/'], ['run', 'Your machines', 'https://openvibe.run/']],++ work: [['watch', 'Alerts for new jobs', 'https://openvibe.watch/'], ['actor', 'An agent on your side', 'https://openvibe.actor/'], ['rent', 'Find a place to live', 'https://openvibe.rent/']],++ rent: [['homes', 'Homes for sale', 'https://openvibe.homes/'], ['work', 'Jobs nearby', 'https://openvibe.work/'], ['food', 'Food near you', 'https://openvibe.food/']],++ homes: [['rent', 'Rentals', 'https://openvibe.rent/'], ['deals', 'Deals', 'https://openvibe.deals/'], ['help', 'Help nearby', 'https://openvibe.help/']],++ food: [['help', 'Help nearby', 'https://openvibe.help/'], ['deals', 'Grocery deals', 'https://openvibe.deals/'], ['coupons', 'Coupons that work', 'https://openvibe.coupons/']],++ deals: [['coupons', 'Coupons that work', 'https://openvibe.coupons/'], ['reviews', 'Reviews with receipts', 'https://openvibe.reviews/'], ['watch', 'Price alerts', 'https://openvibe.watch/']],++ coupons: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['trade', 'Markets and watchlists', 'https://openvibe.trade/']],++ reviews: [['deals', 'Deals', 'https://openvibe.deals/'], ['coupons', 'Coupons', 'https://openvibe.coupons/'], ['host', 'Hosting reviews', 'https://openvibe.host/']],++ trade: [['news', 'News, sourced', 'https://openvibe.news/'], ['watch', 'Market alerts', 'https://openvibe.watch/'], ['deals', 'Deals', 'https://openvibe.deals/']],++ news: [['wiki', 'Knowledge, with sources', 'https://openvibe.wiki/'], ['blog', 'Long-form writing', 'https://openvibe.blog/'], ['community', 'Discuss it', 'https://openvibe.community/']],++ blog: [['news', 'News', 'https://openvibe.news/'], ['wiki', 'Wiki', 'https://openvibe.wiki/'], ['community', 'Community', 'https://openvibe.community/']],++ wiki: [['news', 'News', 'https://openvibe.news/'], ['help', 'Help', 'https://openvibe.help/'], ['blog', 'Blog', 'https://openvibe.blog/']],++ community: [['chat', 'Chat', 'https://openvibe.chat/'], ['blog', 'Blog', 'https://openvibe.blog/'], ['live', 'Live', 'https://openvibe.live/']],++ help: [['food', 'Food near you', 'https://openvibe.food/'], ['wiki', 'Guides', 'https://openvibe.wiki/'], ['community', 'Ask the community', 'https://openvibe.community/']],++ games: [['quest', 'Quests and rewards', 'https://openvibe.quest/'], ['live', 'Stream your game', 'https://openvibe.live/'], ['vip', 'Memberships', 'https://openvibe.vip/']],++ quest: [['games', 'Browser games', 'https://openvibe.games/'], ['vip', 'Memberships', 'https://openvibe.vip/'], ['tips', 'Tips', 'https://openvibe.tips/']],++ tips: [['live', 'Go live', 'https://openvibe.live/'], ['vip', 'Memberships for creators', 'https://openvibe.vip/'], ['openre', 'Restream', 'https://openre.stream/']],++ vip: [['tips', 'Tips and paid messages', 'https://openvibe.tips/'], ['live', 'Live', 'https://openvibe.live/'], ['games', 'Games', 'https://openvibe.games/']],++ host: [['space', 'Host on OpenVibe', 'https://openvibe.space/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['tools', 'Network tools', 'https://openvibe.tools/network-tools']],++ pics: [['video', 'Videos', 'https://openvibe.video/'], ['tools', 'Image tools', 'https://openvibe.tools/image-tools'], ['community', 'Community', 'https://openvibe.community/']],++ network: [['live', 'Live', 'https://openvibe.live/'], ['tools', 'Tools', 'https://openvibe.tools/'], ['chat', 'Chat', 'https://openvibe.chat/'], ['actor', 'Agents', 'https://openvibe.actor/']],++};++const DEFAULT_RELATED = RELATED.network;++++/**++ * "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 = 'Part of OpenVibe', lede = 'One account across every OpenVibe site. Related places to go next:', items } = {}) {++ 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 `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul></section>`;++}++/** The closing call to action. */function cta({ title, text, actions: acts } = {}) {if (!title) return '';@@@ -236,10 +352,51 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}++.sc-head-row{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap}.sc-head-row>div{min-width:0}.sc-head-row h2{margin:0 0 6px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em}++.sc-more{font-weight:700;font-size:14px;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;white-space:nowrap;margin-bottom:18px}.sc-more:hover{text-decoration:underline}++.sc-stats{display:grid;gap:10px;margin:0;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}++.sc-stat{display:flex;flex-direction:column-reverse;gap:2px;padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none}++.sc-stat dd{margin:0;font-size:clamp(24px,3vw,32px);font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums}.sc-stat dt{font-size:13.5px;color:var(--text-secondary,#96a7c2)}++a.sc-stat:hover{border-color:var(--accent,#3b82f6)}++.sc-cards{list-style:none;margin:0;padding:0;display:grid;gap:14px}++.sc-cards.sc-wrap{grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}++.sc-cards.sc-rail{grid-auto-flow:column;grid-auto-columns:minmax(240px,300px);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:8px;scrollbar-width:thin}++.sc-cards.sc-rail>li{scroll-snap-align:start}++.sc-tile{display:flex;flex-direction:column;height:100%;border-radius:16px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .2s,box-shadow .2s}++.sc-tile:hover,.sc-tile:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-3px);box-shadow:0 12px 30px rgba(0,0,0,.28);outline:0}++.sc-thumb{position:relative;display:block;aspect-ratio:var(--sc-ar,16/9);background:linear-gradient(135deg,color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--bg-tertiary,#172238)),var(--bg-tertiary,#172238))}++.sc-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .35s ease}.sc-tile:hover .sc-thumb img{transform:scale(1.04)}++.sc-badge{position:absolute;left:10px;top:10px;padding:3px 8px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#fff;background:rgba(0,0,0,.66)}++.sc-badge.sc-live{background:var(--live-red,#ef4444)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}++.sc-tile-body{display:block;padding:12px 14px 14px}.sc-tile-body b{display:block;font-size:15px;line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}++.sc-tile-body small{display:block;margin-top:4px;font-size:12.5px;color:var(--text-muted,#7386a3)}.sc-tile-text{display:block;margin-top:6px;font-size:13.5px;line-height:1.45;color:var(--text-secondary,#96a7c2)}++.sc-spots{display:grid;gap:28px}++.sc-spot{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:28px;align-items:center}++.sc-spot:nth-child(even) .sc-spot-media{order:2}++.sc-spot-media{border-radius:18px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);box-shadow:0 18px 50px color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}++.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}++.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}++.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}++.sc-faq{display:grid;gap:8px;max-width:820px}++.sc-faq details{border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}++.sc-faq summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}++.sc-faq summary::-webkit-details-marker{display:none}++.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}++.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}++.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}++.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}++.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}++.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}++.sc-net b{display:block;font-size:15px}.sc-net small{display:block;color:var(--text-secondary,#96a7c2);font-size:13px;line-height:1.4}++.sc-hero{position:relative;isolation:isolate}++.sc-hero::before{content:'';position:absolute;z-index:-1;inset:-40px 0 auto;height:340px;pointer-events:none;background:radial-gradient(ellipse at 30% 20%,color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent),transparent 62%),radial-gradient(ellipse at 80% 0%,color-mix(in srgb,var(--secondary,#22d3ee) 14%,transparent),transparent 58%);filter:blur(10px);opacity:.9}++@keyframes scPulse{50%{opacity:.35}}++@supports (animation-timeline:view()){@media (prefers-reduced-motion:no-preference){.sc-sec{animation:scRise linear both;animation-timeline:view();animation-range:entry 0% cover 18%}}}++@keyframes scRise{from{opacity:.001;transform:translateY(18px)}to{opacity:1;transform:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}--@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}++@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}--@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}++@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}`;--module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, nextSteps, cta, CSS, STYLESHEET: 'showcase.css', esc };++module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };@@@ -81,4 +81,34 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:haassert.ok(sc.CSS.includes('.sc-next-main{'), 'styled by the kit');}++// ── Rich home sections (2026-10-25: "more rich content and different types… nice effects… reusable") ──++{++ const st = sc.stats({ title: 'Today', items: [{ value: '1,284', label: 'streams this week' }, { value: '38', label: 'live now', href: '/browse' }, { value: null, label: 'skipped' }] });++ assert.ok(st.includes('<dd>1,284</dd>') && st.includes('<a class="sc-stat" href="/browse">') && !st.includes('skipped'), 'stats: real numbers only, linkable');++ assert.strictEqual(sc.stats({ items: [] }), '');++ const cd = sc.cards({ title: 'Live now', layout: 'rail', ratio: '1/1', more: { label: 'All', href: '/all' }, items: [++ { href: '/@a', title: 'A <b>', meta: '42 watching', image: 'https://openvibe.media/t/a.jpg', live: true },++ { href: 'javascript:alert(1)', title: 'bad' }, { href: '/@c', title: 'C', image: 'http://insecure.example/x.jpg', badge: 'VOD' },++ ] });++ assert.ok(cd.includes('class="sc-cards sc-rail" style="--sc-ar:1 / 1"'), 'the rail at the asked ratio');++ assert.ok(cd.includes('A <b>') && cd.includes('<span class="sc-badge sc-live">LIVE</span>') && cd.includes('loading="lazy"'));++ assert.ok(!cd.includes('javascript:') && !cd.includes('insecure.example'), 'unsafe links and http images are left out');++ assert.ok(cd.includes('<a class="sc-more" href="/all">All'));++ const sp = sc.spotlight({ items: [{ title: 'Clips', text: 'One tap', video: '/v.mp4', image: '/p.jpg', href: '/clips' }, { title: 'Two', image: '/p.jpg' }] });++ assert.ok(sp.includes('<video src="/v.mp4" muted loop playsinline autoplay preload="none" poster="/p.jpg"'), 'a short muted loop, with its poster');++ assert.strictEqual((sp.match(/class="sc-spot"/g) || []).length, 2);++ const fq = sc.faq({ items: [['Do I need OBS?', 'No.'], ['Broken'], ['Free?', 'Yes.']] });++ assert.ok(fq.includes('<details open><summary>Do I need OBS?</summary>') && (fq.match(/<details/g) || []).length === 2, 'an accordion, the first open');++ const ld = sc.faqJsonLd([['Q </script>', 'A']]);++ assert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');++ assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');++ const net = sc.network({ service: 'live' });++ assert.ok(net.includes('Part of OpenVibe') && net.includes('https://openre.stream/?ov_from=live') && net.includes('data-icon="openre"'), 'related products, with the referral tag and ring icons');++ assert.ok(!net.includes('https://openvibe.live/'), 'never links a product to itself');++ assert.ok(sc.network({ service: 'unknown-thing' }).includes('OpenVibe.Live'), 'a product without its own list gets the core ones');++ assert.ok(sc.network({ service: 'x', items: [['tools', 'Tools', 'https://openvibe.tools/'], ['bad', 'Bad', 'ftp://x']] }).includes('OpenVibe.Tools'));++ for (const [svc, list] of Object.entries(sc.RELATED)) for (const [to, what, href] of list) assert.ok(to && what && /^https:\/\//.test(href) && to !== svc, `${svc} → ${to}`);++ assert.ok(sc.CSS.includes('animation-timeline:view()') && sc.CSS.includes('prefers-reduced-motion:no-preference'), 'scroll reveal, only for people who want motion');++}++console.log('showcase: all checks passed');
@@ -6,6 +6,22 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,22 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## 4.13.0 — unreleased
## 4.13.0 — unreleased
+- **Richer home pages for every site** (`showcase.js`, `showcase.css`, `docs/home-pages.md`). The owner, 2026-10-25:
+ "more rich content and different types… shared code so you can make nice home pages across all our different sites…
+ and other people can reuse too". About 30 sites build their homes from showcase.
+ - New sections:
+ - `stats`: a strip of real numbers.
+ - `cards`: rich content in a grid or a swipeable rail, with lazy images that hold their ratio, a LIVE badge and
+ "See all".
+ - `spotlight`: features shown with a picture or a short muted loop, alternating sides.
+ - `faq`: an accordion that works without JavaScript, plus `faqJsonLd()`.
+ - `network`: "Part of OpenVibe", up to four related products from the curated `RELATED` map, with ring icons
+ and the `ov_from` tag, so the network links together in a readable, crawlable way.
+ - `jsonLd()` writes structured data that cannot close its script early.
+ - The hero gets a soft glow from the theme's accent. Sections rise in on scroll (CSS scroll-driven animations, only
+ without reduced motion).
+ - The same safe-link and escaping rules as the other sections.
+
- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up and
- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up and
down button on mobile will actually unmute… those little color sample type s*** so that it loads that then the
down button on mobile will actually unmute… those little color sample type s*** so that it loads that then the
thumbnail… then the actual video… that background effect… the mute button looks a little weird".
thumbnail… then the actual video… that background effect… the mute button looks a little weird".
@@ -142,7 +142,8 @@ async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, hea
@@ -142,7 +142,8 @@ async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, hea
'--mute-audio', '--disk-cache-size=16777216', '--window-size=1280,900', '--disable-features=BackForwardCache', ...args, 'about:blank',
'--mute-audio', '--disk-cache-size=16777216', '--window-size=1280,900', '--disable-features=BackForwardCache', ...args, 'about:blank',
], { stdio: 'ignore', detached: true }); // its own process group: close() stops Chrome and its helpers together
], { stdio: 'ignore', detached: true }); // its own process group: close() stops Chrome and its helpers together
const portFile = path.join(profile, 'DevToolsActivePort');
const portFile = path.join(profile, 'DevToolsActivePort');
- for (let i = 0; i < 200 && !wsUrl; i++) {
+ // Up to 45 s: several Chromes starting at once on a two-core CI runner took longer than 20 s (2026-10-25).
+ for (let i = 0; i < 450 && !wsUrl; i++) {
if (proc.exitCode != null) break;
if (proc.exitCode != null) break;
try {
try {
const [port, p] = fs.readFileSync(portFile, 'utf8').split('\n');
const [port, p] = fs.readFileSync(portFile, 'utf8').split('\n');
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+# Home pages on the OpenVibe network
+
+Every OpenVibe product builds its home page from the same sections, `openvibe-shared/showcase`. So do sites that
+other people build on the network. The sections are rendered on the server as plain HTML: complete without
+JavaScript, readable by search engines and AI crawlers, and styled from the network's theme tokens, so a page follows
+the visitor's theme and light or dark mode.
+
+```js
+const sc = require('openvibe-shared/showcase');
+const serve = require('openvibe-shared/serve');
+
+const page = `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>My product: what it does</title>
+<link rel="stylesheet" href="${serve.url('showcase.css')}">
+<script src="${serve.url('theme-loader.js')}" defer></script>
+${sc.faqJsonLd(FAQ)}
+</head><body>
+<div id="navbar-mount"></div>
+<main>
+${sc.hero({ eyebrow: 'My product', title: 'Do the thing.', accent: 'In seconds.', lede: 'One sentence a person understands.', actions: [{ label: 'Start', href: '/start', primary: true }] })}
+${sc.stats({ items: [{ value: '1,284', label: 'things made this week' }] })}
+${sc.cards({ title: 'New today', layout: 'rail', more: { label: 'See all', href: '/all' }, items })}
+${sc.spotlight({ title: 'How it works', items: [{ title: 'Step one', text: '…', image: '/shot.png' }] })}
+${sc.faq({ items: FAQ })}
+${sc.network({ service: 'my-product' })}
+${sc.cta({ title: 'Ready?', actions: [{ label: 'Start', href: '/start', primary: true }] })}
+</main>
+<script src="${serve.url('navbar.js')}"></script>
+</body></html>`;
+```
+
+## Sections
+
+| Section | What it shows | Notes |
+| --- | --- | --- |
+| `hero` | What the product is, in one line, and where to start | Has a soft glow from the theme's accent |
+| `stats` | A strip of real numbers | Only values the page measured; a link per number is optional |
+| `cards` | Rich content: streams, videos, posts, products, listings | `layout: 'rail'` is one swipeable row. Images are lazy and hold their space (`ratio`). `live: true` shows the LIVE badge |
+| `spotlight` | Features shown with a picture or a short muted loop, alternating sides | Stacks on phones |
+| `features`, `steps` | A grid of what it does; a numbered path to done | |
+| `code`, `demo` | Copyable samples; the product in a frame | |
+| `compare`, `pricing`, `limits` | Feature tables and plans | `limits` names the URL its numbers come from |
+| `stories` | Real uses, in people's words | Never invented |
+| `faq` | An accordion that works without JavaScript | Put `faqJsonLd(items)` in the head for FAQPage data |
+| `network` | "Part of OpenVibe": up to four related products | Curated per product in `RELATED`. Links carry `ov_from=<service>` |
+| `nextSteps` | What to do next on other products after finishing something | |
+| `cta` | The closing call to action | |
+
+## Rules the kit enforces
+
+- **Escaping:** every string is escaped. Only a field named `html` passes markup through, and it must be the caller's own.
+- **Safe links:** links must be `https://` or a path on the same site. `javascript:`, protocol-relative and
+ backslash tricks are left out, and so are `http://` images.
+- **No placeholders:** a section with nothing to show renders nothing, never an empty shell or "coming soon".
+- **Motion:** the scroll reveal uses CSS scroll-driven animations and runs only for people who have not asked for
+ reduced motion. Browsers without the feature show everything at once.
+
+## Linking the network together
+
+`network({ service })` gives each product a short, relevant set of neighbours (Live leads to OpenRestream, Video,
+Chat and Tips; Tools to Codes, Services, Actor and Space). That way readers and search engines see how the sites fit
+together, rather than every page linking every domain. Pass `items` to choose your own: `[[to, what, href]]`, where
+`to` is a service id that picks the ring icon and the "OpenVibe.X" name.
@@ -66,10 +66,51 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@@ -66,10 +66,51 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}
.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
+.sc-head-row{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap}.sc-head-row>div{min-width:0}.sc-head-row h2{margin:0 0 6px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em}
+.sc-more{font-weight:700;font-size:14px;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;white-space:nowrap;margin-bottom:18px}.sc-more:hover{text-decoration:underline}
+.sc-stats{display:grid;gap:10px;margin:0;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
+.sc-stat{display:flex;flex-direction:column-reverse;gap:2px;padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none}
+.sc-stat dd{margin:0;font-size:clamp(24px,3vw,32px);font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums}.sc-stat dt{font-size:13.5px;color:var(--text-secondary,#96a7c2)}
+a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
+.sc-cards{list-style:none;margin:0;padding:0;display:grid;gap:14px}
+.sc-cards.sc-wrap{grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
+.sc-cards.sc-rail{grid-auto-flow:column;grid-auto-columns:minmax(240px,300px);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:8px;scrollbar-width:thin}
+.sc-cards.sc-rail>li{scroll-snap-align:start}
+.sc-tile{display:flex;flex-direction:column;height:100%;border-radius:16px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .2s,box-shadow .2s}
+.sc-tile:hover,.sc-tile:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-3px);box-shadow:0 12px 30px rgba(0,0,0,.28);outline:0}
+.sc-thumb{position:relative;display:block;aspect-ratio:var(--sc-ar,16/9);background:linear-gradient(135deg,color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--bg-tertiary,#172238)),var(--bg-tertiary,#172238))}
+.sc-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .35s ease}.sc-tile:hover .sc-thumb img{transform:scale(1.04)}
+.sc-badge{position:absolute;left:10px;top:10px;padding:3px 8px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#fff;background:rgba(0,0,0,.66)}
+.sc-badge.sc-live{background:var(--live-red,#ef4444)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}
+.sc-tile-body{display:block;padding:12px 14px 14px}.sc-tile-body b{display:block;font-size:15px;line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
+.sc-tile-body small{display:block;margin-top:4px;font-size:12.5px;color:var(--text-muted,#7386a3)}.sc-tile-text{display:block;margin-top:6px;font-size:13.5px;line-height:1.45;color:var(--text-secondary,#96a7c2)}
+.sc-spots{display:grid;gap:28px}
+.sc-spot{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:28px;align-items:center}
+.sc-spot:nth-child(even) .sc-spot-media{order:2}
+.sc-spot-media{border-radius:18px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);box-shadow:0 18px 50px color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}
+.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}
+.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}
+.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}
+.sc-faq{display:grid;gap:8px;max-width:820px}
+.sc-faq details{border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
+.sc-faq summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}
+.sc-faq summary::-webkit-details-marker{display:none}
+.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
+.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}
+.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
+.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
+.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
+.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
+.sc-net b{display:block;font-size:15px}.sc-net small{display:block;color:var(--text-secondary,#96a7c2);font-size:13px;line-height:1.4}
+.sc-hero{position:relative;isolation:isolate}
+.sc-hero::before{content:'';position:absolute;z-index:-1;inset:-40px 0 auto;height:340px;pointer-events:none;background:radial-gradient(ellipse at 30% 20%,color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent),transparent 62%),radial-gradient(ellipse at 80% 0%,color-mix(in srgb,var(--secondary,#22d3ee) 14%,transparent),transparent 58%);filter:blur(10px);opacity:.9}
+@keyframes scPulse{50%{opacity:.35}}
+@supports (animation-timeline:view()){@media (prefers-reduced-motion:no-preference){.sc-sec{animation:scRise linear both;animation-timeline:view();animation-range:entry 0% cover 18%}}}
+@keyframes scRise{from{opacity:.001;transform:translateY(18px)}to{opacity:1;transform:none}}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
-@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}
+@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
-@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}
+@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
@@ -15,7 +15,14 @@
@@ -15,7 +15,14 @@
* + sc.pricing({ title, lede, tiers: [{ name, price, per, items, action, highlight }], note })
* + 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.limits({ title, lede, columns, rows: [{ label, values }], source }) pricing that equals the API's limits
* + sc.stories({ title, items: [{ who, text, outcome }] })
* + sc.stories({ title, items: [{ who, text, outcome }] })
+ * + sc.stats({ title, items: [{ value, label, href }] }) a strip of real numbers
+ * + 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.cta({ title, text, actions })
+ * sc.jsonLd(obj) is a <script type="application/ld+json"> that cannot end early ('<' escaped).
*
*
* Every string is escaped; the only markup that passes through is a field named `html` (trusted, the caller's own).
* Every string is escaped; the only markup that passes through is a field named `html` (trusted, the caller's own).
* `icon` is a Font Awesome class ('fa-code') or an openvibe-shared/icons name ('ov:tools'). `actions` are
* `icon` is a Font Awesome class ('fa-code') or an openvibe-shared/icons name ('ov:tools'). `actions` are
@@ -162,6 +169,115 @@ function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {
@@ -162,6 +169,115 @@ function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {
}).join('')}</ol></section>`;
}).join('')}</ol></section>`;
}
}
+/** A strip of real numbers ("12,400 streams", "184 tools"). The page passes what it measured; nothing is invented here. */
+function stats({ id: sid, title, items } = {}) {
+ const usable = (items || []).filter((x) => x && x.value != null && x.label);
+ if (!usable.length) return '';
+ const h = `sc-${slug(title || 'numbers')}`;
+ return `<section class="sc-sec sc-stats-sec"${id(sid)}${labelled(title, h)}>${title ? `<h2 id="${h}" class="sr-only">${esc(title)}</h2>` : ''}<dl class="sc-stats">${usable.map((x) => {
+ const inner = `<dt>${esc(x.label)}</dt><dd>${esc(x.value)}</dd>`;
+ const href = safeHref(x.href);
+ return href ? `<a class="sc-stat" href="${esc(href)}">${inner}</a>` : `<div class="sc-stat">${inner}</div>`;
+ }).join('')}</dl></section>`;
+}
+
+const RATIOS = { '16/9': '16 / 9', '4/3': '4 / 3', '1/1': '1 / 1', '3/4': '3 / 4', '9/16': '9 / 16' };
+/**
+ * Rich content: live streams, videos, posts, products, listings. `layout: 'rail'` is one swipeable row (scroll snap) for
+ * the top of a page; 'grid' (the default) wraps. Images are lazy and hold their space (`ratio`), so nothing jumps.
+ * `live: true` adds the red LIVE badge; `badge` is any short label, `meta` a quiet line under the title.
+ */
+function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', more } = {}) {
+ const usable = (items || []).filter((c) => c && c.title && safeHref(c.href));
+ if (!usable.length) return '';
+ const h = `sc-${slug(title)}`;
+ const ar = RATIOS[ratio] || RATIOS['16/9'];
+ const moreHref = more && safeHref(more.href);
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${title || moreHref ? `<div class="sc-head-row"><div>${head(title, lede, h)}</div>${moreHref ? `<a class="sc-more" href="${esc(moreHref)}">${esc(more.label || 'See all')} <span aria-hidden="true">→</span></a>` : ''}</div>` : ''}<ul class="sc-cards sc-${layout === 'rail' ? 'rail' : 'wrap'}" style="--sc-ar:${ar}">${usable.map((c) => {
+ const img = /^(https:\/\/|\/(?!\/))/.test(c.image || '') ? `<img src="${esc(c.image)}" alt="${esc(c.alt || '')}" loading="lazy" decoding="async">` : '';
+ const badge = c.live ? '<span class="sc-badge sc-live">LIVE</span>' : c.badge ? `<span class="sc-badge">${esc(c.badge)}</span>` : '';
+ return `<li><a class="sc-tile" href="${esc(safeHref(c.href))}"><span class="sc-thumb">${img}${badge}</span><span class="sc-tile-body"><b>${esc(c.title)}</b>${c.meta ? `<small>${esc(c.meta)}</small>` : ''}${c.text ? `<span class="sc-tile-text">${esc(c.text)}</span>` : ''}</span></a></li>`;
+ }).join('')}</ul></section>`;
+}
+
+/** Features, shown rather than listed: a picture or a short muted loop beside each, alternating sides. */
+function spotlight({ id: sid, title, lede, items } = {}) {
+ const usable = (items || []).filter((x) => x && x.title);
+ if (!usable.length) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-spots">${usable.map((x) => {
+ const video = /^(https:\/\/|\/(?!\/))/.test(x.video || '') ? `<video src="${esc(x.video)}" muted loop playsinline autoplay preload="none"${x.image ? ` poster="${esc(x.image)}"` : ''} aria-label="${esc(x.alt || x.title)}"></video>` : '';
+ const img = !video && /^(https:\/\/|\/(?!\/))/.test(x.image || '') ? `<img src="${esc(x.image)}" alt="${esc(x.alt || '')}" loading="lazy" decoding="async">` : '';
+ const href = safeHref(x.href);
+ return `<article class="sc-spot">${video || img ? `<div class="sc-spot-media">${video || img}</div>` : ''}<div class="sc-spot-body"><h3>${href ? `<a href="${esc(href)}">${esc(x.title)}</a>` : esc(x.title)}</h3>${x.text ? `<p>${esc(x.text)}</p>` : ''}${actions(x.actions, 'sc-actions sc-spot-act')}</div></article>`;
+ }).join('')}</div></section>`;
+}
+
+/** Questions people ask, as an accordion that works without JavaScript. Pair it with faqJsonLd(items) in the head. */
+function faq({ id: sid, title = 'Questions', lede, items } = {}) {
+ const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);
+ if (!usable.length) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;
+}
+/** A JSON-LD block that cannot close its script early. */
+const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
+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 product leads next on the network: real, crawlable links with descriptive text, chosen per product so a
+ * reader (and a search engine) sees how the sites fit together, not one long list of every domain. [to, what, href].
+ */
+const RELATED = {
+ live: [['openre', 'Stream to every platform at once', 'https://openre.stream/'], ['video', 'Watch past streams and clips', 'https://openvibe.video/'], ['chat', 'Chat rooms and DMs', 'https://openvibe.chat/'], ['tips', 'Tips and alerts for your stream', 'https://openvibe.tips/']],
+ openre: [['live', 'Go live from your browser', 'https://openvibe.live/'], ['video', 'Keep your broadcasts', 'https://openvibe.video/'], ['chat', 'One chat for every platform', 'https://openvibe.chat/']],
+ video: [['live', 'Live streams now', 'https://openvibe.live/'], ['pics', 'Pictures, fast and open', 'https://openvibe.pics/'], ['tools', 'Convert and trim media', 'https://openvibe.tools/video-tools']],
+ media: [['video', 'Videos, clips and VODs', 'https://openvibe.video/'], ['pics', 'Share pictures', 'https://openvibe.pics/'], ['download', 'Your files, anywhere', 'https://openvibe.download/']],
+ chat: [['live', 'Live streams with chat', 'https://openvibe.live/'], ['actor', 'Agents that work while you are away', 'https://openvibe.actor/'], ['community', 'Posts and pastes', 'https://openvibe.community/']],
+ tools: [['codes', 'Build apps with AI', 'https://openvibe.codes/'], ['services', 'APIs and SDK for developers', 'https://openvibe.services/'], ['actor', 'Hand a task to an agent', 'https://openvibe.actor/'], ['space', 'Host a site or an app', 'https://openvibe.space/']],
+ codes: [['run', 'Run jobs on your own machine', 'https://openvibe.run/'], ['services', 'The SDK and APIs', 'https://openvibe.services/'], ['space', 'Publish what you build', 'https://openvibe.space/']],
+ actor: [['tools', 'Hundreds of free tools', 'https://openvibe.tools/'], ['watch', 'Alerts when something changes', 'https://openvibe.watch/'], ['bot', 'Drive a robot from your browser', 'https://openvibe.bot/'], ['codes', 'Coding agents', 'https://openvibe.codes/']],
+ services: [['tools', 'Tools with open APIs', 'https://openvibe.tools/developers'], ['space', 'Host apps on the network', 'https://openvibe.space/'], ['network', 'Sign-in for your app', 'https://openvibe.network/']],
+ space: [['codes', 'Build it with AI', 'https://openvibe.codes/'], ['services', 'The SDK your app calls', 'https://openvibe.services/'], ['host', 'Hosting, compared', 'https://openvibe.host/']],
+ run: [['codes', 'Coding agents', 'https://openvibe.codes/'], ['bot', 'Robots and machines', 'https://openvibe.bot/'], ['actor', 'Agents', 'https://openvibe.actor/']],
+ watch: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['work', 'Job alerts', 'https://openvibe.work/'], ['actor', 'An agent to act on alerts', 'https://openvibe.actor/']],
+ bot: [['live', 'Stream your robot', 'https://openvibe.live/'], ['actor', 'Let an agent drive', 'https://openvibe.actor/'], ['run', 'Your machines', 'https://openvibe.run/']],
+ work: [['watch', 'Alerts for new jobs', 'https://openvibe.watch/'], ['actor', 'An agent on your side', 'https://openvibe.actor/'], ['rent', 'Find a place to live', 'https://openvibe.rent/']],
+ rent: [['homes', 'Homes for sale', 'https://openvibe.homes/'], ['work', 'Jobs nearby', 'https://openvibe.work/'], ['food', 'Food near you', 'https://openvibe.food/']],
+ homes: [['rent', 'Rentals', 'https://openvibe.rent/'], ['deals', 'Deals', 'https://openvibe.deals/'], ['help', 'Help nearby', 'https://openvibe.help/']],
+ food: [['help', 'Help nearby', 'https://openvibe.help/'], ['deals', 'Grocery deals', 'https://openvibe.deals/'], ['coupons', 'Coupons that work', 'https://openvibe.coupons/']],
+ deals: [['coupons', 'Coupons that work', 'https://openvibe.coupons/'], ['reviews', 'Reviews with receipts', 'https://openvibe.reviews/'], ['watch', 'Price alerts', 'https://openvibe.watch/']],
+ coupons: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['trade', 'Markets and watchlists', 'https://openvibe.trade/']],
+ reviews: [['deals', 'Deals', 'https://openvibe.deals/'], ['coupons', 'Coupons', 'https://openvibe.coupons/'], ['host', 'Hosting reviews', 'https://openvibe.host/']],
+ trade: [['news', 'News, sourced', 'https://openvibe.news/'], ['watch', 'Market alerts', 'https://openvibe.watch/'], ['deals', 'Deals', 'https://openvibe.deals/']],
+ news: [['wiki', 'Knowledge, with sources', 'https://openvibe.wiki/'], ['blog', 'Long-form writing', 'https://openvibe.blog/'], ['community', 'Discuss it', 'https://openvibe.community/']],
+ blog: [['news', 'News', 'https://openvibe.news/'], ['wiki', 'Wiki', 'https://openvibe.wiki/'], ['community', 'Community', 'https://openvibe.community/']],
+ wiki: [['news', 'News', 'https://openvibe.news/'], ['help', 'Help', 'https://openvibe.help/'], ['blog', 'Blog', 'https://openvibe.blog/']],
+ community: [['chat', 'Chat', 'https://openvibe.chat/'], ['blog', 'Blog', 'https://openvibe.blog/'], ['live', 'Live', 'https://openvibe.live/']],
+ help: [['food', 'Food near you', 'https://openvibe.food/'], ['wiki', 'Guides', 'https://openvibe.wiki/'], ['community', 'Ask the community', 'https://openvibe.community/']],
+ games: [['quest', 'Quests and rewards', 'https://openvibe.quest/'], ['live', 'Stream your game', 'https://openvibe.live/'], ['vip', 'Memberships', 'https://openvibe.vip/']],
+ quest: [['games', 'Browser games', 'https://openvibe.games/'], ['vip', 'Memberships', 'https://openvibe.vip/'], ['tips', 'Tips', 'https://openvibe.tips/']],
+ tips: [['live', 'Go live', 'https://openvibe.live/'], ['vip', 'Memberships for creators', 'https://openvibe.vip/'], ['openre', 'Restream', 'https://openre.stream/']],
+ vip: [['tips', 'Tips and paid messages', 'https://openvibe.tips/'], ['live', 'Live', 'https://openvibe.live/'], ['games', 'Games', 'https://openvibe.games/']],
+ host: [['space', 'Host on OpenVibe', 'https://openvibe.space/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['tools', 'Network tools', 'https://openvibe.tools/network-tools']],
+ pics: [['video', 'Videos', 'https://openvibe.video/'], ['tools', 'Image tools', 'https://openvibe.tools/image-tools'], ['community', 'Community', 'https://openvibe.community/']],
+ network: [['live', 'Live', 'https://openvibe.live/'], ['tools', 'Tools', 'https://openvibe.tools/'], ['chat', 'Chat', 'https://openvibe.chat/'], ['actor', 'Agents', 'https://openvibe.actor/']],
+};
+const DEFAULT_RELATED = RELATED.network;
+
+/**
+ * "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 = 'Part of OpenVibe', lede = 'One account across every OpenVibe site. Related places to go next:', items } = {}) {
+ 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 `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul></section>`;
+}
+
/** The closing call to action. */
/** The closing call to action. */
function cta({ title, text, actions: acts } = {}) {
function cta({ title, text, actions: acts } = {}) {
if (!title) return '';
if (!title) return '';
@@ -236,10 +352,51 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@@ -236,10 +352,51 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}
.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
+.sc-head-row{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap}.sc-head-row>div{min-width:0}.sc-head-row h2{margin:0 0 6px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em}
+.sc-more{font-weight:700;font-size:14px;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;white-space:nowrap;margin-bottom:18px}.sc-more:hover{text-decoration:underline}
+.sc-stats{display:grid;gap:10px;margin:0;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
+.sc-stat{display:flex;flex-direction:column-reverse;gap:2px;padding:16px 18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none}
+.sc-stat dd{margin:0;font-size:clamp(24px,3vw,32px);font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums}.sc-stat dt{font-size:13.5px;color:var(--text-secondary,#96a7c2)}
+a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
+.sc-cards{list-style:none;margin:0;padding:0;display:grid;gap:14px}
+.sc-cards.sc-wrap{grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
+.sc-cards.sc-rail{grid-auto-flow:column;grid-auto-columns:minmax(240px,300px);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:8px;scrollbar-width:thin}
+.sc-cards.sc-rail>li{scroll-snap-align:start}
+.sc-tile{display:flex;flex-direction:column;height:100%;border-radius:16px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .2s,box-shadow .2s}
+.sc-tile:hover,.sc-tile:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-3px);box-shadow:0 12px 30px rgba(0,0,0,.28);outline:0}
+.sc-thumb{position:relative;display:block;aspect-ratio:var(--sc-ar,16/9);background:linear-gradient(135deg,color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--bg-tertiary,#172238)),var(--bg-tertiary,#172238))}
+.sc-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .35s ease}.sc-tile:hover .sc-thumb img{transform:scale(1.04)}
+.sc-badge{position:absolute;left:10px;top:10px;padding:3px 8px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#fff;background:rgba(0,0,0,.66)}
+.sc-badge.sc-live{background:var(--live-red,#ef4444)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}
+.sc-tile-body{display:block;padding:12px 14px 14px}.sc-tile-body b{display:block;font-size:15px;line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
+.sc-tile-body small{display:block;margin-top:4px;font-size:12.5px;color:var(--text-muted,#7386a3)}.sc-tile-text{display:block;margin-top:6px;font-size:13.5px;line-height:1.45;color:var(--text-secondary,#96a7c2)}
+.sc-spots{display:grid;gap:28px}
+.sc-spot{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:28px;align-items:center}
+.sc-spot:nth-child(even) .sc-spot-media{order:2}
+.sc-spot-media{border-radius:18px;overflow:hidden;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);box-shadow:0 18px 50px color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}
+.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}
+.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}
+.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}
+.sc-faq{display:grid;gap:8px;max-width:820px}
+.sc-faq details{border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
+.sc-faq summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}
+.sc-faq summary::-webkit-details-marker{display:none}
+.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
+.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}
+.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
+.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
+.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
+.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
+.sc-net b{display:block;font-size:15px}.sc-net small{display:block;color:var(--text-secondary,#96a7c2);font-size:13px;line-height:1.4}
+.sc-hero{position:relative;isolation:isolate}
+.sc-hero::before{content:'';position:absolute;z-index:-1;inset:-40px 0 auto;height:340px;pointer-events:none;background:radial-gradient(ellipse at 30% 20%,color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent),transparent 62%),radial-gradient(ellipse at 80% 0%,color-mix(in srgb,var(--secondary,#22d3ee) 14%,transparent),transparent 58%);filter:blur(10px);opacity:.9}
+@keyframes scPulse{50%{opacity:.35}}
+@supports (animation-timeline:view()){@media (prefers-reduced-motion:no-preference){.sc-sec{animation:scRise linear both;animation-timeline:view();animation-range:entry 0% cover 18%}}}
+@keyframes scRise{from{opacity:.001;transform:translateY(18px)}to{opacity:1;transform:none}}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
-@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}
+@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
-@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}
+@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}
`;
`;
-module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, nextSteps, cta, CSS, STYLESHEET: 'showcase.css', esc };
+module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
@@ -81,4 +81,34 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -81,4 +81,34 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(sc.CSS.includes('.sc-next-main{'), 'styled by the kit');
assert.ok(sc.CSS.includes('.sc-next-main{'), 'styled by the kit');
}
}
+// ── Rich home sections (2026-10-25: "more rich content and different types… nice effects… reusable") ──
+{
+ const st = sc.stats({ title: 'Today', items: [{ value: '1,284', label: 'streams this week' }, { value: '38', label: 'live now', href: '/browse' }, { value: null, label: 'skipped' }] });
+ assert.ok(st.includes('<dd>1,284</dd>') && st.includes('<a class="sc-stat" href="/browse">') && !st.includes('skipped'), 'stats: real numbers only, linkable');
+ assert.strictEqual(sc.stats({ items: [] }), '');
+ const cd = sc.cards({ title: 'Live now', layout: 'rail', ratio: '1/1', more: { label: 'All', href: '/all' }, items: [
+ { href: '/@a', title: 'A <b>', meta: '42 watching', image: 'https://openvibe.media/t/a.jpg', live: true },
+ { href: 'javascript:alert(1)', title: 'bad' }, { href: '/@c', title: 'C', image: 'http://insecure.example/x.jpg', badge: 'VOD' },
+ ] });
+ assert.ok(cd.includes('class="sc-cards sc-rail" style="--sc-ar:1 / 1"'), 'the rail at the asked ratio');
+ assert.ok(cd.includes('A <b>') && cd.includes('<span class="sc-badge sc-live">LIVE</span>') && cd.includes('loading="lazy"'));
+ assert.ok(!cd.includes('javascript:') && !cd.includes('insecure.example'), 'unsafe links and http images are left out');
+ assert.ok(cd.includes('<a class="sc-more" href="/all">All'));
+ const sp = sc.spotlight({ items: [{ title: 'Clips', text: 'One tap', video: '/v.mp4', image: '/p.jpg', href: '/clips' }, { title: 'Two', image: '/p.jpg' }] });
+ assert.ok(sp.includes('<video src="/v.mp4" muted loop playsinline autoplay preload="none" poster="/p.jpg"'), 'a short muted loop, with its poster');
+ assert.strictEqual((sp.match(/class="sc-spot"/g) || []).length, 2);
+ const fq = sc.faq({ items: [['Do I need OBS?', 'No.'], ['Broken'], ['Free?', 'Yes.']] });
+ assert.ok(fq.includes('<details open><summary>Do I need OBS?</summary>') && (fq.match(/<details/g) || []).length === 2, 'an accordion, the first open');
+ const ld = sc.faqJsonLd([['Q </script>', 'A']]);
+ assert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');
+ assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');
+ const net = sc.network({ service: 'live' });
+ assert.ok(net.includes('Part of OpenVibe') && net.includes('https://openre.stream/?ov_from=live') && net.includes('data-icon="openre"'), 'related products, with the referral tag and ring icons');
+ assert.ok(!net.includes('https://openvibe.live/'), 'never links a product to itself');
+ assert.ok(sc.network({ service: 'unknown-thing' }).includes('OpenVibe.Live'), 'a product without its own list gets the core ones');
+ assert.ok(sc.network({ service: 'x', items: [['tools', 'Tools', 'https://openvibe.tools/'], ['bad', 'Bad', 'ftp://x']] }).includes('OpenVibe.Tools'));
+ for (const [svc, list] of Object.entries(sc.RELATED)) for (const [to, what, href] of list) assert.ok(to && what && /^https:\/\//.test(href) && to !== svc, `${svc} → ${to}`);
+ assert.ok(sc.CSS.includes('animation-timeline:view()') && sc.CSS.includes('prefers-reduced-motion:no-preference'), 'scroll reveal, only for people who want motion');
+}
+
console.log('showcase: all checks passed');
console.log('showcase: all checks passed');