openvibers/openvibe.shared showcase.js

showcase.js

main 75.6 KB · 821 lines · javascript

1'use strict';
2/**
3 * openvibe-shared/showcase — the sections a product's home page is built from (plan T11, D92), rendered on the
4 * server as plain HTML (complete without JavaScript, readable by crawlers) and styled from the network's theme
5 * tokens, so every product's home reads as one family:
6 *
7 * const sc = require('openvibe-shared/showcase');
8 * `<link rel="stylesheet" href="${serve.url('showcase.css')}">` (cached; carries the ring icons' CSS too) or
9 * `<style>${sc.CSS}</style>`, then sc.hero({ eyebrow, title, accent, lede, actions, note, aside }) (an eyebrow that is
10 * only the site's name is left out: the navbar shows it; { icon, text } gives it an icon)
11 * + sc.features({ id, title, lede, items: [{ icon, title, text, href }] })
12 * + sc.steps({ title, lede, items: [{ title, text, href }] })
13 * + sc.code({ title, lede, samples: [{ label, lang, code }] })
14 * + sc.demo({ title, lede, iframe: { src, title, height } }) (or html: trusted markup)
15 * + sc.compare({ title, lede, columns: ['OpenVibe', 'Elsewhere'], rows: [{ label, values: [true, 'paid add-on'] }] })
16 * + sc.pricing({ title, lede, tiers: [{ name, price, per, items, action, highlight }], note })
17 * + sc.limits({ title, lede, columns, rows: [{ label, values }], source }) pricing that equals the API's limits
18 * + sc.stories({ title, items: [{ who, text, outcome }] })
19 * + sc.stats({ title, items: [{ value, label, href, icon }] }) a strip of real numbers (three across on a phone)
20 * + sc.cards({ title, lede, items: [{ href, title, text, image, badge, meta, live }], layout: 'grid'|'rail',
21 * ratio: '16/9'|'4/3'|'1/1', more: { label, href } }) rich content: videos, streams, posts
22 * + sc.spotlight({ title, lede, items: [{ title, text, image, video, alt, href, actions }] }) features, shown
23 * + sc.faq({ title, items: [[question, answer]] }) an accordion, with FAQPage data (faqJsonLd)
24 * + sc.network({ service, title, lede, items }) "Part of OpenVibe": related products
25 * + sc.cta({ title, text, actions })
26 * sc.jsonLd(obj) is a <script type="application/ld+json"> that cannot end early ('<' escaped).
27 *
28 * Every string is escaped; the only markup that passes through is a field named `html` (trusted, the caller's own).
29 * `icon` is a Font Awesome class ('fa-code') or an openvibe-shared/icons name ('ov:tools'). `actions` are
30 * [{ label, href, primary?, icon? }]. Nothing here reads data: the page passes what it knows, and a section with no
31 * items renders nothing (never an empty shell or a "coming soon").
32 */
33const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
34const has = (a) => Array.isArray(a) && a.length > 0;
35let ovIcons = null;
36let glyphSet = null;
37/** 4.227.0 (design rule 142): a site or an app is drawn as its app icon, the menu's and Home's, whole on the server. */
38function appIcon(key, size) {
39 ovIcons = ovIcons || require('./ov-icons');
40 return ovIcons.appHtml(String(key || 'ov').toLowerCase(), { size });
41}
42function icon(name, size = 22) {
43 if (!name) return '';
44 const n = String(name);
45 if (n.startsWith('ov:')) {
46 ovIcons = ovIcons || require('./ov-icons');
47 const key = n.slice(3);
48 // A name the ring set lacks drew as the bare OV mark (Today's "Your agenda", ov:calendar, 2026-11-02): the plain
49 // glyph of that name, in a ring of its own, when the glyph set has it.
50 if (key !== 'ov' && ovIcons.resolve(key) === 'ov') {
51 glyphSet = glyphSet || require('./glyphs');
52 if (glyphSet.NAMES.includes(key)) return `<span class="ov-icon sc-ovi sc-ovi--glyph" data-icon="${esc(key)}" style="--ovi-size:${size}px" aria-hidden="true">${glyphSet.svg(key)}</span>`;
53 }
54 return `<span class="ov-icon sc-ovi" data-icon="${esc(key)}" style="--ovi-size:${size}px" aria-hidden="true">${ovIcons.svg(key)}</span>`;
55 }
56 return `<i class="fa-solid ${esc(n.replace(/[^a-z0-9 -]/gi, ''))}" aria-hidden="true"></i>`;
57}
58const id = (s) => (s ? ` id="${esc(String(s).replace(/[^a-z0-9_-]/gi, '-'))}"` : '');
59// The arrow on a "more" link is the glyph registry's (no text arrows: the owner, 11-13).
60const goGlyph = () => { headGlyphs = headGlyphs || require('./glyphs'); return headGlyphs.svg('arrow-right', { size: 16 }); };
61// Every section's heading carries an icon tile (the owner, 11-09: "these section titles should have an icon… look nicer
62// and more consistent, separate things"): the section's kind gives a default, a site may pass its own (icon: 'name').
63let headGlyphs = null;
64const headIcon = (name) => {
65 if (!name) return '';
66 headGlyphs = headGlyphs || require('./glyphs');
67 const n = String(name).replace(/^ov:/, '');
68 const key = headGlyphs.NAMES.includes(n) ? n : (headGlyphs.fromFa ? headGlyphs.fromFa(n) : null);
69 return key && headGlyphs.path(key) ? `<span class="sc-h-ic" aria-hidden="true">${headGlyphs.svg(key, { size: 18 })}</span>` : '';
70};
71const head = (title, lede, hid, ic) => `${title ? `<h2${hid ? ` id="${hid}"` : ''}>${headIcon(ic)}<span>${esc(title)}</span></h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`;
72// Heading ids come from the title, so a page renders the same bytes every time (ETags, caches).
73const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section';
74const labelled = (title, h) => (title ? ` aria-labelledby="${h}"` : '');
75
76function actions(list, cls = 'sc-actions') {
77 if (!has(list)) return '';
78 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>`;
79}
80
81// A site's own name as the eyebrow repeats the navbar's brand right above it ("OpenVibe.Quest" twice, the owner
82// 2026-10-28): such an eyebrow is left out. An eyebrow that says something else stays ({ icon, text } adds an icon).
83const SITE_NAME_RE = /^(?:OpenVibe\.[A-Za-z]+|OpenRestream|[A-Za-z]+\.OpenVibe\.[A-Za-z]+)$/;
84// "OpenVibe.Food · What should we eat?": the name repeats the navbar's, so the eyebrow keeps only what follows it
85// (4.26.1; the owner, 2026-10-28: "why is [the name] twice… in the navbar and then right after it").
86const SITE_NAME_LEAD_RE = /^(?:OpenVibe\.[A-Za-z]+|OpenRestream|[A-Za-z]+\.OpenVibe\.[A-Za-z]+)\s*[·•|—–:-]\s*/;
87// 4.159.0: the hero shows no eyebrow at all (the owner, 11-09, on the pill above every home's headline: "looks really
88// cheesy… lame… every site has this generic look"). The headline and its lede say what the site is; `eyebrow` is ignored.
89function eyebrowHTML(eyebrow) {
90 const e = eyebrow && typeof eyebrow === 'object' ? eyebrow : { text: eyebrow };
91 let text = String(e.text || '').trim();
92 if (!text || SITE_NAME_RE.test(text)) return '';
93 text = text.replace(SITE_NAME_LEAD_RE, '');
94 text = text.charAt(0).toUpperCase() + text.slice(1);
95 return `<p class="sc-eyebrow">${e.icon ? icon(e.icon, 16) : ''}${esc(text)}</p>`;
96}
97
98/** The top of the page: what the product is, in one line, and where to start. */
99/**
100 * The hero's visual (4.26.0; the owner, 2026-10-28: the heroes were "ugly af… boring… not easy to understand… nice
101 * animated visuals"). An orbit when the site names its places ({ inner: [{ icon, label, href }], outer: [...], center:
102 * { title, sub, href }, label }): the main ones turn on the inner ring with their names, more on the outer ring, every icon
103 * upright. Otherwise the aura: the site's mark in three slowly turning rings with accent dots. Transforms only, still
104 * under either reduced-motion setting. On a phone an orbit sits small above the words, the aura faintly behind them.
105 */
106function visualHTML(orbit) {
107 const mark = '<span class="ov-mark sc-vis-mark" data-size="72" style="width:72px;height:72px"></span>';
108 if (!orbit) {
109 return `<div class="sc-vis sc-aura" aria-hidden="true"><span class="sc-aura-glow"></span><span class="sc-ring r3"><i></i></span><span class="sc-ring r2"><i></i><i></i></span><span class="sc-ring r1"><i></i></span>${mark}</div>`;
110 }
111 const inner = (orbit.inner || []).filter((it) => it && it.icon).slice(0, 8);
112 const outer = (orbit.outer || []).filter((it) => it && it.icon).slice(0, 12);
113 const node = (it, i, n, big) => {
114 const href = safeHref(it.href);
115 const face = `<span class="sc-orb-face">${icon(it.icon, big ? 24 : 18)}${big && it.label ? `<span class="sc-orb-name">${esc(it.label)}</span>` : ''}</span>`;
116 const attrs = ` style="--i:${i};--n:${n}"${it.label ? ` title="${esc(it.label)}"` : ''}`;
117 return href ? `<a class="sc-orb-node${big ? ' is-big' : ''}" href="${esc(href)}"${attrs}${it.label ? ` aria-label="${esc(it.label)}"` : ''}>${face}</a>` : `<span class="sc-orb-node${big ? ' is-big' : ''}"${attrs} aria-hidden="true">${face}</span>`;
118 };
119 const c = orbit.center || {};
120 const core = `${mark}${c.title ? `<b>${esc(c.title)}</b>` : ''}${c.sub ? `<small>${esc(c.sub)}</small>` : ''}`;
121 const coreHref = safeHref(c.href);
122 return `<div class="sc-vis sc-orbit" role="group" aria-label="${esc(orbit.label || c.title || 'What is here')}"><span class="sc-aura-glow" aria-hidden="true"></span>`
123 + `${outer.length ? `<div class="sc-orb-ring o2">${outer.map((it, i) => node(it, i, outer.length, false)).join('')}</div>` : ''}`
124 + `${inner.length ? `<div class="sc-orb-ring o1">${inner.map((it, i) => node(it, i, inner.length, true)).join('')}</div>` : ''}`
125 + `${coreHref ? `<a class="sc-orb-core" href="${esc(coreHref)}">${core}</a>` : `<span class="sc-orb-core">${core}</span>`}</div>`;
126}
127
128function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html, orbit, visual, pageHead: ph = null } = {}) {
129 // Without an aside of its own the hero gets a visual (4.26.0): an orbit of the site's places when it names them, else
130 // the aura. visual: false keeps it plain.
131 const vis = !aside && html == null && visual !== false;
132 const hasOrbit = vis && orbit && (has(orbit.inner) || has(orbit.outer));
133 return `<section class="sc-hero${aside || html || vis ? ' sc-split' : ''}${vis ? ` sc-has-vis${hasOrbit ? ' sc-has-orbit' : ''}` : ''}"><div class="sc-hero-main">${ph ? require('./kit').pageHead({ ...ph, title: [title, accent].filter(Boolean).join(' '), lede }) : `<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>` : vis ? `<div class="sc-hero-aside sc-hero-vis">${visualHTML(hasOrbit ? orbit : null)}</div>` : ''}</section>`;
134}
135
136/** A grid of what the product does; each card may link to where it starts. */
137function features({ id: sid, title, lede, items, icon: secIcon } = {}) {
138 if (!has(items)) return '';
139 const h = `sc-${slug(title)}`;
140 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'grid')}<div class="sc-grid">${items.map((f) => {
141 // A ring icon (ov:…) is its own 40 px tile, so it fills the slot instead of sitting small inside a second tile.
142 const ring = f.icon && String(f.icon).startsWith('ov:');
143 const inner = `${f.icon ? `<span class="sc-ic${ring ? ' sc-ic-ov' : ''}">${icon(f.icon, ring ? 40 : 22)}</span>` : ''}<span><b>${esc(f.title)}</b>${f.text ? `<small>${esc(f.text)}</small>` : ''}</span>`;
144 return f.href ? `<a class="sc-card" href="${esc(f.href)}">${inner}</a>` : `<div class="sc-card">${inner}</div>`;
145 }).join('')}</div></section>`;
146}
147
148/** A numbered path from nothing to done. */
149function steps({ id: sid, title, lede, items, icon: secIcon } = {}) {
150 if (!has(items)) return '';
151 const h = `sc-${slug(title)}`;
152 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'steps')}<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>`;
153}
154
155/** Code the reader can copy: one or more samples, each labelled with its language. */
156function code({ id: sid, title, lede, samples, icon: secIcon } = {}) {
157 if (!has(samples)) return '';
158 const h = `sc-${slug(title)}`;
159 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'code')}<div class="sc-code">${samples.map((c, i) => `<figure><figcaption>${esc(c.label || c.lang || `Sample ${i + 1}`)}</figcaption><pre tabindex="0" aria-label="${esc(c.label || c.lang || `Sample ${i + 1}`)}"><code${c.lang ? ` class="language-${esc(c.lang)}"` : ''}>${esc(String(c.code || '').replace(/^\n+|\s+$/g, ''))}</code></pre></figure>`).join('')}</div></section>`;
160}
161
162/** A live demo: the product itself in a frame, or the caller's own markup. */
163function demo({ id: sid, title, lede, iframe, html, icon: secIcon } = {}) {
164 if (!iframe && html == null) return '';
165 const h = `sc-${slug(title)}`;
166 const body = iframe
167 ? `<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>`
168 : html;
169 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'play')}<div class="sc-demo">${body}</div></section>`;
170}
171
172/**
173 * Read-once material — how it works, the code samples, what every page shows — folded under its own heading. The
174 * heading stays visible and keeps its id, so a link to it still lands and the page outline does not change; `hint` is a
175 * short line beside it on wide screens. `html` is the caller's markup (other showcase sections nest: they lose their
176 * own width and padding inside). Closed by default; `open: true` for a fold the reader should see first.
177 */
178function fold({ id: sid, title, hint, html, open = false } = {}) {
179 if (!title || html == null || html === '') return '';
180 const h = `sc-${slug(title)}`;
181 return `<section class="sc-sec sc-fold-sec"${id(sid)} aria-labelledby="${h}"><details class="sc-fold"${open ? ' open' : ''}><summary><h2 id="${h}">${esc(title)}</h2>${hint ? `<span class="sc-fold-hint">${esc(hint)}</span>` : ''}</summary><div class="sc-fold-body">${html}</div></details></section>`;
182}
183
184/**
185 * parts({ name, items: [{ id, label, count, html }], label, front }) — 4.228.0: a page of sections shown one at a time as
186 * tabs on a phone (the owner, 11-11: "consolidate these into tabs, self-contained tabs, so the page isn't so freaking
187 * huge … at least on mobile"; design rule short-pages-tabs). Every part stays in the HTML (crawlers, AI readers, no
188 * script): radios and :has(), so a browser without :has() simply stacks them. A tab names its part and its count, so a
189 * part's own showcase heading steps aside on a phone (it still names the section for screen readers). Wide screens keep
190 * the parts stacked. Phones are 900 px and under; a front page (front: true) tabs only at 600 px and under. Two to eight
191 * parts get tabs; one, or more than eight, are stacked as they are. Parts with no html are left out, never an empty tab.
192 */
193function parts({ name = 'parts', items = [], label = 'Sections', front = false, checked = '' } = {}) {
194 const list = (items || []).filter((x) => x && x.id != null && x.label && x.html != null && x.html !== '');
195 if (!list.length) return '';
196 const n = slug(name);
197 const key = (x) => String(x.id).replace(/[^a-z0-9_-]/gi, '-');
198 const body = list.map((x) => `<section class="ov-part" data-part="${esc(key(x))}">${x.html}</section>`).join('');
199 if (list.length < 2 || list.length > 8) return `<div class="ov-parts is-stacked">${body}</div>`;
200 const open = checked && list.some((x) => String(x.id) === String(checked)) ? String(checked) : String(list[0].id);
201 const radios = list.map((x) => `<input type="radio" class="ov-parts-r" name="ovp-${n}" id="ovp-${n}-${key(x)}" value="${esc(key(x))}"${String(x.id) === open ? ' checked' : ''}>`).join('');
202 const bar = list.map((x) => `<label for="ovp-${n}-${key(x)}">${esc(x.label)}${x.count != null && x.count !== '' ? ` <span class="ov-parts-n">${esc(x.count)}</span>` : ''}</label>`).join('');
203 return `<div class="ov-parts${front ? ' ov-parts--front' : ''}" data-parts="${list.length}">${radios}<div class="ov-parts-bar" role="group" aria-label="${esc(label)}">${bar}</div>${body}</div>`;
204}
205
206/**
207 * pick({ key, label, head, headId, bodyClass, panes: [{ id, label, html }], checked, front = true }) — 4.228.0: the "pick block" twenty-five
208 * sites each kept a copy of (server/render/short.js), now one: a heading over the page's own blocks, one at a time as
209 * tabs on a phone (parts()), every block in the HTML. Each pane keeps its id, so a link to it opens its tab. The copies
210 * drew a row of chips that only jumped to a pane, so a phone still showed them all (the owner, 11-12: "You also never
211 * released the new GUI with the tabs"). checked: the pane open first (else the first). A pane with no html is left out.
212 * It links ov-parts.css itself (by its content hash), so no site has to.
213 */
214function pick({ key = 'pick', label = 'Sections', head = '', headId = '', bodyClass = '', panes = [], checked = '', front = true } = {}) {
215 const list = (panes || []).filter((p) => p && p.id && p.label && p.html != null && p.html !== '');
216 if (!list.length) return '';
217 serveMod = serveMod || require('./serve');
218 const items = list.map((p) => ({ id: p.id, label: p.label, html: `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>` }));
219 return `<link rel="stylesheet" href="${esc(serveMod.url('ov-parts.css'))}"><div class="pick" id="pick-${esc(String(key).replace(/[^a-z0-9_-]/gi, '-'))}"><div class="pick-bar"><div class="pick-head"><h2${headId ? ` id="${esc(headId)}"` : ''}>${esc(head || label)}</h2></div></div><div class="pick-body${bodyClass ? ` ${esc(bodyClass)}` : ''}">${parts({ name: key, label, front, items, checked: list.some((p) => p.id === checked) ? checked : '' })}</div></div>`;
220}
221let serveMod = null;
222
223/** parts()' look: phones see one part and a tab bar (generated for one to eight parts, at 900 px or, for a front page, 600). */
224const PARTS_CSS = (() => {
225 const on = (scope) => {
226 const k = [1, 2, 3, 4, 5, 6, 7, 8];
227 return [
228 `${scope}>.ov-parts-r{display:block;position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}`,
229 `${scope}>.ov-parts-bar{display:flex}`,
230 `${scope}>.ov-part>.sc-sec>h2:first-child,${scope}>.ov-part>.sc-sec>.sc-fold>summary>h2{display:none}`,
231 `${scope}>.ov-part>.sc-sec{margin-top:0;padding-top:0;border-top:0}`,
232 ...k.map((i) => `${scope}:has(>.ov-parts-r:nth-of-type(${i}):checked)>.ov-parts-bar>label:nth-of-type(${i}){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}`),
233 ...k.map((i) => `${scope}:has(>.ov-parts-r:nth-of-type(${i}):checked)>.ov-part:not(:nth-of-type(${i})){display:none}`),
234 `${scope}:has(>.ov-parts-r:focus-visible)>.ov-parts-bar{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}`,
235 // A link to something inside a part (#faq, a hero's anchor) opens that part, whichever tab was chosen.
236 `${scope}:has(>.ov-part:target,>.ov-part :target)>.ov-part:not(:target):not(:has(:target)){display:none}`,
237 `${scope}:has(>.ov-part:target,>.ov-part :target)>.ov-part:is(:target,:has(:target)){display:block!important}`,
238 ].join('\n');
239 };
240 return [
241 '.ov-parts-r,.ov-parts-bar{display:none}',
242 '.ov-parts-bar{gap:4px;margin:18px 0 14px;padding:4px;border-radius:14px;background:color-mix(in srgb,var(--text-primary,#e6edf7) 5%,transparent);overflow-x:auto;scrollbar-width:none;overscroll-behavior-x:contain}.ov-parts-bar::-webkit-scrollbar{display:none}',
243 '.ov-parts-bar label{flex:1 0 auto;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:42px;padding:0 14px;border-radius:10px;font-size:13.5px;font-weight:700;color:var(--text-secondary,#a8b3c4);white-space:nowrap;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background-color .2s,color .2s}',
244 '.ov-parts-bar label:active{transform:scale(.97)}',
245 '.pick .ov-part+.ov-part{margin-top:26px}.pick .ov-parts-bar{margin-top:10px}',
246 '.ov-parts-n{min-width:18px;padding:0 6px;border-radius:999px;font-size:11.5px;line-height:18px;text-align:center;font-variant-numeric:tabular-nums;background:color-mix(in srgb,var(--text-primary,#e6edf7) 9%,transparent)}',
247 `@supports selector(:has(*)){@media (max-width:900px){\n${on('.ov-parts:not(.is-stacked):not(.ov-parts--front)')}\n}@media (max-width:600px){\n${on('.ov-parts.ov-parts--front')}\n}}`,
248 'html[data-ov-motion=off] .ov-parts-bar label{transition:none}',
249 ].join('\n');
250})();
251
252const 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));
253
254// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name
255// (axe scrollable-region-focusable, WCAG 2.1.1): focusable, and labelled by the section's heading.
256const tableWrap = (title, h) => `<div class="sc-table-wrap" tabindex="0" role="region" ${title ? `aria-labelledby="${h}"` : 'aria-label="Table"'}>`;
257
258/** How the product compares, feature by feature. The first column is this product. */
259function compare({ id: sid, title, lede, columns, rows, icon: secIcon } = {}) {
260 if (!has(rows) || !has(columns)) return '';
261 const h = `sc-${slug(title)}`;
262 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'chart')}${tableWrap(title, h)}<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>`;
263}
264
265/** Plans side by side. A price of 0 reads "Free". */
266function pricing({ id: sid, title, lede, tiers, note, icon: secIcon } = {}) {
267 if (!has(tiers)) return '';
268 const h = `sc-${slug(title)}`;
269 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'coin')}<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>`;
270}
271
272/**
273 * The limits a caller really gets, as a table: pricing that equals the API's limits. `source` names where the numbers
274 * come from (a /limits.json URL), so the page can never promise more than the service enforces.
275 */
276function limits({ id: sid, title, lede, columns, rows, source, icon: secIcon } = {}) {
277 if (!has(rows) || !has(columns)) return '';
278 const h = `sc-${slug(title)}`;
279 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'gauge')}${tableWrap(title, h)}<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>`;
280}
281
282/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */
283function stories({ id: sid, title, lede, items, icon: secIcon } = {}) {
284 if (!has(items)) return '';
285 const h = `sc-${slug(title)}`;
286 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'quote')}<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>`;
287}
288
289// A next step's target names: the service id → the product name the card says it opens ("on OpenVibe.Media").
290const SERVICE_NAME = (to) => {
291 const t = String(to || '').toLowerCase();
292 if (!/^[a-z][a-z0-9-]{0,31}$/.test(t)) return '';
293 if (t === 'openre') return 'OpenRestream';
294 return `OpenVibe.${t.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('')}`;
295};
296// Only links a person can follow safely: https anywhere, or a path on this site. A path may hold no backslash and no
297// tab or newline: the URL parser turns \ into / and drops tab/LF/CR, so "/\evil.example" would leave the site.
298const safeHref = (h) => {
299 const s = String(h || '');
300 if (/^https:\/\//i.test(s)) return s;
301 return /^\/(?!\/)[^\t\n\r\\]*$/.test(s) ? s : '';
302};
303const withFrom = (href, from) => {
304 if (!from || !/^[a-z][a-z0-9-]{0,31}$/.test(String(from))) return href;
305 const [base, hash = ''] = href.split('#');
306 return `${base}${base.includes('?') ? '&' : '?'}ov_from=${encodeURIComponent(from)}${hash ? `#${hash}` : ''}`;
307};
308
309/**
310 * Next steps on other OpenVibe products (Shared ADR 0002, plan D94): after a person finishes something, up to four
311 * concrete things to do next, each a real link that works signed out. Items: `{ to, what, text?, href, from?, embed? }`
312 * — `to` picks the ring icon and the "on OpenVibe.X" label, `from` adds the referral tag `ov_from=<from>` (never anything
313 * about the person), `embed` adds a secondary link to an embeddable view. Presentation only: each site passes its own
314 * items. An item without `what` or a safe `href` is left out; no items → ''.
315 */
316function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {
317 const usable = (items || []).filter((s) => s && s.what && safeHref(s.href)).slice(0, 4);
318 if (!usable.length) return '';
319 const h = `sc-${slug(title)}`;
320 return `<section class="sc-sec sc-next"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ol class="sc-next-list">${usable.map((s) => {
321 const to = SERVICE_NAME(s.to);
322 const href = withFrom(safeHref(s.href), s.from);
323 const embed = safeHref(s.embed);
324 return `<li class="sc-next-item"><a class="sc-next-main" href="${esc(href)}">${s.to && to ? appIcon(s.to, 44) : ''}<span class="sc-next-body"><b>${esc(s.what)}</b>${s.text ? `<small>${esc(s.text)}</small>` : ''}${to ? `<span class="sc-next-on">on ${esc(to)}</span>` : ''}</span><span class="sc-next-go" aria-hidden="true">${goGlyph()}</span></a>${embed ? `<a class="sc-next-embed" href="${esc(embed)}">Embed it</a>` : ''}</li>`;
325 }).join('')}</ol></section>`;
326}
327
328/** A strip of real numbers ("12,400 streams", "184 tools"). The page passes what it measured; nothing is invented here. */
329function stats({ id: sid, title, items, icon: secIcon } = {}) {
330 const usable = (items || []).filter((x) => x && x.value != null && x.label);
331 if (!usable.length) return '';
332 const h = `sc-${slug(title || 'numbers')}`;
333 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) => {
334 const inner = `<dt>${esc(x.label)}</dt><dd>${x.icon ? `<span class="sc-stat-ic">${icon(x.icon, 18)}</span>` : ''}${esc(x.value)}</dd>`;
335 const href = safeHref(x.href);
336 return href ? `<a class="sc-stat" href="${esc(href)}">${inner}</a>` : `<div class="sc-stat">${inner}</div>`;
337 }).join('')}</dl></section>`;
338}
339
340const RATIOS = { '16/9': '16 / 9', '4/3': '4 / 3', '1/1': '1 / 1', '3/4': '3 / 4', '9/16': '9 / 16' };
341/**
342 * Rich content: live streams, videos, posts, products, listings. `layout: 'rail'` is one swipeable row (scroll snap) for
343 * the top of a page; 'grid' (the default) wraps. Images are lazy and hold their space (`ratio`), so nothing jumps.
344 * `live: true` adds the red LIVE badge; `badge` is any short label, `meta` a quiet line under the title.
345 */
346function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', more, icon: secIcon } = {}) {
347 const usable = (items || []).filter((c) => c && c.title && safeHref(c.href));
348 if (!usable.length) return '';
349 const h = `sc-${slug(title)}`;
350 const ar = RATIOS[ratio] || RATIOS['16/9'];
351 const moreHref = more && safeHref(more.href);
352 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${title || moreHref ? `<div class="sc-head-row"><div>${head(title, lede, h, secIcon || 'image')}</div>${moreHref ? `<a class="sc-more" href="${esc(moreHref)}">${esc(more.label || 'See all')} <span class="sc-go" aria-hidden="true">${goGlyph()}</span></a>` : ''}</div>` : ''}<ul class="sc-cards sc-${layout === 'rail' ? 'rail' : 'wrap'}" style="--sc-ar:${ar}">${usable.map((c) => {
353 const img = /^(https:\/\/|\/(?!\/))/.test(c.image || '') ? `<img src="${esc(c.image)}" alt="${esc(c.alt || '')}" loading="lazy" decoding="async">` : '';
354 const badge = c.live ? '<span class="sc-badge sc-live">LIVE</span>' : c.badge ? `<span class="sc-badge">${esc(c.badge)}</span>` : '';
355 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>`;
356 }).join('')}</ul></section>`;
357}
358
359/** Features, shown rather than listed: a picture or a short muted loop beside each, alternating sides. */
360function spotlight({ id: sid, title, lede, items, icon: secIcon } = {}) {
361 const usable = (items || []).filter((x) => x && x.title);
362 if (!usable.length) return '';
363 const h = `sc-${slug(title)}`;
364 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'star')}<div class="sc-spots">${usable.map((x) => {
365 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>` : '';
366 const img = !video && /^(https:\/\/|\/(?!\/))/.test(x.image || '') ? `<img src="${esc(x.image)}" alt="${esc(x.alt || '')}" loading="lazy" decoding="async">` : '';
367 const href = safeHref(x.href);
368 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>`;
369 }).join('')}</div></section>`;
370}
371
372/** Questions people ask, as an accordion that works without JavaScript. Pair it with faqJsonLd(items) in the head. */
373function faq({ id: sid, title = 'Questions', lede, items, icon: secIcon } = {}) {
374 const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);
375 if (!usable.length) return '';
376 const h = `sc-${slug(title)}`;
377 return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'help')}<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>`;
378}
379/** A JSON-LD block that cannot close its script early. */
380const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
381const 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) } })) });
382
383/**
384 * Where each home page leads next on the network: the other places in the same menu group, in the group's own order
385 * (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of 2026-11-10), so "More on OpenVibe" shows the
386 * same neighbourhood the launcher and the footer show. Up to four. [to, what, href]; `to` is the name and icon the
387 * card shows (Email is `email`, OpenRestream `openre`, Studio `studio`, Locker `locker`).
388 */
389const GROUPS = {
390 watch: [
391 ['live', 'Go live, watch streams', 'https://openvibe.live/'],
392 ['video', 'Clips and past streams', 'https://openvibe.video/'],
393 ['pics', 'Share pictures', 'https://openvibe.pics/'],
394 ['download', 'Your files', 'https://openvibe.download/'],
395 ['openre', 'Stream everywhere', 'https://openre.stream/'],
396 ['cam', 'Your camera, in private', 'https://openvibe.cam/'],
397 ['tours', 'Cities, routes, stays', 'https://openvibe.tours/'],
398 ],
399 talk: [
400 ['community', 'Posts and pastes', 'https://openvibe.community/'],
401 ['chat', 'Chat with anyone', 'https://openvibe.chat/'],
402 ['tips', 'Support streamers', 'https://openvibe.tips/'],
403 ['vip', 'Back your creators', 'https://openvibe.vip/'],
404 ['email', 'Masks and inboxes', 'https://openvibe.email/'],
405 ['help', 'Help nearby', 'https://openvibe.help/'],
406 ['network', 'Your account', 'https://openvibe.network/'],
407 ],
408 play: [
409 ['games', 'Play in your browser', 'https://openvibe.games/'],
410 ['quest', 'Earn badges', 'https://openvibe.quest/'],
411 ['surf', 'Surf maps and the web', 'https://openvibe.surf/'],
412 ['fun', 'Something to do', 'https://openvibe.fun/'],
413 ['meme', 'Memes with receipts', 'https://openvibe.meme/'],
414 ['casino', 'Odds shown, chips free', 'https://openvibe.casino/'],
415 ['inventory', 'Your items', 'https://openvibe.my/inventory'],
416 ],
417 read: [
418 ['news', 'Sourced, not spun', 'https://openvibe.news/'],
419 ['today', 'Your schedule', 'https://openvibe.today/'],
420 ['report', 'Your numbers, honest', 'https://openvibe.report/'],
421 ['search', 'Search everything', 'https://search.openvibe.network/'],
422 ['wiki', 'Facts with sources', 'https://openvibe.wiki/'],
423 ['blog', 'Write long-form', 'https://openvibe.blog/'],
424 ['vision', 'Ask a picture', 'https://openvibe.vision/'],
425 ],
426 shop: [
427 ['deals', 'Deals people share', 'https://openvibe.deals/'],
428 ['coupons', 'Codes that work', 'https://openvibe.coupons/'],
429 ['shoes', 'Every shoe, every price', 'https://open.shoes/'],
430 ['reviews', 'Honest reviews', 'https://openvibe.reviews/'],
431 ['trade', 'Watch the markets', 'https://openvibe.trade/'],
432 ['rent', 'Places to rent', 'https://openvibe.rent/'],
433 ['work', 'Find a job', 'https://openvibe.work/'],
434 ],
435 tools: [
436 ['tools', 'Every online tool', 'https://openvibe.tools/'],
437 ['design', 'Try themes live', 'https://openvibe.design/'],
438 ['domains', 'Names and prices', 'https://openvibe.domains/'],
439 ['host', 'Compare hosting', 'https://openvibe.host/'],
440 ['studio', 'Edit photos in your browser', 'https://openvibe.studio/'],
441 ['locker', 'Files only you can open', 'https://openvibe.locker/'],
442 ['food', 'Food near you', 'https://openvibe.food/'],
443 ],
444 make: [
445 ['space', 'Host a site or app', 'https://openvibe.space/'],
446 ['codes', 'Code with any agent', 'https://openvibe.codes/'],
447 ['bot', 'Drive a robot', 'https://openvibe.bot/'],
448 ['actor', 'The agent engine', 'https://openvibe.actor/'],
449 ['computer', 'Your computer, your way', 'https://openvibe.computer/'],
450 ['connect', 'Share a port with Link', 'https://connect.openvibe.link/'],
451 ],
452 build: [
453 ['services', 'APIs, SDK and keys', 'https://openvibe.services/'],
454 ['exchange', 'AI models, one account', 'https://openvibe.exchange/'],
455 ['run', 'Run jobs anywhere', 'https://openvibe.run/'],
456 ['zone', 'S3 storage', 'https://openvibe.zone/'],
457 ['events', 'Event streams', 'https://openvibe.events/'],
458 ['watch', 'Know when it changes', 'https://openvibe.watch/'],
459 ['git', 'Host and browse code', 'https://git.openvibe.codes/'],
460 ],
461};
462// The service names home pages pass that are not the card's own name (Video is media-hub, Media is its API,
463// Restream's service is openre, Email's mail, Studio's image, Locker's lock, Homes is Rent's section, Bench Report's).
464const RELATED_ALIAS = {
465 media: 'video', 'media-hub': 'video', video: 'video', stream: 'openre', openre: 'openre', restream: 'openre',
466 mail: 'email', email: 'email', image: 'studio', studio: 'studio', lock: 'locker', locker: 'locker',
467 homes: 'rent', bench: 'report',
468};
469const RELATED_ALL = Object.entries(GROUPS).flatMap(([group, members]) => members.map(([to, what, href]) => ({ group, to, what, href })));
470const RELATED_BY_ID = new Map(RELATED_ALL.map((x) => [x.to, x]));
471function relatedFor(key) {
472 const id = RELATED_ALIAS[key] || key;
473 const here = RELATED_BY_ID.get(id);
474 if (!here) return null;
475 return RELATED_ALL.filter((x) => x.group === here.group && x.to !== id).slice(0, 4).map((x) => [x.to, x.what, x.href]);
476}
477const RELATED = {};
478for (const x of RELATED_ALL) RELATED[x.to] = relatedFor(x.to);
479for (const [alias, id] of Object.entries(RELATED_ALIAS)) if (RELATED_BY_ID.has(id)) RELATED[alias] = relatedFor(id);
480// A product without its own list shows one place from each of the first four groups, in the groups' order.
481const DEFAULT_RELATED = ['live', 'community', 'games', 'news'].map((id) => {
482 const x = RELATED_BY_ID.get(id);
483 return [x.to, x.what, x.href];
484});
485
486/**
487 * "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same
488 * [to, what, href] or { to, what, href } shape), each a ring icon, its name and why to go there.
489 */
490function network({ id: sid, service, title = 'More on OpenVibe', lede = 'One account across every OpenVibe site.', items, icon: secIcon } = {}) {
491 const list = (items || RELATED[String(service || '').toLowerCase()] || DEFAULT_RELATED)
492 .map((x) => (Array.isArray(x) ? { to: x[0], what: x[1], href: x[2] } : x))
493 .filter((x) => x && x.what && safeHref(x.href) && x.to !== service).slice(0, 4);
494 if (!list.length) return '';
495 const h = `sc-${slug(title)}`;
496 return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'spark')}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}">${appIcon(x.to, 44)}<span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul><div class="sc-net-now" data-ov-now hidden></div><p class="sc-net-all"><a href="https://openvibe.network/sites">Every OpenVibe site <span class="sc-go" aria-hidden="true">${goGlyph()}</span></a></p></section>`;
497}
498
499/**
500 * apps({ id, title, lede, groups: [{ name, items }], items, more: { href, label } }) — a set of apps as a launcher, the
501 * way a phone's home screen shows them: each item { icon ('ov:live' or 'fa-…'), name, text, href }. Phones see icon
502 * tiles three or four to a row with the name under the icon (the one-line text is for screen readers and wider
503 * screens); from 600 px each tile is a row: icon, name, the text in one line. groups put a small heading over each
504 * set (the network's neighbourhoods); items without groups is one grid. A set of two to four, or of eight, twelve…,
505 * gets a column count that fills its rows (four tiles: four across on a phone, two by two from 600 px, four from
506 * 960 px), so no tile sits alone on a row. Empty → ''.
507 */
508function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null, icon: secIcon } = {}) {
509 const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
510 if (!sets.length) return '';
511 const h = `sc-${slug(title || 'apps')}`;
512 const glyph = (x) => { const k = String(x.icon || 'ov:spark'); return k.startsWith('ov:') ? appIcon(k.slice(3), 56) : `<span class="sc-ic">${icon(k, 22)}</span>`; };
513 const tile = (x) => `<li><a href="${esc(safeHref(x.href))}">${glyph(x)}<span class="sc-app-t"><b>${esc(x.name)}</b>${x.text ? `<small>${esc(x.text)}</small>` : ''}</span></a></li>`;
514 // Six or nine are three a row (4.46.1): four columns left one tile alone on the last row.
515 const cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : n % 3 === 0 ? 3 : 0);
516 const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps${cols(g.items.length) ? ` sc-apps--c${cols(g.items.length)}` : ''}">${g.items.map(tile).join('')}</ul>`).join('');
517 const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} <span class="sc-go" aria-hidden="true">${goGlyph()}</span></a></p>` : '';
518 return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'grid')}${body}${end}</section>`;
519}
520
521/** The closing call to action. */
522function cta({ title, text, actions: acts } = {}) {
523 if (!title) return '';
524 return `<section class="sc-cta"><h2>${esc(title)}</h2>${text ? `<p>${esc(text)}</p>` : ''}${actions(acts)}</section>`;
525}
526
527// ── Rooms (plan 2026-10-28 §3.3): a room's front door, in the order every room shares ──
528let roomsLib = null, kitLib = null;
529const fmtNum = (v) => (typeof v === 'number' ? v.toLocaleString('en-US') : String(v));
530/** The room's motif when it brings none: its icon in an aura of its accent, two slow orbits and their dots. */
531function roomMotif(r) {
532 kitLib = kitLib || require('./kit');
533 return `<div class="sc-room-motif" aria-hidden="true"><span class="sc-rm-glow"></span><span class="sc-rm-orbit o1"><i></i></span><span class="sc-rm-orbit o2"><i></i><i></i></span>`
534 + `<span class="sc-rm-core">${kitLib.icon(r ? r.icon : 'spark')}</span></div>`;
535}
536/**
537 * room({ room, hero, proof, rails, scenes, explore, dev, faq, network, html }) — a room's front door, in the order every
538 * room shares: hero → live proof → rails (Right now, For you…) → feature scenes → explore → for developers → questions →
539 * the rest of OpenVibe. Wrapped in .ov-room with the room's accent (rooms.js); what has nothing to show is left out.
540 * room: 'surf' (rooms.js id or host; an unknown one keeps the theme's accent)
541 * hero: { title (≤ 8 words), lede, search: { action, name, placeholder, hints: [typed in turn], label },
542 * start: { href, label } (when there is no box), chips: [{ label, href }] (≤ 5), motif (trusted HTML) }
543 * proof: [{ value, label, live: true } | { value, label, asOf: 'Oct 28' }] — real numbers only; 0 or none is left out
544 * rails: [{ id, title, more, items: [kit.card(...)], size }] — kit.rail
545 * scenes: [{ title, text, html (trusted: the room's own component in a demo state), action: { href, label } }]
546 * explore: { title, items: [{ href, title, text, icon }] } — sc.features · dev: sc.code's arguments, with links
547 * faq: { title, items: [[q, a]] } · network: sc.network's arguments · html: trusted markup after the scenes
548 * Needs kit.css, room.css (cards, rails, the room's accent) and showcase.css; motion.js makes the numbers tick and the box type.
549 */
550function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq: q, network: net, html } = {}) {
551 roomsLib = roomsLib || require('./rooms');
552 kitLib = kitLib || require('./kit');
553 const r = roomsLib.get(key);
554 const chips = (h.chips || []).filter((c) => c && c.label && safeHref(c.href)).slice(0, 5);
555 const box = h.search && h.search.action ? `<form class="sc-room-box" role="search" method="get" action="${esc(safeHref(h.search.action) || '#')}">`
556 + `<label class="sc-sr" for="sc-room-q">${esc(h.search.label || h.search.placeholder || 'Search')}</label>`
557 + `<input id="sc-room-q" type="search" name="${esc(h.search.name || 'q')}" placeholder="${esc(h.search.placeholder || '')}" autocomplete="off" enterkeyhint="search"${has(h.search.hints) ? ` data-ov-typed="${esc(h.search.hints.slice(0, 6).join('|'))}"` : ''}>`
558 + `<button type="submit" aria-label="${esc(h.search.button || 'Go')}">${kitLib.icon('forward')}</button></form>` : '';
559 const start = !box && h.start && safeHref(h.start.href) ? `<div class="sc-actions"><a class="sc-btn sc-primary sc-room-start" href="${esc(safeHref(h.start.href))}">${esc(h.start.label || 'Start')}</a></div>` : '';
560 const nums = (proof || []).filter((p) => p && p.label && p.value != null && p.value !== '' && p.value !== 0);
561 const proofHTML = nums.length ? `<ul class="sc-room-proof" aria-label="${esc(r ? `${r.name} right now` : 'Right now')}">${nums.map((p) => `<li>`
562 + `<b${typeof p.value === 'number' ? ` data-ov-ticker="${p.value}"` : ''}>${esc(fmtNum(p.value))}</b> <span>${esc(p.label)}</span>`
563 + `${p.live ? '<em class="sc-room-live">live</em>' : p.asOf ? `<em>${esc(p.asOf)}</em>` : ''}</li>`).join('')}</ul>` : '';
564 const heroHTML = h.title ? `<section class="sc-room-hero" aria-labelledby="sc-room-h">`
565 + `<div class="sc-room-main">${h.pageHead ? kitLib.pageHead({ ...h.pageHead, title: h.title, lede: h.lede }).replace('<h1>', '<h1 id="sc-room-h">') : `<h1 id="sc-room-h">${esc(h.title)}</h1>${h.lede ? `<p class="sc-room-lede">${esc(h.lede)}</p>` : ''}`}${box}${start}`
566 + `${chips.length ? `<nav class="sc-room-chips" data-ovk-scroll aria-label="Try">${chips.map((c) => `<a href="${esc(safeHref(c.href))}">${esc(c.label)}</a>`).join('')}</nav>` : ''}${proofHTML}</div>`
567 + `<div class="sc-room-pic">${h.motif != null ? h.motif : roomMotif(r)}</div></section>` : '';
568 const railsHTML = (rails || []).map((x) => kitLib.rail(x)).join('');
569 const sceneList = (scenes || []).filter((x) => x && x.title && x.html != null);
570 const scenesHTML = sceneList.length ? `<section class="sc-room-scenes" aria-label="What you can do">${sceneList.map((x, i) => `<article class="sc-scene${i % 2 ? ' is-flip' : ''}" data-ov-reveal>`
571 + `<div class="sc-scene-text"><h2>${esc(x.title)}</h2>${x.text ? `<p>${esc(x.text)}</p>` : ''}${x.action && safeHref(x.action.href) ? `<a class="sc-scene-go" href="${esc(safeHref(x.action.href))}">${esc(x.action.label || 'Try it')}${kitLib.icon('chevron')}</a>` : ''}</div>`
572 + `<div class="sc-scene-stage">${x.html}</div></article>`).join('')}</section>` : '';
573 const devLinks = dev && has(dev.links) ? `<p class="sc-room-devlinks">${dev.links.filter((l) => safeHref(l.href)).map((l) => `<a href="${esc(safeHref(l.href))}">${esc(l.label)}</a>`).join(' · ')}</p>` : '';
574 const devHTML = dev ? code(dev).replace(/<\/section>$/, `${devLinks}</section>`) : '';
575 return `<div class="ov-room sc-room"${r ? ` data-room="${esc(r.id)}" style="${roomsLib.style(r.id)}"` : ''}>${heroHTML}${railsHTML}${scenesHTML}${html != null ? html : ''}`
576 + `${explore ? features(explore) : ''}${devHTML}${q ? faq(q) : ''}${net ? network(net) : ''}</div>`;
577}
578
579// More on OpenVibe's live strip: up to four things happening across the network, filled in the browser from Network's
580// Frame (the same data the menu shows: who is live, the sites' trending cards). Text only; https links only; nothing
581// shows when Network does not answer. A page that renders network() includes this once: `<script>${sc.NETWORK_JS}</script>`.
582const NETWORK_JS = "(function(){try{var box=document.querySelector('[data-ov-now]');if(!box||!window.fetch)return;var here=location.hostname;"
583 + "function ok(u){try{var x=new URL(u);return x.protocol==='https:'&&x.hostname!==here?x.href:null}catch(e){return null}}"
584 + "function row(tag,name,title,href,live){var a=document.createElement('a');a.href=href;var s=document.createElement('span');s.textContent=tag;if(live)s.className='live';a.appendChild(s);var t=document.createElement('em');t.style.fontStyle='normal';t.textContent=title;a.appendChild(t);box.appendChild(a)}"
585 + "Promise.all([fetch('https://openvibe.network/api/frame/pulse',{credentials:'omit'}).then(function(r){return r.ok?r.json():null}).catch(function(){return null}),fetch('https://openvibe.network/api/frame?v=2',{credentials:'omit',headers:{accept:'application/json'}}).then(function(r){return r.ok?r.json():null}).catch(function(){return null})]).then(function(v){var n=0,p=v[0],f=v[1];"
586 + "var h=document.createElement('b');h.textContent='Happening on OpenVibe';box.appendChild(h);"
587 + "if(p&&Number(p.live_now)>0){var names=(p.live||[]).slice(0,3).map(function(c){return c.name}).filter(Boolean).join(', ');row('Live',null,(p.live_now==1?'1 stream live now':p.live_now+' streams live now')+(names?': '+names:''),'https://openvibe.live/',true);n++}"
588 + "(f&&f.cards||[]).forEach(function(c){if(n>=4||!c||!c.items||!c.items.length)return;var it=c.items[0],u=ok(it.url);if(!u||!it.title)return;row(String(c.title||c.site||'').slice(0,24),null,String(it.title).slice(0,90),u,false);n++});"
589 + "if(n)box.hidden=false})}catch(e){}})();";
590
591const CSS = `
592${PARTS_CSS}
593/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
594.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
595.sc-aura-glow{position:absolute;inset:28%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent),transparent);filter:blur(8px);animation:sc-breathe 6s ease-in-out infinite}
596.sc-ring{position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 34%,transparent);}
597.sc-ring.r1{inset:24%}.sc-ring.r2{inset:12%;animation-duration:105s;animation-direction:reverse;opacity:.8}.sc-ring.r3{inset:1%;animation-duration:150s;opacity:.55}
598.sc-ring i{position:absolute;top:-5px;left:calc(50% - 5px);width:10px;height:10px;border-radius:50%;background:var(--accent,#3b82f6);box-shadow:0 0 14px var(--accent,#3b82f6)}
599.sc-ring i+i{top:auto;bottom:-4px;left:calc(30% - 4px);width:8px;height:8px;background:var(--accent-light,#60a5fa)}
600.sc-vis-mark{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:2}
601.sc-orb-ring{position:absolute;inset:0;border-radius:50%}
602.sc-orb-ring::before{content:"";position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 30%,transparent)}
603.sc-orb-ring.o1{--R:30cqw;z-index:2}.sc-orb-ring.o1::before{inset:20%}
604.sc-orb-ring.o2{--R:44cqw;animation-duration:140s;animation-direction:reverse}.sc-orb-ring.o2::before{inset:6%;opacity:.55}
605.sc-orb-node{position:absolute;left:50%;top:50%;width:40px;height:40px;margin:-20px;transform:rotate(calc(360deg / var(--n) * var(--i))) translateY(calc(-1 * var(--R))) rotate(calc(-360deg / var(--n) * var(--i)));color:var(--text-primary,#e6edf7);text-decoration:none}
606.sc-orb-node.is-big{width:54px;height:54px;margin:-27px}
607.sc-orb-face{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;height:100%}
608.o2 .sc-orb-face{animation-duration:140s;animation-direction:normal}
609.sc-orb-face>.ov-icon,.sc-orb-face>svg,.sc-orb-face>i{display:grid;place-items:center;flex:none;width:100%;height:100%;border-radius:50%;background:color-mix(in srgb,var(--bg-secondary,#101828) 88%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent-light,#60a5fa) 30%,transparent),0 8px 22px rgba(0,0,0,.35);transition:transform .3s cubic-bezier(.2,1.4,.3,1)}
610.sc-orb-name{font-size:11.5px;font-weight:700;color:var(--text-secondary,#96a7c2);white-space:nowrap;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--bg-primary,#0a0f1c) 78%,transparent)}
611a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-orb-face>*:first-child{transform:scale(1.15)}
612.sc-orb-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:3;display:flex;flex-direction:column;align-items:center;gap:2px;color:var(--text-primary,#e6edf7);text-decoration:none;text-align:center}
613.sc-orb-core .sc-vis-mark{position:static;transform:none}
614.sc-orb-core b{margin-top:6px;font-size:14px;font-weight:800}.sc-orb-core small{font-size:12px;color:var(--text-muted,#7386a3)}
615
616@keyframes sc-breathe{0%,100%{opacity:.7;transform:scale(.95)}50%{opacity:1;transform:scale(1.05)}}
617@media (max-width:860px){
618.sc-hero.sc-has-vis{display:block}
619.sc-hero.sc-has-vis .sc-hero-vis{position:absolute;z-index:-1;top:-26px;right:0;width:180px;opacity:.36;pointer-events:none}
620.sc-hero.sc-has-vis .sc-aura .sc-vis-mark{display:none!important}
621.sc-hero.sc-has-orbit{display:flex;flex-direction:column}
622.sc-hero.sc-has-orbit .sc-hero-vis{order:1;position:static;width:auto;opacity:1;pointer-events:auto;margin:22px 0 0}
623.sc-hero.sc-has-orbit .sc-vis{width:min(82vw,300px)}
624.sc-hero.sc-has-orbit .sc-orb-core small{max-width:8em;font-size:10px;line-height:1.2}
625.sc-hero.sc-has-orbit .sc-orb-core .sc-vis-mark{width:50px!important;height:50px!important}
626.sc-orb-node{width:32px;height:32px;margin:-16px}.sc-orb-node.is-big{width:44px;height:44px;margin:-22px}.sc-orb-name{font-size:10.5px}
627}
628
629.sc-hero,.sc-sec,.sc-cta{max-width:1080px;margin:0 auto;padding:0 24px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif)}
630.sc-hero{padding-top:56px;padding-bottom:8px}
631.sc-hero.sc-split{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:32px;align-items:center}
632.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}
633.sc-hero h1{font-size:clamp(32px,4.6vw,52px);line-height:1.08;letter-spacing:-1.2px;margin:0;font-weight:800}
634.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}
635.sc-hero-lede{margin:16px 0 0;max-width:640px;font-size:17px;line-height:1.6;color:var(--text-secondary,#96a7c2)}
636.sc-hero-aside{min-width:0}
637.sc-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
638.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}
639.sc-btn:hover,.sc-btn:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-hover,#1c2a44);outline:0}
640.sc-btn.sc-primary{border-color:transparent;color:var(--on-accent,#fff);background:linear-gradient(135deg,var(--accent,#3b82f6),var(--accent-dark,#1d4ed8))}
641.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}
642.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}
643.sc-note a{color:var(--accent-light,#60a5fa)}
644.sc-sec{margin-top:48px;padding-top:32px;border-top:1px solid color-mix(in srgb,var(--border,#263244) 70%,transparent);container-type:inline-size}
645.sc-hero+.sc-sec,.sc-sec:first-child{border-top:0;padding-top:0}
646.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
647.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
648.sc-h-ic svg{display:block}
649.sc-sec>h2:has(.sc-h-ic)+.sc-lede{margin-left:48px}
650@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
651.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
652.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
653@container (min-width:520px) and (max-width:1035px){.sc-grid:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}
654@media (min-width:900px){.sc-grid:has(>:nth-child(5):last-child),.sc-grid:has(>:nth-child(6):last-child),.sc-grid:has(>:nth-child(9):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}}
655.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}
656a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
657.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)}
658.sc-ic.sc-ic-ov{background:none;border-radius:50%}
659.sc-ovi--glyph{border-radius:50%;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent)}.sc-ovi--glyph svg{width:54%!important;height:54%!important}
660.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}
661.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))}
662@container (min-width:584px) and (max-width:1191px){.sc-steps:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}
663.sc-steps li{counter-increment:sc;display:grid;grid-template-columns:34px 1fr;column-gap:12px;align-items:baseline;align-content:start}
664.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)}
665.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}
666.sc-steps span{color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.5}
667.sc-code{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
668.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}
669.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))}
670.sc-code pre{margin:0;padding:14px;overflow:auto;font-size:13px;line-height:1.55}
671.sc-demo{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));overflow:hidden;background:var(--bg-secondary,#101828)}
672.sc-demo iframe{display:block;width:100%;border:0;background:var(--bg-primary,#0a0f1c)}
673.sc-table-wrap{overflow-x:auto;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08))}
674.sc-table{width:100%;border-collapse:collapse;font-size:14px}
675.sc-table th,.sc-table td{padding:11px 14px;text-align:left;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
676.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
677.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
678.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
679.sc-yes{color:var(--success-text,var(--success,#22c55e));font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
680.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
681.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)}
682.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
683.sc-tier-name{font-size:13px;letter-spacing:.6px;text-transform:uppercase;color:var(--text-muted,#7386a3)}
684.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)}
685.sc-tier ul{margin:0;padding-left:18px;color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.6}
686.sc-tier-act{margin-top:auto;padding-top:14px}
687.sc-stories{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}
688.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
689.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}
690.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}
691.sc-next-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
692.sc-next-item{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
693.sc-next-main{flex:1 1 320px;display:flex;align-items:center;gap:14px;padding:14px 16px;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}
694.sc-next-main:hover,.sc-next-main:focus-visible{border-color:var(--accent,#3b82f6);transform:translateX(2px);outline:0}
695.sc-next-body{flex:1;min-width:0}.sc-next-body b{display:block;font-size:15.5px}
696.sc-next-body small{display:block;margin-top:2px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
697.sc-next-on{display:block;margin-top:4px;font-size:12px;font-weight:700;letter-spacing:.3px;color:var(--accent-light,var(--accent,#60a5fa))}
698.sc-next-go{font-size:20px;color:var(--text-muted,#7386a3);transition:transform .15s}.sc-next-main:hover .sc-next-go{transform:translateX(3px);color:var(--accent-light,#60a5fa)}
699.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}
700.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
701.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}
702.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}
703.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}
704.sc-stats{display:grid;gap:10px;margin:0;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
705.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}
706.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)}
707a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
708.sc-cards{list-style:none;margin:0;padding:0;display:grid;gap:14px}
709.sc-cards.sc-wrap{grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
710.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}
711.sc-cards.sc-rail>li{scroll-snap-align:start}
712.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}
713.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}
714.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))}
715.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)}
716.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)}
717.sc-badge.sc-live{background:color-mix(in srgb,var(--live-red,#ef4444) 80%,#000)}.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}
718.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}
719.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)}
720.sc-spots{display:grid;gap:28px}
721.sc-spot{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:28px;align-items:center}
722.sc-spot:nth-child(even) .sc-spot-media{order:2}
723.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)}
724.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}
725.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)}
726.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}
727.sc-faq{display:grid;gap:8px;max-width:820px}
728.sc-faq details,.sc-sec>details:not([class]){border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
729.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}
730.sc-faq summary::-webkit-details-marker,.sc-sec>details:not([class])>summary::-webkit-details-marker{display:none}
731.sc-faq summary::after,.sc-sec>details:not([class])>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}
732.sc-faq details[open] summary::after,.sc-sec>details[open]:not([class])>summary::after{transform:rotate(-135deg);margin-top:-2px}
733.sc-sec>details:not([class])+details:not([class]){margin-top:8px}
734.sc-faq p,.sc-sec>details:not([class])>p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
735.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
736.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}
737.sc-fold>summary::-webkit-details-marker{display:none}
738.sc-fold>summary::after{content:'';position:absolute;right:22px;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}
739.sc-fold[open]>summary::after{transform:rotate(-135deg);margin-top:-2px}
740.sc-fold>summary:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:-2px;border-radius:16px}
741.sc-fold>summary h2{margin:0;font-size:clamp(18px,2.2vw,22px)}
742.sc-fold-hint{color:var(--text-muted,#7386a3);font-size:14px;font-weight:600;text-align:right}
743.sc-fold-body{padding:0 18px 18px}
744.sc-fold-body .sc-sec{max-width:none;margin:0;padding:0}
745.sc-fold-body .sc-sec+.sc-sec{margin-top:22px}
746/* A list that is one sideways row on a phone (scroll snap, the next card peeking) and whatever the page made it on wide
747 screens. Every item stays in the HTML. --sc-row-w sets a card's width (82% by default; 46% for small tiles). */
748@media (max-width:599px){
749.sc-row.sc-row{position:relative;display:flex;flex-wrap:nowrap;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-inline:16px;scrollbar-width:none;gap:10px;margin-left:-16px;margin-right:-16px;padding:0 16px 4px;grid-template-columns:none}
750.sc-row.sc-row::-webkit-scrollbar{display:none}
751.sc-row.sc-row>*{flex:0 0 var(--sc-row-w,82%);min-width:0;scroll-snap-align:start}
752.sc-fold-hint{display:none}
753}
754/* apps(): a launcher. Phones: icon tiles, name under the icon; from 600 px: rows with a one-line tagline. */
755.sc-apps-group{margin:18px 0 8px;font-size:13px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}
756.sc-apps-sec .sc-apps-group:first-of-type{margin-top:6px}
757.sc-apps{list-style:none;margin:0;padding:0;display:grid;gap:6px;grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}
758.sc-apps a{display:flex;flex-direction:column;align-items:center;gap:6px;min-height:44px;padding:10px 4px;border-radius:14px;color:inherit;text-decoration:none;text-align:center;transition:background .15s}
759.sc-apps a:hover,.sc-apps a:focus-visible{background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent);outline:0}
760.sc-apps a:focus-visible{box-shadow:0 0 0 2px var(--accent,#3b82f6)}
761.sc-app-t{min-width:0;max-width:100%}
762.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}
763.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
764.sc-apps-more{margin:14px 0 0;font-weight:700}
765.sc-apps-more a{display:inline-flex;align-items:center;min-height:44px;color:var(--accent-light,#60a5fa);text-decoration:none}
766.sc-apps-more a:hover{text-decoration:underline}
767@media (max-width:599px){.sc-apps--c2{grid-template-columns:repeat(2,minmax(0,1fr))}.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
768@media (min-width:600px){
769.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
770.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
771.sc-apps .ov-appicon{--ova-size:44px!important}
772.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
773.sc-apps b{font-size:15px}
774.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
775.sc-apps--c2,.sc-apps--c4{grid-template-columns:repeat(2,minmax(0,1fr))}
776.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}
777}
778@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
779.sc-net-now{display:grid;gap:8px;margin:14px 0 0;padding:12px;border-radius:14px;background:color-mix(in srgb,var(--accent,#3b82f6) 6%,var(--bg-secondary,#101828));border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--border,#263244))}
780.sc-net-now[hidden]{display:none}
781.sc-net-now b{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#8a96a8)}
782.sc-net-now a{display:flex;gap:10px;align-items:baseline;min-height:36px;color:inherit;text-decoration:none;font-size:14px;line-height:1.4}
783.sc-net-now a span{flex:none;min-width:64px;font-weight:700;color:var(--accent-light,#60a5fa)}
784.sc-net-now a .live{color:var(--live,#ef4444)}
785.sc-net-all{margin:12px 0 0;font-weight:700}.sc-net-all a{color:var(--accent-light,#60a5fa);text-decoration:none}
786.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
787.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}
788.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
789.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}
790.sc-hero{position:relative;isolation:isolate}
791/* The hero's aura is drawn past its right edge (right:-54px below), so a narrow page that uses it was wider than
792 the screen and a phone scrolled sideways. Clip the viewport's horizontal axis in the kit itself: nothing that
793 fits is cut, and the page's own horizontal scrollers (tables, rails) keep working (2026-10-29). */
794html{overflow-x:clip}
795.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}
796@keyframes scPulse{50%{opacity:.35}}
797@supports (animation-timeline:view()){.sc-sec{animation:scRise linear both;animation-timeline:view();animation-range:entry 0% cover 18%}html[data-ov-motion=off] .sc-sec{animation:none}}
798@keyframes scRise{from{transform:translateY(calc(18px * var(--ov-move,1)))}to{transform:none}}
799.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}
800@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}}
801@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
802.sc-stat dd{display:flex;align-items:center;gap:8px}.sc-stat-ic{display:inline-flex;color:var(--accent-light,var(--accent,#60a5fa))}
803@media (max-width:640px){
804.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}
805.sc-hero{padding-top:14px}.sc-hero::before{inset:-60px 0 auto;height:280px}
806.sc-eyebrow{margin-bottom:10px;font-size:11.5px}
807.sc-hero h1{font-size:clamp(27px,7.6vw,34px);line-height:1.1;letter-spacing:-.8px}
808.sc-hero-lede{margin-top:10px;font-size:15.5px;line-height:1.5}
809.sc-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(0,1fr));gap:8px;margin-top:16px}
810.sc-actions .sc-btn{justify-content:center;min-height:46px;padding:10px 12px;font-size:14.5px;text-align:center}
811.sc-note{margin-top:10px;font-size:12px;line-height:1.45}
812.sc-stats-sec{margin-top:14px}
813.sc-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
814.sc-stat{padding:10px 10px 9px;border-radius:13px;gap:1px;justify-content:flex-end}
815.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}
816.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
817}
818`;
819
820module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, parts, pick, PARTS_CSS, cta, jsonLd, faqJsonLd, RELATED, DEFAULT_RELATED, CSS, STYLESHEET: 'showcase.css', esc };
821