openvibers/openvibe.bot Commits
Use shared page headers across Bot pages (#349)
Changes
@@@ -2,7 +2,7 @@"name": "openvibe-bot","version": "0.1.0","private": true,-- "description": "OpenVibe.Bot \u2014 devices, pairing and control for robots on the OpenVibe network: one outbound WebSocket per device, profiles instead of rows, safety by default.",++ "description": "OpenVibe.Bot — devices, pairing and control for robots on the OpenVibe network: one outbound WebSocket per device, profiles instead of rows, safety by default.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -1,4 +1,5 @@'use strict';++const uiKit = require('openvibe-shared/kit');/*** The robot directory (plan T15 "a robot directory (/robots)"), inside the OpenVibe Frame like the front page:@@@ -123,7 +124,9 @@ function short(text, max = 120) {const ownerName = (o) => (o && o.username ? `@${o.username}` : 'an OpenVibe member');function shell({ config, path, title, description, body }) {-- return framePage({ config, title: `${title} · OpenVibe.Bot`, description, canonicalPath: path, head: '<link rel="stylesheet" href="/directory.css">', body: `<main id="main" class="dir">${body}</main>` });++ const parent = path.startsWith('/models/') ? { name: 'Models', url: config.baseUrl + '/models' } : { name: 'Robots', url: config.baseUrl + '/robots' };++ const crumbs = [{ name: 'Home', url: config.baseUrl + '/' }, ...(path !== '/robots' && path !== '/models' ? [parent] : []), { name: title, url: config.baseUrl + path }];++ return framePage({ jsonLd: require('openvibe-shared/seo').jsonLd.breadcrumbs(crumbs), config, title: `${title} · OpenVibe.Bot`, description, canonicalPath: path, head: '<link rel="stylesheet" href="/directory.css">', body: `<main id="main" class="dir">${body}</main>` });}function modelCard(m) {@@@ -154,8 +157,7 @@ ${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`: '<p class="muted">No robots yet. Add one: a simulated rover drives straight away, before any hardware exists.</p>'}</section>`;-- const body = `<header class="dir-hero"><h1>Robots</h1>--<p class="muted">Robots people share on OpenVibe.Bot, and every model it drives. Watch any listed robot live; some take visitors at the controls in turns.</p></header>++ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Robots`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Robots people share on OpenVibe.Bot, and every model it drives. Watch any listed robot live; some take visitors at the controls in turns.` })}</div>${yours}<section class="dir-sec" aria-labelledby="listed-h"><h2 id="listed-h">In the directory</h2><ul class="dir-grid">${DEMO_CARD}${listed.map(robotCard).join('')}</ul>@@@ -173,8 +175,7 @@ function renderModels({ config, models = [] }) {const official = models.filter((m) => !m.community);const community = sortModels(models.filter((m) => m.community));const groups = ['browser', 'phone', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(official).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);-- const body = `<header class="dir-hero"><h1>Models</h1>--<p class="muted">Every robot OpenVibe.Bot has a profile for. A profile lays out the panel, so each of these drives with the controls it really has.</p></header>++ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Models`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Every robot OpenVibe.Bot has a profile for. A profile lays out the panel, so each of these drives with the controls it really has.` })}</div>${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}"><h2 id="g-${esc(k)}">${esc(CONNECT[k].label)}</h2><p class="muted">${esc(CONNECT[k].text)}</p><ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p>@@@ -192,9 +193,8 @@ ${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide'const add = model.connect === 'browser'? `${link('/try', 'Try it now, no account', 'dir-btn dir-primary')} ${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add one to your robots', 'dir-btn')}`: `${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add this robot', 'dir-btn dir-primary')}${model.connect === 'onboard' ? ` ${link(`/setup/sd-card?profile=${encodeURIComponent(model.id)}`, 'Start from a blank microSD card', 'dir-btn')}` : ''}${model.guide ? ` ${link(model.guide.href, model.guide.label, 'dir-btn')}` : ''}`;-- const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>--<header class="dir-hero"><h1>${esc(model.name)}</h1><p>${badge(model.connect)}${communityBadge(model)}${model.vendor ? ` <span class="muted">by ${esc(model.vendor)}</span>` : ''}</p>--<p class="muted">${esc(model.description)}</p><p class="dir-actions">${add}</p></header>++ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `${model.name}`, icon: 'gear', back: { href: '/models', label: 'Models' } })}<p>${badge(model.connect)}${communityBadge(model)}${model.vendor ? ` <span class="muted">by ${esc(model.vendor)}</span>` : ''}</p>++<p class="muted">${esc(model.description)}</p><p class="dir-actions">${add}</p></div><div class="dir-cols"><section class="dir-sec card"><h2>How it connects</h2><p><b>${esc(CONNECT[model.connect].label)}.</b> ${esc(CONNECT[model.connect].text)}</p>${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join('')}</ol>` : ''}</section>@@@ -215,11 +215,10 @@ function renderRobotPage({ config, robot, model, owner = null, state = null, rolconst online = !!(state && state.online);const turn = robot.access_policy === 'queue' ? link(`/panel/${robot.id}`, 'Take a turn at the controls', 'dir-btn dir-primary') : '';const member = role ? link(`/panel/${robot.id}`, role === 'owner' ? 'Open your panel' : 'Open the panel', 'dir-btn dir-primary') : '';-- const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>--<header class="dir-hero"><h1>${esc(robot.name)}</h1>++ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `${robot.name}`, icon: 'gear', back: { href: '/robots', label: 'Robots' } })}<p><span class="dir-state" data-state="${online ? 'online' : 'offline'}"><span class="dir-dot"></span>${online ? 'Online' : 'Offline'}</span> ${link(`/models/${model.id}`, model.name, 'dir-pill')} <span class="muted">by ${esc(ownerName(owner))}</span></p><p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>--${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</header>++${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</div><div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div><p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate and e-stop as on its own panel.</p>`;return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });@@@ -233,8 +232,7 @@ function renderAddRobot({ config, models = [], values = {}, error = null }) {+ `<span class="dir-choice-body"><span class="dir-card-head"><b>${esc(m.name)}</b>${badge(m.connect)}</span><span class="dir-card-text">${esc(CONNECT[m.connect].text)}</span></span></label>`;const groups = [['browser', 'Try it with no hardware'], ['phone', 'A phone, from its browser'], ['onboard', 'A robot with its own computer'], ['bridge', 'A robot you connect through a computer next to it'], ['server', 'A camera Bot connects to']].map(([k, title]) => [title, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);-- const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>--<header class="dir-hero"><h1>Add a robot</h1><p class="muted">Name it, say what it is, and choose who may drive. A simulated rover drives the moment it is added; anything else gets a one-time pairing code and one command to paste on its computer.</p></header>++ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Add a robot`, icon: 'gear', back: { href: '/robots', label: 'Robots' } , lede: `Name it, say what it is, and choose who may drive. A simulated rover drives the moment it is added; anything else gets a one-time pairing code and one command to paste on its computer.` })}</div><section class="dir-form sd-entry" aria-labelledby="add-from-card"><h2 id="add-from-card" class="dir-field-label">Starting from a blank microSD card?</h2><p class="muted">${link(`/setup/sd-card${values.profile_id ? `?profile=${encodeURIComponent(values.profile_id)}` : ''}`, 'Make the card\'s setup files')}: write Raspberry Pi OS with Raspberry Pi Imager, add two files, switch the robot on. It joins your Wi-Fi and adds itself, with no screen, keyboard or command.</p></section><section class="dir-form" aria-labelledby="add-from-device"><h2 id="add-from-device" class="dir-field-label">At the robot already? Start there</h2>@@@ -259,8 +257,7 @@ const STATUS_WORDS = { pending: 'Waiting for review', approved: 'Approved', rejefunction renderSubmit({ config, licenses = [], values = {}, error = null }) {const profile = values.profile != null ? values.profile : JSON.stringify(TEMPLATE, null, 2);-- const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>--<header class="dir-hero"><h1>Submit a robot</h1><p class="muted">Describe your robot with a profile and point at its driver. An OpenVibe admin reviews it; once it is approved, OpenVibe.Bot signs it, it appears under <b>From the community</b>, and anyone can add a robot with it. ${link('/docs/profiles', 'How profiles work')} · ${link('/docs/drivers', 'Write a driver')}</p></header>++ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Submit a robot`, icon: 'gear', back: { href: '/robots', label: 'Robots' } , lede: `Describe your robot with a profile and point at its driver. An OpenVibe admin reviews it; once it is approved, OpenVibe.Bot signs it, it appears under From the community, and anyone can add a robot with it. ${link('/docs/profiles', 'How profiles work')} · ${link('/docs/drivers', 'Write a driver')}` })}</div><form class="dir-form" method="post" action="/robots/submit">${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>@@@ -291,8 +288,7 @@ ${sub.notes ? `<p class="muted">Notes: ${esc(sub.notes)}</p>` : ''}}function renderSubmissions({ config, mine = [], pending = null, names = new Map(), signing = null }) {-- const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>--<header class="dir-hero"><h1>Community robots</h1><p class="muted">Your submitted profiles and where each one stands. ${link('/robots/submit', 'Submit another')}</p></header>++ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Community robots`, icon: 'gear', back: { href: '/robots', label: 'Robots' } , lede: `Your submitted profiles and where each one stands. ${link('/robots/submit', 'Submit another')}` })}</div><section class="dir-sec" aria-labelledby="mine-h"><h2 id="mine-h">Yours</h2>${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('')}</ul>` : '<p class="muted">Nothing submitted yet.</p>'}</section>${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>@@@ -1,4 +1,5 @@'use strict';++const kit = require('openvibe-shared/kit');/*** The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Link, the@@@ -53,8 +54,7 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}function renderDocsIndex({ config }) {const content = `--<h1>Build for OpenVibe.Bot</h1>--<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Link runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>++${kit.pageHead({ title: `Build for OpenVibe.Bot`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the driver — a separate program the OpenVibe Link runs — and the profile, the JSON that turns a driver's capabilities into a panel.` })}<section class="card"><h2>Write a driver</h2><p>A driver is a separate program the OpenVibe Link starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p><p>${inLink('/docs/drivers', 'Write a driver')} covers the lifecycle, every message, the safety rules, a minimal Python driver, and how to test and contribute it.</p></section>@@@ -83,7 +83,7 @@ function renderDocsIndex({ config }) {function renderDriversPage({ config }) {const content = `--<h1>Write a driver</h1>++${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label: 'Home' } })}<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe Link.</p><section class="card"><h2>What a driver is</h2>@@@ -250,8 +250,7 @@ function validationResult(result) {function renderProfilesPage({ config, source = '', result = null } = {}) {const example = JSON.stringify(simRover, null, 2);const content = `--<h1>Robot profiles</h1>--<p class="muted">A robot profile (<code>bot.robot-profile@1</code>) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as <code>server/profiles/*.json</code>, are validated by the loader, and are served at ${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}.</p>++${kit.pageHead({ title: `Robot profiles`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot profile (bot.robot-profile@1) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as server/profiles/*.json, are validated by the loader, and are served at GET /api/v1/profiles.` })}<p>${inLink('/api/v1/profiles', 'Browse profiles')}</p><section class="card"><h2>Fields</h2><ul>@@@ -318,8 +317,7 @@ const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/stepfunction renderAutomationPage({ config }) {const content = `--<h1>Routines, webhooks and your agent</h1>--<p class="muted">A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits, cooldowns and e-stop apply, and the audit records each command.</p>++${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits, cooldowns and e-stop apply, and the audit records each command.` })}<section class="card" aria-labelledby="steps-h"><h2 id="steps-h">Steps</h2><p>Routines and the agent both speak in steps, one per line, checked against the robot's own profile. A robot takes only what it has:</p>@@@ -383,8 +381,7 @@ const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…function renderComputersPage({ config }) {const content = `--<h1>Your computer for your agents</h1>--<p class="muted">The OpenVibe Link's <b>computer</b> plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.</p>++${kit.pageHead({ title: `Your computer for your agents`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `The OpenVibe Link's computer plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.` })}<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2><p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>@@@ -18,6 +18,7 @@const shell = require('openvibe-shared/shell');const showcase = require('openvibe-shared/showcase');const kit = require('openvibe-shared/kit');++const sectionIcon = (name) => kit.icon(name, { cls: 'ovk-heading-icon' });const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const appIcon = require('openvibe-shared/app-icon');@@@ -61,7 +62,7 @@ const capWord = (c) => require('./directory').CAPABILITY_WORDS[c] || c;const POLICY_WORDS = { private: 'Its owner and the people they add drive it', invite: 'People the owner invites drive it', queue: 'Anyone signed in can take a turn' };const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;const link = (href, text, cls = '') => `<a${cls ? ` class="${cls}"` : ''} href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;--const sec = (id, title, lede, inner, more = '') => `<section class="sc-sec hm-sec" id="${id}" aria-labelledby="${id}-h"><div class="hm-head"><h2 id="${id}-h">${esc(title)}</h2>${more}</div>${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}${inner}</section>`;++const sec = (id, title, lede, inner, more = '') => kit.section({ id, cls: 'sc-sec hm-sec', title, icon: id === 'connect' ? 'link' : 'gear', note: lede, body: inner, actions: more ? [more] : [] });/** The model's first sentence, cut near `max` characters at a word. */function short(text, max = 120) {@@@ -77,7 +78,7 @@ function hero({ signedIn, config }) {<pre tabindex="0"><code><span class="hm-prompt">$</span> ${esc(install)}</code></pre><ol class="hm-term-steps"><li>It finds the kit and shows a short code.</li><li>Approve the code at ${link('/link', 'openvibe.bot/link')}.</li><li>Open the robot's panel and drive.</li></ol><p class="hm-term-foot">Raspberry Pi OS or any Linux. A blank microSD card? ${link('/setup/sd-card', 'Make its setup files')}, no screen needed.</p></div>`;-- return showcase.hero({++ return showcase.hero({ pageHead: { icon: 'gear' },eyebrow: 'OpenVibe.Bot · alpha',title: 'Drive your robot', accent: 'from any browser.',lede: 'An open control panel for robots. Pair a robot with one pasted command, then drive it with a touch joystick, '@@@ -111,14 +112,14 @@ function stats({ models, robots }) {function onlineNow({ robots }) {const sim = kit.card({ href: '/try', title: 'Simulated rover', type: 'game', ratio: '16/9', badge: 'Online', sub: 'Camera, range finder, line sensors, lights, horn', who: 'Anyone, no account', share: false });const cards = robots.slice(0, 8).map((r) => kit.card({ href: `/r/${r.id}`, title: r.name, type: 'game', ratio: '16/9', badge: r.online ? 'Online' : 'Offline', sub: `${r.model_name}${r.owner && r.owner.username ? ` · @${r.owner.username}` : ''}`, who: POLICY_WORDS[r.access_policy] || '', share: false }));-- return `<section class="sc-sec hm-sec" id="now">${kit.rail({ id: 'now-rail', title: 'Drive one now', more: { href: '/robots', label: robots.length ? 'The directory' : 'Browse robots' }, items: [sim].concat(cards), wrap: true })}</section>`;++ return `<section class="sc-sec hm-sec" id="now">${kit.rail({ id: 'now-rail', title: 'Drive one now', icon: 'game', more: { href: '/robots', label: robots.length ? 'The directory' : 'Browse robots' }, items: [sim].concat(cards), wrap: true })}</section>`;}function modelsSection({ models }) {if (!models.length) return '';const kindOf = (m) => (KINDS.find((k) => k[0] === m.connect) || [, m.connect])[1];const cards = models.map((m) => kit.card({ href: `/models/${m.id}`, title: m.name, type: 'game', ratio: '16/9', badge: kindOf(m), sub: m.capabilities.slice(0, 3).map(capWord).join(' · '), peek: short(m.description), share: false }));-- return `<section class="sc-sec hm-sec" id="models">${kit.rail({ id: 'models-rail', title: 'Robots it drives today', more: { href: '/models', label: `All ${models.length} models` }, items: cards, wrap: true })}++ return `<section class="sc-sec hm-sec" id="models">${kit.rail({ id: 'models-rail', title: 'Robots it drives today', icon: 'gear', more: { href: '/models', label: `All ${models.length} models` }, items: cards, wrap: true })}<p class="hm-under">Each has a profile the panel draws its controls from: a joystick for a base that drives, sliders for a pan-tilt head, buttons for lights and a horn.</p></section>`;}@@@ -141,7 +142,7 @@ function connectSection({ models }) {function startSection() {return foldBlock({id: 'how',-- head: '<h2 id="how-h">Online in a minute</h2>',++ head: `<h2 id="how-h" class="ovk-titled">${sectionIcon('grid')}Online in a minute</h2>`,html: showcase.steps({id: 'how',title: 'Online in a minute',@@@ -197,14 +198,14 @@ function moreSection({ config, kit: k, models }) {]);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>++ head: `<h2 id="more-h" class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>`,++ html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}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 Link 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">${kit.button({ href: '/docs', label: 'All build pages', icon: 'code', kind: 'quiet' })}</p>` })}</section>`,});}@@@ -212,7 +213,7 @@ function jobsSection({ jobs }) {if (!jobs || !jobs.length) return '';return foldBlock({id: 'jobs',-- head: '<h2 id="jobs-h">Robotics jobs on OpenVibe.Work</h2>',++ head: `<h2 id="jobs-h" class="ovk-titled">${sectionIcon('gear')}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>`,});}@@@ -239,7 +240,7 @@ function faqItems({ models }) {function faqSection(items) {return foldBlock({id: 'faq',-- head: '<h2 id="faq-h">Questions</h2>',++ head: `<h2 id="faq-h" class="ovk-titled">${sectionIcon('help')}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>`,});}@@@ -256,7 +257,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =+ faqSection(faq || faqItems({ models }))+ foldBlock({id: 'network',-- head: '<h2 id="network-h">Part of OpenVibe</h2>',++ head: `<h2 id="network-h" class="ovk-titled">${sectionIcon('task')}Part of OpenVibe</h2>`,html: showcase.network({ service: 'bot' }),});}@@@ -269,7 +270,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =const footerBoot = (footer) => `<script>document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(${JSON.stringify(footer)}); } catch (e) { /* the Frame is optional */ } });</script>`;/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */--function framePage({ config, title, description, canonicalPath = '/', body, head = '', noindex = false }) {++function framePage({ config, title, description, canonicalPath = '/', body, head = '', noindex = false, jsonLd = null }) {const nav = {service: 'bot',apiBase: NETWORK_URL,@@@ -282,14 +283,14 @@ function framePage({ config, title, description, canonicalPath = '/', body, headnotificationsRealtime: true,};return shell.page({-- name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description,++ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, jsonLd,...(noindex ? {} : { summary: description }),canonical: noindex ? undefined : `${config.baseUrl}${canonicalPath}`,robots: noindex ? 'noindex, follow' : 'index, follow',agentLayer: !noindex && require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],-- head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),++ head: [`<link rel="stylesheet" href="${ovServe.url('kit.css')}"><script src="${ovServe.url('kit-web.js')}" defer></script>`, appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),body: `<div id="navbar-mount"></div>\n${body}\n${footerBoot({ service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' })}`,});}@@@ -307,7 +308,7 @@ function renderNotFound({ config }) {UPDATES_FONT,].join('\n'),body: `<main id="main" class="page">--<h1>Page not found</h1>++${kit.pageHead({ title: `Page not found`, icon: 'gear', back: { href: '/', label: 'Home' } })}<p>There is no page at this address on ${esc(host)}. The link may be old or mistyped.</p><div class="sc-actions"><a class="sc-btn sc-primary" href="/">${SITE_NAME} home</a>@@@ -366,7 +367,7 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =head: [appIcon.headTags({ site: 'bot' }),'<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`,-- `<link rel="stylesheet" href="${ovServe.url('kit.css')}">`,++ `<link rel="stylesheet" href="${ovServe.url('kit.css')}"><script src="${ovServe.url('kit-web.js')}" defer></script>`,`<link rel="stylesheet" href="${ovServe.url('room.css')}">`,`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,'<link rel="stylesheet" href="/home.css">',@@@ -1,4 +1,5 @@'use strict';++const kit = require('openvibe-shared/kit');/*** The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).@@@ -80,6 +81,7 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c<meta property="og:url" content="https://openvibe.bot${esc(canonical)}"><meta property="og:image" content="https://openvibe.bot${esc(social.url('bot'))}"><meta name="twitter:card" content="summary_large_image">` : ''}++<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}"><link rel="stylesheet" href="/panel/panel.css"></head><body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>@@@ -569,7 +571,7 @@ ${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') :</header><main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}><div class="panel-head">--<h1>${esc(robot.name)}</h1>++${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>${turnStrip(robot, role)}</div>@@@ -616,7 +618,7 @@ function tryOutro(profile) {*/function renderDrivePage({ robot }) {const body = `<main class="page drive-page" data-drive-page data-robot-id="${esc(robot.id)}">--<div class="card"><h1>${esc(robot.name)}</h1>++<div class="card">${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: { href: '/', label: 'Home' } })}<p data-drive-status role="status">Connecting the panel…</p><p>${outLink(`/panel/${robot.id}`, 'Open the full panel instead')}</p></div></main>`;@@@ -672,7 +674,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label><button type="submit" class="primary">Add robot</button></form>`;-- return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n${kitsSection(kits, profiles)}\n</main>`);++ return page('Your robots', `${topbar()}\n<main class="page">\n${kit.pageHead({ title: `Your robots`, icon: 'gear', back: { href: '/', label: 'Home' } })}\n${list}\n${form}\n${kitsSection(kits, profiles)}\n</main>`);}function renderPairingPage({ robot, pairing, profile = null, phoneUrl = null }) {@@@ -688,7 +690,7 @@ function renderPairingPage({ robot, pairing, profile = null, phoneUrl = null })<button type="button" class="copy" data-copy>${icon('copy')}<span data-copy-label>Copy the command</span></button></li>`;const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<main class="page pairing" data-pair-robot="${esc(robot.id)}">--<h1>Pair ${esc(robot.name)}</h1>++${kit.pageHead({ title: `Pair ${robot.name}`, icon: 'gear', back: { href: '/', label: 'Home' } })}<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p><ol class="steps">${first}@@@ -709,7 +711,7 @@ ${first}function renderSignInPage({ next = '/robots', what = 'this page' } = {}) {const body = `${topbar('')}<main class="page">--<h1>Sign in to continue</h1>++${kit.pageHead({ title: `Sign in to continue`, icon: 'gear', back: { href: '/', label: 'Home' } })}<p>${esc(what.charAt(0).toUpperCase() + what.slice(1))} is for OpenVibe accounts. Sign in and you come straight back here.</p><p><a class="button primary" href="/auth/login?next=${esc(encodeURIComponent(next))}">Sign in with OpenVibe</a></p></main>`;@@@ -724,7 +726,7 @@ function renderSignInPage({ next = '/robots', what = 'this page' } = {}) {function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.bot/install | sh' } = {}) {const body = `${topbar('<span class="crumb">Add a device</span>')}<main class="page link">--<h1>Add a device</h1>++${kit.pageHead({ title: `Add a device`, icon: 'gear', back: { href: '/', label: 'Home' } })}<form class="card" method="get" action="/link"><p>Enter the code the robot's computer shows. It shows one when you run this on it:</p><pre tabindex="0" class="installer"><code>${esc(installer)}</code></pre>@@@ -745,7 +747,7 @@ function renderPhoneLinkPage({ pairing = null, apkUrl = null } = {}) {if (!apkUrl || !pairing) {const body = `${topbar('<span class="crumb">Link on your phone</span>')}<main class="page pairing">--<h1>Link on your phone</h1>++${kit.pageHead({ title: `Link on your phone`, icon: 'gear', back: { href: '/', label: 'Home' } })}<p>OpenVibe Link for Android puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app'sshare sheet. It is in testing and not published yet: when it is, you install it from here and pair it with a code.</p><p class="muted"><a href="/machines">Your machines</a> · <a href="/robots">Your robots</a></p>@@@ -756,7 +758,7 @@ share sheet. It is in testing and not published yet: when it is, you install itconst minutes = Math.max(1, Math.round((Date.parse(pairing.expires_at) - Date.now()) / 60000)) || 15;const body = `${topbar('<span class="crumb">Link on your phone</span>')}<main class="page pairing">--<h1>Link on your phone</h1>++${kit.pageHead({ title: `Link on your phone`, icon: 'gear', back: { href: '/', label: 'Home' } })}<p>OpenVibe Link puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's share sheet.First <a href="${esc(apkUrl)}" rel="noopener">install it on your Android phone</a>, then scan or type the code below.</p><ol class="steps">@@@ -792,8 +794,7 @@ function renderClaimPage({ claim, models = [], values = {}, error = null, now =const live = values.live !== false;const body = `${topbar('<span class="crumb">Add a device</span>')}<main class="page link">--<h1>Add ${esc(claim.hostname)}?</h1>--<p class="muted">Only approve a device you set up yourself, just now. If you did not run the command on it, deny it.</p>++${kit.pageHead({ title: `Add ${claim.hostname}?`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Only approve a device you set up yourself, just now. If you did not run the command on it, deny it.` })}<dl class="card facts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl><form class="card" method="post" action="/link/${esc(claim.id)}/approve">${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}@@@ -811,7 +812,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}function renderClaimApproved({ robot, profile = null, live = false, chatUrl = null }) {const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<main class="page pairing" data-pair-robot="${esc(robot.id)}">--<h1>${esc(robot.name)} is yours</h1>++${kit.pageHead({ title: `${robot.name} is yours`, icon: 'gear', back: { href: '/', label: 'Home' } })}<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p><ol class="steps"><li class="card"><h2>The device carries on by itself${profile ? ` <span class="pill">${esc(profile.name)}</span>` : ''}</h2>@@@ -972,8 +973,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;const body = `${topbar('<span class="crumb">Machines</span>')}<main class="page">--<h1>Your machines</h1>--<p class="muted">A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (<code>openvibe-node stop</code>) always win.</p>++${kit.pageHead({ title: `Your machines`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (openvibe-node stop) always win.` })}<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>${list}${add}@@@ -1010,7 +1010,7 @@ ${rows ? `<ul class="share-list">${rows}</ul>` : ''}</section>`;/** A screen link that does not work (stopped, ended, never existed): one answer for every reason. */function renderShareGone() {-- return page('Screen link', `<main class="page share-page"><h1>This screen link does not work</h1><p class="muted">It was stopped, it ended, or it never existed. Ask whoever shared it for a new one.</p></main>`, { description: 'A shared computer screen on OpenVibe.Bot.' });++ return page('Screen link', `<main class="page share-page">${kit.pageHead({ title: `This screen link does not work`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `It was stopped, it ended, or it never existed. Ask whoever shared it for a new one.` })}</main>`, { description: 'A shared computer screen on OpenVibe.Bot.' });}/** /screen/:token: a shared screen, live, for a frame or a tab; with "use" links, the mouse and keyboard too. */@@@ -1060,8 +1060,7 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}<main class="page">--<h1>${esc(name)}: desks</h1>--<p class="muted">Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.</p>++${kit.pageHead({ title: `${name}: desks`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.` })}${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}${live ? `<p class="setting-ok" role="status">${icon('check')}<span><code>${esc(live.desk)}</code> is live${live.channel ? ` on <a href="${esc(live.channel)}">${esc(live.channel.replace(/^https:\/\//, ''))}</a>` : ' on your OpenVibe Live channel'} for up to 30 minutes. <a href="/machines/${id}/screen?desk=${encodeURIComponent(live.desk)}">Its screen page</a> stops it.</span></p>` : ''}${list}@@@ -1081,7 +1080,7 @@ function renderMachineScreenPage({ machine, until = null, error = null, idle = fif (idle && !error) {const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}<main class="page">--<h1>${esc(name)}: screen</h1>++${kit.pageHead({ title: `${name}: screen`, icon: 'gear', back: { href: '/', label: 'Home' } })}${agentDesk ? `<p>Your agent works on a desk of its own on this computer. Watch it live, and take it over whenever you like: while you use the mouse and keyboard, the agent waits; when you stop, it goes on.</p><form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="${esc(desk)}"><button type="submit" class="primary">Watch your agent's desk</button></form>` : ''}${hasScreen ? `<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>@@@ -1102,7 +1101,7 @@ ${desk !== 'main' ? '<p class="muted small">While you use it, the agent waits onconst body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}<main class="page" data-share="${esc(base)}" data-control="${error ? '0' : '1'}" data-owner="1"><p class="muted small">Showing ${esc(deskWords)}${agentDesk ? ` (<code>${esc(desk)}</code>)` : ''}.</p>--<h1>${esc(name)}: screen</h1>++${kit.pageHead({ title: `${name}: screen`, icon: 'gear', back: { href: '/', label: 'Home' } })}${error ? `<p class="error" role="alert">${esc(error)}</p>` : ends}<figure class="camera machine-screen" data-screen-state="${esc(stateUrl)}" tabindex="0" aria-label="The screen: turn on the mouse and keyboard to click and type on it"><div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">${error ? 'The screen is not shown.' : 'Waiting for the machine…'}</p>@@@ -1128,8 +1127,7 @@ function renderPhonePage() {const body = `<main id="phone" class="phone" data-state="setup"><section class="phone-setup" data-setup><p class="phone-brand">${icon('robot')}<span>OpenVibe<b>.Bot</b></span></p>--<h1>Make this phone a robot</h1>--<p class="muted">Pair it with a robot you added on openvibe.bot. Its camera streams to the robot's panel, it says what the driver types, shows a face, and reports its battery and tilt. Nothing to install: keep this page open.</p>++${kit.pageHead({ title: `Make this phone a robot`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Pair it with a robot you added on openvibe.bot. Its camera streams to the robot's panel, it says what the driver types, shows a face, and reports its battery and tilt. Nothing to install: keep this page open.` })}<form class="phone-form" data-pair-form><div data-pair-fields><label class="field" data-robot-field><span class="field-label">Robot</span><input name="robot" autocomplete="off" spellcheck="false" placeholder="rob_…"></label>@@@ -1207,7 +1205,7 @@ function renderFirstRunPage({ rows = [] }) {}).join('')}</ul>`: '<p class="muted">No machine has reported a first run yet.</p>';const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>--<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>++<div class="dir-hero">${kit.pageHead({ title: `First run`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (node.first_run); nothing here names a person, folder or address.` })}</div><section class="dir-sec"><h2>The funnel</h2><table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption><thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>@@@ -1,4 +1,5 @@'use strict';++const kit = require('openvibe-shared/kit');/*** GET /setup/sd-card: a robot from a blank microSD card (owner 2026-10-24: "if you just have a fresh MicroSD card how to* flash … and be able to connect to the device headlessly … the site needs to guide the user").@@@ -58,8 +59,7 @@ function renderSdCard({ config, signedIn = false, models = [], values = {} }) {: `<p class="sd-signin">${link('/auth/login?next=%2Fsetup%2Fsd-card', 'Sign in')} to make your card's files: they add the robot to your account.</p>`;const body = `<p class="dir-crumb">${link('/robots', 'Robots')} · ${link('/robots/new', 'Add a robot')}</p>--<header class="dir-hero"><h1>A robot from a blank microSD card</h1>--<p class="muted">No screen, no keyboard, nothing to type on the robot. This page makes the card's setup files: on its first boot your Raspberry Pi joins your Wi-Fi, turns on SSH, installs OpenVibe and adds itself to your robots.</p></header>++<div class="dir-hero">${kit.pageHead({ title: `A robot from a blank microSD card`, icon: 'server', back: { href: '/', label: 'Home' } , lede: `No screen, no keyboard, nothing to type on the robot. This page makes the card's setup files: on its first boot your Raspberry Pi joins your Wi-Fi, turns on SSH, installs OpenVibe and adds itself to your robots.` })}</div><section class="dir-sec sd-need" aria-labelledby="sd-need-h"><h2 id="sd-need-h">What you need</h2><ul class="sd-list"><li>The robot's Raspberry Pi (3B+, 4, 5 or Zero 2 W)</li><li>A microSD card, 16 GB or more: everything on it is erased</li><li>A computer with a card reader</li><li>Your Wi-Fi name and password (2.4 GHz works on every Pi)</li></ul></section><ol class="sd-steps">@@@ -23,7 +23,7 @@ function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }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><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><nav class="pick-row" data-ovk-scroll aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;}/**@@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');// 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, /<nav class="pick-row" aria-label="The ways to connect">/);++ assert.match(html, /<nav class="pick-row" data-ovk-scroll 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');@@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/app');assert.match(csp, /frame-ancestors 'none'/);const html = await r.text();assert.ok(html.includes('"silentLogin":"/auth/login?silent=1&next={url}"'), 'first visits use the existing SDK OAuth route');-- assert.match(html, /<h1>Drive your robot<span class="sc-accent"> from any browser\.<\/span><\/h1>/);++ assert.match(html, /<h1>Drive your robot from any browser\.<\/h1>/);assert.match(html, /<meta name="robots" content="index, follow">/);assert.match(html, /<link rel="canonical" href="http:\/\/bot\.test\/">/);assert.match(html, /href="\/auth\/login\?next=%2Frobots">Sign in with OpenVibe/);@@@ -11,7 +11,9 @@ const { boot, check, done } = require('./helpers/app');(async () => {const reports = [];const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };-- const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter } });++ // This test covers ownership and machine UI, not missed-heartbeat expiry. Leave enough++ // grace for page rendering on a busy test host; explicit socket closes still go offline.++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0', BOT_OFFLINE_GRACE_MS: '30000' }, appOpts: { offerReporter: reporter } });const alex = t.network.newUser('alex');const bob = t.network.newUser('bob');const { dispatch } = require('../server/jobs/dispatch');@@@ -85,7 +87,9 @@ const { boot, check, done } = require('./helpers/app');const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });assert.strictEqual(anon.status, 401, 'signed out: a gate page here, noindex (SEO-7)');const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();-- assert.ok(page.includes('Your machines') && page.includes('desk-pc') && page.includes('Online') && page.includes('code jobs'), page);++ for (const text of ['Your machines', 'desk-pc', 'Online', 'code jobs']) {++ assert.ok(page.includes(text), `machine list is missing ${text}`);++ }assert.ok(!page.includes(bob.subject));const added = await form('/machines', alex);assert.strictEqual(added.status, 201);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-bot",
"name": "openvibe-bot",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Bot \u2014 devices, pairing and control for robots on the OpenVibe network: one outbound WebSocket per device, profiles instead of rows, safety by default.",
+ "description": "OpenVibe.Bot — devices, pairing and control for robots on the OpenVibe network: one outbound WebSocket per device, profiles instead of rows, safety by default.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const uiKit = require('openvibe-shared/kit');
/**
/**
* The robot directory (plan T15 "a robot directory (/robots)"), inside the OpenVibe Frame like the front page:
* The robot directory (plan T15 "a robot directory (/robots)"), inside the OpenVibe Frame like the front page:
@@ -123,7 +124,9 @@ function short(text, max = 120) {
@@ -123,7 +124,9 @@ function short(text, max = 120) {
const ownerName = (o) => (o && o.username ? `@${o.username}` : 'an OpenVibe member');
const ownerName = (o) => (o && o.username ? `@${o.username}` : 'an OpenVibe member');
function shell({ config, path, title, description, body }) {
function shell({ config, path, title, description, body }) {
- return framePage({ config, title: `${title} · OpenVibe.Bot`, description, canonicalPath: path, head: '<link rel="stylesheet" href="/directory.css">', body: `<main id="main" class="dir">${body}</main>` });
+ const parent = path.startsWith('/models/') ? { name: 'Models', url: config.baseUrl + '/models' } : { name: 'Robots', url: config.baseUrl + '/robots' };
+ const crumbs = [{ name: 'Home', url: config.baseUrl + '/' }, ...(path !== '/robots' && path !== '/models' ? [parent] : []), { name: title, url: config.baseUrl + path }];
+ return framePage({ jsonLd: require('openvibe-shared/seo').jsonLd.breadcrumbs(crumbs), config, title: `${title} · OpenVibe.Bot`, description, canonicalPath: path, head: '<link rel="stylesheet" href="/directory.css">', body: `<main id="main" class="dir">${body}</main>` });
}
}
function modelCard(m) {
function modelCard(m) {
@@ -154,8 +157,7 @@ ${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-
@@ -154,8 +157,7 @@ ${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-
+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`
+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`
+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`
+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`
: '<p class="muted">No robots yet. Add one: a simulated rover drives straight away, before any hardware exists.</p>'}</section>`;
: '<p class="muted">No robots yet. Add one: a simulated rover drives straight away, before any hardware exists.</p>'}</section>`;
- const body = `<header class="dir-hero"><h1>Robots</h1>
-<p class="muted">Robots people share on OpenVibe.Bot, and every model it drives. Watch any listed robot live; some take visitors at the controls in turns.</p></header>
+ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Robots`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Robots people share on OpenVibe.Bot, and every model it drives. Watch any listed robot live; some take visitors at the controls in turns.` })}</div>
${yours}
${yours}
<section class="dir-sec" aria-labelledby="listed-h"><h2 id="listed-h">In the directory</h2>
<section class="dir-sec" aria-labelledby="listed-h"><h2 id="listed-h">In the directory</h2>
<ul class="dir-grid">${DEMO_CARD}${listed.map(robotCard).join('')}</ul>
<ul class="dir-grid">${DEMO_CARD}${listed.map(robotCard).join('')}</ul>
@@ -173,8 +175,7 @@ function renderModels({ config, models = [] }) {
@@ -173,8 +175,7 @@ function renderModels({ config, models = [] }) {
const official = models.filter((m) => !m.community);
const official = models.filter((m) => !m.community);
const community = sortModels(models.filter((m) => m.community));
const community = sortModels(models.filter((m) => m.community));
const groups = ['browser', 'phone', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(official).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
const groups = ['browser', 'phone', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(official).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
- const body = `<header class="dir-hero"><h1>Models</h1>
-<p class="muted">Every robot OpenVibe.Bot has a profile for. A profile lays out the panel, so each of these drives with the controls it really has.</p></header>
+ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Models`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Every robot OpenVibe.Bot has a profile for. A profile lays out the panel, so each of these drives with the controls it really has.` })}</div>
${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}"><h2 id="g-${esc(k)}">${esc(CONNECT[k].label)}</h2><p class="muted">${esc(CONNECT[k].text)}</p>
${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}"><h2 id="g-${esc(k)}">${esc(CONNECT[k].label)}</h2><p class="muted">${esc(CONNECT[k].text)}</p>
<ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}
<ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}
${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p>
${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p>
@@ -192,9 +193,8 @@ ${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide'
@@ -192,9 +193,8 @@ ${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide'
const add = model.connect === 'browser'
const add = model.connect === 'browser'
? `${link('/try', 'Try it now, no account', 'dir-btn dir-primary')} ${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add one to your robots', 'dir-btn')}`
? `${link('/try', 'Try it now, no account', 'dir-btn dir-primary')} ${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add one to your robots', 'dir-btn')}`
: `${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add this robot', 'dir-btn dir-primary')}${model.connect === 'onboard' ? ` ${link(`/setup/sd-card?profile=${encodeURIComponent(model.id)}`, 'Start from a blank microSD card', 'dir-btn')}` : ''}${model.guide ? ` ${link(model.guide.href, model.guide.label, 'dir-btn')}` : ''}`;
: `${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add this robot', 'dir-btn dir-primary')}${model.connect === 'onboard' ? ` ${link(`/setup/sd-card?profile=${encodeURIComponent(model.id)}`, 'Start from a blank microSD card', 'dir-btn')}` : ''}${model.guide ? ` ${link(model.guide.href, model.guide.label, 'dir-btn')}` : ''}`;
- const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>
-<header class="dir-hero"><h1>${esc(model.name)}</h1><p>${badge(model.connect)}${communityBadge(model)}${model.vendor ? ` <span class="muted">by ${esc(model.vendor)}</span>` : ''}</p>
-<p class="muted">${esc(model.description)}</p><p class="dir-actions">${add}</p></header>
+ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `${model.name}`, icon: 'gear', back: { href: '/models', label: 'Models' } })}<p>${badge(model.connect)}${communityBadge(model)}${model.vendor ? ` <span class="muted">by ${esc(model.vendor)}</span>` : ''}</p>
+<p class="muted">${esc(model.description)}</p><p class="dir-actions">${add}</p></div>
<div class="dir-cols">
<div class="dir-cols">
<section class="dir-sec card"><h2>How it connects</h2><p><b>${esc(CONNECT[model.connect].label)}.</b> ${esc(CONNECT[model.connect].text)}</p>
<section class="dir-sec card"><h2>How it connects</h2><p><b>${esc(CONNECT[model.connect].label)}.</b> ${esc(CONNECT[model.connect].text)}</p>
${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join('')}</ol>` : ''}</section>
${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join('')}</ol>` : ''}</section>
@@ -215,11 +215,10 @@ function renderRobotPage({ config, robot, model, owner = null, state = null, rol
@@ -215,11 +215,10 @@ function renderRobotPage({ config, robot, model, owner = null, state = null, rol
const online = !!(state && state.online);
const online = !!(state && state.online);
const turn = robot.access_policy === 'queue' ? link(`/panel/${robot.id}`, 'Take a turn at the controls', 'dir-btn dir-primary') : '';
const turn = robot.access_policy === 'queue' ? link(`/panel/${robot.id}`, 'Take a turn at the controls', 'dir-btn dir-primary') : '';
const member = role ? link(`/panel/${robot.id}`, role === 'owner' ? 'Open your panel' : 'Open the panel', 'dir-btn dir-primary') : '';
const member = role ? link(`/panel/${robot.id}`, role === 'owner' ? 'Open your panel' : 'Open the panel', 'dir-btn dir-primary') : '';
- const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>
-<header class="dir-hero"><h1>${esc(robot.name)}</h1>
+ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `${robot.name}`, icon: 'gear', back: { href: '/robots', label: 'Robots' } })}
<p><span class="dir-state" data-state="${online ? 'online' : 'offline'}"><span class="dir-dot"></span>${online ? 'Online' : 'Offline'}</span> ${link(`/models/${model.id}`, model.name, 'dir-pill')} <span class="muted">by ${esc(ownerName(owner))}</span></p>
<p><span class="dir-state" data-state="${online ? 'online' : 'offline'}"><span class="dir-dot"></span>${online ? 'Online' : 'Offline'}</span> ${link(`/models/${model.id}`, model.name, 'dir-pill')} <span class="muted">by ${esc(ownerName(owner))}</span></p>
<p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>
<p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>
-${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</header>
+${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</div>
<div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div>
<div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div>
<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate and e-stop as on its own panel.</p>`;
<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate and e-stop as on its own panel.</p>`;
return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });
return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });
@@ -233,8 +232,7 @@ function renderAddRobot({ config, models = [], values = {}, error = null }) {
@@ -233,8 +232,7 @@ function renderAddRobot({ config, models = [], values = {}, error = null }) {
+ `<span class="dir-choice-body"><span class="dir-card-head"><b>${esc(m.name)}</b>${badge(m.connect)}</span><span class="dir-card-text">${esc(CONNECT[m.connect].text)}</span></span></label>`;
+ `<span class="dir-choice-body"><span class="dir-card-head"><b>${esc(m.name)}</b>${badge(m.connect)}</span><span class="dir-card-text">${esc(CONNECT[m.connect].text)}</span></span></label>`;
const groups = [['browser', 'Try it with no hardware'], ['phone', 'A phone, from its browser'], ['onboard', 'A robot with its own computer'], ['bridge', 'A robot you connect through a computer next to it'], ['server', 'A camera Bot connects to']]
const groups = [['browser', 'Try it with no hardware'], ['phone', 'A phone, from its browser'], ['onboard', 'A robot with its own computer'], ['bridge', 'A robot you connect through a computer next to it'], ['server', 'A camera Bot connects to']]
.map(([k, title]) => [title, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
.map(([k, title]) => [title, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
- const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>
-<header class="dir-hero"><h1>Add a robot</h1><p class="muted">Name it, say what it is, and choose who may drive. A simulated rover drives the moment it is added; anything else gets a one-time pairing code and one command to paste on its computer.</p></header>
+ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Add a robot`, icon: 'gear', back: { href: '/robots', label: 'Robots' } , lede: `Name it, say what it is, and choose who may drive. A simulated rover drives the moment it is added; anything else gets a one-time pairing code and one command to paste on its computer.` })}</div>
<section class="dir-form sd-entry" aria-labelledby="add-from-card"><h2 id="add-from-card" class="dir-field-label">Starting from a blank microSD card?</h2>
<section class="dir-form sd-entry" aria-labelledby="add-from-card"><h2 id="add-from-card" class="dir-field-label">Starting from a blank microSD card?</h2>
<p class="muted">${link(`/setup/sd-card${values.profile_id ? `?profile=${encodeURIComponent(values.profile_id)}` : ''}`, 'Make the card\'s setup files')}: write Raspberry Pi OS with Raspberry Pi Imager, add two files, switch the robot on. It joins your Wi-Fi and adds itself, with no screen, keyboard or command.</p></section>
<p class="muted">${link(`/setup/sd-card${values.profile_id ? `?profile=${encodeURIComponent(values.profile_id)}` : ''}`, 'Make the card\'s setup files')}: write Raspberry Pi OS with Raspberry Pi Imager, add two files, switch the robot on. It joins your Wi-Fi and adds itself, with no screen, keyboard or command.</p></section>
<section class="dir-form" aria-labelledby="add-from-device"><h2 id="add-from-device" class="dir-field-label">At the robot already? Start there</h2>
<section class="dir-form" aria-labelledby="add-from-device"><h2 id="add-from-device" class="dir-field-label">At the robot already? Start there</h2>
@@ -259,8 +257,7 @@ const STATUS_WORDS = { pending: 'Waiting for review', approved: 'Approved', reje
@@ -259,8 +257,7 @@ const STATUS_WORDS = { pending: 'Waiting for review', approved: 'Approved', reje
function renderSubmit({ config, licenses = [], values = {}, error = null }) {
function renderSubmit({ config, licenses = [], values = {}, error = null }) {
const profile = values.profile != null ? values.profile : JSON.stringify(TEMPLATE, null, 2);
const profile = values.profile != null ? values.profile : JSON.stringify(TEMPLATE, null, 2);
- const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>
-<header class="dir-hero"><h1>Submit a robot</h1><p class="muted">Describe your robot with a profile and point at its driver. An OpenVibe admin reviews it; once it is approved, OpenVibe.Bot signs it, it appears under <b>From the community</b>, and anyone can add a robot with it. ${link('/docs/profiles', 'How profiles work')} · ${link('/docs/drivers', 'Write a driver')}</p></header>
+ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Submit a robot`, icon: 'gear', back: { href: '/robots', label: 'Robots' } , lede: `Describe your robot with a profile and point at its driver. An OpenVibe admin reviews it; once it is approved, OpenVibe.Bot signs it, it appears under From the community, and anyone can add a robot with it. ${link('/docs/profiles', 'How profiles work')} · ${link('/docs/drivers', 'Write a driver')}` })}</div>
<form class="dir-form" method="post" action="/robots/submit">
<form class="dir-form" method="post" action="/robots/submit">
${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>
<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>
@@ -291,8 +288,7 @@ ${sub.notes ? `<p class="muted">Notes: ${esc(sub.notes)}</p>` : ''}
@@ -291,8 +288,7 @@ ${sub.notes ? `<p class="muted">Notes: ${esc(sub.notes)}</p>` : ''}
}
}
function renderSubmissions({ config, mine = [], pending = null, names = new Map(), signing = null }) {
function renderSubmissions({ config, mine = [], pending = null, names = new Map(), signing = null }) {
- const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>
-<header class="dir-hero"><h1>Community robots</h1><p class="muted">Your submitted profiles and where each one stands. ${link('/robots/submit', 'Submit another')}</p></header>
+ const body = `<div class="dir-hero">${uiKit.pageHead({ title: `Community robots`, icon: 'gear', back: { href: '/robots', label: 'Robots' } , lede: `Your submitted profiles and where each one stands. ${link('/robots/submit', 'Submit another')}` })}</div>
<section class="dir-sec" aria-labelledby="mine-h"><h2 id="mine-h">Yours</h2>
<section class="dir-sec" aria-labelledby="mine-h"><h2 id="mine-h">Yours</h2>
${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('')}</ul>` : '<p class="muted">Nothing submitted yet.</p>'}</section>
${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('')}</ul>` : '<p class="muted">Nothing submitted yet.</p>'}</section>
${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>
${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const kit = require('openvibe-shared/kit');
/**
/**
* The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Link, the
* The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Link, the
@@ -53,8 +54,7 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}
@@ -53,8 +54,7 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}
function renderDocsIndex({ config }) {
function renderDocsIndex({ config }) {
const content = `
const content = `
-<h1>Build for OpenVibe.Bot</h1>
-<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Link runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>
+${kit.pageHead({ title: `Build for OpenVibe.Bot`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the driver — a separate program the OpenVibe Link runs — and the profile, the JSON that turns a driver's capabilities into a panel.` })}
<section class="card"><h2>Write a driver</h2>
<section class="card"><h2>Write a driver</h2>
<p>A driver is a separate program the OpenVibe Link starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p>
<p>A driver is a separate program the OpenVibe Link starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p>
<p>${inLink('/docs/drivers', 'Write a driver')} covers the lifecycle, every message, the safety rules, a minimal Python driver, and how to test and contribute it.</p></section>
<p>${inLink('/docs/drivers', 'Write a driver')} covers the lifecycle, every message, the safety rules, a minimal Python driver, and how to test and contribute it.</p></section>
@@ -83,7 +83,7 @@ function renderDocsIndex({ config }) {
@@ -83,7 +83,7 @@ function renderDocsIndex({ config }) {
function renderDriversPage({ config }) {
function renderDriversPage({ config }) {
const content = `
const content = `
-<h1>Write a driver</h1>
+${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label: 'Home' } })}
<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe Link.</p>
<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe Link.</p>
<section class="card"><h2>What a driver is</h2>
<section class="card"><h2>What a driver is</h2>
@@ -250,8 +250,7 @@ function validationResult(result) {
@@ -250,8 +250,7 @@ function validationResult(result) {
function renderProfilesPage({ config, source = '', result = null } = {}) {
function renderProfilesPage({ config, source = '', result = null } = {}) {
const example = JSON.stringify(simRover, null, 2);
const example = JSON.stringify(simRover, null, 2);
const content = `
const content = `
-<h1>Robot profiles</h1>
-<p class="muted">A robot profile (<code>bot.robot-profile@1</code>) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as <code>server/profiles/*.json</code>, are validated by the loader, and are served at ${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}.</p>
+${kit.pageHead({ title: `Robot profiles`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot profile (bot.robot-profile@1) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as server/profiles/*.json, are validated by the loader, and are served at GET /api/v1/profiles.` })}<p>${inLink('/api/v1/profiles', 'Browse profiles')}</p>
<section class="card"><h2>Fields</h2>
<section class="card"><h2>Fields</h2>
<ul>
<ul>
@@ -318,8 +317,7 @@ const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/step
@@ -318,8 +317,7 @@ const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/step
function renderAutomationPage({ config }) {
function renderAutomationPage({ config }) {
const content = `
const content = `
-<h1>Routines, webhooks and your agent</h1>
-<p class="muted">A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits, cooldowns and e-stop apply, and the audit records each command.</p>
+${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits, cooldowns and e-stop apply, and the audit records each command.` })}
<section class="card" aria-labelledby="steps-h"><h2 id="steps-h">Steps</h2>
<section class="card" aria-labelledby="steps-h"><h2 id="steps-h">Steps</h2>
<p>Routines and the agent both speak in steps, one per line, checked against the robot's own profile. A robot takes only what it has:</p>
<p>Routines and the agent both speak in steps, one per line, checked against the robot's own profile. A robot takes only what it has:</p>
@@ -383,8 +381,7 @@ const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…
@@ -383,8 +381,7 @@ const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…
function renderComputersPage({ config }) {
function renderComputersPage({ config }) {
const content = `
const content = `
-<h1>Your computer for your agents</h1>
-<p class="muted">The OpenVibe Link's <b>computer</b> plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.</p>
+${kit.pageHead({ title: `Your computer for your agents`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `The OpenVibe Link's computer plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.` })}
<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2>
<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2>
<p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
<p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const sectionIcon = (name) => kit.icon(name, { cls: 'ovk-heading-icon' });
const ovServe = require('openvibe-shared/serve');
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');
@@ -61,7 +62,7 @@ const capWord = (c) => require('./directory').CAPABILITY_WORDS[c] || c;
@@ -61,7 +62,7 @@ const capWord = (c) => require('./directory').CAPABILITY_WORDS[c] || c;
const POLICY_WORDS = { private: 'Its owner and the people they add drive it', invite: 'People the owner invites drive it', queue: 'Anyone signed in can take a turn' };
const POLICY_WORDS = { private: 'Its owner and the people they add drive it', invite: 'People the owner invites drive it', queue: 'Anyone signed in can take a turn' };
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const link = (href, text, cls = '') => `<a${cls ? ` class="${cls}"` : ''} href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;
const link = (href, text, cls = '') => `<a${cls ? ` class="${cls}"` : ''} href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;
-const sec = (id, title, lede, inner, more = '') => `<section class="sc-sec hm-sec" id="${id}" aria-labelledby="${id}-h"><div class="hm-head"><h2 id="${id}-h">${esc(title)}</h2>${more}</div>${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}${inner}</section>`;
+const sec = (id, title, lede, inner, more = '') => kit.section({ id, cls: 'sc-sec hm-sec', title, icon: id === 'connect' ? 'link' : 'gear', note: lede, body: inner, actions: more ? [more] : [] });
/** The model's first sentence, cut near `max` characters at a word. */
/** The model's first sentence, cut near `max` characters at a word. */
function short(text, max = 120) {
function short(text, max = 120) {
@@ -77,7 +78,7 @@ function hero({ signedIn, config }) {
@@ -77,7 +78,7 @@ function hero({ signedIn, config }) {
<pre tabindex="0"><code><span class="hm-prompt">$</span> ${esc(install)}</code></pre>
<pre tabindex="0"><code><span class="hm-prompt">$</span> ${esc(install)}</code></pre>
<ol class="hm-term-steps"><li>It finds the kit and shows a short code.</li><li>Approve the code at ${link('/link', 'openvibe.bot/link')}.</li><li>Open the robot's panel and drive.</li></ol>
<ol class="hm-term-steps"><li>It finds the kit and shows a short code.</li><li>Approve the code at ${link('/link', 'openvibe.bot/link')}.</li><li>Open the robot's panel and drive.</li></ol>
<p class="hm-term-foot">Raspberry Pi OS or any Linux. A blank microSD card? ${link('/setup/sd-card', 'Make its setup files')}, no screen needed.</p></div>`;
<p class="hm-term-foot">Raspberry Pi OS or any Linux. A blank microSD card? ${link('/setup/sd-card', 'Make its setup files')}, no screen needed.</p></div>`;
- return showcase.hero({
+ return showcase.hero({ pageHead: { icon: 'gear' },
eyebrow: 'OpenVibe.Bot · alpha',
eyebrow: 'OpenVibe.Bot · alpha',
title: 'Drive your robot', accent: 'from any browser.',
title: 'Drive your robot', accent: 'from any browser.',
lede: 'An open control panel for robots. Pair a robot with one pasted command, then drive it with a touch joystick, '
lede: 'An open control panel for robots. Pair a robot with one pasted command, then drive it with a touch joystick, '
@@ -111,14 +112,14 @@ function stats({ models, robots }) {
@@ -111,14 +112,14 @@ function stats({ models, robots }) {
function onlineNow({ robots }) {
function onlineNow({ robots }) {
const sim = kit.card({ href: '/try', title: 'Simulated rover', type: 'game', ratio: '16/9', badge: 'Online', sub: 'Camera, range finder, line sensors, lights, horn', who: 'Anyone, no account', share: false });
const sim = kit.card({ href: '/try', title: 'Simulated rover', type: 'game', ratio: '16/9', badge: 'Online', sub: 'Camera, range finder, line sensors, lights, horn', who: 'Anyone, no account', share: false });
const cards = robots.slice(0, 8).map((r) => kit.card({ href: `/r/${r.id}`, title: r.name, type: 'game', ratio: '16/9', badge: r.online ? 'Online' : 'Offline', sub: `${r.model_name}${r.owner && r.owner.username ? ` · @${r.owner.username}` : ''}`, who: POLICY_WORDS[r.access_policy] || '', share: false }));
const cards = robots.slice(0, 8).map((r) => kit.card({ href: `/r/${r.id}`, title: r.name, type: 'game', ratio: '16/9', badge: r.online ? 'Online' : 'Offline', sub: `${r.model_name}${r.owner && r.owner.username ? ` · @${r.owner.username}` : ''}`, who: POLICY_WORDS[r.access_policy] || '', share: false }));
- return `<section class="sc-sec hm-sec" id="now">${kit.rail({ id: 'now-rail', title: 'Drive one now', more: { href: '/robots', label: robots.length ? 'The directory' : 'Browse robots' }, items: [sim].concat(cards), wrap: true })}</section>`;
+ return `<section class="sc-sec hm-sec" id="now">${kit.rail({ id: 'now-rail', title: 'Drive one now', icon: 'game', more: { href: '/robots', label: robots.length ? 'The directory' : 'Browse robots' }, items: [sim].concat(cards), wrap: true })}</section>`;
}
}
function modelsSection({ models }) {
function modelsSection({ models }) {
if (!models.length) return '';
if (!models.length) return '';
const kindOf = (m) => (KINDS.find((k) => k[0] === m.connect) || [, m.connect])[1];
const kindOf = (m) => (KINDS.find((k) => k[0] === m.connect) || [, m.connect])[1];
const cards = models.map((m) => kit.card({ href: `/models/${m.id}`, title: m.name, type: 'game', ratio: '16/9', badge: kindOf(m), sub: m.capabilities.slice(0, 3).map(capWord).join(' · '), peek: short(m.description), share: false }));
const cards = models.map((m) => kit.card({ href: `/models/${m.id}`, title: m.name, type: 'game', ratio: '16/9', badge: kindOf(m), sub: m.capabilities.slice(0, 3).map(capWord).join(' · '), peek: short(m.description), share: false }));
- return `<section class="sc-sec hm-sec" id="models">${kit.rail({ id: 'models-rail', title: 'Robots it drives today', more: { href: '/models', label: `All ${models.length} models` }, items: cards, wrap: true })}
+ return `<section class="sc-sec hm-sec" id="models">${kit.rail({ id: 'models-rail', title: 'Robots it drives today', icon: 'gear', more: { href: '/models', label: `All ${models.length} models` }, items: cards, wrap: true })}
<p class="hm-under">Each has a profile the panel draws its controls from: a joystick for a base that drives, sliders for a pan-tilt head, buttons for lights and a horn.</p></section>`;
<p class="hm-under">Each has a profile the panel draws its controls from: a joystick for a base that drives, sliders for a pan-tilt head, buttons for lights and a horn.</p></section>`;
}
}
@@ -141,7 +142,7 @@ function connectSection({ models }) {
@@ -141,7 +142,7 @@ function connectSection({ models }) {
function startSection() {
function startSection() {
return foldBlock({
return foldBlock({
id: 'how',
id: 'how',
- head: '<h2 id="how-h">Online in a minute</h2>',
+ head: `<h2 id="how-h" class="ovk-titled">${sectionIcon('grid')}Online in a minute</h2>`,
html: showcase.steps({
html: showcase.steps({
id: 'how',
id: 'how',
title: 'Online in a minute',
title: 'Online in a minute',
@@ -197,14 +198,14 @@ function moreSection({ config, kit: k, models }) {
@@ -197,14 +198,14 @@ function moreSection({ config, kit: k, models }) {
]);
]);
return foldBlock({
return foldBlock({
id: 'more',
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>
+ head: `<h2 id="more-h" class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>`,
+ html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}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 Link 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 Link 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">${kit.button({ href: '/docs', label: 'All build pages', icon: 'code', kind: 'quiet' })}</p>` })}</section>`,
});
});
}
}
@@ -212,7 +213,7 @@ function jobsSection({ jobs }) {
@@ -212,7 +213,7 @@ function jobsSection({ jobs }) {
if (!jobs || !jobs.length) return '';
if (!jobs || !jobs.length) return '';
return foldBlock({
return foldBlock({
id: 'jobs',
id: 'jobs',
- head: '<h2 id="jobs-h">Robotics jobs on OpenVibe.Work</h2>',
+ head: `<h2 id="jobs-h" class="ovk-titled">${sectionIcon('gear')}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>`,
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>`,
});
});
}
}
@@ -239,7 +240,7 @@ function faqItems({ models }) {
@@ -239,7 +240,7 @@ function faqItems({ models }) {
function faqSection(items) {
function faqSection(items) {
return foldBlock({
return foldBlock({
id: 'faq',
id: 'faq',
- head: '<h2 id="faq-h">Questions</h2>',
+ head: `<h2 id="faq-h" class="ovk-titled">${sectionIcon('help')}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>`,
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>`,
});
});
}
}
@@ -256,7 +257,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =
@@ -256,7 +257,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =
+ faqSection(faq || faqItems({ models }))
+ faqSection(faq || faqItems({ models }))
+ foldBlock({
+ foldBlock({
id: 'network',
id: 'network',
- head: '<h2 id="network-h">Part of OpenVibe</h2>',
+ head: `<h2 id="network-h" class="ovk-titled">${sectionIcon('task')}Part of OpenVibe</h2>`,
html: showcase.network({ service: 'bot' }),
html: showcase.network({ service: 'bot' }),
});
});
}
}
@@ -269,7 +270,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =
@@ -269,7 +270,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =
const footerBoot = (footer) => `<script>document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(${JSON.stringify(footer)}); } catch (e) { /* the Frame is optional */ } });</script>`;
const footerBoot = (footer) => `<script>document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(${JSON.stringify(footer)}); } catch (e) { /* the Frame is optional */ } });</script>`;
/** 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. */
-function framePage({ config, title, description, canonicalPath = '/', body, head = '', noindex = false }) {
+function framePage({ config, title, description, canonicalPath = '/', body, head = '', noindex = false, jsonLd = null }) {
const nav = {
const nav = {
service: 'bot',
service: 'bot',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
@@ -282,14 +283,14 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
@@ -282,14 +283,14 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
notificationsRealtime: true,
notificationsRealtime: true,
};
};
return shell.page({
return shell.page({
- name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description,
+ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, jsonLd,
...(noindex ? {} : { summary: description }),
...(noindex ? {} : { summary: description }),
canonical: noindex ? undefined : `${config.baseUrl}${canonicalPath}`,
canonical: noindex ? undefined : `${config.baseUrl}${canonicalPath}`,
robots: noindex ? 'noindex, follow' : 'index, follow',
robots: noindex ? 'noindex, follow' : 'index, follow',
agentLayer: !noindex && require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',
agentLayer: !noindex && require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',
navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',
navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',
navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
- head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),
+ head: [`<link rel="stylesheet" href="${ovServe.url('kit.css')}"><script src="${ovServe.url('kit-web.js')}" defer></script>`, appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),
body: `<div id="navbar-mount"></div>\n${body}\n${footerBoot({ service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' })}`,
body: `<div id="navbar-mount"></div>\n${body}\n${footerBoot({ service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' })}`,
});
});
}
}
@@ -307,7 +308,7 @@ function renderNotFound({ config }) {
@@ -307,7 +308,7 @@ function renderNotFound({ config }) {
UPDATES_FONT,
UPDATES_FONT,
].join('\n'),
].join('\n'),
body: `<main id="main" class="page">
body: `<main id="main" class="page">
-<h1>Page not found</h1>
+${kit.pageHead({ title: `Page not found`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p>There is no page at this address on ${esc(host)}. The link may be old or mistyped.</p>
<p>There is no page at this address on ${esc(host)}. The link may be old or mistyped.</p>
<div class="sc-actions">
<div class="sc-actions">
<a class="sc-btn sc-primary" href="/">${SITE_NAME} home</a>
<a class="sc-btn sc-primary" href="/">${SITE_NAME} home</a>
@@ -366,7 +367,7 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
@@ -366,7 +367,7 @@ function renderHome({ config, signedIn = false, models = [], robots = [], kit =
head: [
head: [
appIcon.headTags({ site: 'bot' }),
appIcon.headTags({ site: 'bot' }),
'<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`,
'<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`,
- `<link rel="stylesheet" href="${ovServe.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${ovServe.url('kit.css')}"><script src="${ovServe.url('kit-web.js')}" defer></script>`,
`<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">',
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const kit = require('openvibe-shared/kit');
/**
/**
* The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).
* The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).
@@ -80,6 +81,7 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c
@@ -80,6 +81,7 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c
<meta property="og:url" content="https://openvibe.bot${esc(canonical)}">
<meta property="og:url" content="https://openvibe.bot${esc(canonical)}">
<meta property="og:image" content="https://openvibe.bot${esc(social.url('bot'))}">
<meta property="og:image" content="https://openvibe.bot${esc(social.url('bot'))}">
<meta name="twitter:card" content="summary_large_image">` : ''}
<meta name="twitter:card" content="summary_large_image">` : ''}
+<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}">
<link rel="stylesheet" href="/panel/panel.css">
<link rel="stylesheet" href="/panel/panel.css">
</head>
</head>
<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
@@ -569,7 +571,7 @@ ${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') :
@@ -569,7 +571,7 @@ ${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') :
</header>
</header>
<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>
<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>
<div class="panel-head">
<div class="panel-head">
-<h1>${esc(robot.name)}</h1>
+${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
${turnStrip(robot, role)}
${turnStrip(robot, role)}
</div>
</div>
@@ -616,7 +618,7 @@ function tryOutro(profile) {
@@ -616,7 +618,7 @@ function tryOutro(profile) {
*/
*/
function renderDrivePage({ robot }) {
function renderDrivePage({ robot }) {
const body = `<main class="page drive-page" data-drive-page data-robot-id="${esc(robot.id)}">
const body = `<main class="page drive-page" data-drive-page data-robot-id="${esc(robot.id)}">
-<div class="card"><h1>${esc(robot.name)}</h1>
+<div class="card">${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p data-drive-status role="status">Connecting the panel…</p>
<p data-drive-status role="status">Connecting the panel…</p>
<p>${outLink(`/panel/${robot.id}`, 'Open the full panel instead')}</p></div>
<p>${outLink(`/panel/${robot.id}`, 'Open the full panel instead')}</p></div>
</main>`;
</main>`;
@@ -672,7 +674,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
@@ -672,7 +674,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label>
<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label>
<button type="submit" class="primary">Add robot</button>
<button type="submit" class="primary">Add robot</button>
</form>`;
</form>`;
- return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n${kitsSection(kits, profiles)}\n</main>`);
+ return page('Your robots', `${topbar()}\n<main class="page">\n${kit.pageHead({ title: `Your robots`, icon: 'gear', back: { href: '/', label: 'Home' } })}\n${list}\n${form}\n${kitsSection(kits, profiles)}\n</main>`);
}
}
function renderPairingPage({ robot, pairing, profile = null, phoneUrl = null }) {
function renderPairingPage({ robot, pairing, profile = null, phoneUrl = null }) {
@@ -688,7 +690,7 @@ function renderPairingPage({ robot, pairing, profile = null, phoneUrl = null })
@@ -688,7 +690,7 @@ function renderPairingPage({ robot, pairing, profile = null, phoneUrl = null })
<button type="button" class="copy" data-copy>${icon('copy')}<span data-copy-label>Copy the command</span></button></li>`;
<button type="button" class="copy" data-copy>${icon('copy')}<span data-copy-label>Copy the command</span></button></li>`;
const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<main class="page pairing" data-pair-robot="${esc(robot.id)}">
<main class="page pairing" data-pair-robot="${esc(robot.id)}">
-<h1>Pair ${esc(robot.name)}</h1>
+${kit.pageHead({ title: `Pair ${robot.name}`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p>
<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p>
<ol class="steps">
<ol class="steps">
${first}
${first}
@@ -709,7 +711,7 @@ ${first}
@@ -709,7 +711,7 @@ ${first}
function renderSignInPage({ next = '/robots', what = 'this page' } = {}) {
function renderSignInPage({ next = '/robots', what = 'this page' } = {}) {
const body = `${topbar('')}
const body = `${topbar('')}
<main class="page">
<main class="page">
-<h1>Sign in to continue</h1>
+${kit.pageHead({ title: `Sign in to continue`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p>${esc(what.charAt(0).toUpperCase() + what.slice(1))} is for OpenVibe accounts. Sign in and you come straight back here.</p>
<p>${esc(what.charAt(0).toUpperCase() + what.slice(1))} is for OpenVibe accounts. Sign in and you come straight back here.</p>
<p><a class="button primary" href="/auth/login?next=${esc(encodeURIComponent(next))}">Sign in with OpenVibe</a></p>
<p><a class="button primary" href="/auth/login?next=${esc(encodeURIComponent(next))}">Sign in with OpenVibe</a></p>
</main>`;
</main>`;
@@ -724,7 +726,7 @@ function renderSignInPage({ next = '/robots', what = 'this page' } = {}) {
@@ -724,7 +726,7 @@ function renderSignInPage({ next = '/robots', what = 'this page' } = {}) {
function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.bot/install | sh' } = {}) {
function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.bot/install | sh' } = {}) {
const body = `${topbar('<span class="crumb">Add a device</span>')}
const body = `${topbar('<span class="crumb">Add a device</span>')}
<main class="page link">
<main class="page link">
-<h1>Add a device</h1>
+${kit.pageHead({ title: `Add a device`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<form class="card" method="get" action="/link">
<form class="card" method="get" action="/link">
<p>Enter the code the robot's computer shows. It shows one when you run this on it:</p>
<p>Enter the code the robot's computer shows. It shows one when you run this on it:</p>
<pre tabindex="0" class="installer"><code>${esc(installer)}</code></pre>
<pre tabindex="0" class="installer"><code>${esc(installer)}</code></pre>
@@ -745,7 +747,7 @@ function renderPhoneLinkPage({ pairing = null, apkUrl = null } = {}) {
@@ -745,7 +747,7 @@ function renderPhoneLinkPage({ pairing = null, apkUrl = null } = {}) {
if (!apkUrl || !pairing) {
if (!apkUrl || !pairing) {
const body = `${topbar('<span class="crumb">Link on your phone</span>')}
const body = `${topbar('<span class="crumb">Link on your phone</span>')}
<main class="page pairing">
<main class="page pairing">
-<h1>Link on your phone</h1>
+${kit.pageHead({ title: `Link on your phone`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p>OpenVibe Link for Android puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's
<p>OpenVibe Link for Android puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's
share sheet. It is in testing and not published yet: when it is, you install it from here and pair it with a code.</p>
share sheet. It is in testing and not published yet: when it is, you install it from here and pair it with a code.</p>
<p class="muted"><a href="/machines">Your machines</a> · <a href="/robots">Your robots</a></p>
<p class="muted"><a href="/machines">Your machines</a> · <a href="/robots">Your robots</a></p>
@@ -756,7 +758,7 @@ share sheet. It is in testing and not published yet: when it is, you install it
@@ -756,7 +758,7 @@ share sheet. It is in testing and not published yet: when it is, you install it
const minutes = Math.max(1, Math.round((Date.parse(pairing.expires_at) - Date.now()) / 60000)) || 15;
const minutes = Math.max(1, Math.round((Date.parse(pairing.expires_at) - Date.now()) / 60000)) || 15;
const body = `${topbar('<span class="crumb">Link on your phone</span>')}
const body = `${topbar('<span class="crumb">Link on your phone</span>')}
<main class="page pairing">
<main class="page pairing">
-<h1>Link on your phone</h1>
+${kit.pageHead({ title: `Link on your phone`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p>OpenVibe Link puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's share sheet.
<p>OpenVibe Link puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's share sheet.
First <a href="${esc(apkUrl)}" rel="noopener">install it on your Android phone</a>, then scan or type the code below.</p>
First <a href="${esc(apkUrl)}" rel="noopener">install it on your Android phone</a>, then scan or type the code below.</p>
<ol class="steps">
<ol class="steps">
@@ -792,8 +794,7 @@ function renderClaimPage({ claim, models = [], values = {}, error = null, now =
@@ -792,8 +794,7 @@ function renderClaimPage({ claim, models = [], values = {}, error = null, now =
const live = values.live !== false;
const live = values.live !== false;
const body = `${topbar('<span class="crumb">Add a device</span>')}
const body = `${topbar('<span class="crumb">Add a device</span>')}
<main class="page link">
<main class="page link">
-<h1>Add ${esc(claim.hostname)}?</h1>
-<p class="muted">Only approve a device you set up yourself, just now. If you did not run the command on it, deny it.</p>
+${kit.pageHead({ title: `Add ${claim.hostname}?`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Only approve a device you set up yourself, just now. If you did not run the command on it, deny it.` })}
<dl class="card facts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
<dl class="card facts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
<form class="card" method="post" action="/link/${esc(claim.id)}/approve">
<form class="card" method="post" action="/link/${esc(claim.id)}/approve">
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
@@ -811,7 +812,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
@@ -811,7 +812,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
function renderClaimApproved({ robot, profile = null, live = false, chatUrl = null }) {
function renderClaimApproved({ robot, profile = null, live = false, chatUrl = null }) {
const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<main class="page pairing" data-pair-robot="${esc(robot.id)}">
<main class="page pairing" data-pair-robot="${esc(robot.id)}">
-<h1>${esc(robot.name)} is yours</h1>
+${kit.pageHead({ title: `${robot.name} is yours`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p>
<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p>
<ol class="steps">
<ol class="steps">
<li class="card"><h2>The device carries on by itself${profile ? ` <span class="pill">${esc(profile.name)}</span>` : ''}</h2>
<li class="card"><h2>The device carries on by itself${profile ? ` <span class="pill">${esc(profile.name)}</span>` : ''}</h2>
@@ -972,8 +973,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
@@ -972,8 +973,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;
<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;
const body = `${topbar('<span class="crumb">Machines</span>')}
const body = `${topbar('<span class="crumb">Machines</span>')}
<main class="page">
<main class="page">
-<h1>Your machines</h1>
-<p class="muted">A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (<code>openvibe-node stop</code>) always win.</p>
+${kit.pageHead({ title: `Your machines`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (openvibe-node stop) always win.` })}
<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>
<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>
${list}
${list}
${add}
${add}
@@ -1010,7 +1010,7 @@ ${rows ? `<ul class="share-list">${rows}</ul>` : ''}</section>`;
@@ -1010,7 +1010,7 @@ ${rows ? `<ul class="share-list">${rows}</ul>` : ''}</section>`;
/** A screen link that does not work (stopped, ended, never existed): one answer for every reason. */
/** A screen link that does not work (stopped, ended, never existed): one answer for every reason. */
function renderShareGone() {
function renderShareGone() {
- return page('Screen link', `<main class="page share-page"><h1>This screen link does not work</h1><p class="muted">It was stopped, it ended, or it never existed. Ask whoever shared it for a new one.</p></main>`, { description: 'A shared computer screen on OpenVibe.Bot.' });
+ return page('Screen link', `<main class="page share-page">${kit.pageHead({ title: `This screen link does not work`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `It was stopped, it ended, or it never existed. Ask whoever shared it for a new one.` })}</main>`, { description: 'A shared computer screen on OpenVibe.Bot.' });
}
}
/** /screen/:token: a shared screen, live, for a frame or a tab; with "use" links, the mouse and keyboard too. */
/** /screen/:token: a shared screen, live, for a frame or a tab; with "use" links, the mouse and keyboard too. */
@@ -1060,8 +1060,7 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
@@ -1060,8 +1060,7 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;
const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
<main class="page">
<main class="page">
-<h1>${esc(name)}: desks</h1>
-<p class="muted">Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.</p>
+${kit.pageHead({ title: `${name}: desks`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.` })}
${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}
${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}
${live ? `<p class="setting-ok" role="status">${icon('check')}<span><code>${esc(live.desk)}</code> is live${live.channel ? ` on <a href="${esc(live.channel)}">${esc(live.channel.replace(/^https:\/\//, ''))}</a>` : ' on your OpenVibe Live channel'} for up to 30 minutes. <a href="/machines/${id}/screen?desk=${encodeURIComponent(live.desk)}">Its screen page</a> stops it.</span></p>` : ''}
${live ? `<p class="setting-ok" role="status">${icon('check')}<span><code>${esc(live.desk)}</code> is live${live.channel ? ` on <a href="${esc(live.channel)}">${esc(live.channel.replace(/^https:\/\//, ''))}</a>` : ' on your OpenVibe Live channel'} for up to 30 minutes. <a href="/machines/${id}/screen?desk=${encodeURIComponent(live.desk)}">Its screen page</a> stops it.</span></p>` : ''}
${list}
${list}
@@ -1081,7 +1080,7 @@ function renderMachineScreenPage({ machine, until = null, error = null, idle = f
@@ -1081,7 +1080,7 @@ function renderMachineScreenPage({ machine, until = null, error = null, idle = f
if (idle && !error) {
if (idle && !error) {
const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
<main class="page">
<main class="page">
-<h1>${esc(name)}: screen</h1>
+${kit.pageHead({ title: `${name}: screen`, icon: 'gear', back: { href: '/', label: 'Home' } })}
${agentDesk ? `<p>Your agent works on a desk of its own on this computer. Watch it live, and take it over whenever you like: while you use the mouse and keyboard, the agent waits; when you stop, it goes on.</p>
${agentDesk ? `<p>Your agent works on a desk of its own on this computer. Watch it live, and take it over whenever you like: while you use the mouse and keyboard, the agent waits; when you stop, it goes on.</p>
<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="${esc(desk)}"><button type="submit" class="primary">Watch your agent's desk</button></form>` : ''}
<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="${esc(desk)}"><button type="submit" class="primary">Watch your agent's desk</button></form>` : ''}
${hasScreen ? `<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
${hasScreen ? `<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
@@ -1102,7 +1101,7 @@ ${desk !== 'main' ? '<p class="muted small">While you use it, the agent waits on
@@ -1102,7 +1101,7 @@ ${desk !== 'main' ? '<p class="muted small">While you use it, the agent waits on
const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
<main class="page" data-share="${esc(base)}" data-control="${error ? '0' : '1'}" data-owner="1">
<main class="page" data-share="${esc(base)}" data-control="${error ? '0' : '1'}" data-owner="1">
<p class="muted small">Showing ${esc(deskWords)}${agentDesk ? ` (<code>${esc(desk)}</code>)` : ''}.</p>
<p class="muted small">Showing ${esc(deskWords)}${agentDesk ? ` (<code>${esc(desk)}</code>)` : ''}.</p>
-<h1>${esc(name)}: screen</h1>
+${kit.pageHead({ title: `${name}: screen`, icon: 'gear', back: { href: '/', label: 'Home' } })}
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ends}
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ends}
<figure class="camera machine-screen" data-screen-state="${esc(stateUrl)}" tabindex="0" aria-label="The screen: turn on the mouse and keyboard to click and type on it">
<figure class="camera machine-screen" data-screen-state="${esc(stateUrl)}" tabindex="0" aria-label="The screen: turn on the mouse and keyboard to click and type on it">
<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">${error ? 'The screen is not shown.' : 'Waiting for the machine…'}</p>
<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">${error ? 'The screen is not shown.' : 'Waiting for the machine…'}</p>
@@ -1128,8 +1127,7 @@ function renderPhonePage() {
@@ -1128,8 +1127,7 @@ function renderPhonePage() {
const body = `<main id="phone" class="phone" data-state="setup">
const body = `<main id="phone" class="phone" data-state="setup">
<section class="phone-setup" data-setup>
<section class="phone-setup" data-setup>
<p class="phone-brand">${icon('robot')}<span>OpenVibe<b>.Bot</b></span></p>
<p class="phone-brand">${icon('robot')}<span>OpenVibe<b>.Bot</b></span></p>
-<h1>Make this phone a robot</h1>
-<p class="muted">Pair it with a robot you added on openvibe.bot. Its camera streams to the robot's panel, it says what the driver types, shows a face, and reports its battery and tilt. Nothing to install: keep this page open.</p>
+${kit.pageHead({ title: `Make this phone a robot`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Pair it with a robot you added on openvibe.bot. Its camera streams to the robot's panel, it says what the driver types, shows a face, and reports its battery and tilt. Nothing to install: keep this page open.` })}
<form class="phone-form" data-pair-form>
<form class="phone-form" data-pair-form>
<div data-pair-fields>
<div data-pair-fields>
<label class="field" data-robot-field><span class="field-label">Robot</span><input name="robot" autocomplete="off" spellcheck="false" placeholder="rob_…"></label>
<label class="field" data-robot-field><span class="field-label">Robot</span><input name="robot" autocomplete="off" spellcheck="false" placeholder="rob_…"></label>
@@ -1207,7 +1205,7 @@ function renderFirstRunPage({ rows = [] }) {
@@ -1207,7 +1205,7 @@ function renderFirstRunPage({ rows = [] }) {
}).join('')}</ul>`
}).join('')}</ul>`
: '<p class="muted">No machine has reported a first run yet.</p>';
: '<p class="muted">No machine has reported a first run yet.</p>';
const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
-<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>
+<div class="dir-hero">${kit.pageHead({ title: `First run`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (node.first_run); nothing here names a person, folder or address.` })}</div>
<section class="dir-sec"><h2>The funnel</h2>
<section class="dir-sec"><h2>The funnel</h2>
<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const kit = require('openvibe-shared/kit');
/**
/**
* GET /setup/sd-card: a robot from a blank microSD card (owner 2026-10-24: "if you just have a fresh MicroSD card how to
* GET /setup/sd-card: a robot from a blank microSD card (owner 2026-10-24: "if you just have a fresh MicroSD card how to
* flash … and be able to connect to the device headlessly … the site needs to guide the user").
* flash … and be able to connect to the device headlessly … the site needs to guide the user").
@@ -58,8 +59,7 @@ function renderSdCard({ config, signedIn = false, models = [], values = {} }) {
@@ -58,8 +59,7 @@ function renderSdCard({ config, signedIn = false, models = [], values = {} }) {
: `<p class="sd-signin">${link('/auth/login?next=%2Fsetup%2Fsd-card', 'Sign in')} to make your card's files: they add the robot to your account.</p>`;
: `<p class="sd-signin">${link('/auth/login?next=%2Fsetup%2Fsd-card', 'Sign in')} to make your card's files: they add the robot to your account.</p>`;
const body = `<p class="dir-crumb">${link('/robots', 'Robots')} · ${link('/robots/new', 'Add a robot')}</p>
const body = `<p class="dir-crumb">${link('/robots', 'Robots')} · ${link('/robots/new', 'Add a robot')}</p>
-<header class="dir-hero"><h1>A robot from a blank microSD card</h1>
-<p class="muted">No screen, no keyboard, nothing to type on the robot. This page makes the card's setup files: on its first boot your Raspberry Pi joins your Wi-Fi, turns on SSH, installs OpenVibe and adds itself to your robots.</p></header>
+<div class="dir-hero">${kit.pageHead({ title: `A robot from a blank microSD card`, icon: 'server', back: { href: '/', label: 'Home' } , lede: `No screen, no keyboard, nothing to type on the robot. This page makes the card's setup files: on its first boot your Raspberry Pi joins your Wi-Fi, turns on SSH, installs OpenVibe and adds itself to your robots.` })}</div>
<section class="dir-sec sd-need" aria-labelledby="sd-need-h"><h2 id="sd-need-h">What you need</h2>
<section class="dir-sec sd-need" aria-labelledby="sd-need-h"><h2 id="sd-need-h">What you need</h2>
<ul class="sd-list"><li>The robot's Raspberry Pi (3B+, 4, 5 or Zero 2 W)</li><li>A microSD card, 16 GB or more: everything on it is erased</li><li>A computer with a card reader</li><li>Your Wi-Fi name and password (2.4 GHz works on every Pi)</li></ul></section>
<ul class="sd-list"><li>The robot's Raspberry Pi (3B+, 4, 5 or Zero 2 W)</li><li>A microSD card, 16 GB or more: everything on it is erased</li><li>A computer with a card reader</li><li>Your Wi-Fi name and password (2.4 GHz works on every Pi)</li></ul></section>
<ol class="sd-steps">
<ol class="sd-steps">
@@ -23,7 +23,7 @@ function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }
@@ -23,7 +23,7 @@ function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }
const chips = panes.map((p) => `<a class="pick-chip" href="#pp-${esc(key)}-${esc(p.id)}">${esc(p.label)}</a>`).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><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><nav class="pick-row" data-ovk-scroll aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
}
}
/**
/**
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
// The ways to connect: one pick block, a chip link per way, every way in the page.
// 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, /<nav class="pick-row" aria-label="The ways to connect">/);
+ assert.match(html, /<nav class="pick-row" data-ovk-scroll 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 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)];
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');
@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/app');
assert.match(csp, /frame-ancestors 'none'/);
assert.match(csp, /frame-ancestors 'none'/);
const html = await r.text();
const html = await r.text();
assert.ok(html.includes('"silentLogin":"/auth/login?silent=1&next={url}"'), 'first visits use the existing SDK OAuth route');
assert.ok(html.includes('"silentLogin":"/auth/login?silent=1&next={url}"'), 'first visits use the existing SDK OAuth route');
- assert.match(html, /<h1>Drive your robot<span class="sc-accent"> from any browser\.<\/span><\/h1>/);
+ assert.match(html, /<h1>Drive your robot from any browser\.<\/h1>/);
assert.match(html, /<meta name="robots" content="index, follow">/);
assert.match(html, /<meta name="robots" content="index, follow">/);
assert.match(html, /<link rel="canonical" href="http:\/\/bot\.test\/">/);
assert.match(html, /<link rel="canonical" href="http:\/\/bot\.test\/">/);
assert.match(html, /href="\/auth\/login\?next=%2Frobots">Sign in with OpenVibe/);
assert.match(html, /href="\/auth\/login\?next=%2Frobots">Sign in with OpenVibe/);
@@ -11,7 +11,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -11,7 +11,9 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
const reports = [];
const reports = [];
const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
- const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter } });
+ // This test covers ownership and machine UI, not missed-heartbeat expiry. Leave enough
+ // grace for page rendering on a busy test host; explicit socket closes still go offline.
+ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0', BOT_OFFLINE_GRACE_MS: '30000' }, appOpts: { offerReporter: reporter } });
const alex = t.network.newUser('alex');
const alex = t.network.newUser('alex');
const bob = t.network.newUser('bob');
const bob = t.network.newUser('bob');
const { dispatch } = require('../server/jobs/dispatch');
const { dispatch } = require('../server/jobs/dispatch');
@@ -85,7 +87,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -85,7 +87,9 @@ const { boot, check, done } = require('./helpers/app');
const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });
const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });
assert.strictEqual(anon.status, 401, 'signed out: a gate page here, noindex (SEO-7)');
assert.strictEqual(anon.status, 401, 'signed out: a gate page here, noindex (SEO-7)');
const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
- assert.ok(page.includes('Your machines') && page.includes('desk-pc') && page.includes('Online') && page.includes('code jobs'), page);
+ for (const text of ['Your machines', 'desk-pc', 'Online', 'code jobs']) {
+ assert.ok(page.includes(text), `machine list is missing ${text}`);
+ }
assert.ok(!page.includes(bob.subject));
assert.ok(!page.includes(bob.subject));
const added = await form('/machines', alex);
const added = await form('/machines', alex);
assert.strictEqual(added.status, 201);
assert.strictEqual(added.status, 201);