openvibers/openvibe.bot Commits

Short home on a phone: the ways to connect become chips, the rest folds (#235)

OP OpenVibers committed 14 days ago · 14ec78c0c1 · 4 files, +181 −11

The home was 6.6 screens tall at 390 px. It is now 3.8.

What changed
- The ways to connect are one pick block: a row of chips picks one of them,
  the chip names it, and every way stays in the page.
- Online in a minute, More on OpenVibe.Bot, the questions and Part of OpenVibe
  are folds: closed on a phone, open where there is room, and their content
  stays in the page.
- The page's own markup keeps every fold open, so a browser without the script
  shows the page as before.
- The hero, the numbers, the robots and Drive one now stay where they are.

How it was checked
- The suite: OV_ALLOW_SUITE=1 npm test (55 files; one flaky file, worker-machines,
  passes on its own and touches another page).
- A new markup test (test/home-short.test.js) checks the pick block, one chip
  per way, the first one picked, the folds and the CSS guard.
- The home measured at 390 px and at 1280 px.

Changes

ddiff --git a/public/home.css b/public/home.css
iindex b994656..75e3c70 100644
---- a/public/home.css
++++ b/public/home.css
@@@ -104,3 +104,40 @@
.hm-more-sec .sc-sec { margin: 0; padding: 0; }
.hm .ovk-stats { margin-top: 18px; }
.hm .ovk-stats { font-family: var(--ovk-font); }
++
++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the home's blocks of the same kind are one at a
++ time, picked with a row of chips. 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-head { display: none; }
++.pick-row { display: none; }
++.pick-pane { min-width: 0; }
++@supports selector(:has(*)) { @media (max-width: 640px) {
++ .pick-head { display: block; }
++ .pick-head h2 { margin: 0 0 4px; font-size: 1.15rem; }
++ .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, .08)); color: var(--text-secondary, #96a7c2); font-size: .9rem; 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; }
++ /* The chip names the block, so the block's own heading gives way to it. */
++ .hm-sec .hm-head h2 { display: none; }
++} }
++
++/* 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: 28px 0; }
++.hm-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; color: var(--text-primary, #e6edf7); font-weight: 700; list-style: none; border: 1px solid var(--border, rgba(255, 255, 255, .08)); border-radius: 14px; background: var(--bg-secondary, #101828); }
++.hm-fold-block > summary::-webkit-details-marker { display: none; }
++.hm-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }
++.hm-fold-block > summary span { color: var(--text-secondary, #96a7c2); font-weight: 600; font-size: .86rem; text-align: right; }
++.hm-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
++.hm-fold-block .sc-sec { margin: 0; }
++.hm-fold-block .sc-sec > h2, .hm-fold-block .hm-h { display: none; }
++@media (min-width: 641px) {
++ .hm-fold-block { border: 0; background: none; margin-top: 40px; }
++ .hm-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }
++ .hm-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.15rem; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); padding-bottom: .2em; }
++ .hm-fold-block > summary span { display: none; }
++}
ddiff --git a/server/web/home.js b/server/web/home.js
iindex be0d236..7e0a91c 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -22,6 +22,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const appIcon = require('openvibe-shared/app-icon');
const seo = require('openvibe-shared/seo');
++const { pickBlock, pickRules, foldBlock } = require('./short');
const SITE_NAME = 'OpenVibe.Bot';
const NETWORK_URL = 'https://openvibe.network';
@@@ -121,16 +122,27 @@ function modelsSection({ models }) {
}
/** The connection kinds as one list, each with how many models use it. */
--function connectSection({ models }) {
++function connectSection({ models, rules }) {
const groups = KINDS.map(([k, label, text]) => ({ k, label, text, ms: models.filter((m) => m.connect === k) })).filter((g) => g.ms.length);
if (!groups.length) return '';
const n = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight'][groups.length] || String(groups.length);
++ // The ways to connect are blocks of the same kind: on a phone a row of chips picks one of them and the chip names
++ // it, so the list is one way at a time. Every way stays in the page.
++ const panes = groups.map((g) => ({
++ id: g.k,
++ label: g.label,
++ html: kit.list([kit.row({ href: g.ms.length === 1 ? `/models/${g.ms[0].id}` : `/models#k-${g.k}`, title: g.label, sub: g.text, meta: plural(g.ms.length, 'model', 'models') })]),
++ }));
++ if (rules) rules.push(pickRules('connect', panes.map((p) => p.id)));
return sec('connect', `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, 'However a robot reaches OpenVibe, it gets the same panel, the same gate and the same emergency stop.',
-- kit.list(groups.map((g) => kit.row({ href: g.ms.length === 1 ? `/models/${g.ms[0].id}` : `/models#k-${g.k}`, title: g.label, sub: g.text, meta: plural(g.ms.length, 'model', 'models') }))));
++ pickBlock({ key: 'connect', label: 'The ways to connect', head: `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, panes }));
}
function startSection() {
-- return showcase.steps({
++ return foldBlock({
++ id: 'how',
++ head: '<h2 id="how-h">Online in a minute</h2>',
++ html: showcase.steps({
id: 'how',
title: 'Online in a minute',
lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router and no stock control server left exposed.',
@@@ -139,6 +151,7 @@ function startSection() {
{ title: 'Approve it', text: 'Sign in at openvibe.bot/link and enter the code. The robot\'s credential is stored hashed, and you can revoke it any time.' },
{ title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included. Share it, list it, or bind it to your OpenVibe.Live channel.' },
],
++ }),
});
}
@@@ -182,18 +195,26 @@ function moreSection({ config, kit: k, models }) {
['/robots/submit', 'Submit a robot', 'Describe your robot with a profile and point at its driver. Once reviewed, anyone can add one.'],
['https://github.com/OpenVibers/OpenVibe.Bot', 'The source', 'OpenVibe.Bot and the OpenVibe Node are open source under the AGPL-3.0.'],
]);
-- return `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 id="more-h" class="hm-h">More on OpenVibe.Bot</h2>
++ return foldBlock({
++ id: 'more',
++ head: '<h2 id="more-h" class="hm-h">More on OpenVibe.Bot</h2>',
++ html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="hm-h">More on OpenVibe.Bot</h2>
${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}
${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}
${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Node', body: `<p class="hm-fold-lede">The same OpenVibe Node runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}
${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}
${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}
--${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${link('/docs', 'All build pages', 'hm-more')}</p>` })}</section>`;
++${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${link('/docs', 'All build pages', 'hm-more')}</p>` })}</section>`,
++ });
}
function jobsSection({ jobs }) {
if (!jobs || !jobs.length) return '';
-- return `<section class="sc-sec hm-sec" id="jobs">${kit.list(jobs.slice(0, 3).map((j) => kit.row({ href: j.url, rel: 'noopener', title: j.title, sub: [j.location, j.remote ? 'Remote' : '', j.type].filter(Boolean).join(' · ') })), { title: 'Robotics jobs on OpenVibe.Work', more: { href: 'https://openvibe.work/jobs?q=robotics', label: 'More jobs' } })}</section>`;
++ return foldBlock({
++ id: 'jobs',
++ head: '<h2 id="jobs-h">Robotics jobs on OpenVibe.Work</h2>',
++ html: `<section class="sc-sec hm-sec" id="jobs">${kit.list(jobs.slice(0, 3).map((j) => kit.row({ href: j.url, rel: 'noopener', title: j.title, sub: [j.location, j.remote ? 'Remote' : '', j.type].filter(Boolean).join(' · ') })), { title: 'Robotics jobs on OpenVibe.Work', more: { href: 'https://openvibe.work/jobs?q=robotics', label: 'More jobs' } })}</section>`,
++ });
}
/** Questions people ask first, answered from what Bot does today; the model list comes from the catalogue. */
@@@ -216,20 +237,28 @@ function faqItems({ models }) {
/** The questions, closed until opened (showcase.faq). */
function faqSection(items) {
-- return `<section class="sc-sec hm-sec" id="faq">${showcase.faq({ id: 'faq-list', title: 'Questions', items: items.map((x) => [x.q, x.a]) })}</section>`;
++ return foldBlock({
++ id: 'faq',
++ head: '<h2 id="faq-h">Questions</h2>',
++ html: `<section class="sc-sec hm-sec" id="faq">${showcase.faq({ id: 'faq-list', title: 'Questions', items: items.map((x) => [x.q, x.a]) })}</section>`,
++ });
}
--function sections({ signedIn = false, config, models = [], robots = [], kit: k = null, jobs = [], faq = null } = {}) {
++function sections({ signedIn = false, config, models = [], robots = [], kit: k = null, jobs = [], faq = null, rules = null } = {}) {
return hero({ signedIn, config })
+ stats({ models, robots })
+ onlineNow({ robots })
+ modelsSection({ models })
-- + connectSection({ models })
++ + connectSection({ models, rules })
+ startSection()
+ moreSection({ config, kit: k, models })
+ jobsSection({ jobs })
+ faqSection(faq || faqItems({ models }))
-- + showcase.network({ service: 'bot' });
++ + foldBlock({
++ id: 'network',
++ head: '<h2 id="network-h">Part of OpenVibe</h2>',
++ html: showcase.network({ service: 'bot' }),
++ });
}
/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */
@@@ -276,7 +305,10 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
};
const footer = { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
const url = `${config.baseUrl}/`;
++ // The rules that show the picked block of the pick block are written for this page.
++ const rules = [];
const faq = faqItems({ models });
++ const body = sections({ signedIn, config, models, robots, kit, jobs, faq, rules });
return shell.page({
name: SITE_NAME, service: 'bot', lang: 'en',
title: `${SITE_NAME}: drive your robot from any browser`,
@@@ -300,10 +332,11 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
`<link rel="stylesheet" href="${ovServe.url('room.css')}">`,
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
'<link rel="stylesheet" href="/home.css">',
++ rules.length ? `<style>@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}</style>` : '',
].join('\n'),
body: `<div id="navbar-mount"></div>
<main id="main" class="page hm">
--${sections({ signedIn, config, models, robots, kit, jobs, faq })}
++${body}
</main>`,
});
}
ddiff --git a/server/web/short.js b/server/web/short.js
nnew file mode 100644
iindex 0000000..f075e75
---- /dev/null
++++ b/server/web/short.js
@@@ -0,0 +1,50 @@
++'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/css/app.css; the rule that shows the picked block is written for the page,
++ * because there is one per block.
++ */
++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * A pick block. The panes are the page's own blocks; the block's heading gives way to the chip that names it on a
++ * phone, and the chips are gone on a wide screen, where every pane shows at once.
++ *
++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
++ */
++function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }) {
++ if (!panes || !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 body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.html}</div>`).join('');
++ const heading = `<h2${headId ? ` id="${esc(headId)}"` : ''}>${esc(head || label)}</h2>`;
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
++}
++
++/** The rules for every pane of a pick block, in the order they appear: one rule per pane, so the CSS is written for the page. */
++function pickRules(key, ids) {
++ return ids.map((id) => `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`).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="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>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, pickRules, foldBlock, foldScript };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..89dac3f
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++// The front page on a phone (the owner's short-pages rule, 2026-10-30): the ways to connect 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.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ openre: false });
++ const get = (p) => fetch(t.base + p, { redirect: 'manual' });
++
++ await check('GET /: the ways to connect are chips, the parts read once are folds', async () => {
++ const r = await get('/');
++ assert.strictEqual(r.status, 200);
++ const html = await r.text();
++
++ // The ways to connect: one pick block, a chip per way, the first one picked.
++ assert.ok(html.includes('<div class="pick" id="pick-connect">'), 'the ways to connect are one pick block');
++ assert.match(html, /<div class="pick-row" role="radiogroup" aria-label="The ways to connect">/);
++ const chips = [...html.matchAll(/<input type="radio" name="pick-connect" id="pk-connect-([a-z-]+)" value="\1"( checked)?><label for="pk-connect-\1">([^<]+)<\/label>/g)];
++ assert.ok(chips.length >= 3, 'a chip per way to connect');
++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['browser'], 'the first way is picked');
++ assert.strictEqual((html.match(/class="pick-pane"/g) || []).length, chips.length, 'one pane per chip');
++ // Every way stays in the page for the crawlers.
++ for (const label of ['Simulated', 'A phone', 'On-board']) assert.ok(html.includes(`<strong>${label}</strong>`), `${label} is in the page`);
++ // The rule that shows the picked way is written for this page.
++ assert.ok(html.includes('.pick:has(#pk-connect-browser:checked) #pp-connect-browser{display:block}'), 'the picked way shows');
++
++ // The folds: open in the page, closed on a phone, and their content stays in the page.
++ for (const id of ['how', 'more', 'faq', 'network']) {
++ assert.ok(html.includes(`<details class="hm-fold hm-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
++ assert.ok(html.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
++ }
++ assert.ok(html.includes('Run one command on the robot'), 'the path online is still in the page');
++ assert.ok(html.includes('What the panel does'), 'the parts read once are still in the page');
++ assert.ok(html.includes('Do I need to open ports on my router?'), 'the questions are still in the page');
++ assert.ok(html.includes('Part of OpenVibe'), 'the other OpenVibe places are still in the page');
++
++ // The CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood.
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'home.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-pane \{ display: none; \}/, 'the blocks are hidden on a phone');
++ });
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });