openvibers/openvibe.bot Commits
Short home on a phone: the ways to connect become chips, the rest folds (#235)
Changes
@@@ -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; }++}@@@ -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>`,});}nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * 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 };nnew file mode 100644@@@ -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); });
@@ -104,3 +104,40 @@
@@ -104,3 +104,40 @@
.hm-more-sec .sc-sec { margin: 0; padding: 0; }
.hm-more-sec .sc-sec { margin: 0; padding: 0; }
.hm .ovk-stats { margin-top: 18px; }
.hm .ovk-stats { margin-top: 18px; }
.hm .ovk-stats { font-family: var(--ovk-font); }
.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; }
+}
@@ -22,6 +22,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -22,6 +22,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const { pickBlock, pickRules, foldBlock } = require('./short');
const SITE_NAME = 'OpenVibe.Bot';
const SITE_NAME = 'OpenVibe.Bot';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -121,16 +122,27 @@ function modelsSection({ models }) {
@@ -121,16 +122,27 @@ function modelsSection({ models }) {
}
}
/** The connection kinds as one list, each with how many models use it. */
/** 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);
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 '';
if (!groups.length) return '';
const n = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight'][groups.length] || String(groups.length);
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.',
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() {
function startSection() {
- return showcase.steps({
+ return foldBlock({
+ id: 'how',
+ head: '<h2 id="how-h">Online in a minute</h2>',
+ html: showcase.steps({
id: 'how',
id: 'how',
title: 'Online in a minute',
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.',
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() {
@@ -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: '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.' },
{ 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 }) {
@@ -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.'],
['/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.'],
['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: '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: '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: '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 })}
${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 }) : ''}
${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 }) {
function jobsSection({ jobs }) {
if (!jobs || !jobs.length) return '';
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. */
/** Questions people ask first, answered from what Bot does today; the model list comes from the catalogue. */
@@ -216,20 +237,28 @@ function faqItems({ models }) {
@@ -216,20 +237,28 @@ function faqItems({ models }) {
/** The questions, closed until opened (showcase.faq). */
/** The questions, closed until opened (showcase.faq). */
function faqSection(items) {
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 })
return hero({ signedIn, config })
+ stats({ models, robots })
+ stats({ models, robots })
+ onlineNow({ robots })
+ onlineNow({ robots })
+ modelsSection({ models })
+ modelsSection({ models })
- + connectSection({ models })
+ + connectSection({ models, rules })
+ startSection()
+ startSection()
+ moreSection({ config, kit: k, models })
+ moreSection({ config, kit: k, models })
+ jobsSection({ jobs })
+ jobsSection({ jobs })
+ faqSection(faq || faqItems({ models }))
+ 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. */
/** 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 =
@@ -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 footer = { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
const url = `${config.baseUrl}/`;
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 faq = faqItems({ models });
+ const body = sections({ signedIn, config, models, robots, kit, jobs, faq, rules });
return shell.page({
return shell.page({
name: SITE_NAME, service: 'bot', lang: 'en',
name: SITE_NAME, service: 'bot', lang: 'en',
title: `${SITE_NAME}: drive your robot from any browser`,
title: `${SITE_NAME}: drive your robot from any browser`,
@@ -300,10 +332,11 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
@@ -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('room.css')}">`,
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
'<link rel="stylesheet" href="/home.css">',
'<link rel="stylesheet" href="/home.css">',
+ rules.length ? `<style>@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}</style>` : '',
].join('\n'),
].join('\n'),
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
<main id="main" class="page hm">
<main id="main" class="page hm">
-${sections({ signedIn, config, models, robots, kit, jobs, faq })}
+${body}
</main>`,
</main>`,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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); });