openvibers/openvibe.shared Commits
Shared 2.3.0: openvibe-shared/showcase (plan T11, D92), the sections a product home is built from — hero, features, steps, code, demo, compare, pricing, limits (pricing that equals the API's limits, citing its source), stories, cta — server-rendered plain HTML with inline CSS on the theme tokens; every string escaped (only an html field passes markup), empty sections render nothing, heading ids from titles. test/showcase.test.js; 47/47.
Changes
@@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 2.3.0 — 2026-09-29++++- `openvibe-shared/showcase` (plan T11, D92): the sections a product's home page is built from — `hero`, `features`,++ `steps`, `code`, `demo`, `compare`, `pricing`, `limits` (pricing that equals the API's limits, citing its source),++ `stories`, `cta` — rendered on the server as plain HTML with inline `CSS` on the theme tokens. Every string is++ escaped (only a field named `html` passes markup through), a section with no items renders nothing, and heading ids++ come from titles so a page renders the same bytes every time.++## 2.2.0 — 2026-09-29Boost on real sites (plan T11):@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "2.2.0",++ "version": "2.3.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -53,6 +53,7 @@"./release-watch": "./release-watch.js","./seo": "./seo.js","./serve": "./serve.js",++ "./showcase": "./showcase.js","./shipped": "./shipped.js","./sso-client": "./sso-client.js","./test-runner": "./test-runner.js",nnew file mode 100644@@@ -0,0 +1,186 @@++'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');++ * `<style>${sc.CSS}</style>` + sc.hero({ eyebrow, title, accent, lede, actions, note, aside })++ * + 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.cta({ title, text, actions })++ *++ * 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++ * [{ label, href, primary?, icon? }]. Nothing here reads data: the page passes what it knows, and a section with no++ * items renders nothing (never an empty shell or a "coming soon").++ */++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const has = (a) => Array.isArray(a) && a.length > 0;++let ovIcons = null;++function icon(name, size = 22) {++ if (!name) return '';++ const n = String(name);++ if (n.startsWith('ov:')) {++ ovIcons = ovIcons || require('./ov-icons');++ return `<span class="ov-icon sc-ovi" data-icon="${esc(n.slice(3))}" style="--ovi-size:${size}px" aria-hidden="true">${ovIcons.svg(n.slice(3))}</span>`;++ }++ return `<i class="fa-solid ${esc(n.replace(/[^a-z0-9 -]/gi, ''))}" aria-hidden="true"></i>`;++}++const id = (s) => (s ? ` id="${esc(String(s).replace(/[^a-z0-9_-]/gi, '-'))}"` : '');++const head = (title, lede, hid) => `${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`;++// Heading ids come from the title, so a page renders the same bytes every time (ETags, caches).++const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section';++const labelled = (title, h) => (title ? ` aria-labelledby="${h}"` : '');++++function actions(list, cls = 'sc-actions') {++ if (!has(list)) return '';++ return `<div class="${cls}">${list.map((a) => `<a class="sc-btn${a.primary ? ' sc-primary' : ''}" href="${esc(a.href)}"${/^https?:/.test(a.href || '') && a.external ? ' rel="noopener"' : ''}>${a.icon ? icon(a.icon, 18) : ''}${esc(a.label)}</a>`).join('')}</div>`;++}++++/** The top of the page: what the product is, in one line, and where to start. */++function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html } = {}) {++ return `<section class="sc-hero${aside || html ? ' sc-split' : ''}"><div class="sc-hero-main">${eyebrow ? `<p class="sc-eyebrow">${esc(eyebrow)}</p>` : ''}<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : ''}</section>`;++}++++/** A grid of what the product does; each card may link to where it starts. */++function features({ id: sid, title, lede, items } = {}) {++ if (!has(items)) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-grid">${items.map((f) => {++ const inner = `${f.icon ? `<span class="sc-ic">${icon(f.icon)}</span>` : ''}<span><b>${esc(f.title)}</b>${f.text ? `<small>${esc(f.text)}</small>` : ''}</span>`;++ return f.href ? `<a class="sc-card" href="${esc(f.href)}">${inner}</a>` : `<div class="sc-card">${inner}</div>`;++ }).join('')}</div></section>`;++}++++/** A numbered path from nothing to done. */++function steps({ id: sid, title, lede, items } = {}) {++ if (!has(items)) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ol class="sc-steps">${items.map((s) => `<li><b>${s.href ? `<a href="${esc(s.href)}">${esc(s.title)}</a>` : esc(s.title)}</b>${s.text ? `<span>${esc(s.text)}</span>` : ''}</li>`).join('')}</ol></section>`;++}++++/** Code the reader can copy: one or more samples, each labelled with its language. */++function code({ id: sid, title, lede, samples } = {}) {++ if (!has(samples)) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-code">${samples.map((c, i) => `<figure><figcaption>${esc(c.label || c.lang || `Sample ${i + 1}`)}</figcaption><pre><code${c.lang ? ` class="language-${esc(c.lang)}"` : ''}>${esc(String(c.code || '').replace(/^\n+|\s+$/g, ''))}</code></pre></figure>`).join('')}</div></section>`;++}++++/** A live demo: the product itself in a frame, or the caller's own markup. */++function demo({ id: sid, title, lede, iframe, html } = {}) {++ if (!iframe && html == null) return '';++ const h = `sc-${slug(title)}`;++ const body = iframe++ ? `<iframe src="${esc(iframe.src)}" title="${esc(iframe.title || title || 'Live demo')}" loading="lazy" style="height:${Math.max(160, Math.min(900, Number(iframe.height) || 420))}px" referrerpolicy="strict-origin-when-cross-origin"${iframe.sandbox ? ` sandbox="${esc(iframe.sandbox)}"` : ''}></iframe>`++ : html;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-demo">${body}</div></section>`;++}++++const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));++++/** How the product compares, feature by feature. The first column is this product. */++function compare({ id: sid, title, lede, columns, rows } = {}) {++ if (!has(rows) || !has(columns)) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;++}++++/** Plans side by side. A price of 0 reads "Free". */++function pricing({ id: sid, title, lede, tiers, note } = {}) {++ if (!has(tiers)) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-tiers">${tiers.map((t) => `<div class="sc-tier${t.highlight ? ' sc-hl' : ''}"><b class="sc-tier-name">${esc(t.name)}</b><p class="sc-price">${t.price === 0 || t.price === '0' ? 'Free' : esc(t.price)}${t.per && !(t.price === 0 || t.price === '0') ? `<small> / ${esc(t.per)}</small>` : ''}</p>${has(t.items) ? `<ul>${t.items.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}${t.action ? actions([{ ...t.action, primary: t.highlight }], 'sc-actions sc-tier-act') : ''}</div>`).join('')}</div>${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</section>`;++}++++/**++ * The limits a caller really gets, as a table: pricing that equals the API's limits. `source` names where the numbers++ * come from (a /limits.json URL), so the page can never promise more than the service enforces.++ */++function limits({ id: sid, title, lede, columns, rows, source } = {}) {++ if (!has(rows) || !has(columns)) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;++}++++/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */++function stories({ id: sid, title, lede, items } = {}) {++ if (!has(items)) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;++}++++/** The closing call to action. */++function cta({ title, text, actions: acts } = {}) {++ if (!title) return '';++ return `<section class="sc-cta"><h2>${esc(title)}</h2>${text ? `<p>${esc(text)}</p>` : ''}${actions(acts)}</section>`;++}++++const CSS = `++.sc-hero,.sc-sec,.sc-cta{max-width:1080px;margin:0 auto;padding:0 24px}++.sc-hero{padding-top:56px;padding-bottom:8px}++.sc-hero.sc-split{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:32px;align-items:center}++.sc-eyebrow{display:inline-block;margin:0 0 14px;padding:4px 12px;border-radius:999px;border:1px solid var(--border-light,var(--border,#2c3d5c));background:var(--bg-secondary,#101828);color:var(--accent-light,var(--accent,#60a5fa));font-size:12px;font-weight:700;letter-spacing:.4px}++.sc-hero h1{font-size:clamp(32px,4.6vw,52px);line-height:1.08;letter-spacing:-1.2px;margin:0;font-weight:800}++.sc-accent{background:linear-gradient(120deg,var(--accent-light,#60a5fa),var(--accent,#3b82f6),var(--secondary,#22d3ee));-webkit-background-clip:text;background-clip:text;color:transparent}++.sc-hero-lede{margin:16px 0 0;max-width:640px;font-size:17px;line-height:1.6;color:var(--text-secondary,#96a7c2)}++.sc-hero-aside{min-width:0}++.sc-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}++.sc-btn{display:inline-flex;align-items:center;gap:8px;padding:11px 20px;border-radius:12px;font-weight:700;font-size:15px;text-decoration:none;color:var(--text-primary,#e6edf7);border:1px solid var(--border-light,var(--border,#2c3d5c));background:transparent;transition:border-color .15s,background .15s,transform .15s}++.sc-btn:hover,.sc-btn:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-hover,#1c2a44);outline:0}++.sc-btn.sc-primary{border-color:transparent;color:var(--on-accent,#fff);background:linear-gradient(135deg,var(--accent,#3b82f6),var(--accent-dark,#1d4ed8))}++.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}++.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}++.sc-note a{color:var(--accent-light,#60a5fa)}++.sc-sec{margin-top:56px}++.sc-sec>h2,.sc-cta>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}++.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}++.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}++.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;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}++a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}++.sc-ic{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;font-size:17px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}++.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}++.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}++.sc-steps li{counter-increment:sc;display:grid;grid-template-columns:34px 1fr;column-gap:12px;align-items:baseline}++.sc-steps li::before{content:counter(sc);grid-row:span 2;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:13px;color:var(--accent-light,#60a5fa);border:1px solid var(--border-light,#2c3d5c);background:var(--bg-secondary,#101828)}++.sc-steps b{font-size:15.5px}.sc-steps b a{color:inherit;text-decoration:underline;text-decoration-color:var(--accent,#3b82f6);text-underline-offset:3px}++.sc-steps span{color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.5}++.sc-code{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}++.sc-code figure{margin:0;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-primary,#0a0f1c);overflow:hidden;min-width:0}++.sc-code figcaption{padding:8px 14px;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);border-bottom:1px solid var(--border,rgba(255,255,255,.08))}++.sc-code pre{margin:0;padding:14px;overflow:auto;font-size:13px;line-height:1.55}++.sc-demo{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));overflow:hidden;background:var(--bg-secondary,#101828)}++.sc-demo iframe{display:block;width:100%;border:0;background:var(--bg-primary,#0a0f1c)}++.sc-table-wrap{overflow-x:auto;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08))}++.sc-table{width:100%;border-collapse:collapse;font-size:14px}++.sc-table th,.sc-table td{padding:11px 14px;text-align:left;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}++.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}++.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}++.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}++.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}++.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}++.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}++.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}++.sc-tier-name{font-size:13px;letter-spacing:.6px;text-transform:uppercase;color:var(--text-muted,#7386a3)}++.sc-price{margin:6px 0 10px;font-size:28px;font-weight:800}.sc-price small{font-size:14px;font-weight:600;color:var(--text-secondary,#96a7c2)}++.sc-tier ul{margin:0;padding-left:18px;color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.6}++.sc-tier-act{margin-top:auto;padding-top:14px}++.sc-stories{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}++.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}++.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}++.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}++.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}++.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: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{transition:none}}++`;++++module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, cta, CSS, esc };nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++// openvibe-shared/showcase: escaping, empty sections, deterministic output, the pieces each section promises.++const assert = require('assert');++const sc = require('../showcase');++++const hero = sc.hero({ eyebrow: 'E', title: 'Build <on>', accent: 'OpenVibe', lede: 'L', actions: [{ label: 'Go', href: '/go?a=1&b=2', primary: true, icon: 'fa-plus' }], note: 'N' });++assert.ok(hero.includes('<h1>Build <on><span class="sc-accent"> OpenVibe</span></h1>'), 'the title is escaped');++assert.ok(hero.includes('href="/go?a=1&b=2"') && hero.includes('sc-btn sc-primary'), 'actions are links, escaped');++assert.ok(sc.hero({ title: 'T', aside: { html: '<pre>x</pre>' } }).includes('<div class="sc-hero-aside"><pre>x</pre></div>'), 'aside.html passes through');++++for (const [name, out] of Object.entries({++ features: sc.features({ title: 'F', items: [] }), steps: sc.steps({ title: 'S' }), code: sc.code({ title: 'C', samples: [] }),++ demo: sc.demo({ title: 'D' }), compare: sc.compare({ title: 'X', columns: ['a'], rows: [] }), pricing: sc.pricing({ title: 'P', tiers: [] }),++ limits: sc.limits({ title: 'L', columns: [], rows: [{ label: 'r' }] }), stories: sc.stories({ title: 'St' }), cta: sc.cta({}),++})) assert.strictEqual(out, '', `${name} with nothing to show renders nothing`);++++const f = sc.features({ title: 'What you get', items: [{ icon: 'fa-key', title: '<b>', text: 't', href: '/k' }, { icon: 'ov:tools', title: 'Tools' }] });++assert.ok(f.includes('aria-labelledby="sc-what-you-get"') && f.includes('id="sc-what-you-get"'), 'the heading labels its section');++assert.ok(f.includes('<a class="sc-card" href="/k">') && f.includes('<b>') && f.includes('<div class="sc-card">'), 'linked and plain cards');++assert.ok(f.includes('data-icon="tools"'), 'ov: icons render the shared ring icon');++assert.strictEqual(f, sc.features({ title: 'What you get', items: [{ icon: 'fa-key', title: '<b>', text: 't', href: '/k' }, { icon: 'ov:tools', title: 'Tools' }] }), 'the same input renders the same bytes');++++const c = sc.compare({ title: 'Compare', columns: ['Us', 'Them'], rows: [{ label: 'Open', values: [true, false] }, { label: 'Price', values: ['Free', 'Paid'] }] });++assert.ok(c.includes('aria-label="yes"') && c.includes('aria-label="no"') && c.includes('<td class="sc-us">Free</td>'));++const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'month', items: ['a'] }, { name: 'Pro', price: '$5', per: 'month', highlight: true, action: { label: 'Buy', href: '/buy' } }] });++assert.ok(p.includes('<p class="sc-price">Free</p>') && p.includes('$5<small> / month</small>') && p.includes('sc-tier sc-hl'));++const l = sc.limits({ title: 'Limits', columns: ['Free'], rows: [{ label: 'Requests per minute', values: [60] }], source: 'https://openvibe.network/limits.json' });++assert.ok(l.includes('<td>60</td>') && l.includes('href="https://openvibe.network/limits.json"'), 'limits cite their source');++const code = sc.code({ title: 'Code', samples: [{ label: 'Node', lang: 'js', code: '\n<script>x</script>\n' }] });++assert.ok(code.includes('class="language-js"') && code.includes('<script>x</script></code>'), 'code is escaped and trimmed');++const d = sc.demo({ title: 'Try it', iframe: { src: 'https://x.example/?a=1&b=2', height: 5000 } });++assert.ok(d.includes('loading="lazy"') && d.includes('height:900px') && d.includes('&b=2'), 'a demo frame is lazy and bounded');++assert.ok(typeof sc.CSS === 'string' && sc.CSS.includes('.sc-card') && sc.CSS.includes('prefers-reduced-motion'));++console.log('showcase: all checks passed');
@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 2.3.0 — 2026-09-29
+
+- `openvibe-shared/showcase` (plan T11, D92): the sections a product's home page is built from — `hero`, `features`,
+ `steps`, `code`, `demo`, `compare`, `pricing`, `limits` (pricing that equals the API's limits, citing its source),
+ `stories`, `cta` — rendered on the server as plain HTML with inline `CSS` on the theme tokens. Every string is
+ escaped (only a field named `html` passes markup through), a section with no items renders nothing, and heading ids
+ come from titles so a page renders the same bytes every time.
+
## 2.2.0 — 2026-09-29
## 2.2.0 — 2026-09-29
Boost on real sites (plan T11):
Boost on real sites (plan T11):
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.2.0",
+ "version": "2.3.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -53,6 +53,7 @@
@@ -53,6 +53,7 @@
"./release-watch": "./release-watch.js",
"./release-watch": "./release-watch.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
"./serve": "./serve.js",
"./serve": "./serve.js",
+ "./showcase": "./showcase.js",
"./shipped": "./shipped.js",
"./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
"./test-runner": "./test-runner.js",
"./test-runner": "./test-runner.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'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');
+ * `<style>${sc.CSS}</style>` + sc.hero({ eyebrow, title, accent, lede, actions, note, aside })
+ * + 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.cta({ title, text, actions })
+ *
+ * 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
+ * [{ label, href, primary?, icon? }]. Nothing here reads data: the page passes what it knows, and a section with no
+ * items renders nothing (never an empty shell or a "coming soon").
+ */
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const has = (a) => Array.isArray(a) && a.length > 0;
+let ovIcons = null;
+function icon(name, size = 22) {
+ if (!name) return '';
+ const n = String(name);
+ if (n.startsWith('ov:')) {
+ ovIcons = ovIcons || require('./ov-icons');
+ return `<span class="ov-icon sc-ovi" data-icon="${esc(n.slice(3))}" style="--ovi-size:${size}px" aria-hidden="true">${ovIcons.svg(n.slice(3))}</span>`;
+ }
+ return `<i class="fa-solid ${esc(n.replace(/[^a-z0-9 -]/gi, ''))}" aria-hidden="true"></i>`;
+}
+const id = (s) => (s ? ` id="${esc(String(s).replace(/[^a-z0-9_-]/gi, '-'))}"` : '');
+const head = (title, lede, hid) => `${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`;
+// Heading ids come from the title, so a page renders the same bytes every time (ETags, caches).
+const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section';
+const labelled = (title, h) => (title ? ` aria-labelledby="${h}"` : '');
+
+function actions(list, cls = 'sc-actions') {
+ if (!has(list)) return '';
+ return `<div class="${cls}">${list.map((a) => `<a class="sc-btn${a.primary ? ' sc-primary' : ''}" href="${esc(a.href)}"${/^https?:/.test(a.href || '') && a.external ? ' rel="noopener"' : ''}>${a.icon ? icon(a.icon, 18) : ''}${esc(a.label)}</a>`).join('')}</div>`;
+}
+
+/** The top of the page: what the product is, in one line, and where to start. */
+function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html } = {}) {
+ return `<section class="sc-hero${aside || html ? ' sc-split' : ''}"><div class="sc-hero-main">${eyebrow ? `<p class="sc-eyebrow">${esc(eyebrow)}</p>` : ''}<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : ''}</section>`;
+}
+
+/** A grid of what the product does; each card may link to where it starts. */
+function features({ id: sid, title, lede, items } = {}) {
+ if (!has(items)) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-grid">${items.map((f) => {
+ const inner = `${f.icon ? `<span class="sc-ic">${icon(f.icon)}</span>` : ''}<span><b>${esc(f.title)}</b>${f.text ? `<small>${esc(f.text)}</small>` : ''}</span>`;
+ return f.href ? `<a class="sc-card" href="${esc(f.href)}">${inner}</a>` : `<div class="sc-card">${inner}</div>`;
+ }).join('')}</div></section>`;
+}
+
+/** A numbered path from nothing to done. */
+function steps({ id: sid, title, lede, items } = {}) {
+ if (!has(items)) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ol class="sc-steps">${items.map((s) => `<li><b>${s.href ? `<a href="${esc(s.href)}">${esc(s.title)}</a>` : esc(s.title)}</b>${s.text ? `<span>${esc(s.text)}</span>` : ''}</li>`).join('')}</ol></section>`;
+}
+
+/** Code the reader can copy: one or more samples, each labelled with its language. */
+function code({ id: sid, title, lede, samples } = {}) {
+ if (!has(samples)) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-code">${samples.map((c, i) => `<figure><figcaption>${esc(c.label || c.lang || `Sample ${i + 1}`)}</figcaption><pre><code${c.lang ? ` class="language-${esc(c.lang)}"` : ''}>${esc(String(c.code || '').replace(/^\n+|\s+$/g, ''))}</code></pre></figure>`).join('')}</div></section>`;
+}
+
+/** A live demo: the product itself in a frame, or the caller's own markup. */
+function demo({ id: sid, title, lede, iframe, html } = {}) {
+ if (!iframe && html == null) return '';
+ const h = `sc-${slug(title)}`;
+ const body = iframe
+ ? `<iframe src="${esc(iframe.src)}" title="${esc(iframe.title || title || 'Live demo')}" loading="lazy" style="height:${Math.max(160, Math.min(900, Number(iframe.height) || 420))}px" referrerpolicy="strict-origin-when-cross-origin"${iframe.sandbox ? ` sandbox="${esc(iframe.sandbox)}"` : ''}></iframe>`
+ : html;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-demo">${body}</div></section>`;
+}
+
+const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));
+
+/** How the product compares, feature by feature. The first column is this product. */
+function compare({ id: sid, title, lede, columns, rows } = {}) {
+ if (!has(rows) || !has(columns)) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
+}
+
+/** Plans side by side. A price of 0 reads "Free". */
+function pricing({ id: sid, title, lede, tiers, note } = {}) {
+ if (!has(tiers)) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-tiers">${tiers.map((t) => `<div class="sc-tier${t.highlight ? ' sc-hl' : ''}"><b class="sc-tier-name">${esc(t.name)}</b><p class="sc-price">${t.price === 0 || t.price === '0' ? 'Free' : esc(t.price)}${t.per && !(t.price === 0 || t.price === '0') ? `<small> / ${esc(t.per)}</small>` : ''}</p>${has(t.items) ? `<ul>${t.items.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}${t.action ? actions([{ ...t.action, primary: t.highlight }], 'sc-actions sc-tier-act') : ''}</div>`).join('')}</div>${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</section>`;
+}
+
+/**
+ * The limits a caller really gets, as a table: pricing that equals the API's limits. `source` names where the numbers
+ * come from (a /limits.json URL), so the page can never promise more than the service enforces.
+ */
+function limits({ id: sid, title, lede, columns, rows, source } = {}) {
+ if (!has(rows) || !has(columns)) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;
+}
+
+/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */
+function stories({ id: sid, title, lede, items } = {}) {
+ if (!has(items)) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;
+}
+
+/** The closing call to action. */
+function cta({ title, text, actions: acts } = {}) {
+ if (!title) return '';
+ return `<section class="sc-cta"><h2>${esc(title)}</h2>${text ? `<p>${esc(text)}</p>` : ''}${actions(acts)}</section>`;
+}
+
+const CSS = `
+.sc-hero,.sc-sec,.sc-cta{max-width:1080px;margin:0 auto;padding:0 24px}
+.sc-hero{padding-top:56px;padding-bottom:8px}
+.sc-hero.sc-split{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:32px;align-items:center}
+.sc-eyebrow{display:inline-block;margin:0 0 14px;padding:4px 12px;border-radius:999px;border:1px solid var(--border-light,var(--border,#2c3d5c));background:var(--bg-secondary,#101828);color:var(--accent-light,var(--accent,#60a5fa));font-size:12px;font-weight:700;letter-spacing:.4px}
+.sc-hero h1{font-size:clamp(32px,4.6vw,52px);line-height:1.08;letter-spacing:-1.2px;margin:0;font-weight:800}
+.sc-accent{background:linear-gradient(120deg,var(--accent-light,#60a5fa),var(--accent,#3b82f6),var(--secondary,#22d3ee));-webkit-background-clip:text;background-clip:text;color:transparent}
+.sc-hero-lede{margin:16px 0 0;max-width:640px;font-size:17px;line-height:1.6;color:var(--text-secondary,#96a7c2)}
+.sc-hero-aside{min-width:0}
+.sc-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
+.sc-btn{display:inline-flex;align-items:center;gap:8px;padding:11px 20px;border-radius:12px;font-weight:700;font-size:15px;text-decoration:none;color:var(--text-primary,#e6edf7);border:1px solid var(--border-light,var(--border,#2c3d5c));background:transparent;transition:border-color .15s,background .15s,transform .15s}
+.sc-btn:hover,.sc-btn:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-hover,#1c2a44);outline:0}
+.sc-btn.sc-primary{border-color:transparent;color:var(--on-accent,#fff);background:linear-gradient(135deg,var(--accent,#3b82f6),var(--accent-dark,#1d4ed8))}
+.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}
+.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}
+.sc-note a{color:var(--accent-light,#60a5fa)}
+.sc-sec{margin-top:56px}
+.sc-sec>h2,.sc-cta>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
+.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
+.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
+.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;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}
+a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
+.sc-ic{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;font-size:17px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}
+.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
+.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
+.sc-steps li{counter-increment:sc;display:grid;grid-template-columns:34px 1fr;column-gap:12px;align-items:baseline}
+.sc-steps li::before{content:counter(sc);grid-row:span 2;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:13px;color:var(--accent-light,#60a5fa);border:1px solid var(--border-light,#2c3d5c);background:var(--bg-secondary,#101828)}
+.sc-steps b{font-size:15.5px}.sc-steps b a{color:inherit;text-decoration:underline;text-decoration-color:var(--accent,#3b82f6);text-underline-offset:3px}
+.sc-steps span{color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.5}
+.sc-code{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
+.sc-code figure{margin:0;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-primary,#0a0f1c);overflow:hidden;min-width:0}
+.sc-code figcaption{padding:8px 14px;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
+.sc-code pre{margin:0;padding:14px;overflow:auto;font-size:13px;line-height:1.55}
+.sc-demo{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));overflow:hidden;background:var(--bg-secondary,#101828)}
+.sc-demo iframe{display:block;width:100%;border:0;background:var(--bg-primary,#0a0f1c)}
+.sc-table-wrap{overflow-x:auto;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08))}
+.sc-table{width:100%;border-collapse:collapse;font-size:14px}
+.sc-table th,.sc-table td{padding:11px 14px;text-align:left;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
+.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
+.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
+.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
+.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
+.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
+.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
+.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
+.sc-tier-name{font-size:13px;letter-spacing:.6px;text-transform:uppercase;color:var(--text-muted,#7386a3)}
+.sc-price{margin:6px 0 10px;font-size:28px;font-weight:800}.sc-price small{font-size:14px;font-weight:600;color:var(--text-secondary,#96a7c2)}
+.sc-tier ul{margin:0;padding-left:18px;color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.6}
+.sc-tier-act{margin-top:auto;padding-top:14px}
+.sc-stories{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}
+.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
+.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}
+.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}
+.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}
+.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: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{transition:none}}
+`;
+
+module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, cta, CSS, esc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+// openvibe-shared/showcase: escaping, empty sections, deterministic output, the pieces each section promises.
+const assert = require('assert');
+const sc = require('../showcase');
+
+const hero = sc.hero({ eyebrow: 'E', title: 'Build <on>', accent: 'OpenVibe', lede: 'L', actions: [{ label: 'Go', href: '/go?a=1&b=2', primary: true, icon: 'fa-plus' }], note: 'N' });
+assert.ok(hero.includes('<h1>Build <on><span class="sc-accent"> OpenVibe</span></h1>'), 'the title is escaped');
+assert.ok(hero.includes('href="/go?a=1&b=2"') && hero.includes('sc-btn sc-primary'), 'actions are links, escaped');
+assert.ok(sc.hero({ title: 'T', aside: { html: '<pre>x</pre>' } }).includes('<div class="sc-hero-aside"><pre>x</pre></div>'), 'aside.html passes through');
+
+for (const [name, out] of Object.entries({
+ features: sc.features({ title: 'F', items: [] }), steps: sc.steps({ title: 'S' }), code: sc.code({ title: 'C', samples: [] }),
+ demo: sc.demo({ title: 'D' }), compare: sc.compare({ title: 'X', columns: ['a'], rows: [] }), pricing: sc.pricing({ title: 'P', tiers: [] }),
+ limits: sc.limits({ title: 'L', columns: [], rows: [{ label: 'r' }] }), stories: sc.stories({ title: 'St' }), cta: sc.cta({}),
+})) assert.strictEqual(out, '', `${name} with nothing to show renders nothing`);
+
+const f = sc.features({ title: 'What you get', items: [{ icon: 'fa-key', title: '<b>', text: 't', href: '/k' }, { icon: 'ov:tools', title: 'Tools' }] });
+assert.ok(f.includes('aria-labelledby="sc-what-you-get"') && f.includes('id="sc-what-you-get"'), 'the heading labels its section');
+assert.ok(f.includes('<a class="sc-card" href="/k">') && f.includes('<b>') && f.includes('<div class="sc-card">'), 'linked and plain cards');
+assert.ok(f.includes('data-icon="tools"'), 'ov: icons render the shared ring icon');
+assert.strictEqual(f, sc.features({ title: 'What you get', items: [{ icon: 'fa-key', title: '<b>', text: 't', href: '/k' }, { icon: 'ov:tools', title: 'Tools' }] }), 'the same input renders the same bytes');
+
+const c = sc.compare({ title: 'Compare', columns: ['Us', 'Them'], rows: [{ label: 'Open', values: [true, false] }, { label: 'Price', values: ['Free', 'Paid'] }] });
+assert.ok(c.includes('aria-label="yes"') && c.includes('aria-label="no"') && c.includes('<td class="sc-us">Free</td>'));
+const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'month', items: ['a'] }, { name: 'Pro', price: '$5', per: 'month', highlight: true, action: { label: 'Buy', href: '/buy' } }] });
+assert.ok(p.includes('<p class="sc-price">Free</p>') && p.includes('$5<small> / month</small>') && p.includes('sc-tier sc-hl'));
+const l = sc.limits({ title: 'Limits', columns: ['Free'], rows: [{ label: 'Requests per minute', values: [60] }], source: 'https://openvibe.network/limits.json' });
+assert.ok(l.includes('<td>60</td>') && l.includes('href="https://openvibe.network/limits.json"'), 'limits cite their source');
+const code = sc.code({ title: 'Code', samples: [{ label: 'Node', lang: 'js', code: '\n<script>x</script>\n' }] });
+assert.ok(code.includes('class="language-js"') && code.includes('<script>x</script></code>'), 'code is escaped and trimmed');
+const d = sc.demo({ title: 'Try it', iframe: { src: 'https://x.example/?a=1&b=2', height: 5000 } });
+assert.ok(d.includes('loading="lazy"') && d.includes('height:900px') && d.includes('&b=2'), 'a demo frame is lazy and bounded');
+assert.ok(typeof sc.CSS === 'string' && sc.CSS.includes('.sc-card') && sc.CSS.includes('prefers-reduced-motion'));
+console.log('showcase: all checks passed');