openvibers/openvibe.bot Commits

Embedded panel: the page's theme tokens and a self-reported height (#374)

OP OpenVibers committed 3 days ago · 9b1a79055d · 6 files, +342 −14

The framed panel listens for ov-bot-theme from the origins it already trusts
(BOT_EMBED_ORIGINS), applies only validated plain colours, and follows
prefers-color-scheme with the network's default pair until one arrives. It
sends ov-bot-size on load and content changes (ResizeObserver, 5x/s), and its
styles map the shared tokens everywhere instead of fixed greys. The embed
body no longer forces a viewport minimum, and the layout has no horizontal
overflow from 280 px up.

Changes

ddiff --git a/public/embed-frame.js b/public/embed-frame.js
nnew file mode 100644
iindex 0000000..8d372b5
---- /dev/null
++++ b/public/embed-frame.js
@@@ -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 };
++});
ddiff --git a/public/panel.css b/public/panel.css
iindex c18e69a..89d5cdc 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -1,18 +1,48 @@
/* 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. */
++/* 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, under
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;
--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; }
ddiff --git a/public/panel.js b/public/panel.js
iindex 8bd6cde..4530760 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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;
ddiff --git a/server/web/render.js b/server/web/render.js
iindex a5e4058..473769d 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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_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'] });
}
/**
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex c66368c..51f8640 100644
---- a/server/web/routes.js
++++ b/server/web/routes.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, 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)); };
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'));
ddiff --git a/test/embed-frame.test.js b/test/embed-frame.test.js
nnew file mode 100644
iindex 0000000..e9a4ab0
---- /dev/null
++++ b/test/embed-frame.test.js
@@@ -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();
++})();