openvibers/openvibe.events Commits
Short home on a phone: one category at a time, the rest folded (#166)
Changes
@@@ -19,6 +19,7 @@ const showcase = require('openvibe-shared/showcase');const ovServe = require('openvibe-shared/serve');const appIcon = require('openvibe-shared/app-icon');const seo = require('openvibe-shared/seo');++const ovIcons = require('openvibe-shared/ov-icons');const SITE_NAME = 'OpenVibe.Events';const NETWORK_URL = 'https://openvibe.network';@@@ -96,15 +97,50 @@ const humanDate = (ms) => {return `${d.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC', hour12: false })} UTC`;};++/**++ * A pick block: several blocks of the same kind, one at a time on a phone (the owner's short-pages rule, 2026-10-30).++ * Radio inputs and :has(), no script: the chips are hidden and every block shows until 640 px, where a row of chips++ * picks one block. Every block stays in the page for crawlers, and a wider screen shows them all.++ *++ * pickBlock({ key, label, panes }) the markup; panes: [{ id, label, head, html }]++ */++function pickBlock({ key, label, panes, bodyClass = '' }) {++ if (!panes.length) return '';++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');++ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.head ? `<div class="pick-h"><h3>${esc(p.head)}</h3></div>` : ''}${p.html}</div>`).join('');++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;++}++++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */++function pickRule(key, id) {++ return `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`;++}++++/**++ * A fold: a part of the page a reader rarely needs, closed on a phone and open where there is room (the owner's++ * short-pages rule, 2026-10-30). The page's own markup keeps the block open, so a browser without the script (or++ * with scripts off) shows it as before; the script closes it on a narrow screen and remembers the reader's choice.++ *++ * foldBlock({ id, head, html }) the markup, with the small script that closes it on a phone++ */++function foldBlock({ id, head, html }) {++ return `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;++}++++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */++function foldScript(id) {++ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;++}++/** One public event type as a search-readable card: title, real arrival time, place and publisher, description, count. */--function eventCard(r) {++function eventCard(r, cat) {const type = String(r.type);const ns = nsOf(type);const iso = isoDate(r.last);const url = serviceUrl(ns);const category = url ? `<a href="${esc(url)}">${esc(ns)}</a>` : esc(ns);const when = iso ? `<time datetime="${iso}">${esc(humanDate(r.last))}</time>` : '<span class="hm-event-undated">date not recorded</span>';-- return `<li class="hm-event" id="event-${esc(slug(type))}">++ return `<li class="hm-event" id="event-${esc(slug(type))}" data-cat="${esc(cat)}"><p class="hm-event-cat">Category: ${category}</p><h3 class="hm-event-title">${esc(eventTitle(type))}</h3><p class="hm-event-meta">${when} · Online, from ${serviceLink(ns)} · ${esc(fmt(r.n))} this week</p>@@@ -113,21 +149,14 @@ function eventCard(r) {</li>`;}--/** The category links above the list: one per publishing service, to its site (plain text when it has none). */--function categoryLinks(events) {-- const seen = [];-- for (const r of events) { const ns = nsOf(r.type); if (!seen.includes(ns)) seen.push(ns); }-- return `<nav class="hm-cats" aria-label="Event categories">${seen.map((ns) => {-- const url = serviceUrl(ns);-- return url ? `<a href="${esc(url)}">${esc(serviceName(ns))}</a>` : `<span>${esc(serviceName(ns))}</span>`;-- }).join('')}</nav>`;--}--/*** The public event types anyone may read, newest arrival first. The first page (24 types) is rendered here, on the* server, so a crawler reads it without running any JavaScript; there is no client-side list to duplicate it. Each* card carries a real <time datetime> from the last arrival the store recorded, never a guessed date. A slow or failed* stats read leaves a clear empty state instead of an empty page.++ *++ * On a phone the list is one category at a time, picked with a row of chips (the owner's short-pages rule): the++ * category links above the list become the chips, and every card stays in the page for crawlers.*/function eventsSection(stats) {const publicTypes = (stats && stats.public) || [];@@@ -138,14 +167,41 @@ function eventsSection(stats) {} else if (!list.length) {inner = '<p class="sc-lede">No public events have arrived this week. The bus is running; public event types appear here as they arrive.</p>';} else {++ // The categories, the one with the newest arrival first: one chip each.++ const cats = [];++ for (const r of list) { const ns = nsOf(r.type); if (!cats.includes(ns)) cats.push(ns); }++ const chips = cats.map((ns, i) => `<input type="radio" name="pick-events" id="pk-events-${i}" value="${i}"${i === 0 ? ' checked' : ''}><label for="pk-events-${i}">${esc(serviceName(ns))}</label>`).join('');++ const cards = list.map((r) => eventCard(r, String(cats.indexOf(nsOf(r.type))))).join('');inner = `<p class="sc-lede">The public event types anyone may read, newest arrival first: when each last arrived, who published it and what it means. Subscribe to a pattern such as <code>live.stream.*</code> to get them as signed webhooks.</p>--${categoryLinks(list)}--<ul class="hm-events">${list.map(eventCard).join('')}</ul>++<div class="pick pick-events" id="pick-events"><div class="pick-row" role="radiogroup" aria-label="Public events this week">${chips}</div>++<ul class="hm-events">${cards}</ul></div>${publicTypes.length > list.length ? `<p class="hm-note">And ${esc(fmt(publicTypes.length - list.length))} more public event types this week.</p>` : ''}`;}return `<section class="sc-sec" id="events" aria-labelledby="h-events"><h2 id="h-events">Public events this week</h2>${inner}</section>`;}++/** The rule that shows the picked category of the public event list: one per category. */++function eventsRule(i) {++ return `.pick-events:has(#pk-events-${i}:checked) .hm-events>li[data-cat="${i}"]{display:block}`;++}++++/** The categories of the public event list, the one with the newest arrival first. */++function eventCategories(stats) {++ const publicTypes = (stats && stats.public) || [];++ const list = publicTypes.slice().sort((a, b) => Number(b.last) - Number(a.last)).slice(0, 24);++ const cats = [];++ for (const r of list) { const ns = nsOf(r.type); if (!cats.includes(ns)) cats.push(ns); }++ return cats;++}++++/** The page's pick-block rules: one per block, so the CSS shows the block the chip picked. */++function pickRules(stats) {++ const rules = [];++ for (let i = 0; i < FEATURES.length; i++) rules.push(pickRule('features', String(i)));++ for (let i = 0; i < eventCategories(stats).length; i++) rules.push(eventsRule(i));++ return rules.join('');++}++/** The ItemList of public events, each with a real startDate only where the store recorded an arrival. */function eventsJsonLd({ siteUrl, stats }) {const publicTypes = (stats && stats.public) || [];@@@ -194,10 +250,15 @@ function activity(stats) {const statRow = tiles.length ? `<section class="sc-sec hm-stats-sec" aria-label="The bus right now"><ul class="hm-stats">${tiles.map(([n, label]) => `<li><b>${esc(fmt(n))}</b><span>${esc(label)}</span></li>`).join('')}</ul>${stats.dead === 0 && stats.delivered ? '<p class="hm-note">No deliveries are waiting in the dead-letter queue.</p>' : ''}</section>` : '';const top = stats.services.slice(0, 16);const max = top.length ? top[0].n : 1;-- const who = top.length ? `<section class="sc-sec" aria-labelledby="h-who"><h2 id="h-who">Who published this week</h2>--<p class="sc-lede">Events by the service that published them over the last seven days. Every one was stored before any subscriber saw it.</p>++ // Who published is a fold on a phone (the owner's short-pages rule): a chart of sixteen bars is a screen of its++ // own, and a reader on a phone is here for the events.++ const who = top.length ? foldBlock({++ id: 'who',++ head: '<h2 id="h-who">Who published this week</h2>',++ html: `<p class="sc-lede">Events by the service that published them over the last seven days. Every one was stored before any subscriber saw it.</p><ul class="hm-bars">${top.map((r) => `<li><span class="hm-bar-name">${serviceLink(r.ns)}</span><span class="hm-bar" aria-hidden="true"><i style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></i></span><span class="hm-bar-n">${esc(fmt(r.n))}</span></li>`).join('')}</ul>--${stats.services.length > top.length ? `<p class="hm-note">And ${esc(stats.services.length - top.length)} more.</p>` : ''}</section>` : '';++${stats.services.length > top.length ? `<p class="hm-note">And ${esc(stats.services.length - top.length)} more.</p>` : ''}`,++ }) : '';return { statRow, who, events };}@@@ -212,6 +273,22 @@ const FAQ = [['Are sandbox and production separate?', 'Yes. A developer app\'s sandbox events never reach production apps or subscriptions, and are pruned after seven days.'],];++/** What the bus gives a service: the six things a reader can read the cards of. */++const FEATURES = [++ { icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },++ { icon: 'ov:bell', title: 'Signed webhooks', text: 'Subscribe with a topic pattern such as media.vod.*. Every delivery is HMAC-signed with its timestamp and retried with backoff.' },++ { icon: 'ov:history', title: 'Dead letters and replay', text: 'Deliveries that keep failing wait in a dead-letter queue. Replay them, or catch a new subscription up from a point in its history.' },++ { icon: 'ov:download', title: 'Pull with a cursor', text: 'Read events in order with an opaque cursor when you would rather pull than receive webhooks.' },++ { icon: 'ov:live', title: 'Realtime for browsers', text: 'Server-sent events, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.' },++ { icon: 'ov:code', title: 'Developer apps', text: 'An app publishes under its own project\'s names and keeps sandbox and production apart: sandbox events never reach production.' },++];++++/** One feature card, in the shared showcase's own card markup: a ring icon in a 40 px tile beside the words. */++function featureCard(f) {++ const name = String(f.icon || '').replace(/^ov:/, '');++ return `<div class="sc-card"><span class="sc-ic sc-ic-ov"><span class="ov-icon sc-ovi" data-icon="${esc(name)}" style="--ovi-size:40px" aria-hidden="true">${ovIcons.svg(name)}</span></span><span><b>${esc(f.title)}</b><small>${esc(f.text)}</small></span></div>`;++}++function sections({ apiUrl, stats = null }) {const live = activity(stats);const publish = [@@@ -233,6 +310,15 @@ function sections({ apiUrl, stats = null }) {'# then the same with &after=<next_cursor> from the previous page',].join('\n');const publishers = stats && stats.services.filter((r) => r.ns !== 'app').length;++ // What you get: on a phone one card at a time, picked with a row of chips (the owner's short-pages rule). The++ // cards keep the shared showcase's own markup, so a wider screen shows the same grid of six.++ const features = `<section class="sc-sec" aria-labelledby="h-features"><h2 id="h-features">What you get</h2>++${pickBlock({++ key: 'features',++ label: 'What you get',++ bodyClass: 'sc-grid',++ panes: FEATURES.map((f, i) => ({ id: String(i), label: f.title, html: featureCard(f) })),++ })}</section>`;return showcase.hero({eyebrow: 'OpenVibe.Events · part of OpenVibe Services · alpha',title: 'Events your services', accent: 'can count on.',@@@ -240,17 +326,7 @@ function sections({ apiUrl, stats = null }) {+ `retried with backoff, and replayable after a failure.${publishers ? ` ${publishers} OpenVibe services published to it this week.` : ''}`,actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,-- }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.events + live.who + showcase.features({-- title: 'What you get',-- items: [-- { icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },-- { icon: 'ov:bell', title: 'Signed webhooks', text: 'Subscribe with a topic pattern such as media.vod.*. Every delivery is HMAC-signed with its timestamp and retried with backoff.' },-- { icon: 'ov:history', title: 'Dead letters and replay', text: 'Deliveries that keep failing wait in a dead-letter queue. Replay them, or catch a new subscription up from a point in its history.' },-- { icon: 'ov:download', title: 'Pull with a cursor', text: 'Read events in order with an opaque cursor when you would rather pull than receive webhooks.' },-- { icon: 'ov:live', title: 'Realtime for browsers', text: 'Server-sent events, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.' },-- { icon: 'ov:code', title: 'Developer apps', text: 'An app publishes under its own project\'s names and keeps sandbox and production apart: sandbox events never reach production.' },-- ],-- }) + '<details class="hm-fold"><summary>Publish, subscribe, pull<span>curl examples</span></summary>' + showcase.code({++ }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.events + live.who + features + '<details class="hm-fold"><summary>Publish, subscribe, pull<span>curl examples</span></summary>' + showcase.code({id: 'examples',title: 'Publish, subscribe, pull',lede: 'With an app token from OpenVibe.Network (client credentials, audience openvibe.events).',@@@ -259,23 +335,29 @@ function sections({ apiUrl, stats = null }) {{ label: 'Subscribe', lang: 'bash', code: subscribe },{ label: 'Pull', lang: 'bash', code: pull },],-- }) + '</details>' + showcase.steps({-- title: 'Receive a webhook safely',-- items: [-- { title: 'Keep the secret', text: 'The subscription\'s secret is shown once, when you create it or rotate it.' },-- { title: 'Check the signature', text: 'Verify X-OpenVibe-Signature-V2 over the timestamp and the raw body, and refuse anything older than five minutes.' },-- { title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },-- { title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },-- ],-- }) + `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>--<div class="hm-faq">${FAQ.map(([q, a], i) => `<details class="hm-qa"${i === 0 ? ' open' : ''}><summary><h3>${esc(q)}</h3></summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;++ }) + '</details>' + foldBlock({++ id: 'howto',++ head: '<h2 id="h-howto">Receive a webhook safely</h2>',++ html: showcase.steps({++ items: [++ { title: 'Keep the secret', text: 'The subscription\'s secret is shown once, when you create it or rotate it.' },++ { title: 'Check the signature', text: 'Verify X-OpenVibe-Signature-V2 over the timestamp and the raw body, and refuse anything older than five minutes.' },++ { title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },++ { title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },++ ],++ }),++ }) + foldBlock({++ id: 'faq',++ head: '<h2 id="h-faq">Questions</h2>',++ html: `<div class="hm-faq">${FAQ.map(([q, a], i) => `<details class="hm-qa"${i === 0 ? ' open' : ''}><summary><h3>${esc(q)}</h3></summary><p>${esc(a)}</p></details>`).join('')}</div>`,++ });}// The page's own few rules (it has no stylesheet beyond the showcase's): no browser margin around the body, so the Frame's// footer meets the edges; each curl sample on its own full-width row (three columns cut every command off); the four// webhook steps two by two instead of three and one alone, each title level with its number.// The data sections' rules follow: the stat tiles, the per-service bars, the public event cards and the questions, in theme tokens (accent-light is drawn for dark themes, so text in it is mixed toward the text colour).--const HOME_STYLE = '<style>body{margin:0}/* The hero\'s aura is drawn 54px past its right edge (shared showcase, sc-hero-vis at right:-54px); without a clip the phone scrolls sideways by ~43px (2026-10-29). */.page{overflow-x:clip}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}'++const HOME_STYLE = (rules = '') => '<style>body{margin:0}/* The hero\'s aura is drawn 54px past its right edge (shared showcase, sc-hero-vis at right:-54px); without a clip the phone scrolls sideways by ~43px (2026-10-29). */.page{overflow-x:clip}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}'+ '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}'+ '.hm-stats-sec{margin-top:40px}.hm-stats{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(130px,100%),1fr))}'+ '.hm-stats li{display:grid;gap:2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}'@@@ -293,7 +375,32 @@ const HOME_STYLE = '<style>body{margin:0}/* The hero\'s aura is drawn 54px past+ '.hm-event-title{margin:4px 0 6px;font-size:17px}.hm-event-meta{margin:0 0 6px;font-size:13.5px;line-height:1.5;color:var(--text-secondary,#96a7c2)}'+ '.hm-event-desc{margin:0 0 6px;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}.hm-event-type{margin:0}.hm-event-type code{font-size:12.5px;overflow-wrap:anywhere;color:var(--text-secondary,#96a7c2)}'+ '.hm-fold{box-sizing:border-box;max-width:1080px;margin:20px max(16px,calc((100% - 1080px) / 2)) 0;font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;background:var(--bg-card,#131c2e)}.hm-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:48px;padding:0 16px;cursor:pointer;font-weight:700;list-style:none}.hm-fold>summary::-webkit-details-marker{display:none}.hm-fold>summary span{color:var(--text-muted,#7386a3);font-weight:600;font-size:.86rem}.hm-fold .sc-sec{margin:0;padding:0 16px 14px}.hm-qa>summary{list-style:none;cursor:pointer}.hm-qa>summary::-webkit-details-marker{display:none}.hm-qa>summary h3{margin:0}'-- + '@media (max-width:599px){.hm-events>li:nth-child(n+7),.hm-bars>li:nth-child(n+11){display:none}}'++ + '@media (max-width:599px){.hm-bars>li:nth-child(n+11){display:none}}'++ // Pick (the owner's short-pages rule, 2026-10-30): on a phone, one of several blocks of the same kind at a time.++ // Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only where :has() is++ // understood, so a wider screen, or an older browser, shows every block and no chips.++ + '.pick-row{display:none}'++ + '@supports selector(:has(*)){@media (max-width:640px){'++ + '.pick-row{display:flex;gap:8px;overflow-x:auto;margin:10px -16px 0;padding:2px 16px 4px;scrollbar-width:none;overscroll-behavior-x:contain}'++ + '.pick-row::-webkit-scrollbar{display:none}'++ + '.pick-row input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}'++ + '.pick-row label{display:inline-flex;align-items:center;flex:none;min-height:44px;padding:0 15px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-secondary,#a8b3c4);font-size:13.5px;font-weight:650;white-space:nowrap;cursor:pointer}'++ + '.pick-row input:checked+label{color:var(--text-primary,#e6edf7);border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}'++ + '.pick-row input:focus-visible+label{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}'++ + '.pick-pane{display:none}'++ + '.pick-pane>.pick-h{display:none}'++ // The public event list: the cards of the picked category, the others out of the way.++ + '.pick-events .hm-events>li{display:none}'++ // The rule for each block of the page's pick blocks: the picked one shows, the others stay out of the way.++ + rules++ + '}}'++ // The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section++ // it replaces: a heading and its content, with no card around it.++ + '.hm-fold-block{margin-top:56px}.hm-fold-block>summary h2{margin:0;font-size:clamp(19px,2.2vw,26px);letter-spacing:-.01em}'++ + '@media (min-width:641px){.hm-fold-block{border:0;background:none;border-radius:0;padding:0 24px}'++ + '.hm-fold-block>summary{display:block;min-height:0;padding:0}.hm-fold-block>summary h2{margin:0 0 6px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em}'++ + '.hm-fold-block>summary span{display:none}.hm-fold-block .sc-sec{padding:0}.hm-fold-block .hm-faq,.hm-fold-block .hm-bars{padding:0}}'++ + '@media (max-width:640px){.hm-fold-block{margin-top:32px}}'+ '.hm-faq{display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr))}.hm-qa{padding:4px 0 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}'+ '.hm-qa h3{margin:0 0 6px;font-size:16px}.hm-qa p{margin:0;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}'+ '@media (max-width:520px){.page .sc-code pre{white-space:pre-wrap;overflow-wrap:anywhere}.hm-bars li{grid-template-columns:minmax(0,10.5em) minmax(0,1fr) auto;font-size:13.5px;gap:8px}.hm-bar-n{min-width:3.5em}'@@@ -362,7 +469,7 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {head: [appIcon.headTags({ site: 'events' }),`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,-- HOME_STYLE,++ HOME_STYLE(pickRules(stats)),].join('\n'),body: `<div id="navbar-mount"></div><main id="main" class="page">nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++// The home on a phone (the owner's short-pages rule, 2026-10-30): the blocks of the same kind are one pick block,++// a row of chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no++// script for the chips; every block stays in the page for crawlers; a wider screen shows them all and no chips.++const assert = require('assert');++const { renderHome } = require('../server/home');++++const last = Date.parse('2026-10-29T09:41:00Z');++const STATS = {++ day: 4, week: 4, types: 2, services: [{ ns: 'live', n: 3 }, { ns: 'blog', n: 1 }], subscriptions: 0, consumers: 0,++ delivered: 0, dead: 0, measuredAt: last,++ public: [++ { type: 'live.stream.started', n: 3, last },++ { type: 'blog.post.published', n: 1, last: 0 },++ ],++};++++const t = (name, fn) => { try { fn(); console.log(`ok ${name}`); } catch (e) { console.error(`FAIL ${name}: ${e.message}`); process.exitCode = 1; } };++++t('the public events are one pick block: a chip per category, the first picked, every card in the page', () => {++ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });++ const block = page.slice(page.indexOf('<div class="pick pick-events"'), page.indexOf('</section>', page.indexOf('<div class="pick pick-events"')));++ assert.ok(block.startsWith('<div class="pick pick-events"'), 'the pick block is on the home');++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Public events this week">/);++ // One chip per category, in the order the categories appear.++ const chips = [...block.matchAll(/<input type="radio" name="pick-events" id="pk-events-(\d+)" value="\1"( checked)?><label for="pk-events-\1">([^<]+)<\/label>/g)];++ assert.strictEqual(chips.length, 2, `a chip per category (${chips.length})`);++ assert.deepStrictEqual(chips.map((m) => m[1]), ['0', '1'], 'the chips are in order');++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['0'], 'the first category is picked');++ assert.ok(chips[0][3].includes('OpenVibe.Live'), `the chip names the category (${chips[0][3]})`);++ // Every card stays in the page: the crawler still reads them all.++ const cards = [...block.matchAll(/<li class="hm-event" id="event-([a-z0-9-]+)" data-cat="(\d+)">/g)];++ assert.strictEqual(cards.length, 2, `every event card is in the page (${cards.length})`);++ assert.deepStrictEqual(cards.map((m) => m[2]), ['0', '1'], 'each card knows its category');++});++++t('what you get is one pick block too: six chips, the first picked, every card in the page', () => {++ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });++ const block = page.slice(page.indexOf('<div class="pick" id="pick-features">'), page.indexOf('</section>', page.indexOf('<div class="pick" id="pick-features">')));++ const chips = [...block.matchAll(/<input type="radio" name="pick-features" id="pk-features-(\d+)" value="\1"( checked)?><label for="pk-features-\1">([^<]+)<\/label>/g)];++ assert.strictEqual(chips.length, 6, `a chip per feature (${chips.length})`);++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['0'], 'the first feature is picked');++ assert.strictEqual((block.match(/class="sc-card"/g) || []).length, 6, 'every card is in the page');++ // The CSS that shows the picked block, one rule per block.++ for (let i = 0; i < 6; i++) {++ assert.ok(page.includes(`.pick:has(#pk-features-${i}:checked) #pp-features-${i}{display:block}`), `feature ${i} shows when its chip is picked`);++ }++});++++t('the CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood', () => {++ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });++ assert.ok(page.includes('.pick-row{display:none}'), 'the chips are hidden by default');++ assert.ok(page.includes('@supports selector(:has(*)){@media (max-width:640px){'), 'the phone rules are under :has() and 640 px');++ assert.ok(page.includes('.pick-pane{display:none}'), 'the blocks are hidden on a phone');++ assert.match(page, /\.pick-row label\{[^}]*min-height:44px/, 'a chip is a 44 px target');++ assert.ok(page.includes('.pick-events .hm-events>li{display:none}'), 'only the picked category shows on a phone');++ assert.ok(page.includes('.pick-events:has(#pk-events-0:checked) .hm-events>li[data-cat="0"]{display:block}'), 'the picked category shows');++});++++t('the parts a reader rarely needs are folds, open in the page and closed on a phone', () => {++ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });++ for (const id of ['who', 'howto', 'faq']) {++ assert.ok(page.includes(`<details class="hm-fold hm-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);++ assert.ok(page.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);++ }++ // The content of the folds stays in the page.++ assert.match(page, /<h2 id="h-who">Who published this week<\/h2>/);++ assert.match(page, /<h2 id="h-faq">Questions<\/h2>/);++ assert.match(page, /What is OpenVibe\.Events\?/, 'the questions are still in the page');++});@@@ -10,6 +10,8 @@ const t = suite('home-stats');let h;const html = async () => (await request(h.base, 'GET', '/', { headers: { Accept: 'text/html' } })).text;const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(page) || [''])[0];++// The parts a reader rarely needs are folds now (the owner's short-pages rule, 2026-10-30): the same block, folded.++const fold = (page, id) => (new RegExp(`<details class="hm-fold hm-fold-block" id="${id}"[\\s\\S]*?</details>`).exec(page) || [''])[0];t('boot', async () => { h = await boot(); });@@@ -44,7 +46,7 @@ t('published events show as counts by service, public types and the latest publiconst { renderHome } = require('../server/home');const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });const events = section(page, 'h-events');-- assert.match(section(page, 'h-who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);++ assert.match(fold(page, 'who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);assert.match(events, /A channel went live on OpenVibe\.Live/);assert.match(events, /<time datetime="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z">/);assert.match(events, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a> · 2 this week/);@@@ -72,7 +74,9 @@ t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', aassert.strictEqual(scripts.length, 1, 'the page carries exactly one JSON-LD script');const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));for (const type of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(ld.some((x) => x['@type'] === type), type);-- const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(page) || [''])[0];++ // The questions are in the fold now; they are the only bare <h3> left on the page.++ const faqAt = page.indexOf('<details class="hm-fold hm-fold-block" id="faq"');++ const faq = faqAt >= 0 ? page.slice(faqAt, page.indexOf('</main>')) : '';const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));assert.ok(shown.length >= 6);assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);@@@ -175,7 +175,10 @@ t('the server-rendered event list: title, real <time datetime>, place, descriptiassert.match(card, /Published by OpenVibe\.Live as live\.stream\.started/, 'the short description');assert.match(card, /3 this week/, 'the count');assert.match(card, /Category: <a href="https:\/\/openvibe\.live">live<\/a>/, 'a category link');-- assert.match(page, /<nav class="hm-cats"[^>]*><a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the category nav');++ // The category nav became the row of chips that picks one category at a time on a phone (the short-pages rule);++ // the links it held are still in the page, on the cards themselves.++ assert.match(page, /<label for="pk-events-0">OpenVibe\.Live<\/label>/, 'the category chips');++ assert.match(page, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the publisher link is still in the page');assert.match(page, /<code>live\.stream\.started<\/code>/, 'the exact event type');// One JSON-LD script per page: every node lives in the page's @graph.
@@ -19,6 +19,7 @@ const showcase = require('openvibe-shared/showcase');
@@ -19,6 +19,7 @@ const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const ovIcons = require('openvibe-shared/ov-icons');
const SITE_NAME = 'OpenVibe.Events';
const SITE_NAME = 'OpenVibe.Events';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -96,15 +97,50 @@ const humanDate = (ms) => {
@@ -96,15 +97,50 @@ const humanDate = (ms) => {
return `${d.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC', hour12: false })} UTC`;
return `${d.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC', hour12: false })} UTC`;
};
};
+/**
+ * A pick block: several blocks of the same kind, one at a time on a phone (the owner's short-pages rule, 2026-10-30).
+ * Radio inputs and :has(), no script: the chips are hidden and every block shows until 640 px, where a row of chips
+ * picks one block. Every block stays in the page for crawlers, and a wider screen shows them all.
+ *
+ * pickBlock({ key, label, panes }) the markup; panes: [{ id, label, head, html }]
+ */
+function pickBlock({ key, label, panes, bodyClass = '' }) {
+ if (!panes.length) return '';
+ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
+ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.head ? `<div class="pick-h"><h3>${esc(p.head)}</h3></div>` : ''}${p.html}</div>`).join('');
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
+}
+
+/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
+function pickRule(key, id) {
+ return `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`;
+}
+
+/**
+ * A fold: a part of the page a reader rarely needs, closed on a phone and open where there is room (the owner's
+ * short-pages rule, 2026-10-30). The page's own markup keeps the block open, so a browser without the script (or
+ * with scripts off) shows it as before; the script closes it on a narrow screen and remembers the reader's choice.
+ *
+ * foldBlock({ id, head, html }) the markup, with the small script that closes it on a phone
+ */
+function foldBlock({ id, head, html }) {
+ return `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;
+}
+
+/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
+function foldScript(id) {
+ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;
+}
+
/** One public event type as a search-readable card: title, real arrival time, place and publisher, description, count. */
/** One public event type as a search-readable card: title, real arrival time, place and publisher, description, count. */
-function eventCard(r) {
+function eventCard(r, cat) {
const type = String(r.type);
const type = String(r.type);
const ns = nsOf(type);
const ns = nsOf(type);
const iso = isoDate(r.last);
const iso = isoDate(r.last);
const url = serviceUrl(ns);
const url = serviceUrl(ns);
const category = url ? `<a href="${esc(url)}">${esc(ns)}</a>` : esc(ns);
const category = url ? `<a href="${esc(url)}">${esc(ns)}</a>` : esc(ns);
const when = iso ? `<time datetime="${iso}">${esc(humanDate(r.last))}</time>` : '<span class="hm-event-undated">date not recorded</span>';
const when = iso ? `<time datetime="${iso}">${esc(humanDate(r.last))}</time>` : '<span class="hm-event-undated">date not recorded</span>';
- return `<li class="hm-event" id="event-${esc(slug(type))}">
+ return `<li class="hm-event" id="event-${esc(slug(type))}" data-cat="${esc(cat)}">
<p class="hm-event-cat">Category: ${category}</p>
<p class="hm-event-cat">Category: ${category}</p>
<h3 class="hm-event-title">${esc(eventTitle(type))}</h3>
<h3 class="hm-event-title">${esc(eventTitle(type))}</h3>
<p class="hm-event-meta">${when} · Online, from ${serviceLink(ns)} · ${esc(fmt(r.n))} this week</p>
<p class="hm-event-meta">${when} · Online, from ${serviceLink(ns)} · ${esc(fmt(r.n))} this week</p>
@@ -113,21 +149,14 @@ function eventCard(r) {
@@ -113,21 +149,14 @@ function eventCard(r) {
</li>`;
</li>`;
}
}
-/** The category links above the list: one per publishing service, to its site (plain text when it has none). */
-function categoryLinks(events) {
- const seen = [];
- for (const r of events) { const ns = nsOf(r.type); if (!seen.includes(ns)) seen.push(ns); }
- return `<nav class="hm-cats" aria-label="Event categories">${seen.map((ns) => {
- const url = serviceUrl(ns);
- return url ? `<a href="${esc(url)}">${esc(serviceName(ns))}</a>` : `<span>${esc(serviceName(ns))}</span>`;
- }).join('')}</nav>`;
-}
-
/**
/**
* The public event types anyone may read, newest arrival first. The first page (24 types) is rendered here, on the
* The public event types anyone may read, newest arrival first. The first page (24 types) is rendered here, on the
* server, so a crawler reads it without running any JavaScript; there is no client-side list to duplicate it. Each
* server, so a crawler reads it without running any JavaScript; there is no client-side list to duplicate it. Each
* card carries a real <time datetime> from the last arrival the store recorded, never a guessed date. A slow or failed
* card carries a real <time datetime> from the last arrival the store recorded, never a guessed date. A slow or failed
* stats read leaves a clear empty state instead of an empty page.
* stats read leaves a clear empty state instead of an empty page.
+ *
+ * On a phone the list is one category at a time, picked with a row of chips (the owner's short-pages rule): the
+ * category links above the list become the chips, and every card stays in the page for crawlers.
*/
*/
function eventsSection(stats) {
function eventsSection(stats) {
const publicTypes = (stats && stats.public) || [];
const publicTypes = (stats && stats.public) || [];
@@ -138,14 +167,41 @@ function eventsSection(stats) {
@@ -138,14 +167,41 @@ function eventsSection(stats) {
} else if (!list.length) {
} else if (!list.length) {
inner = '<p class="sc-lede">No public events have arrived this week. The bus is running; public event types appear here as they arrive.</p>';
inner = '<p class="sc-lede">No public events have arrived this week. The bus is running; public event types appear here as they arrive.</p>';
} else {
} else {
+ // The categories, the one with the newest arrival first: one chip each.
+ const cats = [];
+ for (const r of list) { const ns = nsOf(r.type); if (!cats.includes(ns)) cats.push(ns); }
+ const chips = cats.map((ns, i) => `<input type="radio" name="pick-events" id="pk-events-${i}" value="${i}"${i === 0 ? ' checked' : ''}><label for="pk-events-${i}">${esc(serviceName(ns))}</label>`).join('');
+ const cards = list.map((r) => eventCard(r, String(cats.indexOf(nsOf(r.type))))).join('');
inner = `<p class="sc-lede">The public event types anyone may read, newest arrival first: when each last arrived, who published it and what it means. Subscribe to a pattern such as <code>live.stream.*</code> to get them as signed webhooks.</p>
inner = `<p class="sc-lede">The public event types anyone may read, newest arrival first: when each last arrived, who published it and what it means. Subscribe to a pattern such as <code>live.stream.*</code> to get them as signed webhooks.</p>
-${categoryLinks(list)}
-<ul class="hm-events">${list.map(eventCard).join('')}</ul>
+<div class="pick pick-events" id="pick-events"><div class="pick-row" role="radiogroup" aria-label="Public events this week">${chips}</div>
+<ul class="hm-events">${cards}</ul></div>
${publicTypes.length > list.length ? `<p class="hm-note">And ${esc(fmt(publicTypes.length - list.length))} more public event types this week.</p>` : ''}`;
${publicTypes.length > list.length ? `<p class="hm-note">And ${esc(fmt(publicTypes.length - list.length))} more public event types this week.</p>` : ''}`;
}
}
return `<section class="sc-sec" id="events" aria-labelledby="h-events"><h2 id="h-events">Public events this week</h2>${inner}</section>`;
return `<section class="sc-sec" id="events" aria-labelledby="h-events"><h2 id="h-events">Public events this week</h2>${inner}</section>`;
}
}
+/** The rule that shows the picked category of the public event list: one per category. */
+function eventsRule(i) {
+ return `.pick-events:has(#pk-events-${i}:checked) .hm-events>li[data-cat="${i}"]{display:block}`;
+}
+
+/** The categories of the public event list, the one with the newest arrival first. */
+function eventCategories(stats) {
+ const publicTypes = (stats && stats.public) || [];
+ const list = publicTypes.slice().sort((a, b) => Number(b.last) - Number(a.last)).slice(0, 24);
+ const cats = [];
+ for (const r of list) { const ns = nsOf(r.type); if (!cats.includes(ns)) cats.push(ns); }
+ return cats;
+}
+
+/** The page's pick-block rules: one per block, so the CSS shows the block the chip picked. */
+function pickRules(stats) {
+ const rules = [];
+ for (let i = 0; i < FEATURES.length; i++) rules.push(pickRule('features', String(i)));
+ for (let i = 0; i < eventCategories(stats).length; i++) rules.push(eventsRule(i));
+ return rules.join('');
+}
+
/** The ItemList of public events, each with a real startDate only where the store recorded an arrival. */
/** The ItemList of public events, each with a real startDate only where the store recorded an arrival. */
function eventsJsonLd({ siteUrl, stats }) {
function eventsJsonLd({ siteUrl, stats }) {
const publicTypes = (stats && stats.public) || [];
const publicTypes = (stats && stats.public) || [];
@@ -194,10 +250,15 @@ function activity(stats) {
@@ -194,10 +250,15 @@ function activity(stats) {
const statRow = tiles.length ? `<section class="sc-sec hm-stats-sec" aria-label="The bus right now"><ul class="hm-stats">${tiles.map(([n, label]) => `<li><b>${esc(fmt(n))}</b><span>${esc(label)}</span></li>`).join('')}</ul>${stats.dead === 0 && stats.delivered ? '<p class="hm-note">No deliveries are waiting in the dead-letter queue.</p>' : ''}</section>` : '';
const statRow = tiles.length ? `<section class="sc-sec hm-stats-sec" aria-label="The bus right now"><ul class="hm-stats">${tiles.map(([n, label]) => `<li><b>${esc(fmt(n))}</b><span>${esc(label)}</span></li>`).join('')}</ul>${stats.dead === 0 && stats.delivered ? '<p class="hm-note">No deliveries are waiting in the dead-letter queue.</p>' : ''}</section>` : '';
const top = stats.services.slice(0, 16);
const top = stats.services.slice(0, 16);
const max = top.length ? top[0].n : 1;
const max = top.length ? top[0].n : 1;
- const who = top.length ? `<section class="sc-sec" aria-labelledby="h-who"><h2 id="h-who">Who published this week</h2>
-<p class="sc-lede">Events by the service that published them over the last seven days. Every one was stored before any subscriber saw it.</p>
+ // Who published is a fold on a phone (the owner's short-pages rule): a chart of sixteen bars is a screen of its
+ // own, and a reader on a phone is here for the events.
+ const who = top.length ? foldBlock({
+ id: 'who',
+ head: '<h2 id="h-who">Who published this week</h2>',
+ html: `<p class="sc-lede">Events by the service that published them over the last seven days. Every one was stored before any subscriber saw it.</p>
<ul class="hm-bars">${top.map((r) => `<li><span class="hm-bar-name">${serviceLink(r.ns)}</span><span class="hm-bar" aria-hidden="true"><i style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></i></span><span class="hm-bar-n">${esc(fmt(r.n))}</span></li>`).join('')}</ul>
<ul class="hm-bars">${top.map((r) => `<li><span class="hm-bar-name">${serviceLink(r.ns)}</span><span class="hm-bar" aria-hidden="true"><i style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></i></span><span class="hm-bar-n">${esc(fmt(r.n))}</span></li>`).join('')}</ul>
-${stats.services.length > top.length ? `<p class="hm-note">And ${esc(stats.services.length - top.length)} more.</p>` : ''}</section>` : '';
+${stats.services.length > top.length ? `<p class="hm-note">And ${esc(stats.services.length - top.length)} more.</p>` : ''}`,
+ }) : '';
return { statRow, who, events };
return { statRow, who, events };
}
}
@@ -212,6 +273,22 @@ const FAQ = [
@@ -212,6 +273,22 @@ const FAQ = [
['Are sandbox and production separate?', 'Yes. A developer app\'s sandbox events never reach production apps or subscriptions, and are pruned after seven days.'],
['Are sandbox and production separate?', 'Yes. A developer app\'s sandbox events never reach production apps or subscriptions, and are pruned after seven days.'],
];
];
+/** What the bus gives a service: the six things a reader can read the cards of. */
+const FEATURES = [
+ { icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },
+ { icon: 'ov:bell', title: 'Signed webhooks', text: 'Subscribe with a topic pattern such as media.vod.*. Every delivery is HMAC-signed with its timestamp and retried with backoff.' },
+ { icon: 'ov:history', title: 'Dead letters and replay', text: 'Deliveries that keep failing wait in a dead-letter queue. Replay them, or catch a new subscription up from a point in its history.' },
+ { icon: 'ov:download', title: 'Pull with a cursor', text: 'Read events in order with an opaque cursor when you would rather pull than receive webhooks.' },
+ { icon: 'ov:live', title: 'Realtime for browsers', text: 'Server-sent events, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.' },
+ { icon: 'ov:code', title: 'Developer apps', text: 'An app publishes under its own project\'s names and keeps sandbox and production apart: sandbox events never reach production.' },
+];
+
+/** One feature card, in the shared showcase's own card markup: a ring icon in a 40 px tile beside the words. */
+function featureCard(f) {
+ const name = String(f.icon || '').replace(/^ov:/, '');
+ return `<div class="sc-card"><span class="sc-ic sc-ic-ov"><span class="ov-icon sc-ovi" data-icon="${esc(name)}" style="--ovi-size:40px" aria-hidden="true">${ovIcons.svg(name)}</span></span><span><b>${esc(f.title)}</b><small>${esc(f.text)}</small></span></div>`;
+}
+
function sections({ apiUrl, stats = null }) {
function sections({ apiUrl, stats = null }) {
const live = activity(stats);
const live = activity(stats);
const publish = [
const publish = [
@@ -233,6 +310,15 @@ function sections({ apiUrl, stats = null }) {
@@ -233,6 +310,15 @@ function sections({ apiUrl, stats = null }) {
'# then the same with &after=<next_cursor> from the previous page',
'# then the same with &after=<next_cursor> from the previous page',
].join('\n');
].join('\n');
const publishers = stats && stats.services.filter((r) => r.ns !== 'app').length;
const publishers = stats && stats.services.filter((r) => r.ns !== 'app').length;
+ // What you get: on a phone one card at a time, picked with a row of chips (the owner's short-pages rule). The
+ // cards keep the shared showcase's own markup, so a wider screen shows the same grid of six.
+ const features = `<section class="sc-sec" aria-labelledby="h-features"><h2 id="h-features">What you get</h2>
+${pickBlock({
+ key: 'features',
+ label: 'What you get',
+ bodyClass: 'sc-grid',
+ panes: FEATURES.map((f, i) => ({ id: String(i), label: f.title, html: featureCard(f) })),
+ })}</section>`;
return showcase.hero({
return showcase.hero({
eyebrow: 'OpenVibe.Events · part of OpenVibe Services · alpha',
eyebrow: 'OpenVibe.Events · part of OpenVibe Services · alpha',
title: 'Events your services', accent: 'can count on.',
title: 'Events your services', accent: 'can count on.',
@@ -240,17 +326,7 @@ function sections({ apiUrl, stats = null }) {
@@ -240,17 +326,7 @@ function sections({ apiUrl, stats = null }) {
+ `retried with backoff, and replayable after a failure.${publishers ? ` ${publishers} OpenVibe services published to it this week.` : ''}`,
+ `retried with backoff, and replayable after a failure.${publishers ? ` ${publishers} OpenVibe services published to it this week.` : ''}`,
actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],
actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],
note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,
note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,
- }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.events + live.who + showcase.features({
- title: 'What you get',
- items: [
- { icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },
- { icon: 'ov:bell', title: 'Signed webhooks', text: 'Subscribe with a topic pattern such as media.vod.*. Every delivery is HMAC-signed with its timestamp and retried with backoff.' },
- { icon: 'ov:history', title: 'Dead letters and replay', text: 'Deliveries that keep failing wait in a dead-letter queue. Replay them, or catch a new subscription up from a point in its history.' },
- { icon: 'ov:download', title: 'Pull with a cursor', text: 'Read events in order with an opaque cursor when you would rather pull than receive webhooks.' },
- { icon: 'ov:live', title: 'Realtime for browsers', text: 'Server-sent events, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.' },
- { icon: 'ov:code', title: 'Developer apps', text: 'An app publishes under its own project\'s names and keeps sandbox and production apart: sandbox events never reach production.' },
- ],
- }) + '<details class="hm-fold"><summary>Publish, subscribe, pull<span>curl examples</span></summary>' + showcase.code({
+ }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.events + live.who + features + '<details class="hm-fold"><summary>Publish, subscribe, pull<span>curl examples</span></summary>' + showcase.code({
id: 'examples',
id: 'examples',
title: 'Publish, subscribe, pull',
title: 'Publish, subscribe, pull',
lede: 'With an app token from OpenVibe.Network (client credentials, audience openvibe.events).',
lede: 'With an app token from OpenVibe.Network (client credentials, audience openvibe.events).',
@@ -259,23 +335,29 @@ function sections({ apiUrl, stats = null }) {
@@ -259,23 +335,29 @@ function sections({ apiUrl, stats = null }) {
{ label: 'Subscribe', lang: 'bash', code: subscribe },
{ label: 'Subscribe', lang: 'bash', code: subscribe },
{ label: 'Pull', lang: 'bash', code: pull },
{ label: 'Pull', lang: 'bash', code: pull },
],
],
- }) + '</details>' + showcase.steps({
- title: 'Receive a webhook safely',
- items: [
- { title: 'Keep the secret', text: 'The subscription\'s secret is shown once, when you create it or rotate it.' },
- { title: 'Check the signature', text: 'Verify X-OpenVibe-Signature-V2 over the timestamp and the raw body, and refuse anything older than five minutes.' },
- { title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },
- { title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },
- ],
- }) + `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
-<div class="hm-faq">${FAQ.map(([q, a], i) => `<details class="hm-qa"${i === 0 ? ' open' : ''}><summary><h3>${esc(q)}</h3></summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;
+ }) + '</details>' + foldBlock({
+ id: 'howto',
+ head: '<h2 id="h-howto">Receive a webhook safely</h2>',
+ html: showcase.steps({
+ items: [
+ { title: 'Keep the secret', text: 'The subscription\'s secret is shown once, when you create it or rotate it.' },
+ { title: 'Check the signature', text: 'Verify X-OpenVibe-Signature-V2 over the timestamp and the raw body, and refuse anything older than five minutes.' },
+ { title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },
+ { title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },
+ ],
+ }),
+ }) + foldBlock({
+ id: 'faq',
+ head: '<h2 id="h-faq">Questions</h2>',
+ html: `<div class="hm-faq">${FAQ.map(([q, a], i) => `<details class="hm-qa"${i === 0 ? ' open' : ''}><summary><h3>${esc(q)}</h3></summary><p>${esc(a)}</p></details>`).join('')}</div>`,
+ });
}
}
// The page's own few rules (it has no stylesheet beyond the showcase's): no browser margin around the body, so the Frame's
// The page's own few rules (it has no stylesheet beyond the showcase's): no browser margin around the body, so the Frame's
// footer meets the edges; each curl sample on its own full-width row (three columns cut every command off); the four
// footer meets the edges; each curl sample on its own full-width row (three columns cut every command off); the four
// webhook steps two by two instead of three and one alone, each title level with its number.
// webhook steps two by two instead of three and one alone, each title level with its number.
// The data sections' rules follow: the stat tiles, the per-service bars, the public event cards and the questions, in theme tokens (accent-light is drawn for dark themes, so text in it is mixed toward the text colour).
// The data sections' rules follow: the stat tiles, the per-service bars, the public event cards and the questions, in theme tokens (accent-light is drawn for dark themes, so text in it is mixed toward the text colour).
-const HOME_STYLE = '<style>body{margin:0}/* The hero\'s aura is drawn 54px past its right edge (shared showcase, sc-hero-vis at right:-54px); without a clip the phone scrolls sideways by ~43px (2026-10-29). */.page{overflow-x:clip}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}'
+const HOME_STYLE = (rules = '') => '<style>body{margin:0}/* The hero\'s aura is drawn 54px past its right edge (shared showcase, sc-hero-vis at right:-54px); without a clip the phone scrolls sideways by ~43px (2026-10-29). */.page{overflow-x:clip}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}'
+ '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}'
+ '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}'
+ '.hm-stats-sec{margin-top:40px}.hm-stats{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(130px,100%),1fr))}'
+ '.hm-stats-sec{margin-top:40px}.hm-stats{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(130px,100%),1fr))}'
+ '.hm-stats li{display:grid;gap:2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}'
+ '.hm-stats li{display:grid;gap:2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}'
@@ -293,7 +375,32 @@ const HOME_STYLE = '<style>body{margin:0}/* The hero\'s aura is drawn 54px past
@@ -293,7 +375,32 @@ const HOME_STYLE = '<style>body{margin:0}/* The hero\'s aura is drawn 54px past
+ '.hm-event-title{margin:4px 0 6px;font-size:17px}.hm-event-meta{margin:0 0 6px;font-size:13.5px;line-height:1.5;color:var(--text-secondary,#96a7c2)}'
+ '.hm-event-title{margin:4px 0 6px;font-size:17px}.hm-event-meta{margin:0 0 6px;font-size:13.5px;line-height:1.5;color:var(--text-secondary,#96a7c2)}'
+ '.hm-event-desc{margin:0 0 6px;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}.hm-event-type{margin:0}.hm-event-type code{font-size:12.5px;overflow-wrap:anywhere;color:var(--text-secondary,#96a7c2)}'
+ '.hm-event-desc{margin:0 0 6px;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}.hm-event-type{margin:0}.hm-event-type code{font-size:12.5px;overflow-wrap:anywhere;color:var(--text-secondary,#96a7c2)}'
+ '.hm-fold{box-sizing:border-box;max-width:1080px;margin:20px max(16px,calc((100% - 1080px) / 2)) 0;font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;background:var(--bg-card,#131c2e)}.hm-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:48px;padding:0 16px;cursor:pointer;font-weight:700;list-style:none}.hm-fold>summary::-webkit-details-marker{display:none}.hm-fold>summary span{color:var(--text-muted,#7386a3);font-weight:600;font-size:.86rem}.hm-fold .sc-sec{margin:0;padding:0 16px 14px}.hm-qa>summary{list-style:none;cursor:pointer}.hm-qa>summary::-webkit-details-marker{display:none}.hm-qa>summary h3{margin:0}'
+ '.hm-fold{box-sizing:border-box;max-width:1080px;margin:20px max(16px,calc((100% - 1080px) / 2)) 0;font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;background:var(--bg-card,#131c2e)}.hm-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:48px;padding:0 16px;cursor:pointer;font-weight:700;list-style:none}.hm-fold>summary::-webkit-details-marker{display:none}.hm-fold>summary span{color:var(--text-muted,#7386a3);font-weight:600;font-size:.86rem}.hm-fold .sc-sec{margin:0;padding:0 16px 14px}.hm-qa>summary{list-style:none;cursor:pointer}.hm-qa>summary::-webkit-details-marker{display:none}.hm-qa>summary h3{margin:0}'
- + '@media (max-width:599px){.hm-events>li:nth-child(n+7),.hm-bars>li:nth-child(n+11){display:none}}'
+ + '@media (max-width:599px){.hm-bars>li:nth-child(n+11){display:none}}'
+ // Pick (the owner's short-pages rule, 2026-10-30): on a phone, one of several blocks of the same kind at a time.
+ // Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only where :has() is
+ // understood, so a wider screen, or an older browser, shows every block and no chips.
+ + '.pick-row{display:none}'
+ + '@supports selector(:has(*)){@media (max-width:640px){'
+ + '.pick-row{display:flex;gap:8px;overflow-x:auto;margin:10px -16px 0;padding:2px 16px 4px;scrollbar-width:none;overscroll-behavior-x:contain}'
+ + '.pick-row::-webkit-scrollbar{display:none}'
+ + '.pick-row input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}'
+ + '.pick-row label{display:inline-flex;align-items:center;flex:none;min-height:44px;padding:0 15px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-secondary,#a8b3c4);font-size:13.5px;font-weight:650;white-space:nowrap;cursor:pointer}'
+ + '.pick-row input:checked+label{color:var(--text-primary,#e6edf7);border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}'
+ + '.pick-row input:focus-visible+label{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}'
+ + '.pick-pane{display:none}'
+ + '.pick-pane>.pick-h{display:none}'
+ // The public event list: the cards of the picked category, the others out of the way.
+ + '.pick-events .hm-events>li{display:none}'
+ // The rule for each block of the page's pick blocks: the picked one shows, the others stay out of the way.
+ + rules
+ + '}}'
+ // The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section
+ // it replaces: a heading and its content, with no card around it.
+ + '.hm-fold-block{margin-top:56px}.hm-fold-block>summary h2{margin:0;font-size:clamp(19px,2.2vw,26px);letter-spacing:-.01em}'
+ + '@media (min-width:641px){.hm-fold-block{border:0;background:none;border-radius:0;padding:0 24px}'
+ + '.hm-fold-block>summary{display:block;min-height:0;padding:0}.hm-fold-block>summary h2{margin:0 0 6px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em}'
+ + '.hm-fold-block>summary span{display:none}.hm-fold-block .sc-sec{padding:0}.hm-fold-block .hm-faq,.hm-fold-block .hm-bars{padding:0}}'
+ + '@media (max-width:640px){.hm-fold-block{margin-top:32px}}'
+ '.hm-faq{display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr))}.hm-qa{padding:4px 0 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}'
+ '.hm-faq{display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr))}.hm-qa{padding:4px 0 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}'
+ '.hm-qa h3{margin:0 0 6px;font-size:16px}.hm-qa p{margin:0;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}'
+ '.hm-qa h3{margin:0 0 6px;font-size:16px}.hm-qa p{margin:0;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}'
+ '@media (max-width:520px){.page .sc-code pre{white-space:pre-wrap;overflow-wrap:anywhere}.hm-bars li{grid-template-columns:minmax(0,10.5em) minmax(0,1fr) auto;font-size:13.5px;gap:8px}.hm-bar-n{min-width:3.5em}'
+ '@media (max-width:520px){.page .sc-code pre{white-space:pre-wrap;overflow-wrap:anywhere}.hm-bars li{grid-template-columns:minmax(0,10.5em) minmax(0,1fr) auto;font-size:13.5px;gap:8px}.hm-bar-n{min-width:3.5em}'
@@ -362,7 +469,7 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
@@ -362,7 +469,7 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
head: [
head: [
appIcon.headTags({ site: 'events' }),
appIcon.headTags({ site: 'events' }),
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
- HOME_STYLE,
+ HOME_STYLE(pickRules(stats)),
].join('\n'),
].join('\n'),
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
<main id="main" class="page">
<main id="main" class="page">
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+// The home on a phone (the owner's short-pages rule, 2026-10-30): the blocks of the same kind are one pick block,
+// a row of chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no
+// script for the chips; every block stays in the page for crawlers; a wider screen shows them all and no chips.
+const assert = require('assert');
+const { renderHome } = require('../server/home');
+
+const last = Date.parse('2026-10-29T09:41:00Z');
+const STATS = {
+ day: 4, week: 4, types: 2, services: [{ ns: 'live', n: 3 }, { ns: 'blog', n: 1 }], subscriptions: 0, consumers: 0,
+ delivered: 0, dead: 0, measuredAt: last,
+ public: [
+ { type: 'live.stream.started', n: 3, last },
+ { type: 'blog.post.published', n: 1, last: 0 },
+ ],
+};
+
+const t = (name, fn) => { try { fn(); console.log(`ok ${name}`); } catch (e) { console.error(`FAIL ${name}: ${e.message}`); process.exitCode = 1; } };
+
+t('the public events are one pick block: a chip per category, the first picked, every card in the page', () => {
+ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });
+ const block = page.slice(page.indexOf('<div class="pick pick-events"'), page.indexOf('</section>', page.indexOf('<div class="pick pick-events"')));
+ assert.ok(block.startsWith('<div class="pick pick-events"'), 'the pick block is on the home');
+ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Public events this week">/);
+ // One chip per category, in the order the categories appear.
+ const chips = [...block.matchAll(/<input type="radio" name="pick-events" id="pk-events-(\d+)" value="\1"( checked)?><label for="pk-events-\1">([^<]+)<\/label>/g)];
+ assert.strictEqual(chips.length, 2, `a chip per category (${chips.length})`);
+ assert.deepStrictEqual(chips.map((m) => m[1]), ['0', '1'], 'the chips are in order');
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['0'], 'the first category is picked');
+ assert.ok(chips[0][3].includes('OpenVibe.Live'), `the chip names the category (${chips[0][3]})`);
+ // Every card stays in the page: the crawler still reads them all.
+ const cards = [...block.matchAll(/<li class="hm-event" id="event-([a-z0-9-]+)" data-cat="(\d+)">/g)];
+ assert.strictEqual(cards.length, 2, `every event card is in the page (${cards.length})`);
+ assert.deepStrictEqual(cards.map((m) => m[2]), ['0', '1'], 'each card knows its category');
+});
+
+t('what you get is one pick block too: six chips, the first picked, every card in the page', () => {
+ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });
+ const block = page.slice(page.indexOf('<div class="pick" id="pick-features">'), page.indexOf('</section>', page.indexOf('<div class="pick" id="pick-features">')));
+ const chips = [...block.matchAll(/<input type="radio" name="pick-features" id="pk-features-(\d+)" value="\1"( checked)?><label for="pk-features-\1">([^<]+)<\/label>/g)];
+ assert.strictEqual(chips.length, 6, `a chip per feature (${chips.length})`);
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['0'], 'the first feature is picked');
+ assert.strictEqual((block.match(/class="sc-card"/g) || []).length, 6, 'every card is in the page');
+ // The CSS that shows the picked block, one rule per block.
+ for (let i = 0; i < 6; i++) {
+ assert.ok(page.includes(`.pick:has(#pk-features-${i}:checked) #pp-features-${i}{display:block}`), `feature ${i} shows when its chip is picked`);
+ }
+});
+
+t('the CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood', () => {
+ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });
+ assert.ok(page.includes('.pick-row{display:none}'), 'the chips are hidden by default');
+ assert.ok(page.includes('@supports selector(:has(*)){@media (max-width:640px){'), 'the phone rules are under :has() and 640 px');
+ assert.ok(page.includes('.pick-pane{display:none}'), 'the blocks are hidden on a phone');
+ assert.match(page, /\.pick-row label\{[^}]*min-height:44px/, 'a chip is a 44 px target');
+ assert.ok(page.includes('.pick-events .hm-events>li{display:none}'), 'only the picked category shows on a phone');
+ assert.ok(page.includes('.pick-events:has(#pk-events-0:checked) .hm-events>li[data-cat="0"]{display:block}'), 'the picked category shows');
+});
+
+t('the parts a reader rarely needs are folds, open in the page and closed on a phone', () => {
+ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });
+ for (const id of ['who', 'howto', 'faq']) {
+ assert.ok(page.includes(`<details class="hm-fold hm-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
+ assert.ok(page.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
+ }
+ // The content of the folds stays in the page.
+ assert.match(page, /<h2 id="h-who">Who published this week<\/h2>/);
+ assert.match(page, /<h2 id="h-faq">Questions<\/h2>/);
+ assert.match(page, /What is OpenVibe\.Events\?/, 'the questions are still in the page');
+});
@@ -10,6 +10,8 @@ const t = suite('home-stats');
@@ -10,6 +10,8 @@ const t = suite('home-stats');
let h;
let h;
const html = async () => (await request(h.base, 'GET', '/', { headers: { Accept: 'text/html' } })).text;
const html = async () => (await request(h.base, 'GET', '/', { headers: { Accept: 'text/html' } })).text;
const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(page) || [''])[0];
const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(page) || [''])[0];
+// The parts a reader rarely needs are folds now (the owner's short-pages rule, 2026-10-30): the same block, folded.
+const fold = (page, id) => (new RegExp(`<details class="hm-fold hm-fold-block" id="${id}"[\\s\\S]*?</details>`).exec(page) || [''])[0];
t('boot', async () => { h = await boot(); });
t('boot', async () => { h = await boot(); });
@@ -44,7 +46,7 @@ t('published events show as counts by service, public types and the latest publi
@@ -44,7 +46,7 @@ t('published events show as counts by service, public types and the latest publi
const { renderHome } = require('../server/home');
const { renderHome } = require('../server/home');
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });
const events = section(page, 'h-events');
const events = section(page, 'h-events');
- assert.match(section(page, 'h-who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);
+ assert.match(fold(page, 'who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);
assert.match(events, /A channel went live on OpenVibe\.Live/);
assert.match(events, /A channel went live on OpenVibe\.Live/);
assert.match(events, /<time datetime="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z">/);
assert.match(events, /<time datetime="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z">/);
assert.match(events, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a> · 2 this week/);
assert.match(events, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a> · 2 this week/);
@@ -72,7 +74,9 @@ t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', a
@@ -72,7 +74,9 @@ t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', a
assert.strictEqual(scripts.length, 1, 'the page carries exactly one JSON-LD script');
assert.strictEqual(scripts.length, 1, 'the page carries exactly one JSON-LD script');
const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
for (const type of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(ld.some((x) => x['@type'] === type), type);
for (const type of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(ld.some((x) => x['@type'] === type), type);
- const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(page) || [''])[0];
+ // The questions are in the fold now; they are the only bare <h3> left on the page.
+ const faqAt = page.indexOf('<details class="hm-fold hm-fold-block" id="faq"');
+ const faq = faqAt >= 0 ? page.slice(faqAt, page.indexOf('</main>')) : '';
const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));
const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));
assert.ok(shown.length >= 6);
assert.ok(shown.length >= 6);
assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
@@ -175,7 +175,10 @@ t('the server-rendered event list: title, real <time datetime>, place, descripti
@@ -175,7 +175,10 @@ t('the server-rendered event list: title, real <time datetime>, place, descripti
assert.match(card, /Published by OpenVibe\.Live as live\.stream\.started/, 'the short description');
assert.match(card, /Published by OpenVibe\.Live as live\.stream\.started/, 'the short description');
assert.match(card, /3 this week/, 'the count');
assert.match(card, /3 this week/, 'the count');
assert.match(card, /Category: <a href="https:\/\/openvibe\.live">live<\/a>/, 'a category link');
assert.match(card, /Category: <a href="https:\/\/openvibe\.live">live<\/a>/, 'a category link');
- assert.match(page, /<nav class="hm-cats"[^>]*><a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the category nav');
+ // The category nav became the row of chips that picks one category at a time on a phone (the short-pages rule);
+ // the links it held are still in the page, on the cards themselves.
+ assert.match(page, /<label for="pk-events-0">OpenVibe\.Live<\/label>/, 'the category chips');
+ assert.match(page, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the publisher link is still in the page');
assert.match(page, /<code>live\.stream\.started<\/code>/, 'the exact event type');
assert.match(page, /<code>live\.stream\.started<\/code>/, 'the exact event type');
// One JSON-LD script per page: every node lives in the page's @graph.
// One JSON-LD script per page: every node lives in the page's @graph.