openvibers/openvibe.chat Commits

Short home on phones: the sections after the room become chips, the rest folds (#267)

OP OpenVibers committed 15 days ago · 367590cfee · 6 files, +221 −20

The front page on a phone was 6.9 screens. It is now 3.4: the sections
after the global chat are one pick block, a row of chips picks one of
them (radio inputs and :has(), no script), and the parts a reader rarely
needs (the questions, the other OpenVibe sites) are folds, closed on a
phone and open where there is room. The signed-in view, the rooms and the
direct messages are untouched.

Every section stays in the page for crawlers, each keeps its own id, and
a wider screen shows them all in the order they had, with no chips.

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 5e6ba85..5604e8e 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -631,3 +631,55 @@ div.oc-set-row { cursor: default; }
.oc-home-head a { position: relative; }
.oc-home-head a::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
}
++
++/* The front page on a phone (the owner's short-pages rule, 2026-10-30): the sections after the room become one pick
++ block, a row of chips picks one of them, and the parts a reader rarely needs become folds. Radio inputs and :has(),
++ no script for the chips. Everything is off until 640 px, so a wide screen shows the page as it was. */
++.pick-head { display: none; }
++.pick-row { display: none; }
++@supports selector(:has(*)) { @media (max-width: 640px) {
++ .pick-bar { margin-top: 40px; }
++ .pick-head { display: block; }
++ .pick-head h2 { margin: 0 0 4px; font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif); font-size: clamp(20px, 2.4vw, 28px); font-weight: 700; letter-spacing: -.02em; }
++ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px 0 0; padding: 2px 0 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(--oc-line); color: var(--oc-muted); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
++ .pick-row input:checked + label { color: var(--oc-text); border-color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 12%, transparent); }
++ .pick-row input:focus-visible + label { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++ .pick .pick-pane { display: none; }
++ /* A link that points into a pane (a heading the page links to) opens that pane. */
++ .pick .pick-pane:target, .pick .pick-pane:has(:target) { display: block !important; }
++ .pick:has(.pick-pane:target) .pick-pane:not(:target):not(:has(:target)) { display: none !important; }
++ .pick .pick-pane h2 { display: none; }
++ /* The showcase cards are a little tighter on a phone: the same six cards, less air. */
++ .pick .pick-pane .sc-card { gap: 10px; padding: 12px; }
++ .pick .pick-pane .sc-card small { font-size: 13px; }
++ .pick .pick-pane .sc-grid { gap: 8px; }
++ .pick .pick-pane .oc-home-head { justify-content: flex-end; }
++ .pick .pick-pane .oc-home-sec { margin-top: 20px; }
++} }
++
++/* The guest's front page: the room is a taste of the chat, not the whole thing, so the sign-in below it comes into
++ view sooner. The signed-in room (.oc-global) keeps its full height: it is the app. */
++@media (max-width: 560px) {
++ .oc-room:not(.oc-global) .oc-feed { max-height: 44vh; }
++}
++
++/* A fold: a part a reader rarely needs. On a phone it is a closed card; where there is room it reads as the section
++ it was. The page's own markup keeps it open, so the script only closes it on a narrow screen. */
++.oc-fold-block { margin-top: 40px; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }
++.oc-fold-block > summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; min-height: 48px; cursor: pointer; list-style: none; }
++.oc-fold-block > summary::-webkit-details-marker { display: none; }
++.oc-fold-block > summary h2 { margin: 0; flex: 1; font-size: 1.05rem; font-weight: 700; letter-spacing: -.01em; }
++.oc-fold-hint { color: var(--oc-muted); font-size: .8rem; font-weight: 600; white-space: nowrap; }
++.oc-fold-block[open] > summary { border-bottom: 1px solid var(--oc-line); }
++.oc-fold-block > .oc-home-sec { margin-top: 0; padding: 0 14px 14px; }
++@media (min-width: 641px) {
++ .oc-fold-block { margin-top: 56px; border: 0; border-radius: 0; background: none; }
++ .oc-fold-block > summary { padding: 0; min-height: 0; }
++ .oc-fold-block[open] > summary { border-bottom: 0; }
++ .oc-fold-block > summary h2 { margin: 0 0 .5rem; font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif); font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; }
++ .oc-fold-hint { display: none; }
++ .oc-fold-block > .oc-home-sec { margin-top: 0; padding: 0; }
++}
ddiff --git a/server/web/home.js b/server/web/home.js
iindex c5c5e34..47b9c54 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -133,19 +133,23 @@ function streamsSection(a, app) {
<ul class="oc-streams" role="list">${a.streams.map(item).join('')}</ul></section>`;
}
--function placesSection(a, app) {
++/** The public servers and the public rooms, one part each: on a phone each is a chip of its own. */
++function serversSection(a, app) {
++ if (!a.servers.length) return '';
if (app) {
-- return [
-- a.servers.length ? listSection('oc-h-servers', 'Public servers', '<a href="/servers">All</a>', a.servers.map((s) => row(`/s/${esc(s.slug)}`, s.name, ui.serverMark(s), s.official ? '<span class="oc-badge oc-badge-official">official</span>' : '', esc(s.description || plural(s.members, 'member', 'members'))))) : '',
-- a.rooms.length ? listSection('oc-h-rooms', 'Public rooms', '<a href="/rooms">All</a>', a.rooms.map((r) => row(`/r/${esc(r.slug)}`, r.name, ui.initials(r.name), '', `${esc(r.topic || plural(r.members, 'member', 'members'))}${r.last_message_at ? ` · last ${esc(ago(r.last_message_at))}` : ''}`))) : '',
-- ].join('\n');
++ return listSection('oc-h-servers', 'Public servers', '<a href="/servers">All</a>', a.servers.map((s) => row(`/s/${esc(s.slug)}`, s.name, ui.serverMark(s), s.official ? '<span class="oc-badge oc-badge-official">official</span>' : '', esc(s.description || plural(s.members, 'member', 'members')))));
}
const servers = a.servers.map((s) => `<li><a class="oc-stream" href="/s/${esc(s.slug)}"><span class="oc-stream-name"><strong>${esc(s.name)}</strong>${s.official ? ' <span class="oc-badge oc-badge-official">official</span>' : ''}</span><span class="oc-muted">${esc(s.description || plural(s.members, 'member', 'members'))}</span></a></li>`);
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-servers"><div class="oc-home-head"><h2 id="oc-h-servers">Public servers</h2><a href="/servers">All servers</a></div><ul class="oc-streams" role="list">${servers.join('')}</ul></section>`;
++}
++
++function roomsSection(a, app) {
++ if (!a.rooms.length) return '';
++ if (app) {
++ return listSection('oc-h-rooms', 'Public rooms', '<a href="/rooms">All</a>', a.rooms.map((r) => row(`/r/${esc(r.slug)}`, r.name, ui.initials(r.name), '', `${esc(r.topic || plural(r.members, 'member', 'members'))}${r.last_message_at ? ` · last ${esc(ago(r.last_message_at))}` : ''}`)));
++ }
const rooms = a.rooms.map((r) => `<li><a class="oc-stream" href="/r/${esc(r.slug)}"><span class="oc-stream-name"><strong>${esc(r.name)}</strong></span><span class="oc-muted">${esc(r.topic || plural(r.members, 'member', 'members'))}${r.last_message_at ? ` · last ${esc(ago(r.last_message_at))}` : ''}</span></a></li>`);
-- return [
-- servers.length ? `<section class="oc-home-sec" aria-labelledby="oc-h-servers"><div class="oc-home-head"><h2 id="oc-h-servers">Public servers</h2><a href="/servers">All servers</a></div><ul class="oc-streams" role="list">${servers.join('')}</ul></section>` : '',
-- rooms.length ? `<section class="oc-home-sec" aria-labelledby="oc-h-rooms"><div class="oc-home-head"><h2 id="oc-h-rooms">Public rooms</h2><a href="/rooms">All rooms</a></div><ul class="oc-streams" role="list">${rooms.join('')}</ul></section>` : '',
-- ].join('\n');
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-rooms"><div class="oc-home-head"><h2 id="oc-h-rooms">Public rooms</h2><a href="/rooms">All rooms</a></div><ul class="oc-streams" role="list">${rooms.join('')}</ul></section>`;
}
/** Ask Vibe from the front page: the question opens a new Vibe chat with it in the box (never sent for you). */
@@@ -168,8 +172,9 @@ function stepsSection() {
return `<section class="oc-home-sec" aria-labelledby="oc-h-how"><h2 id="oc-h-how">How it works</h2><ol class="oc-steps">${steps.map(([t, d]) => `<li><h3>${esc(t)}</h3><p>${esc(d)}</p></li>`).join('')}</ol></section>`;
}
++/** The questions: the heading sits in the fold that holds them, so the fold's summary names the part. */
function faqSection() {
-- return `<section class="oc-home-sec" aria-labelledby="oc-h-faq"><h2 id="oc-h-faq">Questions</h2><div class="oc-faq">${FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div></section>`;
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-faq"><div class="oc-faq">${FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div></section>`;
}
function moreSection() {
@@@ -179,12 +184,16 @@ function moreSection() {
['https://openvibe.pics', 'OpenVibe.Pics', 'Where the pictures you send in chat live.'],
['https://openvibe.games', 'OpenVibe.Games', 'Games to play with the people you chat with.'],
];
-- return `<section class="oc-home-sec" aria-labelledby="oc-h-more"><h2 id="oc-h-more">More on OpenVibe</h2><ul class="oc-more" role="list">${more.map(([href, name, text]) => `<li><a href="${href}/"><strong>${esc(name)}</strong><span>${esc(text)}</span></a></li>`).join('')}</ul></section>`;
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-more"><ul class="oc-more" role="list">${more.map(([href, name, text]) => `<li><a href="${href}/"><strong>${esc(name)}</strong><span>${esc(text)}</span></a></li>`).join('')}</ul></section>`;
}
/** Everything around global chat: the numbers, Ask Vibe, then stream chats, servers and rooms, how it works, questions, more. */
function sections(a, { app = false } = {}) {
-- return { stats: app ? '' : stats(a), vibe: vibeSection(app), streams: streamsSection(a, app), places: placesSection(a, app), steps: app ? '' : stepsSection(), faq: app ? '' : faqSection(), more: app ? '' : moreSection() };
++ return {
++ stats: app ? '' : stats(a), vibe: vibeSection(app), streams: streamsSection(a, app),
++ servers: serversSection(a, app), rooms: roomsSection(a, app),
++ steps: app ? '' : stepsSection(), faq: app ? '' : faqSection(), more: app ? '' : moreSection(),
++ };
}
/** The room's header line for someone signed in: how many people wrote this week. */
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex bcc0445..6c5a118 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -105,7 +105,9 @@ function contentSecurityPolicy(nonce) {
* vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
-- * 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings)
++ * 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
++ * inlineStyles (CSS the page needs and the site's stylesheet cannot hold: the rule that shows the picked block of a
++ * pick block is written for the page, because there is one per block)
*/
function renderPage(o) {
const { config } = o;
@@@ -140,6 +142,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
++${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 6274b29..e1eea29 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -40,6 +40,7 @@ const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = requ
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const home = require('./home');
++const { pickBlock, pickRules, foldBlock } = require('./short');
const sharedSeo = require('openvibe-shared/seo');
const session = require('../auth/network-session');
const historyStore = require('../chat/history-store');
@@@ -274,6 +275,20 @@ function createWebRoutes({ config }) {
// the hero and the full footer, are for people who are new here, and for search).
const activity = await home.activity();
const parts = home.sections(activity, { app: signedIn });
++ // On a phone the front page is one or two screens (the owner's short-pages rule, 2026-10-30): the sections
++ // after the room become one pick block (a row of chips, one at a time) and the parts a reader rarely needs
++ // become folds. Every section stays in the page for crawlers; a wider screen shows them all, with no chips.
++ // The panes keep the order the sections had, so a wide screen reads as it did; the first one is what a phone
++ // shows first.
++ const panes = [
++ { id: 'pane-streams', label: 'Stream chats', html: parts.streams },
++ { id: 'pane-servers', label: 'Servers', html: parts.servers },
++ { id: 'pane-rooms', label: 'Rooms', html: parts.rooms },
++ { id: 'pane-what', label: 'What Chat does', html: whatChatDoes(signedIn) },
++ { id: 'pane-how', label: 'How it works', html: parts.steps },
++ ].filter((pane) => pane.html);
++ const explore = pickBlock({ key: 'explore', label: 'Explore OpenVibe.Chat', head: 'Explore OpenVibe.Chat', panes });
++ const inlineStyles = [`@supports selector(:has(*)){@media (max-width:640px){${pickRules('explore', panes.map((pane) => pane.id))}}}`];
const feedList = `<ol class="oc-feed${signedIn ? ' oc-room-feed' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>`;
const appBody = () => `<section class="oc-room oc-global" aria-labelledby="oc-h">
<header class="oc-room-head"><div class="oc-room-id"><h1 id="oc-h">Global chat</h1>
@@@ -285,7 +300,8 @@ ${composer}
</section>
${parts.vibe}
${parts.streams}
--${parts.places}`;
++${parts.servers}
++${parts.rooms}`;
await page(req, res, 200, {
actor, fullTitle: 'OpenVibe.Chat: global chat, servers, rooms and calls', path: '/', active: 'global', robots: 'index, follow', script: true,
description: 'Chat with everyone on OpenVibe, start a server with text and voice channels, message friends and call them. Free and open source.',
@@@ -306,13 +322,11 @@ ${error}
${feedList}
${composer}
</section>
--${parts.streams}
--${parts.places}
--${whatChatDoes(signedIn)}
--${parts.steps}
--${parts.faq}
--${parts.more}`,
++${explore}
++${foldBlock({ id: 'faq', head: '<h2 id="oc-h-faq">Questions</h2>', html: parts.faq })}
++${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html: parts.more })}`,
showcase: true,
++ inlineStyles,
});
});
ddiff --git a/server/web/short.js b/server/web/short.js
nnew file mode 100644
iindex 0000000..ccd9d0d
---- /dev/null
++++ b/server/web/short.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ *
++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
++ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
++ * browser without :has()) shows them all and no chips.
++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
++ * screen and remembers the reader's choice.
++ *
++ * The CSS for both lives in public/web/chat.css; the rule that shows the picked block is written for the page,
++ * because there is one per block.
++ */
++const { esc } = require('./layout');
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
++ */
++function pickBlock({ key, label, head = '', panes }) {
++ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
++ panes = (panes || []).filter((p) => p.html);
++ 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('');
++ // The pane carries its own id, so a link that points into it (a heading the page links to) still opens it on a
++ // phone: the CSS shows the pane a link points at.
++ const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${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) #${id}{display:block}`;
++}
++
++/** The rules for every block of a pick block, in the order they appear. */
++function pickRules(key, ids) {
++ return ids.map((id) => pickRule(key, id)).join('');
++}
++
++/**
++ * A fold. The head is the heading the block had; the html is what it holds.
++ *
++ * foldBlock({ id, head, html })
++ */
++function foldBlock({ id, head, html }) {
++ return `<details class="oc-fold oc-fold-block" id="${esc(id)}" open><summary>${head}<span class="oc-fold-hint">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>`;
++}
++
++module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..b014554
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the front page's sections 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 section stays in the page for crawlers; a wider screen shows them all and no chips.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, suite } = require('./helpers');
++
++const t = suite('home short');
++let h;
++
++t('boot', async () => { h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } }); });
++
++t('the home is one pick block: a chip per section, the first picked, every section in the page', async () => {
++ // A public room and a public server, and a live stream, so the page has the parts it lists.
++ const owner = { id: 1, subject_id: 'usr_home_short' };
++ await require('../server/rooms/rooms').create(owner, { name: 'Coffee talk', slug: 'coffee-talk', topic: 'A room to talk in' });
++ await require('../server/rooms/servers').create(owner, { name: 'Makers', slug: 'makers', description: 'People who build things' });
++ const db = require('../server/db/database');
++ const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
++ await db.run("INSERT INTO ctx_users (username, display_name, created_at) VALUES ('streamer_x', 'Streamer X', ?)", [stamp]);
++ const streamer = await db.get("SELECT id FROM ctx_users WHERE username = 'streamer_x'");
++ await db.run("INSERT INTO ctx_streams (user_id, title, is_live, started_at) VALUES (?, 'Live from the studio', 1, ?)", [streamer.id, stamp]);
++ const page = await fetch(`${h.base}/`).then((r) => r.text());
++ const start = page.indexOf('<div class="pick" id="pick-explore">');
++ assert.ok(start > 0, 'the pick block is on the home');
++ const block = page.slice(start, page.indexOf('<div class="pick" id="pick-', start + 10) > 0 ? page.indexOf('<div class="pick" id="pick-', start + 10) : start + 60000);
++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Explore OpenVibe.Chat">/);
++ const chips = [...block.matchAll(/<input type="radio" name="pick-explore" id="pk-explore-([a-z-]+)" value="\1"( checked)?><label for="pk-explore-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 3, `a chip per section (${chips.length})`);
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first section is picked');
++ // Every section stays in the page, with the id its chip picks.
++ const panes = [...block.matchAll(/<div class="pick-pane" id="([a-z-]+)">/g)].map((m) => m[1]);
++ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every section is in the page, one per chip');
++ // Nothing was removed: the sections of the front page are all there.
++ for (const title of ['Stream chats on OpenVibe.Live', 'Public servers', 'Public rooms', 'What OpenVibe.Chat does', 'How it works']) {
++ assert.ok(page.includes(title), `${title} is on the page`);
++ }
++});
++
++t('the CSS guard: the chips are hidden and the sections all show until 640 px and where :has() is understood', async () => {
++ const page = await fetch(`${h.base}/`).then((r) => r.text());
++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-explore-pane-streams:checked\) #pane-streams\{display:block\}/, 'the rule that shows the picked section');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'chat.css'), 'utf8');
++ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');
++ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');
++ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');
++ assert.match(css, /\.pick \.pick-pane \{ display: none; \}/, 'the sections are hidden on a phone');
++});
++
++t('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
++ const page = await fetch(`${h.base}/`).then((r) => r.text());
++ for (const id of ['faq', 'more']) {
++ assert.ok(page.includes(`<details class="oc-fold oc-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, /<summary>Do I need an account to read\?<\/summary>/);
++ assert.match(page, /OpenVibe\.Live<\/strong><span>Go live from your browser/);
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });