openvibers/openvibe.bot Commits
Embedded panel: the page's theme tokens and a self-reported height (#374)
Changes
nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++++/**++ * The embeddable panel's frame adapter (plan T15: "the robot control is not perfected … fit any page it is placed in").++ *++ * The page around the frame (OpenVibe.Live's public/js/bot-embed.js) owns the look and the height:++ *++ * ov-bot-theme { tokens: { '--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent',++ * '--accent-strong', '--on-accent-strong', '--border', '--color-scheme' } }++ * → set each valid plain colour on <html>; --color-scheme must be 'dark' or 'light'.++ * ov-bot-size { height } ← the embed's own content height, sent on load and when it changes, at most five++ * times a second (a trailing edge, so the last size always goes).++ *++ * Only the origins already trusted for the panel's other messages (the page's data-parent-origins, from++ * BOT_EMBED_ORIGINS) and only the window that frames it are accepted; a message from anywhere else is ignored.++ * Until one arrives, public/panel.css follows prefers-color-scheme with the network's default (Vibe / cloud).++ *++ * Plain script for the browser (window.EmbedFrame) and require() for the tests.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.EmbedFrame = api;++})(typeof self !== 'undefined' ? self : this, function () {++ const TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];++ const MAX_LEN = 64;++ // Plain colours only: hex (3/4/6/8), or the functional notations a computed style may give. No var(), calc(),++ // url(), color-mix(), named colours or anything that could carry a second declaration.++ const COLOR_RE = /^(?:#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(\s*[0-9a-z.%+\-\s,/]+\))$/i;++ const SIZE_GAP_MS = 200; // five a second++++ function validColor(value) {++ return typeof value === 'string' && value.length <= MAX_LEN && COLOR_RE.test(value.trim());++ }++++ /** 'dark' | 'light' | null. */++ function validScheme(value) {++ return value === 'dark' || value === 'light' ? value : null;++ }++++ /** The tokens worth applying: only the known names, only plain colours, never inherited keys. */++ function sanitizeTokens(tokens) {++ const out = {};++ if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) return out;++ for (const name of TOKENS) {++ if (Object.prototype.hasOwnProperty.call(tokens, name) && validColor(tokens[name])) out[name] = tokens[name].trim();++ }++ return out;++ }++++ function validOrigin(value) {++ if (typeof value !== 'string' || !value) return false;++ try {++ const u = new URL(value);++ return !u.username && !u.password && u.search === '' && u.hash === '' && (u.pathname === '' || u.pathname === '/') && u.origin === value;++ } catch { return false; }++ }++++ /**++ * start({ win, parents, robotId }) → stop(). `parents` is the page's data-parent-origins list (server:++ * BOT_EMBED_ORIGINS). Nothing starts without one: an unframed or untrusted page is left alone.++ */++ function start({ win = typeof window !== 'undefined' ? window : null, parents = [], robotId = null } = {}) {++ const doc = win && win.document;++ const html = doc && doc.documentElement;++ const targets = parents.filter(validOrigin);++ if (!win || !doc || !html || !targets.length || win.parent === win) return () => {};++++ const robot = typeof robotId === 'string' ? robotId : null;++ let stopAt = 0;++ let timer = 0;++ let lastHeight = -1;++++ const measure = () => {++ const body = doc.body;++ if (!body) return 0;++ let height = 0;++ try {++ height = Math.max(height, body.scrollHeight || 0, Math.ceil(body.getBoundingClientRect().height));++ } catch { /* a document going away */ }++ return Math.max(0, Math.ceil(height));++ };++ const post = (message) => {++ for (const origin of targets) {++ try { win.parent.postMessage(message, origin); } catch { /* not that origin */ }++ }++ };++ const send = (force) => {++ const now = Date.now();++ const wait = SIZE_GAP_MS - (now - stopAt);++ if (!force && wait > 0) {++ if (!timer) timer = win.setTimeout(() => { timer = 0; send(true); }, wait);++ return;++ }++ if (timer) { win.clearTimeout(timer); timer = 0; }++ const height = measure();++ if (!force && height === lastHeight) return;++ stopAt = now;++ lastHeight = height;++ post(robot ? { type: 'ov-bot-size', robot_id: robot, height } : { type: 'ov-bot-size', height });++ };++++ const onLoad = () => send(true);++ const onResize = () => send(false);++ const onMessage = (e) => {++ if (!e || e.source !== win.parent || !targets.includes(e.origin)) return;++ const d = e.data;++ if (!d || typeof d !== 'object' || d.type !== 'ov-bot-theme') return;++ if (robot && typeof d.robot_id === 'string' && d.robot_id !== robot) return;++ const tokens = sanitizeTokens(d.tokens);++ for (const [name, value] of Object.entries(tokens)) html.style.setProperty(name, value);++ const scheme = validScheme(d.tokens && d.tokens['--color-scheme']);++ if (scheme) {++ html.style.setProperty('--color-scheme', scheme);++ html.setAttribute('data-theme-mode', scheme);++ }++ if (Object.keys(tokens).length || scheme) send(false);++ };++++ win.addEventListener('message', onMessage);++ win.addEventListener('load', onLoad);++ win.addEventListener('resize', onResize);++ let observer = null;++ if (typeof win.ResizeObserver === 'function') {++ observer = new win.ResizeObserver(onResize);++ if (doc.body) observer.observe(doc.body);++ observer.observe(html);++ }++ send(true);++++ return () => {++ win.removeEventListener('message', onMessage);++ win.removeEventListener('load', onLoad);++ win.removeEventListener('resize', onResize);++ if (timer) { win.clearTimeout(timer); timer = 0; }++ if (observer) observer.disconnect();++ };++ }++++ return { TOKENS, validColor, validScheme, sanitizeTokens, validOrigin, start };++});@@@ -1,18 +1,48 @@/* The signed-in pages' one sheet (no build step). A dark control-room look, mobile-first: every control atleast 44 px, the e-stop always in view, and the panel laid out for a phone held either way. */++/* The embeddable panel takes the page's tokens (public/embed-frame.js): --bg-primary, --bg-card, --text-primary,++ --text-secondary, --accent, --accent-strong, --on-accent-strong, --border and --color-scheme. Without a message it++ follows prefers-color-scheme with the network's default pair (Vibe dark, cloud light) below. */:root {-- --bg: #0d1117; --surface: #161b22; --raised: #1f2630; --line: #2d3540; --text: #e6edf3; --muted: #8b96a3;-- --accent: #3d8bfd; --accent-ink: #fff; --ok: #3fb950; --warn: #d29922; --danger: #e5484d; --danger-deep: #a3191e;++ --bg: var(--bg-primary, #0d1117); --surface: var(--bg-card, #161b22); --raised: color-mix(in srgb, var(--surface) 84%, var(--text) 16%);++ --line: var(--border, #2d3540); --text: var(--text-primary, #e6edf3); --muted: var(--text-secondary, #8b96a3);++ --accent: #3d8bfd; --accent-ink: var(--on-accent, #fff); --ok: var(--success, #3fb950);++ --warn: var(--warning, #d29922); --danger: var(--danger, #e5484d); --danger-deep: #a3191e;/* Filled controls with white text use the strong shades: white on --accent is 3.3:1 and on --danger 3.9:1, underWCAG AA's 4.5:1; these are 5.2:1 and 5.0:1 (the network's readable accent fills). */--fill-accent: var(--accent-strong, #2563eb); --on-fill-accent: var(--on-accent-strong, #fff); --fill-danger: #d0343a;--radius: 12px; --tap: 44px;++ color-scheme: var(--color-scheme, dark);++}++/* The network's default theme (Vibe), followed until the page around the frame sends its own tokens. */++html.embed-page {++ --bg: var(--bg-primary, #0a0f1c); --surface: var(--bg-card, #131c2e);++ --line: var(--border, #1f2d47); --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);++ --accent: #3b82f6;++}++@media (prefers-color-scheme: light) {++ html.embed-page {++ --bg: var(--bg-primary, #f0f5ff); --surface: var(--bg-card, #f5f8ff);++ --line: var(--border, #d0d8ea); --text: var(--text-primary, #1a2040); --muted: var(--text-secondary, #4a5070);++ --accent: #245dd5; --ok: var(--success-text, #157a3a); --warn: var(--warning-text, #935f07);++ --danger: var(--danger-text, #c43838); --danger-deep: #9b1c1c; --fill-danger: #c43838;++ color-scheme: light;++ }++}++html[data-theme-mode="dark"] {++ --ok: #3fb950; --warn: #d29922; --danger: #e5484d; --danger-deep: #a3191e; --fill-danger: #d0343a;color-scheme: dark;}++html[data-theme-mode="light"] {++ --ok: var(--success-text, #157a3a); --warn: var(--warning-text, #935f07);++ --danger: var(--danger-text, #c43838); --danger-deep: #9b1c1c; --fill-danger: #c43838;++ color-scheme: light;++}*, *::before, *::after { box-sizing: border-box; }[hidden] { display: none !important; }html { -webkit-text-size-adjust: 100%; }body { margin: 0; min-height: 100vh; font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); }++body.embed-page { min-height: 0; }a { color: var(--accent); }h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 0.25rem; letter-spacing: -0.01em; }h2 { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.75rem; }@@@ -28,7 +58,7 @@ button, .button {text-align: center; /* a link styled as a button wraps its text like a button does */touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background 0.12s, border-color 0.12s, transform 0.06s;}--button:hover:not(:disabled), .button:hover { border-color: #46505d; }++button:hover:not(:disabled), .button:hover { border-color: color-mix(in srgb, var(--line) 65%, var(--text) 35%); }button:active:not(:disabled) { transform: scale(0.97); }button.primary, .button.primary { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); font-weight: 600; }button.quiet, .button.quiet { background: transparent; }@@@ -43,7 +73,7 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width.pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }.dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--muted); flex: none; }.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }--.notice, .error { padding: 0.65rem 0.9rem; border-radius: 10px; background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.5); color: #f0d48a; }++.notice, .error { padding: 0.65rem 0.9rem; border-radius: 10px; background: color-mix(in srgb, var(--warn) 12%, var(--bg)); border: 1px solid color-mix(in srgb, var(--warn) 50%, var(--line)); color: var(--warn); }/* Top bar */.topbar { display: flex; align-items: center; gap: 0.75rem; min-height: 3rem; padding: 0 max(1rem, env(safe-area-inset-left)); }@@@ -53,7 +83,7 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width.crumb::before { content: "/"; margin-right: 0.75rem; opacity: 0.5; }/* ── The e-stop: always in view; clear is calm, latched is unmistakable ──────────────────────────────── */--.estop-banner { position: sticky; top: 0; z-index: 10; background: rgba(13, 17, 23, 0.94); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }++.estop-banner { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 94%, transparent); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }.estop-bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem max(1rem, env(safe-area-inset-right)) 0.6rem max(1rem, env(safe-area-inset-left)); }.estop-state { flex: 1; display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600; min-width: 0; }.estop-note { font-weight: 400; color: inherit; opacity: 0.85; }@@@ -1109,6 +1109,8 @@// and the next Drive opens the popup as before.const PARENTS = String(main.dataset.parentOrigins || '').split(' ').filter(Boolean);const framed = window.parent !== window && PARENTS.length > 0;++ // The page around the frame (OpenVibe.Live) sets the panel's colours and gets its height: public/embed-frame.js.++ if (framed && window.EmbedFrame) window.EmbedFrame.start({ parents: PARENTS, robotId: ROBOT_ID });let parentSilent = false;let asked = null;let popup = null;@@@ -63,13 +63,13 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" arconst AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };--function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null } = {}) {++function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null, htmlClass = '', colorScheme = 'dark' } = {}) {return `<!doctype html>--<html lang="en">++<html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">--<meta name="color-scheme" content="dark">++<meta name="color-scheme" content="${esc(colorScheme)}"><meta name="theme-color" content="#11151c"><title>${esc(title)} — OpenVibe.Bot</title>${robots ? `<meta name="robots" content="${esc(robots)}">` : ''}${description ? `@@@ -169,9 +169,10 @@ function actuatorControl(name, a, enabled, profileId = '') {const isSim = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');/** The panel's scripts: the client, plus the shared arena and its camera for a simulated robot (and /try's local rover). */--function panelScripts(profile, local = false) {-- if (!isSim(profile)) return ['/panel/panel.js'];-- return ['/panel/sim-world.js', ...(local ? ['/panel/try-device.js'] : []), '/panel/panel.js', '/panel/sim-view.js'];++function panelScripts(profile, local = false, embed = false) {++ const frame = embed ? ['/panel/embed-frame.js'] : [];++ if (!isSim(profile)) return [...frame, '/panel/panel.js'];++ return [...frame, '/panel/sim-world.js', ...(local ? ['/panel/try-device.js'] : []), '/panel/panel.js', '/panel/sim-view.js'];}function camerasOf(profile = {}) {@@@ -584,7 +585,8 @@ ${widgets.join('\n')}${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {-- scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',++ scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',++ htmlClass: embed ? 'embed-page' : '', colorScheme: embed ? 'dark light' : 'dark',description: tryIt ? TRY_DESCRIPTION : null, canonical: tryIt ? '/try' : null,});}@@@ -632,7 +634,7 @@ function renderEmbedRefused({ robotId, parentOrigins = [] }) {<p>${outLink(`/panel/${robotId}#on-stream`, 'Open on openvibe.bot', 'button primary')}</p></div></main>`;// refused.js tells the page around the frame, so a stream page can hide the box or point its owner to the switch.-- return page('Not shown here yet', body, { bodyClass: 'embed-page', scripts: ['/panel/refused.js'] });++ return page('Not shown here yet', body, { bodyClass: 'embed-page', htmlClass: 'embed-page', colorScheme: 'dark light', scripts: ['/panel/refused.js'] });}/**@@@ -22,7 +22,8 @@* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303-- * GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)++ * GET /panel/panel.js, /panel/embed-frame.js, /panel/panel.css the panel client, the framed panel's theme and++ * height adapter, and its sheet (public/, no build step)** The panel and embed pages carry their own CSP (PANEL_CSP / embedCsp): the live camera plays the robot's* OpenRestream WebRTC session with the pinned mediasoup-client ESM build from esm.sh, so those two pages alone@@@ -228,6 +229,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', cache.assetHeaders(req.path, { hashed: false })); res.type(type).sendFile(path.join(PUBLIC, file)); };r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));r.get('/panel/panel.css', asset('panel.css', 'text/css'));++ r.get('/panel/embed-frame.js', asset('embed-frame.js', 'application/javascript'));r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));r.get('/panel/refused.js', asset('refused.js', 'application/javascript'));nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++// The embeddable panel's frame adapter (public/embed-frame.js): the page around the frame hands it the theme++// tokens and asks for its height. Only a plain colour is ever applied, only from the page that already frames it++// (data-parent-origins / BOT_EMBED_ORIGINS), and the height goes out on load and on a change, at most 5×/s.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { check, done } = require('./helpers/app');++const EmbedFrame = require('../public/embed-frame.js');++const { renderPanel, renderEmbedRefused } = require('../server/web/render');++++// A window just big enough for start(): listeners, a parent, a document with a size, and a fake ResizeObserver.++function fakeWin({ height = 300 } = {}) {++ const posted = [];++ const parent = { postMessage: (msg, origin) => posted.push([msg, origin]) };++ const listeners = new Map();++ const props = {};++ const attrs = {};++ const timers = new Map();++ let nextTimer = 0;++ const html = {++ style: { setProperty: (k, v) => { props[k] = v; }, removeProperty: (k) => { delete props[k]; } },++ setAttribute: (k, v) => { attrs[k] = String(v); },++ getAttribute: (k) => (k in attrs ? attrs[k] : null),++ };++ const body = { scrollHeight: height, getBoundingClientRect: () => ({ height }) };++ let observer = null;++ const win = {++ document: { documentElement: html, body },++ parent,++ addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn); },++ removeEventListener(type, fn) { if (listeners.has(type)) listeners.get(type).delete(fn); },++ setTimeout(fn, ms) { const id = ++nextTimer; timers.set(id, { fn, at: Date.now() + ms }); return id; },++ clearTimeout(id) { timers.delete(id); },++ ResizeObserver: class { constructor(cb) { this.cb = cb; observer = this; } observe() {} disconnect() { this.gone = true; } },++ };++ win.__posted = posted;++ win.__props = props;++ win.__attrs = attrs;++ win.__listeners = listeners;++ win.__timers = timers;++ win.__observer = () => observer;++ win.__fire = (type, event) => { for (const fn of [...(listeners.get(type) || [])]) fn(event); };++ win.__setHeight = (h) => { body.scrollHeight = h; };++ win.__tick = () => { for (const [id, t] of [...timers]) if (t.at <= Date.now()) { timers.delete(id); t.fn(); } };++ return win;++}++++const PARENT = 'https://openvibe.live';++const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id: 'rob_x', tokens, ...extra });++++(async () => {++ await check('validColor: plain hex and functional colours pass; anything else is refused', () => {++ for (const ok of ['#fff', '#ffff', '#0a0f1c', '#0a0f1cff', 'rgb(1, 2, 3)', 'rgb(1 2 3 / 50%)', 'rgba(1,2,3,.5)', 'hsl(210 40% 50%)', 'oklch(0.7 0.1 200)']) {++ assert.ok(EmbedFrame.validColor(ok), ok);++ }++ for (const bad of ['', 'red', 'var(--x)', 'color-mix(in srgb, red, blue)', 'url(https://evil.example/x)', 'rgb(1,2,3);--x:y', '#12', '#12345', 'hsl(1 2 3) ; background: url(x)', 123, null, {}, 'x'.repeat(65)]) {++ assert.ok(!EmbedFrame.validColor(bad), JSON.stringify(bad));++ }++ assert.strictEqual(EmbedFrame.validScheme('light'), 'light');++ assert.strictEqual(EmbedFrame.validScheme('Light'), null);++ assert.strictEqual(EmbedFrame.validScheme('dark'), 'dark');++ assert.strictEqual(EmbedFrame.validScheme('auto'), null);++ });++++ await check('sanitizeTokens: only the known names with a plain colour survive', () => {++ const out = EmbedFrame.sanitizeTokens({++ '--bg-primary': '#0a0f1c', '--text-secondary': 'rgb(1, 2, 3)',++ '--accent': 'url(https://evil.example/a)', '--border': 12, '--nope': '#fff',++ '__proto__': '#fff', '--bg-card': 'color-mix(in srgb, red, blue)',++ });++ assert.deepStrictEqual(out, { '--bg-primary': '#0a0f1c', '--text-secondary': 'rgb(1, 2, 3)' });++ assert.deepStrictEqual(EmbedFrame.sanitizeTokens(null), {});++ assert.deepStrictEqual(EmbedFrame.sanitizeTokens(['#fff']), {});++ });++++ await check('embed page: the frame adapter is loaded for the embed only, with the trusted origins on the panel', () => {++ const profile = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [] };++ const robot = { id: 'rob_x1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };++ const embed = renderPanel({ robot, profile, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false, parentOrigins: [PARENT, 'https://www.openvibe.live'] });++ const at = (s) => embed.indexOf(s);++ assert.ok(embed.includes('<html lang="en" class="embed-page">'), 'the embed follows prefers-color-scheme before the first token message');++ assert.ok(embed.includes('<meta name="color-scheme" content="dark light">'));++ assert.ok(at('/panel/embed-frame.js') > 0 && at('/panel/embed-frame.js') < at('/panel/panel.js'), 'the adapter loads before the panel client');++ assert.ok(embed.includes('data-parent-origins="https://openvibe.live https://www.openvibe.live"'));++ const page = renderPanel({ robot, profile, role: 'owner', allowed_commands: ['drive'] });++ assert.ok(!page.includes('embed-frame.js') && page.includes('<html lang="en">') && page.includes('<meta name="color-scheme" content="dark">'));++ const refused = renderEmbedRefused({ robotId: 'rob_x1', parentOrigins: [PARENT] });++ assert.ok(refused.includes('class="embed-page"') && refused.includes('content="dark light"') && !refused.includes('embed-frame.js'));++ const route = fs.readFileSync(path.join(__dirname, '..', 'server', 'web', 'routes.js'), 'utf8');++ assert.ok(route.includes("r.get('/panel/embed-frame.js', asset('embed-frame.js', 'application/javascript'));"));++ });++++ await check('start(): the height goes on load, and only a trusted, framing origin can set the colours', () => {++ const win = fakeWin({ height: 321 });++ const stop = EmbedFrame.start({ win, parents: [PARENT], robotId: 'rob_x' });++ assert.deepStrictEqual(win.__posted, [[{ type: 'ov-bot-size', robot_id: 'rob_x', height: 321 }, PARENT]], 'one size on start, to the trusted origin');++++ // A stranger's message (another origin, another window, another robot) changes nothing.++ win.__fire('message', { origin: 'https://evil.example', source: win.parent, data: themeMessage({ '--bg-primary': '#111111' }) });++ win.__fire('message', { origin: PARENT, source: {}, data: themeMessage({ '--bg-primary': '#222222' }) });++ win.__fire('message', { origin: PARENT, source: win.parent, data: themeMessage({ '--bg-primary': '#333333' }, { robot_id: 'rob_other' }) });++ win.__fire('message', { origin: PARENT, source: win.parent, data: { type: 'ov-bot-ticket', robot_id: 'rob_x' } });++ assert.deepStrictEqual(win.__props, {}, 'nothing applied');++++ win.__fire('message', { origin: PARENT, source: win.parent, data: themeMessage({++ '--bg-primary': '#0a0f1c', '--bg-card': '#131c2e', '--text-primary': '#e6edf7', '--text-secondary': '#96a7c2',++ '--accent': 'rgb(59, 130, 246)', '--accent-strong': '#3472d8', '--on-accent-strong': '#ffffff', '--border': '#1f2d47',++ '--color-scheme': 'light',++ }) });++ assert.strictEqual(win.__props['--bg-primary'], '#0a0f1c');++ assert.strictEqual(win.__props['--accent'], 'rgb(59, 130, 246)');++ assert.strictEqual(win.__props['--color-scheme'], 'light');++ assert.strictEqual(win.__attrs['data-theme-mode'], 'light');++ stop();++ assert.strictEqual(win.__listeners.get('message').size, 0);++ });++++ await check('start(): a size change arrives, throttled to about five a second, and the last one always goes', async () => {++ const win = fakeWin({ height: 300 });++ const stop = EmbedFrame.start({ win, parents: [PARENT], robotId: 'rob_x' });++ assert.strictEqual(win.__posted.length, 1);++ win.__setHeight(420);++ for (let i = 0; i < 4; i++) win.__observer().cb(); // rapid changes: one trailing send, not four++ assert.strictEqual(win.__posted.length, 1, 'nothing sooner than 200 ms');++ await new Promise((r) => setTimeout(r, 220));++ win.__tick();++ assert.deepStrictEqual(win.__posted.at(-1)[0], { type: 'ov-bot-size', robot_id: 'rob_x', height: 420 });++ assert.ok(win.__posted.length <= 3, `at most about five a second, got ${win.__posted.length}`);++ win.__setHeight(300);++ win.__fire('resize', {});++ await new Promise((r) => setTimeout(r, 220));++ win.__tick();++ assert.strictEqual(win.__posted.at(-1)[0].height, 300, 'a shrinking panel reports the smaller height too');++ stop();++ assert.strictEqual(win.__observer().gone, true, 'stop() disconnects the observer');++ assert.strictEqual(win.__listeners.get('resize').size, 0);++ });++++ await check('start(): an unframed page or one with no trusted parents does nothing', () => {++ const unframed = fakeWin();++ unframed.parent = unframed;++ assert.strictEqual(typeof EmbedFrame.start({ win: unframed, parents: [PARENT] }), 'function');++ assert.deepStrictEqual(unframed.__posted, []);++ const alone = fakeWin();++ EmbedFrame.start({ win: alone, parents: [] })();++ assert.deepStrictEqual(alone.__posted, []);++ });++++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+
+/**
+ * The embeddable panel's frame adapter (plan T15: "the robot control is not perfected … fit any page it is placed in").
+ *
+ * The page around the frame (OpenVibe.Live's public/js/bot-embed.js) owns the look and the height:
+ *
+ * ov-bot-theme { tokens: { '--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent',
+ * '--accent-strong', '--on-accent-strong', '--border', '--color-scheme' } }
+ * → set each valid plain colour on <html>; --color-scheme must be 'dark' or 'light'.
+ * ov-bot-size { height } ← the embed's own content height, sent on load and when it changes, at most five
+ * times a second (a trailing edge, so the last size always goes).
+ *
+ * Only the origins already trusted for the panel's other messages (the page's data-parent-origins, from
+ * BOT_EMBED_ORIGINS) and only the window that frames it are accepted; a message from anywhere else is ignored.
+ * Until one arrives, public/panel.css follows prefers-color-scheme with the network's default (Vibe / cloud).
+ *
+ * Plain script for the browser (window.EmbedFrame) and require() for the tests.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.EmbedFrame = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ const TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];
+ const MAX_LEN = 64;
+ // Plain colours only: hex (3/4/6/8), or the functional notations a computed style may give. No var(), calc(),
+ // url(), color-mix(), named colours or anything that could carry a second declaration.
+ const COLOR_RE = /^(?:#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(\s*[0-9a-z.%+\-\s,/]+\))$/i;
+ const SIZE_GAP_MS = 200; // five a second
+
+ function validColor(value) {
+ return typeof value === 'string' && value.length <= MAX_LEN && COLOR_RE.test(value.trim());
+ }
+
+ /** 'dark' | 'light' | null. */
+ function validScheme(value) {
+ return value === 'dark' || value === 'light' ? value : null;
+ }
+
+ /** The tokens worth applying: only the known names, only plain colours, never inherited keys. */
+ function sanitizeTokens(tokens) {
+ const out = {};
+ if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) return out;
+ for (const name of TOKENS) {
+ if (Object.prototype.hasOwnProperty.call(tokens, name) && validColor(tokens[name])) out[name] = tokens[name].trim();
+ }
+ return out;
+ }
+
+ function validOrigin(value) {
+ if (typeof value !== 'string' || !value) return false;
+ try {
+ const u = new URL(value);
+ return !u.username && !u.password && u.search === '' && u.hash === '' && (u.pathname === '' || u.pathname === '/') && u.origin === value;
+ } catch { return false; }
+ }
+
+ /**
+ * start({ win, parents, robotId }) → stop(). `parents` is the page's data-parent-origins list (server:
+ * BOT_EMBED_ORIGINS). Nothing starts without one: an unframed or untrusted page is left alone.
+ */
+ function start({ win = typeof window !== 'undefined' ? window : null, parents = [], robotId = null } = {}) {
+ const doc = win && win.document;
+ const html = doc && doc.documentElement;
+ const targets = parents.filter(validOrigin);
+ if (!win || !doc || !html || !targets.length || win.parent === win) return () => {};
+
+ const robot = typeof robotId === 'string' ? robotId : null;
+ let stopAt = 0;
+ let timer = 0;
+ let lastHeight = -1;
+
+ const measure = () => {
+ const body = doc.body;
+ if (!body) return 0;
+ let height = 0;
+ try {
+ height = Math.max(height, body.scrollHeight || 0, Math.ceil(body.getBoundingClientRect().height));
+ } catch { /* a document going away */ }
+ return Math.max(0, Math.ceil(height));
+ };
+ const post = (message) => {
+ for (const origin of targets) {
+ try { win.parent.postMessage(message, origin); } catch { /* not that origin */ }
+ }
+ };
+ const send = (force) => {
+ const now = Date.now();
+ const wait = SIZE_GAP_MS - (now - stopAt);
+ if (!force && wait > 0) {
+ if (!timer) timer = win.setTimeout(() => { timer = 0; send(true); }, wait);
+ return;
+ }
+ if (timer) { win.clearTimeout(timer); timer = 0; }
+ const height = measure();
+ if (!force && height === lastHeight) return;
+ stopAt = now;
+ lastHeight = height;
+ post(robot ? { type: 'ov-bot-size', robot_id: robot, height } : { type: 'ov-bot-size', height });
+ };
+
+ const onLoad = () => send(true);
+ const onResize = () => send(false);
+ const onMessage = (e) => {
+ if (!e || e.source !== win.parent || !targets.includes(e.origin)) return;
+ const d = e.data;
+ if (!d || typeof d !== 'object' || d.type !== 'ov-bot-theme') return;
+ if (robot && typeof d.robot_id === 'string' && d.robot_id !== robot) return;
+ const tokens = sanitizeTokens(d.tokens);
+ for (const [name, value] of Object.entries(tokens)) html.style.setProperty(name, value);
+ const scheme = validScheme(d.tokens && d.tokens['--color-scheme']);
+ if (scheme) {
+ html.style.setProperty('--color-scheme', scheme);
+ html.setAttribute('data-theme-mode', scheme);
+ }
+ if (Object.keys(tokens).length || scheme) send(false);
+ };
+
+ win.addEventListener('message', onMessage);
+ win.addEventListener('load', onLoad);
+ win.addEventListener('resize', onResize);
+ let observer = null;
+ if (typeof win.ResizeObserver === 'function') {
+ observer = new win.ResizeObserver(onResize);
+ if (doc.body) observer.observe(doc.body);
+ observer.observe(html);
+ }
+ send(true);
+
+ return () => {
+ win.removeEventListener('message', onMessage);
+ win.removeEventListener('load', onLoad);
+ win.removeEventListener('resize', onResize);
+ if (timer) { win.clearTimeout(timer); timer = 0; }
+ if (observer) observer.disconnect();
+ };
+ }
+
+ return { TOKENS, validColor, validScheme, sanitizeTokens, validOrigin, start };
+});
@@ -1,18 +1,48 @@
@@ -1,18 +1,48 @@
/* The signed-in pages' one sheet (no build step). A dark control-room look, mobile-first: every control at
/* The signed-in pages' one sheet (no build step). A dark control-room look, mobile-first: every control at
least 44 px, the e-stop always in view, and the panel laid out for a phone held either way. */
least 44 px, the e-stop always in view, and the panel laid out for a phone held either way. */
+/* The embeddable panel takes the page's tokens (public/embed-frame.js): --bg-primary, --bg-card, --text-primary,
+ --text-secondary, --accent, --accent-strong, --on-accent-strong, --border and --color-scheme. Without a message it
+ follows prefers-color-scheme with the network's default pair (Vibe dark, cloud light) below. */
:root {
:root {
- --bg: #0d1117; --surface: #161b22; --raised: #1f2630; --line: #2d3540; --text: #e6edf3; --muted: #8b96a3;
- --accent: #3d8bfd; --accent-ink: #fff; --ok: #3fb950; --warn: #d29922; --danger: #e5484d; --danger-deep: #a3191e;
+ --bg: var(--bg-primary, #0d1117); --surface: var(--bg-card, #161b22); --raised: color-mix(in srgb, var(--surface) 84%, var(--text) 16%);
+ --line: var(--border, #2d3540); --text: var(--text-primary, #e6edf3); --muted: var(--text-secondary, #8b96a3);
+ --accent: #3d8bfd; --accent-ink: var(--on-accent, #fff); --ok: var(--success, #3fb950);
+ --warn: var(--warning, #d29922); --danger: var(--danger, #e5484d); --danger-deep: #a3191e;
/* Filled controls with white text use the strong shades: white on --accent is 3.3:1 and on --danger 3.9:1, under
/* Filled controls with white text use the strong shades: white on --accent is 3.3:1 and on --danger 3.9:1, under
WCAG AA's 4.5:1; these are 5.2:1 and 5.0:1 (the network's readable accent fills). */
WCAG AA's 4.5:1; these are 5.2:1 and 5.0:1 (the network's readable accent fills). */
--fill-accent: var(--accent-strong, #2563eb); --on-fill-accent: var(--on-accent-strong, #fff); --fill-danger: #d0343a;
--fill-accent: var(--accent-strong, #2563eb); --on-fill-accent: var(--on-accent-strong, #fff); --fill-danger: #d0343a;
--radius: 12px; --tap: 44px;
--radius: 12px; --tap: 44px;
+ color-scheme: var(--color-scheme, dark);
+}
+/* The network's default theme (Vibe), followed until the page around the frame sends its own tokens. */
+html.embed-page {
+ --bg: var(--bg-primary, #0a0f1c); --surface: var(--bg-card, #131c2e);
+ --line: var(--border, #1f2d47); --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);
+ --accent: #3b82f6;
+}
+@media (prefers-color-scheme: light) {
+ html.embed-page {
+ --bg: var(--bg-primary, #f0f5ff); --surface: var(--bg-card, #f5f8ff);
+ --line: var(--border, #d0d8ea); --text: var(--text-primary, #1a2040); --muted: var(--text-secondary, #4a5070);
+ --accent: #245dd5; --ok: var(--success-text, #157a3a); --warn: var(--warning-text, #935f07);
+ --danger: var(--danger-text, #c43838); --danger-deep: #9b1c1c; --fill-danger: #c43838;
+ color-scheme: light;
+ }
+}
+html[data-theme-mode="dark"] {
+ --ok: #3fb950; --warn: #d29922; --danger: #e5484d; --danger-deep: #a3191e; --fill-danger: #d0343a;
color-scheme: dark;
color-scheme: dark;
}
}
+html[data-theme-mode="light"] {
+ --ok: var(--success-text, #157a3a); --warn: var(--warning-text, #935f07);
+ --danger: var(--danger-text, #c43838); --danger-deep: #9b1c1c; --fill-danger: #c43838;
+ color-scheme: light;
+}
*, *::before, *::after { box-sizing: border-box; }
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; min-height: 100vh; font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); }
body { margin: 0; min-height: 100vh; font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); }
+body.embed-page { min-height: 0; }
a { color: var(--accent); }
a { color: var(--accent); }
h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 0.25rem; letter-spacing: -0.01em; }
h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 0.25rem; letter-spacing: -0.01em; }
h2 { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.75rem; }
h2 { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.75rem; }
@@ -28,7 +58,7 @@ button, .button {
@@ -28,7 +58,7 @@ button, .button {
text-align: center; /* a link styled as a button wraps its text like a button does */
text-align: center; /* a link styled as a button wraps its text like a button does */
touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background 0.12s, border-color 0.12s, transform 0.06s;
touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background 0.12s, border-color 0.12s, transform 0.06s;
}
}
-button:hover:not(:disabled), .button:hover { border-color: #46505d; }
+button:hover:not(:disabled), .button:hover { border-color: color-mix(in srgb, var(--line) 65%, var(--text) 35%); }
button:active:not(:disabled) { transform: scale(0.97); }
button:active:not(:disabled) { transform: scale(0.97); }
button.primary, .button.primary { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); font-weight: 600; }
button.primary, .button.primary { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); font-weight: 600; }
button.quiet, .button.quiet { background: transparent; }
button.quiet, .button.quiet { background: transparent; }
@@ -43,7 +73,7 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width
@@ -43,7 +73,7 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width
.pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }
.pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }
.dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--muted); flex: none; }
.dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--muted); flex: none; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
-.notice, .error { padding: 0.65rem 0.9rem; border-radius: 10px; background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.5); color: #f0d48a; }
+.notice, .error { padding: 0.65rem 0.9rem; border-radius: 10px; background: color-mix(in srgb, var(--warn) 12%, var(--bg)); border: 1px solid color-mix(in srgb, var(--warn) 50%, var(--line)); color: var(--warn); }
/* Top bar */
/* Top bar */
.topbar { display: flex; align-items: center; gap: 0.75rem; min-height: 3rem; padding: 0 max(1rem, env(safe-area-inset-left)); }
.topbar { display: flex; align-items: center; gap: 0.75rem; min-height: 3rem; padding: 0 max(1rem, env(safe-area-inset-left)); }
@@ -53,7 +83,7 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width
@@ -53,7 +83,7 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width
.crumb::before { content: "/"; margin-right: 0.75rem; opacity: 0.5; }
.crumb::before { content: "/"; margin-right: 0.75rem; opacity: 0.5; }
/* ── The e-stop: always in view; clear is calm, latched is unmistakable ──────────────────────────────── */
/* ── The e-stop: always in view; clear is calm, latched is unmistakable ──────────────────────────────── */
-.estop-banner { position: sticky; top: 0; z-index: 10; background: rgba(13, 17, 23, 0.94); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }
+.estop-banner { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 94%, transparent); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }
.estop-bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem max(1rem, env(safe-area-inset-right)) 0.6rem max(1rem, env(safe-area-inset-left)); }
.estop-bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem max(1rem, env(safe-area-inset-right)) 0.6rem max(1rem, env(safe-area-inset-left)); }
.estop-state { flex: 1; display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600; min-width: 0; }
.estop-state { flex: 1; display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600; min-width: 0; }
.estop-note { font-weight: 400; color: inherit; opacity: 0.85; }
.estop-note { font-weight: 400; color: inherit; opacity: 0.85; }
@@ -1109,6 +1109,8 @@
@@ -1109,6 +1109,8 @@
// and the next Drive opens the popup as before.
// and the next Drive opens the popup as before.
const PARENTS = String(main.dataset.parentOrigins || '').split(' ').filter(Boolean);
const PARENTS = String(main.dataset.parentOrigins || '').split(' ').filter(Boolean);
const framed = window.parent !== window && PARENTS.length > 0;
const framed = window.parent !== window && PARENTS.length > 0;
+ // The page around the frame (OpenVibe.Live) sets the panel's colours and gets its height: public/embed-frame.js.
+ if (framed && window.EmbedFrame) window.EmbedFrame.start({ parents: PARENTS, robotId: ROBOT_ID });
let parentSilent = false;
let parentSilent = false;
let asked = null;
let asked = null;
let popup = null;
let popup = null;
@@ -63,13 +63,13 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
@@ -63,13 +63,13 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
-function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null } = {}) {
+function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null, htmlClass = '', colorScheme = 'dark' } = {}) {
return `<!doctype html>
return `<!doctype html>
-<html lang="en">
+<html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}>
<head>
<head>
<meta charset="utf-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
-<meta name="color-scheme" content="dark">
+<meta name="color-scheme" content="${esc(colorScheme)}">
<meta name="theme-color" content="#11151c">
<meta name="theme-color" content="#11151c">
<title>${esc(title)} — OpenVibe.Bot</title>${robots ? `
<title>${esc(title)} — OpenVibe.Bot</title>${robots ? `
<meta name="robots" content="${esc(robots)}">` : ''}${description ? `
<meta name="robots" content="${esc(robots)}">` : ''}${description ? `
@@ -169,9 +169,10 @@ function actuatorControl(name, a, enabled, profileId = '') {
@@ -169,9 +169,10 @@ function actuatorControl(name, a, enabled, profileId = '') {
const isSim = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');
const isSim = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');
/** The panel's scripts: the client, plus the shared arena and its camera for a simulated robot (and /try's local rover). */
/** The panel's scripts: the client, plus the shared arena and its camera for a simulated robot (and /try's local rover). */
-function panelScripts(profile, local = false) {
- if (!isSim(profile)) return ['/panel/panel.js'];
- return ['/panel/sim-world.js', ...(local ? ['/panel/try-device.js'] : []), '/panel/panel.js', '/panel/sim-view.js'];
+function panelScripts(profile, local = false, embed = false) {
+ const frame = embed ? ['/panel/embed-frame.js'] : [];
+ if (!isSim(profile)) return [...frame, '/panel/panel.js'];
+ return [...frame, '/panel/sim-world.js', ...(local ? ['/panel/try-device.js'] : []), '/panel/panel.js', '/panel/sim-view.js'];
}
}
function camerasOf(profile = {}) {
function camerasOf(profile = {}) {
@@ -584,7 +585,8 @@ ${widgets.join('\n')}
@@ -584,7 +585,8 @@ ${widgets.join('\n')}
${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
- scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
+ scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',
+ htmlClass: embed ? 'embed-page' : '', colorScheme: embed ? 'dark light' : 'dark',
description: tryIt ? TRY_DESCRIPTION : null, canonical: tryIt ? '/try' : null,
description: tryIt ? TRY_DESCRIPTION : null, canonical: tryIt ? '/try' : null,
});
});
}
}
@@ -632,7 +634,7 @@ function renderEmbedRefused({ robotId, parentOrigins = [] }) {
@@ -632,7 +634,7 @@ function renderEmbedRefused({ robotId, parentOrigins = [] }) {
<p>${outLink(`/panel/${robotId}#on-stream`, 'Open on openvibe.bot', 'button primary')}</p></div>
<p>${outLink(`/panel/${robotId}#on-stream`, 'Open on openvibe.bot', 'button primary')}</p></div>
</main>`;
</main>`;
// refused.js tells the page around the frame, so a stream page can hide the box or point its owner to the switch.
// refused.js tells the page around the frame, so a stream page can hide the box or point its owner to the switch.
- return page('Not shown here yet', body, { bodyClass: 'embed-page', scripts: ['/panel/refused.js'] });
+ return page('Not shown here yet', body, { bodyClass: 'embed-page', htmlClass: 'embed-page', colorScheme: 'dark light', scripts: ['/panel/refused.js'] });
}
}
/**
/**
@@ -22,7 +22,8 @@
@@ -22,7 +22,8 @@
* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel
* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel
* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303
* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
- * GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)
+ * GET /panel/panel.js, /panel/embed-frame.js, /panel/panel.css the panel client, the framed panel's theme and
+ * height adapter, and its sheet (public/, no build step)
*
*
* The panel and embed pages carry their own CSP (PANEL_CSP / embedCsp): the live camera plays the robot's
* The panel and embed pages carry their own CSP (PANEL_CSP / embedCsp): the live camera plays the robot's
* OpenRestream WebRTC session with the pinned mediasoup-client ESM build from esm.sh, so those two pages alone
* OpenRestream WebRTC session with the pinned mediasoup-client ESM build from esm.sh, so those two pages alone
@@ -228,6 +229,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -228,6 +229,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', cache.assetHeaders(req.path, { hashed: false })); res.type(type).sendFile(path.join(PUBLIC, file)); };
const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', cache.assetHeaders(req.path, { hashed: false })); res.type(type).sendFile(path.join(PUBLIC, file)); };
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
+ r.get('/panel/embed-frame.js', asset('embed-frame.js', 'application/javascript'));
r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));
r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
r.get('/panel/refused.js', asset('refused.js', 'application/javascript'));
r.get('/panel/refused.js', asset('refused.js', 'application/javascript'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+// The embeddable panel's frame adapter (public/embed-frame.js): the page around the frame hands it the theme
+// tokens and asks for its height. Only a plain colour is ever applied, only from the page that already frames it
+// (data-parent-origins / BOT_EMBED_ORIGINS), and the height goes out on load and on a change, at most 5×/s.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { check, done } = require('./helpers/app');
+const EmbedFrame = require('../public/embed-frame.js');
+const { renderPanel, renderEmbedRefused } = require('../server/web/render');
+
+// A window just big enough for start(): listeners, a parent, a document with a size, and a fake ResizeObserver.
+function fakeWin({ height = 300 } = {}) {
+ const posted = [];
+ const parent = { postMessage: (msg, origin) => posted.push([msg, origin]) };
+ const listeners = new Map();
+ const props = {};
+ const attrs = {};
+ const timers = new Map();
+ let nextTimer = 0;
+ const html = {
+ style: { setProperty: (k, v) => { props[k] = v; }, removeProperty: (k) => { delete props[k]; } },
+ setAttribute: (k, v) => { attrs[k] = String(v); },
+ getAttribute: (k) => (k in attrs ? attrs[k] : null),
+ };
+ const body = { scrollHeight: height, getBoundingClientRect: () => ({ height }) };
+ let observer = null;
+ const win = {
+ document: { documentElement: html, body },
+ parent,
+ addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn); },
+ removeEventListener(type, fn) { if (listeners.has(type)) listeners.get(type).delete(fn); },
+ setTimeout(fn, ms) { const id = ++nextTimer; timers.set(id, { fn, at: Date.now() + ms }); return id; },
+ clearTimeout(id) { timers.delete(id); },
+ ResizeObserver: class { constructor(cb) { this.cb = cb; observer = this; } observe() {} disconnect() { this.gone = true; } },
+ };
+ win.__posted = posted;
+ win.__props = props;
+ win.__attrs = attrs;
+ win.__listeners = listeners;
+ win.__timers = timers;
+ win.__observer = () => observer;
+ win.__fire = (type, event) => { for (const fn of [...(listeners.get(type) || [])]) fn(event); };
+ win.__setHeight = (h) => { body.scrollHeight = h; };
+ win.__tick = () => { for (const [id, t] of [...timers]) if (t.at <= Date.now()) { timers.delete(id); t.fn(); } };
+ return win;
+}
+
+const PARENT = 'https://openvibe.live';
+const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id: 'rob_x', tokens, ...extra });
+
+(async () => {
+ await check('validColor: plain hex and functional colours pass; anything else is refused', () => {
+ for (const ok of ['#fff', '#ffff', '#0a0f1c', '#0a0f1cff', 'rgb(1, 2, 3)', 'rgb(1 2 3 / 50%)', 'rgba(1,2,3,.5)', 'hsl(210 40% 50%)', 'oklch(0.7 0.1 200)']) {
+ assert.ok(EmbedFrame.validColor(ok), ok);
+ }
+ for (const bad of ['', 'red', 'var(--x)', 'color-mix(in srgb, red, blue)', 'url(https://evil.example/x)', 'rgb(1,2,3);--x:y', '#12', '#12345', 'hsl(1 2 3) ; background: url(x)', 123, null, {}, 'x'.repeat(65)]) {
+ assert.ok(!EmbedFrame.validColor(bad), JSON.stringify(bad));
+ }
+ assert.strictEqual(EmbedFrame.validScheme('light'), 'light');
+ assert.strictEqual(EmbedFrame.validScheme('Light'), null);
+ assert.strictEqual(EmbedFrame.validScheme('dark'), 'dark');
+ assert.strictEqual(EmbedFrame.validScheme('auto'), null);
+ });
+
+ await check('sanitizeTokens: only the known names with a plain colour survive', () => {
+ const out = EmbedFrame.sanitizeTokens({
+ '--bg-primary': '#0a0f1c', '--text-secondary': 'rgb(1, 2, 3)',
+ '--accent': 'url(https://evil.example/a)', '--border': 12, '--nope': '#fff',
+ '__proto__': '#fff', '--bg-card': 'color-mix(in srgb, red, blue)',
+ });
+ assert.deepStrictEqual(out, { '--bg-primary': '#0a0f1c', '--text-secondary': 'rgb(1, 2, 3)' });
+ assert.deepStrictEqual(EmbedFrame.sanitizeTokens(null), {});
+ assert.deepStrictEqual(EmbedFrame.sanitizeTokens(['#fff']), {});
+ });
+
+ await check('embed page: the frame adapter is loaded for the embed only, with the trusted origins on the panel', () => {
+ const profile = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [] };
+ const robot = { id: 'rob_x1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };
+ const embed = renderPanel({ robot, profile, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false, parentOrigins: [PARENT, 'https://www.openvibe.live'] });
+ const at = (s) => embed.indexOf(s);
+ assert.ok(embed.includes('<html lang="en" class="embed-page">'), 'the embed follows prefers-color-scheme before the first token message');
+ assert.ok(embed.includes('<meta name="color-scheme" content="dark light">'));
+ assert.ok(at('/panel/embed-frame.js') > 0 && at('/panel/embed-frame.js') < at('/panel/panel.js'), 'the adapter loads before the panel client');
+ assert.ok(embed.includes('data-parent-origins="https://openvibe.live https://www.openvibe.live"'));
+ const page = renderPanel({ robot, profile, role: 'owner', allowed_commands: ['drive'] });
+ assert.ok(!page.includes('embed-frame.js') && page.includes('<html lang="en">') && page.includes('<meta name="color-scheme" content="dark">'));
+ const refused = renderEmbedRefused({ robotId: 'rob_x1', parentOrigins: [PARENT] });
+ assert.ok(refused.includes('class="embed-page"') && refused.includes('content="dark light"') && !refused.includes('embed-frame.js'));
+ const route = fs.readFileSync(path.join(__dirname, '..', 'server', 'web', 'routes.js'), 'utf8');
+ assert.ok(route.includes("r.get('/panel/embed-frame.js', asset('embed-frame.js', 'application/javascript'));"));
+ });
+
+ await check('start(): the height goes on load, and only a trusted, framing origin can set the colours', () => {
+ const win = fakeWin({ height: 321 });
+ const stop = EmbedFrame.start({ win, parents: [PARENT], robotId: 'rob_x' });
+ assert.deepStrictEqual(win.__posted, [[{ type: 'ov-bot-size', robot_id: 'rob_x', height: 321 }, PARENT]], 'one size on start, to the trusted origin');
+
+ // A stranger's message (another origin, another window, another robot) changes nothing.
+ win.__fire('message', { origin: 'https://evil.example', source: win.parent, data: themeMessage({ '--bg-primary': '#111111' }) });
+ win.__fire('message', { origin: PARENT, source: {}, data: themeMessage({ '--bg-primary': '#222222' }) });
+ win.__fire('message', { origin: PARENT, source: win.parent, data: themeMessage({ '--bg-primary': '#333333' }, { robot_id: 'rob_other' }) });
+ win.__fire('message', { origin: PARENT, source: win.parent, data: { type: 'ov-bot-ticket', robot_id: 'rob_x' } });
+ assert.deepStrictEqual(win.__props, {}, 'nothing applied');
+
+ win.__fire('message', { origin: PARENT, source: win.parent, data: themeMessage({
+ '--bg-primary': '#0a0f1c', '--bg-card': '#131c2e', '--text-primary': '#e6edf7', '--text-secondary': '#96a7c2',
+ '--accent': 'rgb(59, 130, 246)', '--accent-strong': '#3472d8', '--on-accent-strong': '#ffffff', '--border': '#1f2d47',
+ '--color-scheme': 'light',
+ }) });
+ assert.strictEqual(win.__props['--bg-primary'], '#0a0f1c');
+ assert.strictEqual(win.__props['--accent'], 'rgb(59, 130, 246)');
+ assert.strictEqual(win.__props['--color-scheme'], 'light');
+ assert.strictEqual(win.__attrs['data-theme-mode'], 'light');
+ stop();
+ assert.strictEqual(win.__listeners.get('message').size, 0);
+ });
+
+ await check('start(): a size change arrives, throttled to about five a second, and the last one always goes', async () => {
+ const win = fakeWin({ height: 300 });
+ const stop = EmbedFrame.start({ win, parents: [PARENT], robotId: 'rob_x' });
+ assert.strictEqual(win.__posted.length, 1);
+ win.__setHeight(420);
+ for (let i = 0; i < 4; i++) win.__observer().cb(); // rapid changes: one trailing send, not four
+ assert.strictEqual(win.__posted.length, 1, 'nothing sooner than 200 ms');
+ await new Promise((r) => setTimeout(r, 220));
+ win.__tick();
+ assert.deepStrictEqual(win.__posted.at(-1)[0], { type: 'ov-bot-size', robot_id: 'rob_x', height: 420 });
+ assert.ok(win.__posted.length <= 3, `at most about five a second, got ${win.__posted.length}`);
+ win.__setHeight(300);
+ win.__fire('resize', {});
+ await new Promise((r) => setTimeout(r, 220));
+ win.__tick();
+ assert.strictEqual(win.__posted.at(-1)[0].height, 300, 'a shrinking panel reports the smaller height too');
+ stop();
+ assert.strictEqual(win.__observer().gone, true, 'stop() disconnects the observer');
+ assert.strictEqual(win.__listeners.get('resize').size, 0);
+ });
+
+ await check('start(): an unframed page or one with no trusted parents does nothing', () => {
+ const unframed = fakeWin();
+ unframed.parent = unframed;
+ assert.strictEqual(typeof EmbedFrame.start({ win: unframed, parents: [PARENT] }), 'function');
+ assert.deepStrictEqual(unframed.__posted, []);
+ const alone = fakeWin();
+ EmbedFrame.start({ win: alone, parents: [] })();
+ assert.deepStrictEqual(alone.__posted, []);
+ });
+
+ done();
+})();