openvibers/openvibe.bot Commits

Bot: the Build docs live inside the OpenVibe Frame with a docs layout (sidebar, readable widths, theme colours) (#53)

OP OpenVibers committed 10 Oct 2026 · b5ca8c4e51 · 5 files, +109 −39

Design pass on the Build pages from #52. They used the panel's bare layout: no OpenVibe navbar or footer, and the links at the top were plain text.

- **Frame:** `/docs`, `/docs/drivers` and `/docs/profiles` now sit inside the OpenVibe Frame like the front page (navbar, footer, theme), with the front page's CSP. `home.js` gains `framePage`, the shared shell call.
- **Layout:** a docs layout (`public/docs.css`, served at `/docs/docs.css`).
  - A sticky sidebar lists the three pages and marks the current one. On phones it becomes a row of tabs.
  - Text widths are readable, and the cards, code blocks and validator form use the theme's colours.
- **Tests:** `test/docs-pages.test.js` checks the frame, the stylesheet and the current-page mark. The suite passes.

Changes

ddiff --git a/public/docs.css b/public/docs.css
nnew file mode 100644
iindex 0000000..64173e7
---- /dev/null
++++ b/public/docs.css
@@@ -0,0 +1,48 @@
++/* OpenVibe.Bot's "Build" pages (/docs, /docs/drivers, /docs/profiles): a docs layout inside the OpenVibe Frame.
++ Colours come from the Frame's theme variables, so the pages follow the person's theme. */
++.docs-wrap { max-width: 1120px; margin: 0 auto; padding: 32px 16px 64px; display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 40px; align-items: start; }
++.docs-side { position: sticky; top: 76px; display: flex; flex-direction: column; gap: 2px; }
++.docs-side .docs-side-title { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); margin: 0 0 8px 10px; }
++.docs-side a { display: block; padding: 8px 10px; border-radius: 8px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: 14px; font-weight: 600; }
++.docs-side a:hover { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #101828); }
++.docs-side a[aria-current="page"] { color: var(--text-primary, #e6edf7); background: var(--accent-glow, rgba(59,130,246,.14)); box-shadow: inset 2px 0 0 var(--accent, #3b82f6); }
++.docs-side .docs-side-rule { height: 1px; background: var(--border, #1f2d47); margin: 10px 0; }
++
++.docs-main { min-width: 0; color: var(--text-primary, #e6edf7); font-size: 15.5px; line-height: 1.65; }
++.docs-main h1 { font-size: clamp(28px, 4vw, 38px); line-height: 1.15; letter-spacing: -.02em; margin: 0 0 12px; }
++.docs-main > p.muted:first-of-type { font-size: 17px; margin: 0 0 28px; }
++.docs-main h2 { font-size: 20px; letter-spacing: -.01em; margin: 0 0 10px; }
++.docs-main h3 { font-size: 16px; margin: 18px 0 6px; }
++.docs-main p { margin: 0 0 12px; }
++.docs-main ul, .docs-main ol { margin: 0 0 12px; padding-left: 22px; }
++.docs-main li { margin: 4px 0; }
++.docs-main a { color: var(--accent-light, #60a5fa); text-underline-offset: 2px; }
++.docs-main .muted { color: var(--text-secondary, #96a7c2); }
++.docs-main code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 5px; padding: 1px 5px; }
++.docs-main pre { margin: 10px 0 14px; padding: 14px 16px; overflow-x: auto; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); border-radius: 10px; line-height: 1.5; }
++.docs-main pre code { background: none; border: 0; padding: 0; font-size: 13px; white-space: pre; }
++
++.docs-main .card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 20px 22px; margin: 0 0 16px; }
++.docs-main .card > :last-child { margin-bottom: 0; }
++.docs-main .card h2 { font-size: 13px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); margin-bottom: 12px; }
++.docs-main .card .card { background: var(--bg-primary, #0a0f1c); padding: 14px 16px; margin: 10px 0; }
++
++.docs-main form { display: grid; gap: 12px; margin: 6px 0 10px; }
++.docs-main .field { display: grid; gap: 6px; }
++.docs-main .field-label { font-size: 13px; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++.docs-main textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: var(--text-primary, #e6edf7); background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 14px; resize: vertical; }
++.docs-main textarea:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++.docs-main button.primary { justify-self: start; font: inherit; font-weight: 700; font-size: 14px; color: #fff; background: var(--accent, #3b82f6); border: 0; border-radius: 10px; padding: 10px 18px; cursor: pointer; }
++.docs-main button.primary:hover { filter: brightness(1.08); }
++.docs-main .error { border: 1px solid rgba(239, 68, 68, .45); background: rgba(239, 68, 68, .08); border-radius: 10px; padding: 12px 14px; }
++.docs-main [role="status"] strong { color: #22c55e; }
++.docs-foot { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border, #1f2d47); font-size: 13.5px; }
++
++@media (max-width: 860px) {
++ .docs-wrap { grid-template-columns: 1fr; gap: 18px; padding-top: 18px; }
++ .docs-side { position: static; flex-direction: row; flex-wrap: wrap; gap: 6px; }
++ .docs-side .docs-side-title, .docs-side .docs-side-rule { display: none; }
++ .docs-side a { padding: 7px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; font-size: 13px; }
++ .docs-side a[aria-current="page"] { box-shadow: none; border-color: var(--accent, #3b82f6); }
++ .docs-main .card { padding: 16px; }
++}
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex 1c667a3..942da09 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -13,7 +13,8 @@
* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate
* formatProblems(problems) / validationResult(result)
*/
--const { page, esc } = require('./render');
++const { esc } = require('./render');
++const { framePage } = require('./home');
const simRover = require('../profiles/sim.rover.json');
// The driver protocol and the bundled drivers live in OpenVibe.Node (the Node core and its plugins/ tree).
@@@ -28,19 +29,20 @@ const out = (href, text) => `<a href="${esc(href)}" rel="noopener">${esc(text)}<
/** A link on this site. */
const inLink = (href, text) => `<a href="${esc(href)}">${esc(text)}</a>`;
--/** The shared header of the three pages, marking the current one. Plain markup: no script anywhere. */
--function docsNav(current) {
-- const items = [['/docs', 'Build'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles'], ['/robots', 'Robots']];
-- const links = items.map(([href, label]) => (href === current
-- ? `<span class="pill">${esc(label)}</span>`
-- : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' ');
-- return `<nav class="topbar"><a class="brand" href="/"><span>OpenVibe<b>.Bot</b></span></a><span class="crumbs">${links}</span></nav>`;
--}
++const PAGES = [['/docs', 'Overview'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles']];
--/** The shared footer of the three pages. */
--const docsFoot = () => `<footer class="page"><p class="muted">The Node and its bundled drivers are in `
-- + `${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is `
-- + `${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p></footer>`;
++/** The Build pages inside the OpenVibe Frame: a sidebar of the three pages beside the content, plain markup. */
++function docsPage({ config, path, title, description, content }) {
++ const side = PAGES.map(([href, label]) => `<a href="${esc(href)}"${href === path ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('');
++ const body = `<div class="docs-wrap">
++<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav>
++<main id="main" class="docs-main">
++${content}
++<p class="docs-foot muted">The Node and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>
++</main>
++</div>`;
++ return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });
++}
/** A message type: its name, a sentence, and a short JSON example. */
const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}</code> — ${esc(text)}</p>${code(sample)}</div>`;
@@@ -48,8 +50,7 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}
// ── GET /docs ─────────────────────────────────────────────────────────────────────────────────────
function renderDocsIndex({ config }) {
-- const body = `${docsNav('/docs')}
--<main class="page">
++ 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 Node runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>
<section class="card"><h2>Write a driver</h2>
@@@ -68,16 +69,14 @@ function renderDocsIndex({ config }) {
<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')} and ${out(`${TREE}/plugins/relay`, 'relay')}.</li>
<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
</ul>
--</main>
--${docsFoot()}`;
-- return page('Build for OpenVibe.Bot', body);
++`;
++ return docsPage({ config, path: '/docs', title: 'Build for OpenVibe.Bot', description: 'Build drivers and robot profiles for OpenVibe.Bot, the open robot control panel: the protocol, the profile format and a validator.', content });
}
// ── GET /docs/drivers ──────────────────────────────────────────────────────────────────────────────
function renderDriversPage({ config }) {
-- const body = `${docsNav('/docs/drivers')}
--<main class="page">
++ const content = `
<h1>Write a driver</h1>
<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.Node.</p>
@@@ -219,9 +218,8 @@ ${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.js
<section class="card"><h2>Contribute it</h2>
<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Node. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
--</main>
--${docsFoot()}`;
-- return page('Write a driver', body);
++`;
++ return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Node: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });
}
// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────
@@@ -244,8 +242,7 @@ function validationResult(result) {
function renderProfilesPage({ config, source = '', result = null } = {}) {
const example = JSON.stringify(simRover, null, 2);
-- const body = `${docsNav('/docs/profiles')}
--<main class="page">
++ 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>
@@@ -279,9 +276,8 @@ ${code(example)}</section>
<p class="muted">Tools can POST the profile as JSON to <code>/api/v1/profiles/validate</code> and read <code>{ valid, problems }</code> back.</p></section>
${validationResult(result)}
--</main>
--${docsFoot()}`;
-- return page('Robot profiles', body);
++`;
++ return docsPage({ config, path: '/docs/profiles', title: 'Robot profiles', description: 'The bot.robot-profile@1 format that turns a driver\'s capabilities into a panel, a full example, and a validator for your own profile.', content });
}
module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, validationResult };
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 6a50e1c..4660857 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -80,6 +80,28 @@ function sections({ signedIn }) {
<p class="sc-actions"><a class="sc-btn sc-primary" href="/docs">Build for OpenVibe.Bot</a> <a class="sc-btn" href="/docs/drivers">Write a driver</a> <a class="sc-btn" href="/docs/profiles">Robot profiles</a></p></section>`;
}
++/** 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 = '' }) {
++ const nav = {
++ service: 'bot',
++ apiBase: NETWORK_URL,
++ links: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
++ history: { type: 'page', title },
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
++ notificationsRealtime: true,
++ };
++ return shell.page({
++ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,
++ canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',
++ navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME }, home: '/',
++ navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
++ head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', head].filter(Boolean).join('\n'),
++ body: `<div id="navbar-mount"></div>\n${body}`,
++ });
++}
++
function renderHome({ config, signedIn = false }) {
const nav = {
service: 'bot',
@@@ -113,4 +135,4 @@ ${sections({ signedIn })}
});
}
--module.exports = { renderHome, HOME_CSP, sections };
++module.exports = { renderHome, framePage, HOME_CSP, sections };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 3746d4c..dfc4a05 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -126,10 +126,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
// profile validator is anonymous, so it is rate-limited per address the way the app's other public limits
// are, and a cross-site form post is refused as every other form post refuses one.
const validateLimit = limits ? [limits('bot.docs.validate', { minute: 30, hour: 300 })] : [];
-- r.get('/docs', (req, res) => res.type('html').send(renderDocsIndex({ config })));
-- r.get('/docs/drivers', (req, res) => res.type('html').send(renderDriversPage({ config })));
-- r.get('/docs/profiles', (req, res) => res.type('html').send(renderProfilesPage({ config })));
-- r.post('/docs/profiles/validate', ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {
++ // The Build pages run inside the OpenVibe Frame (navbar, footer, theme), so they take the front page's CSP.
++ const frameCsp = (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); };
++ r.get('/docs/docs.css', asset('docs.css', 'text/css'));
++ r.get('/docs', frameCsp, (req, res) => res.type('html').send(renderDocsIndex({ config })));
++ r.get('/docs/drivers', frameCsp, (req, res) => res.type('html').send(renderDriversPage({ config })));
++ r.get('/docs/profiles', frameCsp, (req, res) => res.type('html').send(renderProfilesPage({ config })));
++ r.post('/docs/profiles/validate', frameCsp, ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {
try {
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('cross-site form posts are refused\n');
res.setHeader('Cache-Control', 'no-store');
ddiff --git a/test/docs-pages.test.js b/test/docs-pages.test.js
iindex 9e0198e..b1b96b7 100644
---- a/test/docs-pages.test.js
++++ b/test/docs-pages.test.js
@@@ -1,7 +1,7 @@
'use strict';
// The public build pages (plan T15): GET /docs, /docs/drivers and /docs/profiles, the profile validator they
// carry (POST /docs/profiles/validate, POST /api/v1/profiles/validate), and the sitemap that lists them. The
--// pages carry no script, so the site-wide CSP stands; the validator runs the same checks a shipped profile does.
++// pages run inside the OpenVibe Frame with the front page's CSP; the validator runs the same checks a shipped profile does.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
@@@ -9,8 +9,8 @@ const { boot, check, done } = require('./helpers/app');
const ROOT = path.join(__dirname, '..');
const sim = JSON.parse(fs.readFileSync(path.join(ROOT, 'server', 'profiles', 'sim.rover.json'), 'utf8'));
--// The app's own CSP (server/app.js), which the docs pages do not loosen.
--const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
++// The docs pages run inside the OpenVibe Frame (navbar, footer, theme), so they carry the front page's CSP.
++const { HOME_CSP: SITE_CSP } = require('../server/web/home');
(async () => {
const t = await boot({ openre: false });
@@@ -21,7 +21,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
body: new URLSearchParams({ profile }).toString(),
});
-- await check('GET /docs: the build index, linking both guides and the robots page, under the site CSP', async () => {
++ await check('GET /docs: the build index, linking both guides and the robots page, inside the OpenVibe Frame', async () => {
const r = await get('/docs');
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /^text\/html/);
@@@ -31,7 +31,8 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
assert.match(html, /href="\/docs\/drivers"/);
assert.match(html, /href="\/docs\/profiles"/);
assert.match(html, /href="\/robots"/);
-- assert.ok(!/<script\b/i.test(html), 'no script on a docs page');
++ assert.ok(html.includes('id="navbar-mount"') && html.includes('/docs/docs.css'), 'inside the OpenVibe Frame, with the docs stylesheet');
++ assert.ok(html.includes('aria-current="page"'), 'the sidebar marks the current page');
});
await check('GET /docs/drivers: the guide, connection kinds, every message, safety rules and testing', async () => {
@@@ -72,7 +73,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
assert.match(html, /sim\.rover/);
assert.match(html, /action="\/docs\/profiles\/validate"/);
assert.match(html, /<textarea name="profile"/);
-- assert.ok(!/<script\b/i.test(html), 'no script on the profiles page');
++ assert.ok(html.includes('id="navbar-mount"'), 'inside the OpenVibe Frame');
});
await check('POST /docs/profiles/validate: the shipped sim.rover profile is valid', async () => {