openvibers/openvibe.events Commits

Give Events shared page headers and section controls (#272)

OP OpenVibers committed 4 days ago · 229275bf3c · 4 files, +33 −23

* Use shared icon headings and actions on Events pages

* Pin the Shared fix for square section icon tiles

* Adopt shared page headers and mark horizontal content rows

* Merge origin/main; read the page head on 4.163.0

Keeps main's newer pin round and readme/status; the shared page head is read from the page-head commit built on 4.163.0 so the site gets main's kit fixes as well as kit.pageHead.

* Merge remote-tracking branch 'origin/main' into ui/sections-events-1109

# Conflicts:
#	package-lock.json
#	package.json

* Merge remote-tracking branch 'origin/main' into ui/sections-events-1109

Changes

ddiff --git a/server/home.js b/server/home.js
iindex 413c79a..1aed344 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -1,4 +1,6 @@
'use strict';
++const kit = require('openvibe-shared/kit');
++const sectionIcon = (name) => kit.icon(name, { cls: 'ovk-heading-icon' });
/**
* The product home of OpenVibe.Events (openvibe.events, plan T7: the API origin is the product origin too).
@@@ -119,8 +121,8 @@ const humanDate = (ms) => {
function pickBlock({ key, label, panes, bodyClass = '' }) {
if (!panes.length) return '';
const chips = panes.map((p) => `<a class="pick-chip" href="#pp-${esc(key)}-${esc(p.id)}">${esc(p.label)}</a>`).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)}"><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
++ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.head ? `<div class="pick-h"><h3 class="ovk-titled">${sectionIcon('grid')}${esc(p.head)}</h3></div>` : ''}${p.html}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><nav class="pick-row" data-ovk-scroll aria-label="${esc(label)}">${chips}</nav><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
}
@@@ -166,7 +168,7 @@ function eventCard(r, cat) {
// list; each card says only its own: what happened, when it last arrived, who published it, how often, its name.
void category;
return `<li class="hm-event" id="event-${esc(slug(type))}" data-cat="${esc(cat)}">
--<h3 class="hm-event-title">${esc(eventTitle(type))}</h3>
++<h3 class="ovk-titled hm-event-title">${sectionIcon('live')}${esc(eventTitle(type))}</h3>
<p class="hm-event-meta">${when} · from ${serviceLink(ns)} · ${esc(fmt(r.n))} this week</p>
<p class="hm-event-type"><code>${esc(type)}</code></p>
</li>`;
@@@ -188,7 +190,7 @@ function eventsSection(stats) {
if (!stats) {
inner = '<p class="sc-lede">The live bus figures are unavailable right now, so this page is showing the product shell. The API, webhooks and the realtime stream described below keep running.</p>';
} 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 = kit.empty({ icon: 'live', text: 'No public events have arrived this week. The bus is running; public event types appear here as they arrive.', action: { href: '#howto', label: 'Set up a webhook', icon: 'code' } });
} else {
// The categories, the one with the newest arrival first: one chip each.
const cats = [];
@@@ -198,11 +200,11 @@ function eventsSection(stats) {
const chips = cats.map((ns) => `<a class="pick-chip" href="#event-${esc(slug(String(firstOf(ns).type)))}">${esc(serviceName(ns))}</a>`).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; any reader can also pull them with a cursor.</p>
--<div class="pick pick-events" id="pick-events"><nav class="pick-row" aria-label="Public events this week">${chips}</nav>
++<div class="pick pick-events" id="pick-events"><nav class="pick-row" data-ovk-scroll aria-label="Public events this week">${chips}</nav>
<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>`;
++ return kit.section({ id: 'events', cls: 'sc-sec', title: 'Public events this week', icon: 'live', actions: [kit.button({ href: '#howto', label: 'Set up a webhook', icon: 'code', kind: 'quiet' })], body: inner });
}
@@@ -260,7 +262,7 @@ function activity(stats) {
// 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>',
++ head: `<h2 id="h-who" class="ovk-titled">${sectionIcon('live')}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>` : ''}`,
@@@ -318,14 +320,14 @@ function sections({ apiUrl, stats = null }) {
const publishers = stats && stats.services.filter((r) => r.ns !== 'app').length;
// What you get: every card shown; on a phone a row of chips jumps to one. 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>
++ const features = `<section class="sc-sec" aria-labelledby="h-features"><h2 id="h-features" class="ovk-titled">${sectionIcon('grid')}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({ pageHead: { icon: 'live' },
eyebrow: 'OpenVibe.Events · part of OpenVibe Services · alpha',
title: 'Events your services', accent: 'can count on.',
lede: 'The event backbone of the OpenVibe network. Publish an event once and every subscriber gets it: stored durably, signed, '
@@@ -344,7 +346,7 @@ ${pickBlock({
],
}) + '</details>' + foldBlock({
id: 'howto',
-- head: '<h2 id="h-howto">Receive a webhook safely</h2>',
++ head: `<h2 id="h-howto" class="ovk-titled">${sectionIcon('grid')}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.' },
@@@ -355,8 +357,8 @@ ${pickBlock({
}),
}) + 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>`,
++ head: `<h2 id="h-faq" class="ovk-titled">${sectionIcon('help')}Questions</h2>`,
++ html: `<div class="hm-faq">${FAQ.map(([q, a], i) => `<details class="hm-qa"${i === 0 ? ' open' : ''}><summary><h3 class="ovk-titled">${sectionIcon('help')}${esc(q)}</h3></summary><p>${esc(a)}</p></details>`).join('')}</div>`,
});
}
@@@ -472,6 +474,7 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],
head: [
appIcon.headTags({ site: 'events' }),
++ `<link rel="stylesheet" href="${ovServe.url('kit.css')}"><script src="${ovServe.url('kit-web.js')}" defer></script>`,
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
HOME_STYLE(),
].join('\n'),
@@@ -499,11 +502,12 @@ function renderNotFound({ siteUrl = 'https://openvibe.events' } = {}) {
footer: FOOTER, home: '/', navLinks: links,
head: [
appIcon.headTags({ site: 'events' }),
++ `<link rel="stylesheet" href="${ovServe.url('kit.css')}"><script src="${ovServe.url('kit-web.js')}" defer></script>`,
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
].join('\n'),
body: `<div id="navbar-mount"></div>
<main id="main" class="page">
--<h1>Page not found</h1>
++${kit.pageHead({ title: `Page not found`, icon: 'live', back: { href: '/', label: 'Home' } })}
<p>There is no page at this address on ${esc(host)}. The link may be old or mistyped.</p>
<div class="sc-actions">
<a class="sc-btn sc-primary" href="/">${SITE_NAME} home</a>
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex adff19f..b305155 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -2,6 +2,7 @@
// The home on a phone: the blocks of the same kind are all shown, and a row of chips jumps to one (the owner,
// 2026-10-31: never hide content to shorten); the parts a reader rarely needs are folds. A wider screen shows no chips.
const assert = require('assert');
++const { parseHTML } = require('linkedom');
const { renderHome } = require('../server/home');
const last = Date.parse('2026-10-29T09:41:00Z');
@@@ -20,7 +21,7 @@ t('the public events: every card shown, a chip per category that jumps to its fi
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 block is on the home');
-- assert.match(block, /<nav class="pick-row" aria-label="Public events this week">/);
++ assert.match(block, /<nav class="pick-row" data-ovk-scroll aria-label="Public events this week">/);
const chips = [...block.matchAll(/<a class="pick-chip" href="#(event-[a-z0-9-]+)">([^<]+)<\/a>/g)];
assert.strictEqual(chips.length, 2, `a chip per category (${chips.length})`);
assert.ok(chips[0][2].includes('OpenVibe.Live'), `the chip names the category (${chips[0][2]})`);
@@@ -55,7 +56,7 @@ t('the parts a reader rarely needs are folds, open in the page and closed on a p
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.strictEqual(parseHTML(page).document.querySelector('#h-who').textContent, 'Who published this week');
++ assert.strictEqual(parseHTML(page).document.querySelector('#h-faq').textContent, 'Questions');
assert.match(page, /What is OpenVibe\.Events\?/, 'the questions are still in the page');
});
ddiff --git a/test/home-stats.test.js b/test/home-stats.test.js
iindex f93f5b5..ae805a4 100644
---- a/test/home-stats.test.js
++++ b/test/home-stats.test.js
@@@ -3,13 +3,14 @@
// the public events with when each last arrived, never a payload, internal types never named, a clear empty state on
// an empty or failed bus, and a FAQPage that holds exactly the questions the page shows.
const assert = require('assert');
++const { parseHTML } = require('linkedom');
const { boot, request, serviceToken, envelope, suite } = require('./helpers');
const { createHomeStats } = require('../server/home-stats');
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];
++const section = (page, id) => parseHTML(page).document.querySelector(`section[aria-labelledby="${id}"]`)?.outerHTML || '';
// 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];
@@@ -19,9 +20,12 @@ t('an empty bus: the counts are left out, the public event list says so, the res
const page = await html();
assert.ok(!page.includes('aria-labelledby="h-who"'));
assert.ok(!page.includes('class="hm-stats"'), 'no tiles of zeros');
-- assert.match(page, /id="h-events">Public events this week<\/h2><p class="sc-lede">No public events have arrived this week\./);
++ const doc = parseHTML(page).document;
++ assert.strictEqual(doc.querySelector('#events-h').textContent, 'Public events this week');
++ assert.match(doc.querySelector('#events .ovk-empty').textContent, /No public events have arrived this week\./);
++ assert.strictEqual(doc.querySelector('#events .ovk-empty .ovk-btn').getAttribute('href'), '#howto');
assert.match(page, /Events your services/);
-- assert.match(page, /id="h-faq">Questions</);
++ assert.strictEqual(parseHTML(page).document.querySelector('#h-faq').textContent, 'Questions');
});
t('published events show as counts by service, public types and the latest public events, never a payload', async () => {
@@@ -45,7 +49,7 @@ t('published events show as counts by service, public types and the latest publi
const { renderHome } = require('../server/home');
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });
-- const events = section(page, 'h-events');
++ const events = section(page, 'events-h');
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">/);
@@@ -77,7 +81,7 @@ t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', a
// 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(/&#39;/g, "'"));
++ const shown = [...parseHTML(faq).document.querySelectorAll('h3')].map((el) => el.textContent);
assert.ok(shown.length >= 6);
assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
const description = /<meta name="description" content="([^"]*)"/.exec(page)[1];
ddiff --git a/test/home.test.js b/test/home.test.js
iindex e44c553..2487001 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -5,6 +5,7 @@
// It also serves the pinned OpenVibe Frame at /shared, answers robots.txt, sitemap.xml, llms.txt and
// llms-full.txt, and names its own origin everywhere. A parse-only check of the nginx vhost too.
const assert = require('assert');
++const { parseHTML } = require('linkedom');
function assertSitemapLastmods(xml, tag = 'url') {
const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
@@@ -139,7 +140,7 @@ t('/favicon.ico is the app icon (SVG), cached a day', async () => {
t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
const r = await raw(h.base, '/', { Accept: 'text/html' });
-- const href = /<link rel="stylesheet" href="([^"]+)"/.exec(r.body);
++ const href = /<link rel="stylesheet" href="(\/shared\/showcase\.css[^"]+)"/.exec(r.body);
assert.ok(href && href[1].startsWith('/shared/showcase.css?v='), `showcase.css link, got ${href && href[1]}`);
const sheet = await raw(h.base, href[1]);
assert.strictEqual(sheet.status, 200);
@@@ -183,7 +184,7 @@ t('the server-rendered event list: title, real <time datetime>, place, descripti
assert.match(page, /signed webhooks; any reader can also pull them with a cursor/, 'said once in the lede');
// A type without its own words reads as its publisher and what happened.
const odd = renderHome({ siteUrl: SITE, apiUrl: SITE, stats: { ...stats, public: [{ type: 'host.release_note.published', n: 1, last }] } });
-- assert.match(odd, /<h3 class="hm-event-title">OpenVibe\.Host: release note published<\/h3>/, 'its publisher and what happened');
++ assert.strictEqual(parseHTML(odd).document.querySelector('.hm-event-title').textContent, 'OpenVibe.Host: release note published', 'its publisher and what happened');
assert.ok(!odd.includes('An event of type'), 'never "An event of type …"');
// 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.