openvibers/openvibe.events Commits
Home: every event card and feature shows on a phone; the chips are links (#198)
Changes
@@@ -108,23 +108,18 @@ const humanDate = (ms) => {};/**-- * 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.++ * A pick block: several blocks of the same kind, every one shown; on a phone a row of chips jumps to one (the owner,++ * 2026-10-31: never hide content to shorten). A wider screen shows them all and no chips.** 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 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)}"><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;++ 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>`;}--/** 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@@@ -165,8 +160,8 @@ function eventCard(r, cat) {* 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.++ * Every card is shown; on a phone a row of chips jumps to each category's first card (the owner, 2026-10-31: never hide++ * content to shorten).*/function eventsSection(stats) {const publicTypes = (stats && stats.public) || [];@@@ -180,37 +175,20 @@ function eventsSection(stats) {// 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('');++ // A chip per category jumps to its first card (the newest arrival of that category).++ const firstOf = (ns) => list.find((r) => nsOf(r.type) === ns);++ 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.</p>--<div class="pick pick-events" id="pick-events"><div class="pick-row" role="radiogroup" aria-label="Public events this week">${chips}</div>++<div class="pick pick-events" id="pick-events"><nav class="pick-row" 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>`;}--/** 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 }) {@@@ -320,8 +298,8 @@ 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.++ // 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>${pickBlock({key: 'features',@@@ -368,7 +346,7 @@ ${pickBlock({// 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 = (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}'++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}'+ '.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)}'@@@ -387,25 +365,17 @@ const HOME_STYLE = (rules = '') => '<style>body{margin:0}/* The hero\'s aura is+ '.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-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: every block is shown; on a phone the chips are a row of links that jump to one (the owner, 2026-10-31:++ // never hide content to shorten). A wider screen shows no chips.+ '.pick-row{display:none}'-- + '@supports selector(:has(*)){@media (max-width:640px){'++ + '@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-- + '}}'++ + '.pick-chip{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;text-decoration:none}'++ + '.pick-chip:hover,.pick-chip:focus-visible{color:var(--text-primary,#e6edf7);border-color:var(--accent,#3b82f6)}'++ + '.pick-chip:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}'++ + '.pick-pane,.hm-event{scroll-margin-top:72px}'++ + '}'// 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}'@@@ -485,7 +455,7 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {head: [appIcon.headTags({ site: 'events' }),`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,-- HOME_STYLE(pickRules(stats)),++ HOME_STYLE(),].join('\n'),body: `<div id="navbar-mount"></div><main id="main" class="page">@@@ -1,7 +1,6 @@'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.++// 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 { renderHome } = require('../server/home');@@@ -17,44 +16,36 @@ const STATS = {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', () => {++t('the public events: every card shown, a chip per category that jumps to its first card', () => {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.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">/);++ 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.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.++ assert.ok(chips[0][2].includes('OpenVibe.Live'), `the chip names the category (${chips[0][2]})`);++ for (const [, id] of chips) assert.ok(block.includes(`<li class="hm-event" id="${id}"`), `${id} is a card in the page`);++ assert.ok(!/type="radio"/.test(page), 'no chip is a radio: nothing is picked');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', () => {++t('what you get: six cards shown, a chip per card that jumps to it', () => {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)];++ const chips = [...block.matchAll(/<a class="pick-chip" href="#(pp-features-\d+)">([^<]+)<\/a>/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');++ for (const [, id] of chips) assert.ok(block.includes(`<div class="pick-pane" id="${id}">`), `${id} is in the page`);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', () => {++t('the CSS: no chips where there is room, link chips on a phone, and nothing hidden', () => {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');++ assert.ok(page.includes('.pick-row{display:none}'), 'no chips where there is room');++ assert.match(page, /\.pick-chip\{[^}]*min-height:44px/, 'a chip is a 44 px target');++ assert.ok(!page.includes('.pick-pane{display:none}') && !page.includes('.hm-events>li{display:none}'), 'no block or card is hidden');++ assert.ok(!/nth-child\(n\+11\)\{display:none\}/.test(page), 'no list is cut short on a phone');++ assert.ok(!page.includes(':checked'), 'no picker rules');});t('the parts a reader rarely needs are folds, open in the page and closed on a phone', () => {@@@ -182,7 +182,7 @@ t('the server-rendered event list: title, real <time datetime>, place, descriptiassert.match(card, /Category: <a href="https:\/\/openvibe\.live">live<\/a>/, 'a category link');// 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, /<a class="pick-chip" href="#event-[a-z0-9-]+">OpenVibe\.Live<\/a>/, 'the category chips jump to their first card');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');
@@ -108,23 +108,18 @@ const humanDate = (ms) => {
@@ -108,23 +108,18 @@ const humanDate = (ms) => {
};
};
/**
/**
- * 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.
+ * A pick block: several blocks of the same kind, every one shown; on a phone a row of chips jumps to one (the owner,
+ * 2026-10-31: never hide content to shorten). A wider screen shows them all and no chips.
*
*
* pickBlock({ key, label, panes }) the markup; panes: [{ id, label, head, html }]
* pickBlock({ key, label, panes }) the markup; panes: [{ id, label, head, html }]
*/
*/
function pickBlock({ key, label, panes, bodyClass = '' }) {
function pickBlock({ key, label, panes, bodyClass = '' }) {
if (!panes.length) return '';
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 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('');
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>`;
+ 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>`;
}
}
-/** 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
* A fold: a part of the page a reader rarely needs, closed on a phone and open where there is room (the owner's
@@ -165,8 +160,8 @@ function eventCard(r, cat) {
@@ -165,8 +160,8 @@ function eventCard(r, cat) {
* 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.
+ * Every card is shown; on a phone a row of chips jumps to each category's first card (the owner, 2026-10-31: never hide
+ * content to shorten).
*/
*/
function eventsSection(stats) {
function eventsSection(stats) {
const publicTypes = (stats && stats.public) || [];
const publicTypes = (stats && stats.public) || [];
@@ -180,37 +175,20 @@ function eventsSection(stats) {
@@ -180,37 +175,20 @@ function eventsSection(stats) {
// The categories, the one with the newest arrival first: one chip each.
// The categories, the one with the newest arrival first: one chip each.
const cats = [];
const cats = [];
for (const r of list) { const ns = nsOf(r.type); if (!cats.includes(ns)) cats.push(ns); }
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('');
+ // A chip per category jumps to its first card (the newest arrival of that category).
+ const firstOf = (ns) => list.find((r) => nsOf(r.type) === ns);
+ 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('');
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>
-<div class="pick pick-events" id="pick-events"><div class="pick-row" role="radiogroup" aria-label="Public events this week">${chips}</div>
+<div class="pick pick-events" id="pick-events"><nav class="pick-row" aria-label="Public events this week">${chips}</nav>
<ul class="hm-events">${cards}</ul></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 }) {
@@ -320,8 +298,8 @@ function sections({ apiUrl, stats = null }) {
@@ -320,8 +298,8 @@ 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.
+ // 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">What you get</h2>
${pickBlock({
${pickBlock({
key: 'features',
key: 'features',
@@ -368,7 +346,7 @@ ${pickBlock({
@@ -368,7 +346,7 @@ ${pickBlock({
// 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 = (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}'
+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}'
+ '.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)}'
@@ -387,25 +365,17 @@ const HOME_STYLE = (rules = '') => '<style>body{margin:0}/* The hero\'s aura is
@@ -387,25 +365,17 @@ const HOME_STYLE = (rules = '') => '<style>body{margin:0}/* The hero\'s aura is
+ '.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-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: every block is shown; on a phone the chips are a row of links that jump to one (the owner, 2026-10-31:
+ // never hide content to shorten). A wider screen shows no chips.
+ '.pick-row{display:none}'
+ '.pick-row{display:none}'
- + '@supports selector(:has(*)){@media (max-width:640px){'
+ + '@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{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::-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
- + '}}'
+ + '.pick-chip{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;text-decoration:none}'
+ + '.pick-chip:hover,.pick-chip:focus-visible{color:var(--text-primary,#e6edf7);border-color:var(--accent,#3b82f6)}'
+ + '.pick-chip:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}'
+ + '.pick-pane,.hm-event{scroll-margin-top:72px}'
+ + '}'
// The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section
// 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.
// 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}'
+ '.hm-fold-block{margin-top:56px}.hm-fold-block>summary h2{margin:0;font-size:clamp(19px,2.2vw,26px);letter-spacing:-.01em}'
@@ -485,7 +455,7 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
@@ -485,7 +455,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(pickRules(stats)),
+ HOME_STYLE(),
].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">
@@ -1,7 +1,6 @@
@@ -1,7 +1,6 @@
'use strict';
'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.
+// 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 assert = require('assert');
const { renderHome } = require('../server/home');
const { renderHome } = require('../server/home');
@@ -17,44 +16,36 @@ const STATS = {
@@ -17,44 +16,36 @@ const STATS = {
const t = (name, fn) => { try { fn(); console.log(`ok ${name}`); } catch (e) { console.error(`FAIL ${name}: ${e.message}`); process.exitCode = 1; } };
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', () => {
+t('the public events: every card shown, a chip per category that jumps to its first card', () => {
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });
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"')));
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.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">/);
+ 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.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.
+ assert.ok(chips[0][2].includes('OpenVibe.Live'), `the chip names the category (${chips[0][2]})`);
+ for (const [, id] of chips) assert.ok(block.includes(`<li class="hm-event" id="${id}"`), `${id} is a card in the page`);
+ assert.ok(!/type="radio"/.test(page), 'no chip is a radio: nothing is picked');
const cards = [...block.matchAll(/<li class="hm-event" id="event-([a-z0-9-]+)" data-cat="(\d+)">/g)];
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.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', () => {
+t('what you get: six cards shown, a chip per card that jumps to it', () => {
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });
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 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)];
+ const chips = [...block.matchAll(/<a class="pick-chip" href="#(pp-features-\d+)">([^<]+)<\/a>/g)];
assert.strictEqual(chips.length, 6, `a chip per feature (${chips.length})`);
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');
+ for (const [, id] of chips) assert.ok(block.includes(`<div class="pick-pane" id="${id}">`), `${id} is in the page`);
assert.strictEqual((block.match(/class="sc-card"/g) || []).length, 6, 'every card is in the page');
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', () => {
+t('the CSS: no chips where there is room, link chips on a phone, and nothing hidden', () => {
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats: STATS });
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');
+ assert.ok(page.includes('.pick-row{display:none}'), 'no chips where there is room');
+ assert.match(page, /\.pick-chip\{[^}]*min-height:44px/, 'a chip is a 44 px target');
+ assert.ok(!page.includes('.pick-pane{display:none}') && !page.includes('.hm-events>li{display:none}'), 'no block or card is hidden');
+ assert.ok(!/nth-child\(n\+11\)\{display:none\}/.test(page), 'no list is cut short on a phone');
+ assert.ok(!page.includes(':checked'), 'no picker rules');
});
});
t('the parts a reader rarely needs are folds, open in the page and closed on a phone', () => {
t('the parts a reader rarely needs are folds, open in the page and closed on a phone', () => {
@@ -182,7 +182,7 @@ t('the server-rendered event list: title, real <time datetime>, place, descripti
@@ -182,7 +182,7 @@ t('the server-rendered event list: title, real <time datetime>, place, descripti
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');
// The category nav became the row of chips that picks one category at a time on a phone (the short-pages rule);
// 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.
// 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, /<a class="pick-chip" href="#event-[a-z0-9-]+">OpenVibe\.Live<\/a>/, 'the category chips jump to their first card');
assert.match(page, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the publisher link is still in the page');
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');