openvibers/openvibe.chat Commits
Short home on phones: the sections after the room become chips, the rest folds (#267)
Changes
@@@ -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; }++}@@@ -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. */@@@ -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')}@@@ -40,6 +40,7 @@ const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = requconst { 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,});});nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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(); });
@@ -631,3 +631,55 @@ div.oc-set-row { cursor: default; }
@@ -631,3 +631,55 @@ div.oc-set-row { cursor: default; }
.oc-home-head a { position: relative; }
.oc-home-head a { position: relative; }
.oc-home-head a::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
.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; }
+}
@@ -133,19 +133,23 @@ function streamsSection(a, app) {
@@ -133,19 +133,23 @@ function streamsSection(a, app) {
<ul class="oc-streams" role="list">${a.streams.map(item).join('')}</ul></section>`;
<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) {
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>`);
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>`);
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). */
/** 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() {
@@ -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>`;
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() {
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() {
function moreSection() {
@@ -179,12 +184,16 @@ function moreSection() {
@@ -179,12 +184,16 @@ function moreSection() {
['https://openvibe.pics', 'OpenVibe.Pics', 'Where the pictures you send in chat live.'],
['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.'],
['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. */
/** Everything around global chat: the numbers, Ask Vibe, then stream chats, servers and rooms, how it works, questions, more. */
function sections(a, { app = false } = {}) {
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. */
/** The room's header line for someone signed in: how many people wrote this week. */
@@ -105,7 +105,9 @@ function contentSecurityPolicy(nonce) {
@@ -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
* 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
* (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;
* 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) {
function renderPage(o) {
const { config } = o;
const { config } = o;
@@ -140,6 +142,7 @@ ${INSTALL_LINKS}
@@ -140,6 +142,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
<link rel="stylesheet" href="${asset('chat.css')}">
<link rel="stylesheet" href="${ovServe.url('items.css')}">
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.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.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.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')}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
@@ -40,6 +40,7 @@ const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = requ
@@ -40,6 +40,7 @@ const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = requ
const { createSessionRoutes } = require('./session');
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const home = require('./home');
const home = require('./home');
+const { pickBlock, pickRules, foldBlock } = require('./short');
const sharedSeo = require('openvibe-shared/seo');
const sharedSeo = require('openvibe-shared/seo');
const session = require('../auth/network-session');
const session = require('../auth/network-session');
const historyStore = require('../chat/history-store');
const historyStore = require('../chat/history-store');
@@ -274,6 +275,20 @@ function createWebRoutes({ config }) {
@@ -274,6 +275,20 @@ function createWebRoutes({ config }) {
// the hero and the full footer, are for people who are new here, and for search).
// the hero and the full footer, are for people who are new here, and for search).
const activity = await home.activity();
const activity = await home.activity();
const parts = home.sections(activity, { app: signedIn });
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 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">
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>
<header class="oc-room-head"><div class="oc-room-id"><h1 id="oc-h">Global chat</h1>
@@ -285,7 +300,8 @@ ${composer}
@@ -285,7 +300,8 @@ ${composer}
</section>
</section>
${parts.vibe}
${parts.vibe}
${parts.streams}
${parts.streams}
-${parts.places}`;
+${parts.servers}
+${parts.rooms}`;
await page(req, res, 200, {
await page(req, res, 200, {
actor, fullTitle: 'OpenVibe.Chat: global chat, servers, rooms and calls', path: '/', active: 'global', robots: 'index, follow', script: true,
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.',
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}
@@ -306,13 +322,11 @@ ${error}
${feedList}
${feedList}
${composer}
${composer}
</section>
</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,
showcase: true,
+ inlineStyles,
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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(); });