openvibers/openvibe.bot Commits
Home on phones: every way to connect shows; the chips are links (#269)
Changes
@@@ -105,25 +105,24 @@.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: on a phone every pane is shown, stacked, each with its own heading; the chips are a row of links that++ jump to a pane (the owner's rule, 2026-10-31: never hide content to shorten). A wide screen shows the panes as++ before, with no chips. */++.pick-head, .pick-row { display: none; }++@media (max-width: 640px) {++ .pick-bar { margin-top: 30px; }.pick-head { display: block; }-- .pick-head h2 { margin: 0 0 4px; font-size: 1.15rem; }++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }.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, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); 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; }--} }++ .pick-chip { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; text-decoration: none; }++ .pick-chip:hover, .pick-chip:focus-visible { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); }++ .pick-chip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ .pick .pick-pane { scroll-margin-top: 72px; }++ .pick .pick-pane + .pick-pane { margin-top: 26px; }++ /* The connect section already carries this heading above its lede; the pick bar only adds the chips. */++ .hm-sec#connect .pick-head { display: none; }++}/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section itreplaces: a heading and its content, with no card around it. */@@@ -22,7 +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 { pickBlock, foldBlock } = require('./short');const SITE_NAME = 'OpenVibe.Bot';const NETWORK_URL = 'https://openvibe.network';@@@ -122,18 +122,17 @@ function modelsSection({ models }) {}/** The connection kinds as one list, each with how many models use it. */--function connectSection({ models, rules }) {++function connectSection({ models }) {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.++ // The ways to connect are blocks of the same kind: every way stays in the page, stacked, and on a phone a row++ // of chip links jumps to one.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.',pickBlock({ key: 'connect', label: 'The ways to connect', head: `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, panes }));}@@@ -244,12 +243,12 @@ function faqSection(items) {});}--function sections({ signedIn = false, config, models = [], robots = [], kit: k = null, jobs = [], faq = null, rules = null } = {}) {++function sections({ signedIn = false, config, models = [], robots = [], kit: k = null, jobs = [], faq = null } = {}) {return hero({ signedIn, config })+ stats({ models, robots })+ onlineNow({ robots })+ modelsSection({ models })-- + connectSection({ models, rules })++ + connectSection({ models })+ startSection()+ moreSection({ config, kit: k, models })+ jobsSection({ jobs })@@@ -343,10 +342,8 @@ 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 });++ const body = sections({ signedIn, config, models, robots, kit, jobs, faq });return shell.page({name: SITE_NAME, service: 'bot', lang: 'en',title: `${SITE_NAME}: drive your robot from any browser`,@@@ -370,7 +367,6 @@ 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">@@@ -2,35 +2,28 @@/*** 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.++ * pickBlock() several blocks of the same kind, all shown, each with its own heading. On a phone a row of chip++ * links jumps to a block; nothing is hidden and no script is needed.* 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.++ * The CSS for both lives in public/home.css.*/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.++ * A pick block. The panes are the page's own blocks; every pane shows, stacked, each with its own heading. On a++ * phone the chips are links that jump to a pane; a wide screen shows no chips.** 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 chips = panes.map((p) => `<a class="pick-chip" href="#pp-${esc(key)}-${esc(p.id)}">${esc(p.label)}</a>`).join('');const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.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('');++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;}/**@@@ -47,4 +40,4 @@ 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 };++module.exports = { pickBlock, foldBlock, foldScript };@@@ -1,7 +1,7 @@'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.++// The front page on a phone (the owner's short-pages rule, 2026-10-31): the ways to connect are all shown, stacked,++// and the chips are links that jump to a way. No content is hidden; the parts a reader rarely needs are folds.++// Every block stays in the page for crawlers; a wider screen shows no chips.const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -11,22 +11,25 @@ const { boot, check, done } = require('./helpers/app');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 () => {++ await check('GET /: the ways to connect are linked chips over every way; 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.++ // The ways to connect: one pick block, a chip link per way, every way in the page.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.match(html, /<nav class="pick-row" aria-label="The ways to connect">/);++ const paneIds = [...html.matchAll(/<div class="pick-pane" id="(pp-connect-[a-z-]+)">/g)].map((m) => m[1]);++ const chips = [...html.matchAll(/<a class="pick-chip" href="#(pp-connect-[a-z-]+)">([^<]+)<\/a>/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');++ assert.deepStrictEqual(chips.map((m) => m[1]), paneIds, 'one chip per pane, in the same order');// 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 chips are links, not radios, and their targets exist in the page.++ const pick = html.slice(html.indexOf('<div class="pick" id="pick-connect">'), html.indexOf('</section>', html.indexOf('<div class="pick" id="pick-connect">')));++ assert.ok(!/type="radio"/.test(pick), 'no radio input inside the pick block');++ assert.ok(!/pickRule|pk-connect/.test(html), 'no inline rule that shows one picked pane');++ for (const m of chips) assert.ok(html.includes(`id="${m[1]}"`), `the pane ${m[1]} exists`);// 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']) {@@@ -38,12 +41,14 @@ const { boot, check, done } = require('./helpers/app');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.++ // The stylesheet: the chips are a wide-screen no-show, shown on a phone as links; nothing hides a pane.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');++ assert.match(css, /\.pick-head, \.pick-row \{ display: none; \}/, 'the chips are hidden by default');++ assert.match(css, /@media \(max-width: 640px\) \{/, 'the phone rules are under 640 px');++ assert.match(css, /\.pick-chip \{[^}]*min-height: 44px/, 'a chip is a 44 px target');++ assert.ok(!/\.pick-pane[^{]*\{[^}]*display:\s*none/.test(css), 'no rule hides a pane');++ assert.ok(!/\.pick[^,{]*:has\(/.test(css), 'no picked-pane rule');++ assert.match(css, /\.hm-sec#connect \.pick-head \{ display: none; \}/, 'the connect section keeps one heading, not a duplicate');});await t.close();done();
@@ -105,25 +105,24 @@
@@ -105,25 +105,24 @@
.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: on a phone every pane is shown, stacked, each with its own heading; the chips are a row of links that
+ jump to a pane (the owner's rule, 2026-10-31: never hide content to shorten). A wide screen shows the panes as
+ before, with no chips. */
+.pick-head, .pick-row { display: none; }
+@media (max-width: 640px) {
+ .pick-bar { margin-top: 30px; }
.pick-head { display: block; }
.pick-head { display: block; }
- .pick-head h2 { margin: 0 0 4px; font-size: 1.15rem; }
+ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }
.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 { 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::-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, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); 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; }
-} }
+ .pick-chip { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; text-decoration: none; }
+ .pick-chip:hover, .pick-chip:focus-visible { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); }
+ .pick-chip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ .pick .pick-pane { scroll-margin-top: 72px; }
+ .pick .pick-pane + .pick-pane { margin-top: 26px; }
+ /* The connect section already carries this heading above its lede; the pick bar only adds the chips. */
+ .hm-sec#connect .pick-head { 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
/* 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. */
replaces: a heading and its content, with no card around it. */
@@ -22,7 +22,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -22,7 +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 { pickBlock, 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';
@@ -122,18 +122,17 @@ function modelsSection({ models }) {
@@ -122,18 +122,17 @@ 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, rules }) {
+function connectSection({ models }) {
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.
+ // The ways to connect are blocks of the same kind: every way stays in the page, stacked, and on a phone a row
+ // of chip links jumps to one.
const panes = groups.map((g) => ({
const panes = groups.map((g) => ({
id: g.k,
id: g.k,
label: g.label,
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') })]),
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.',
pickBlock({ key: 'connect', label: 'The ways to connect', head: `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, panes }));
pickBlock({ key: 'connect', label: 'The ways to connect', head: `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, panes }));
}
}
@@ -244,12 +243,12 @@ function faqSection(items) {
@@ -244,12 +243,12 @@ function faqSection(items) {
});
});
}
}
-function sections({ signedIn = false, config, models = [], robots = [], kit: k = null, jobs = [], faq = null, rules = null } = {}) {
+function sections({ signedIn = false, config, models = [], robots = [], kit: k = null, jobs = [], faq = 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, rules })
+ + connectSection({ models })
+ startSection()
+ startSection()
+ moreSection({ config, kit: k, models })
+ moreSection({ config, kit: k, models })
+ jobsSection({ jobs })
+ jobsSection({ jobs })
@@ -343,10 +342,8 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
@@ -343,10 +342,8 @@ 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 });
+ const body = sections({ signedIn, config, models, robots, kit, jobs, faq });
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`,
@@ -370,7 +367,6 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
@@ -370,7 +367,6 @@ 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">
@@ -2,35 +2,28 @@
@@ -2,35 +2,28 @@
/**
/**
* The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
* 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.
+ * pickBlock() several blocks of the same kind, all shown, each with its own heading. On a phone a row of chip
+ * links jumps to a block; nothing is hidden and no script is needed.
* foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
* 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
* 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.
* 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.
+ * The CSS for both lives in public/home.css.
*/
*/
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
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.
+ * A pick block. The panes are the page's own blocks; every pane shows, stacked, each with its own heading. On a
+ * phone the chips are links that jump to a pane; a wide screen shows no chips.
*
*
* pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
* pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
*/
*/
function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }) {
function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }) {
if (!panes || !panes.length) return '';
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 chips = panes.map((p) => `<a class="pick-chip" href="#pp-${esc(key)}-${esc(p.id)}">${esc(p.label)}</a>`).join('');
const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.html}</div>`).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>`;
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('');
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
}
}
/**
/**
@@ -47,4 +40,4 @@ function foldScript(id) {
@@ -47,4 +40,4 @@ 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>`;
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 };
+module.exports = { pickBlock, foldBlock, foldScript };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'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.
+// The front page on a phone (the owner's short-pages rule, 2026-10-31): the ways to connect are all shown, stacked,
+// and the chips are links that jump to a way. No content is hidden; the parts a reader rarely needs are folds.
+// Every block stays in the page for crawlers; a wider screen shows no chips.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -11,22 +11,25 @@ const { boot, check, done } = require('./helpers/app');
@@ -11,22 +11,25 @@ const { boot, check, done } = require('./helpers/app');
const t = await boot({ openre: false });
const t = await boot({ openre: false });
const get = (p) => fetch(t.base + p, { redirect: 'manual' });
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 () => {
+ await check('GET /: the ways to connect are linked chips over every way; the parts read once are folds', async () => {
const r = await get('/');
const r = await get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const html = await r.text();
const html = await r.text();
- // The ways to connect: one pick block, a chip per way, the first one picked.
+ // The ways to connect: one pick block, a chip link per way, every way in the page.
assert.ok(html.includes('<div class="pick" id="pick-connect">'), 'the ways to connect are one pick block');
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.match(html, /<nav class="pick-row" aria-label="The ways to connect">/);
+ const paneIds = [...html.matchAll(/<div class="pick-pane" id="(pp-connect-[a-z-]+)">/g)].map((m) => m[1]);
+ const chips = [...html.matchAll(/<a class="pick-chip" href="#(pp-connect-[a-z-]+)">([^<]+)<\/a>/g)];
assert.ok(chips.length >= 3, 'a chip per way to connect');
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');
+ assert.deepStrictEqual(chips.map((m) => m[1]), paneIds, 'one chip per pane, in the same order');
// Every way stays in the page for the crawlers.
// 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`);
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 chips are links, not radios, and their targets exist in the page.
+ const pick = html.slice(html.indexOf('<div class="pick" id="pick-connect">'), html.indexOf('</section>', html.indexOf('<div class="pick" id="pick-connect">')));
+ assert.ok(!/type="radio"/.test(pick), 'no radio input inside the pick block');
+ assert.ok(!/pickRule|pk-connect/.test(html), 'no inline rule that shows one picked pane');
+ for (const m of chips) assert.ok(html.includes(`id="${m[1]}"`), `the pane ${m[1]} exists`);
// The folds: open in the page, closed on a phone, and their content stays in the page.
// 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']) {
for (const id of ['how', 'more', 'faq', 'network']) {
@@ -38,12 +41,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -38,12 +41,14 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(html.includes('Do I need to open ports on my router?'), 'the questions 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');
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.
+ // The stylesheet: the chips are a wide-screen no-show, shown on a phone as links; nothing hides a pane.
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'home.css'), 'utf8');
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');
+ assert.match(css, /\.pick-head, \.pick-row \{ display: none; \}/, 'the chips are hidden by default');
+ assert.match(css, /@media \(max-width: 640px\) \{/, 'the phone rules are under 640 px');
+ assert.match(css, /\.pick-chip \{[^}]*min-height: 44px/, 'a chip is a 44 px target');
+ assert.ok(!/\.pick-pane[^{]*\{[^}]*display:\s*none/.test(css), 'no rule hides a pane');
+ assert.ok(!/\.pick[^,{]*:has\(/.test(css), 'no picked-pane rule');
+ assert.match(css, /\.hm-sec#connect \.pick-head \{ display: none; \}/, 'the connect section keeps one heading, not a duplicate');
});
});
await t.close();
await t.close();
done();
done();