openvibers/openvibe.events Commits

Home: every event card and feature shows on a phone; the chips are links (#198)

OP OpenVibers committed 11 days ago · 64cb4ee583 · 3 files, +41 −80

Changes

ddiff --git a/server/home.js b/server/home.js
iindex 66ff67c..27fb4c9 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -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">
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex aa25936..adff19f 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -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', () => {
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 04be041..c388495 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -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');
// 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');