openvibers/openvibe.live Commits
Robot panel embed: the page's theme tokens and the panel's height (#769)
Changes
@@@ -20,6 +20,10 @@person is on the page and not in the frame: WASD and a controller drive fromthe stream page. Never a key typed into chat or a field, never with Ctrl/Alt/⌘.++ It also hands the panel the page's look and follows its height: ov-bot-theme { tokens } from this page's computed++ style on the frame's load and on ov:theme/ov:display (openvibe-shared/theme-loader.js); ov-bot-size { height } from++ the panel sets the frame between 160 and 720 px and stops the hand resize.++Plain script for the browser (window.BotEmbed) and require() for the tests.*/(function (root, factory) {@@@ -80,6 +84,33 @@return frame;}++ // The panel takes the page's colours (OpenVibe.Bot public/embed-frame.js): these tokens, read from the page's++ // computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.++ const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];++ const MIN_HEIGHT = 160;++ const MAX_HEIGHT = 720;++++ function pageTheme(win) {++ try {++ const doc = win.document;++ const cs = win.getComputedStyle ? win.getComputedStyle(doc.documentElement) : null;++ if (!cs) return null;++ const tokens = {};++ for (const name of THEME_TOKENS) {++ const value = String(cs.getPropertyValue(name) || '').trim();++ if (value) tokens[name] = value;++ }++ let scheme = String(cs.getPropertyValue('--color-scheme') || '').trim();++ const root = doc.documentElement;++ if (scheme !== 'dark' && scheme !== 'light' && root && typeof root.getAttribute === 'function') scheme = root.getAttribute('data-theme-mode');++ if (scheme !== 'dark' && scheme !== 'light') {++ scheme = win.matchMedia && win.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';++ }++ tokens['--color-scheme'] = scheme;++ return tokens;++ } catch { return null; }++ }++function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, win = typeof window !== 'undefined' ? window : null }) {const src = embedSrc(botEmbed);if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};@@@ -89,6 +120,37 @@const reply = (msg) => {try { if (frame.contentWindow) frame.contentWindow.postMessage(Object.assign({ robot_id: robotId }, msg), origin); } catch { /* the frame went away */ }};++ // The panel's height: it measures its content (a ResizeObserver on its end, at most 5×/s) and the page sets++ // the card around the frame to it, between MIN_HEIGHT and MAX_HEIGHT. Once it has spoken the card is no++ // longer hand-resizable (#ch-bot-container's resize handle in public/css/features/channel.css).++ const setHeight = (value) => {++ const height = Number(value);++ if (!Number.isFinite(height) || height <= 0) return;++ const clamped = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(height)));++ try {++ const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;++ const host = card || frame;++ host.style.height = `${clamped}px`;++ host.style.resize = 'none';++ if (card) frame.style.height = '100%'; // the card grows; the frame fills it (channel.css)++ } catch { /* a fake frame in the tests */ }++ };++ // The page's theme, now (a kept frame) and again when the shared loader repaints one: ov:theme on first apply++ // and every change, ov:display when comfort/OLED settings change the tokens under it. Bot's loader has no++ // openvibe-theme-changed; ov:theme is its real event (openvibe-shared/theme-loader.js).++ const sendTheme = () => {++ const tokens = pageTheme(win);++ if (!tokens) return;++ try { if (frame.contentWindow) frame.contentWindow.postMessage({ type: 'ov-bot-theme', robot_id: robotId, tokens }, origin); } catch { /* the frame went away */ }++ };++ const onTheme = () => sendTheme();++ const onLoad = () => sendTheme();++ sendTheme();++ if (typeof frame.addEventListener === 'function') frame.addEventListener('load', onLoad);++ if (typeof win.addEventListener === 'function') {++ win.addEventListener('ov:theme', onTheme);++ win.addEventListener('ov:display', onTheme);++ }// The page's keyboard and gamepad while the panel drives (Maticus, 2026-10-27: "WASD keyboard AND game controller"// on the stream page). The panel says which keys its controls take; only those are sent and kept from the@@@ -160,6 +222,9 @@const onMessage = async (e) => {if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;const d = e.data || {};++ // The panel's size is about this frame, not about driving: accept it with or without the id, but never++ // for another robot.++ if (d.type === 'ov-bot-size') { if (d.robot_id == null || d.robot_id === robotId) setHeight(d.height); return; }if (!d || d.robot_id !== robotId) return;if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }if (d.type === 'ov-bot-controls') { setControls(d); return; }@@@ -174,8 +239,16 @@} finally { asking = false; }};win.addEventListener('message', onMessage);-- return () => { deactivate(); win.removeEventListener('message', onMessage); };++ return () => {++ deactivate();++ win.removeEventListener('message', onMessage);++ if (typeof frame.removeEventListener === 'function') frame.removeEventListener('load', onLoad);++ if (typeof win.removeEventListener === 'function') {++ win.removeEventListener('ov:theme', onTheme);++ win.removeEventListener('ov:display', onTheme);++ }++ };}-- return { iframeAttrs, mount, listen };++ return { iframeAttrs, mount, listen, THEME_TOKENS, MIN_HEIGHT, MAX_HEIGHT };});@@@ -320,6 +320,76 @@ async function check(name, fn) {assert.strictEqual(listeners.size, 0, 'nothing listens for a url it would not frame');});++ await check('listen(): the page\'s theme tokens go to the frame on load and again when the theme changes', async () => {++ const frameListeners = new Map();++ const winListeners = new Map();++ const add = (map) => (t, f) => { if (!map.has(t)) map.set(t, new Set()); map.get(t).add(f); };++ const del = (map) => (t, f) => { if (map.has(t)) map.get(t).delete(f); };++ const theme = {++ '--bg-primary': '#f0f5ff', '--bg-card': '#f5f8ff', '--text-primary': '#1a2040', '--text-secondary': '#4a5070',++ '--accent': '#245dd5', '--accent-strong': '#2563eb', '--on-accent-strong': '#ffffff', '--border': '#d0d8ea', '--color-scheme': 'light',++ };++ const win = {++ document: { documentElement: { getAttribute: () => 'light' } },++ getComputedStyle: () => ({ getPropertyValue: (name) => theme[name] || '' }),++ matchMedia: () => ({ matches: true }),++ addEventListener: add(winListeners), removeEventListener: del(winListeners),++ };++ const posted = [];++ const panel = { postMessage: (msg, origin) => posted.push([msg, origin]) };++ const frame = { contentWindow: panel, addEventListener: add(frameListeners), removeEventListener: del(frameListeners) };++ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => ({}) });++ const sent = () => posted.at(-1);++ assert.strictEqual(sent()[0].type, 'ov-bot-theme');++ assert.strictEqual(sent()[0].robot_id, 'rob_sim123');++ assert.deepStrictEqual(sent()[0].tokens, theme, 'every token the page shows');++ assert.strictEqual(sent()[1], 'https://openvibe.bot', 'only to Bot\'s origin');++ theme['--bg-primary'] = '#0a0f1c'; theme['--color-scheme'] = 'dark';++ for (const f of [...winListeners.get('ov:theme')]) f();++ assert.strictEqual(sent()[0].tokens['--bg-primary'], '#0a0f1c', 'a theme change goes again');++ assert.strictEqual(sent()[0].tokens['--color-scheme'], 'dark');++ theme['--accent'] = '#ff006e';++ for (const f of [...winListeners.get('ov:display')]) f();++ assert.strictEqual(sent()[0].tokens['--accent'], '#ff006e', 'a display change repaints and is sent too');++ for (const f of [...frameListeners.get('load')]) f();++ assert.strictEqual(posted.filter(([m]) => m.type === 'ov-bot-theme').length, 4, 'the frame load sends it again');++ stop();++ assert.ok(!winListeners.get('ov:theme').size && !winListeners.get('ov:display').size && !frameListeners.get('load').size, 'stop() removes them');++ });++++ await check('listen(): the panel\'s height sets the frame between 160 and 720 px, and only from its own frame', async () => {++ const listeners = new Set();++ const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };++ const card = { style: {} };++ const panel = { postMessage: () => {} };++ const frame = { contentWindow: panel, style: {}, closest: (sel) => (sel === '#ch-bot-container' ? card : null) };++ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => ({}) });++ const send = async (data, { origin = 'https://openvibe.bot', source = panel } = {}) => {++ for (const f of [...listeners]) await f({ data, origin, source });++ };++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 4000 });++ assert.deepStrictEqual([card.style.height, card.style.resize], ['720px', 'none'], 'a tall panel is capped, and the card stops being hand-resized');++ assert.strictEqual(frame.style.height, '100%', 'the frame fills the card');++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 20 });++ assert.strictEqual(card.style.height, '160px', 'a short panel keeps the controls reachable');++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 340 });++ assert.strictEqual(card.style.height, '340px');++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 340.6 });++ assert.strictEqual(card.style.height, '341px', 'rounded to a whole pixel');++ for (const bad of [0, -5, NaN, Infinity, 'tall', null]) {++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: bad });++ assert.strictEqual(card.style.height, '341px', `a non-height (${String(bad)}) changes nothing`);++ }++ await send({ type: 'ov-bot-size', robot_id: 'rob_other', height: 500 });++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 500 }, { origin: 'https://evil.example' });++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 500 }, { source: {} });++ assert.strictEqual(card.style.height, '341px', 'another robot, origin or window cannot size it');++ await send({ type: 'ov-bot-size', height: 300 }, {});++ assert.strictEqual(card.style.height, '300px', 'the size needs no robot id (the origin and window are checked)');++ stop();++ assert.strictEqual(listeners.size, 0);++ });++await check('listen(): while the panel drives, the keys it names and the gamepad go to it; never typing, never after', async () => {const on = {};const add = (t, f) => { (on[t] = on[t] || new Set()).add(f); };
@@ -20,6 +20,10 @@
@@ -20,6 +20,10 @@
person is on the page and not in the frame: WASD and a controller drive from
person is on the page and not in the frame: WASD and a controller drive from
the stream page. Never a key typed into chat or a field, never with Ctrl/Alt/⌘.
the stream page. Never a key typed into chat or a field, never with Ctrl/Alt/⌘.
+ It also hands the panel the page's look and follows its height: ov-bot-theme { tokens } from this page's computed
+ style on the frame's load and on ov:theme/ov:display (openvibe-shared/theme-loader.js); ov-bot-size { height } from
+ the panel sets the frame between 160 and 720 px and stops the hand resize.
+
Plain script for the browser (window.BotEmbed) and require() for the tests.
Plain script for the browser (window.BotEmbed) and require() for the tests.
*/
*/
(function (root, factory) {
(function (root, factory) {
@@ -80,6 +84,33 @@
@@ -80,6 +84,33 @@
return frame;
return frame;
}
}
+ // The panel takes the page's colours (OpenVibe.Bot public/embed-frame.js): these tokens, read from the page's
+ // computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.
+ const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];
+ const MIN_HEIGHT = 160;
+ const MAX_HEIGHT = 720;
+
+ function pageTheme(win) {
+ try {
+ const doc = win.document;
+ const cs = win.getComputedStyle ? win.getComputedStyle(doc.documentElement) : null;
+ if (!cs) return null;
+ const tokens = {};
+ for (const name of THEME_TOKENS) {
+ const value = String(cs.getPropertyValue(name) || '').trim();
+ if (value) tokens[name] = value;
+ }
+ let scheme = String(cs.getPropertyValue('--color-scheme') || '').trim();
+ const root = doc.documentElement;
+ if (scheme !== 'dark' && scheme !== 'light' && root && typeof root.getAttribute === 'function') scheme = root.getAttribute('data-theme-mode');
+ if (scheme !== 'dark' && scheme !== 'light') {
+ scheme = win.matchMedia && win.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
+ }
+ tokens['--color-scheme'] = scheme;
+ return tokens;
+ } catch { return null; }
+ }
+
function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, win = typeof window !== 'undefined' ? window : null }) {
function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, win = typeof window !== 'undefined' ? window : null }) {
const src = embedSrc(botEmbed);
const src = embedSrc(botEmbed);
if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};
if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};
@@ -89,6 +120,37 @@
@@ -89,6 +120,37 @@
const reply = (msg) => {
const reply = (msg) => {
try { if (frame.contentWindow) frame.contentWindow.postMessage(Object.assign({ robot_id: robotId }, msg), origin); } catch { /* the frame went away */ }
try { if (frame.contentWindow) frame.contentWindow.postMessage(Object.assign({ robot_id: robotId }, msg), origin); } catch { /* the frame went away */ }
};
};
+ // The panel's height: it measures its content (a ResizeObserver on its end, at most 5×/s) and the page sets
+ // the card around the frame to it, between MIN_HEIGHT and MAX_HEIGHT. Once it has spoken the card is no
+ // longer hand-resizable (#ch-bot-container's resize handle in public/css/features/channel.css).
+ const setHeight = (value) => {
+ const height = Number(value);
+ if (!Number.isFinite(height) || height <= 0) return;
+ const clamped = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(height)));
+ try {
+ const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;
+ const host = card || frame;
+ host.style.height = `${clamped}px`;
+ host.style.resize = 'none';
+ if (card) frame.style.height = '100%'; // the card grows; the frame fills it (channel.css)
+ } catch { /* a fake frame in the tests */ }
+ };
+ // The page's theme, now (a kept frame) and again when the shared loader repaints one: ov:theme on first apply
+ // and every change, ov:display when comfort/OLED settings change the tokens under it. Bot's loader has no
+ // openvibe-theme-changed; ov:theme is its real event (openvibe-shared/theme-loader.js).
+ const sendTheme = () => {
+ const tokens = pageTheme(win);
+ if (!tokens) return;
+ try { if (frame.contentWindow) frame.contentWindow.postMessage({ type: 'ov-bot-theme', robot_id: robotId, tokens }, origin); } catch { /* the frame went away */ }
+ };
+ const onTheme = () => sendTheme();
+ const onLoad = () => sendTheme();
+ sendTheme();
+ if (typeof frame.addEventListener === 'function') frame.addEventListener('load', onLoad);
+ if (typeof win.addEventListener === 'function') {
+ win.addEventListener('ov:theme', onTheme);
+ win.addEventListener('ov:display', onTheme);
+ }
// The page's keyboard and gamepad while the panel drives (Maticus, 2026-10-27: "WASD keyboard AND game controller"
// The page's keyboard and gamepad while the panel drives (Maticus, 2026-10-27: "WASD keyboard AND game controller"
// on the stream page). The panel says which keys its controls take; only those are sent and kept from the
// on the stream page). The panel says which keys its controls take; only those are sent and kept from the
@@ -160,6 +222,9 @@
@@ -160,6 +222,9 @@
const onMessage = async (e) => {
const onMessage = async (e) => {
if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;
if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;
const d = e.data || {};
const d = e.data || {};
+ // The panel's size is about this frame, not about driving: accept it with or without the id, but never
+ // for another robot.
+ if (d.type === 'ov-bot-size') { if (d.robot_id == null || d.robot_id === robotId) setHeight(d.height); return; }
if (!d || d.robot_id !== robotId) return;
if (!d || d.robot_id !== robotId) return;
if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }
if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }
if (d.type === 'ov-bot-controls') { setControls(d); return; }
if (d.type === 'ov-bot-controls') { setControls(d); return; }
@@ -174,8 +239,16 @@
@@ -174,8 +239,16 @@
} finally { asking = false; }
} finally { asking = false; }
};
};
win.addEventListener('message', onMessage);
win.addEventListener('message', onMessage);
- return () => { deactivate(); win.removeEventListener('message', onMessage); };
+ return () => {
+ deactivate();
+ win.removeEventListener('message', onMessage);
+ if (typeof frame.removeEventListener === 'function') frame.removeEventListener('load', onLoad);
+ if (typeof win.removeEventListener === 'function') {
+ win.removeEventListener('ov:theme', onTheme);
+ win.removeEventListener('ov:display', onTheme);
+ }
+ };
}
}
- return { iframeAttrs, mount, listen };
+ return { iframeAttrs, mount, listen, THEME_TOKENS, MIN_HEIGHT, MAX_HEIGHT };
});
});
@@ -320,6 +320,76 @@ async function check(name, fn) {
@@ -320,6 +320,76 @@ async function check(name, fn) {
assert.strictEqual(listeners.size, 0, 'nothing listens for a url it would not frame');
assert.strictEqual(listeners.size, 0, 'nothing listens for a url it would not frame');
});
});
+ await check('listen(): the page\'s theme tokens go to the frame on load and again when the theme changes', async () => {
+ const frameListeners = new Map();
+ const winListeners = new Map();
+ const add = (map) => (t, f) => { if (!map.has(t)) map.set(t, new Set()); map.get(t).add(f); };
+ const del = (map) => (t, f) => { if (map.has(t)) map.get(t).delete(f); };
+ const theme = {
+ '--bg-primary': '#f0f5ff', '--bg-card': '#f5f8ff', '--text-primary': '#1a2040', '--text-secondary': '#4a5070',
+ '--accent': '#245dd5', '--accent-strong': '#2563eb', '--on-accent-strong': '#ffffff', '--border': '#d0d8ea', '--color-scheme': 'light',
+ };
+ const win = {
+ document: { documentElement: { getAttribute: () => 'light' } },
+ getComputedStyle: () => ({ getPropertyValue: (name) => theme[name] || '' }),
+ matchMedia: () => ({ matches: true }),
+ addEventListener: add(winListeners), removeEventListener: del(winListeners),
+ };
+ const posted = [];
+ const panel = { postMessage: (msg, origin) => posted.push([msg, origin]) };
+ const frame = { contentWindow: panel, addEventListener: add(frameListeners), removeEventListener: del(frameListeners) };
+ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => ({}) });
+ const sent = () => posted.at(-1);
+ assert.strictEqual(sent()[0].type, 'ov-bot-theme');
+ assert.strictEqual(sent()[0].robot_id, 'rob_sim123');
+ assert.deepStrictEqual(sent()[0].tokens, theme, 'every token the page shows');
+ assert.strictEqual(sent()[1], 'https://openvibe.bot', 'only to Bot\'s origin');
+ theme['--bg-primary'] = '#0a0f1c'; theme['--color-scheme'] = 'dark';
+ for (const f of [...winListeners.get('ov:theme')]) f();
+ assert.strictEqual(sent()[0].tokens['--bg-primary'], '#0a0f1c', 'a theme change goes again');
+ assert.strictEqual(sent()[0].tokens['--color-scheme'], 'dark');
+ theme['--accent'] = '#ff006e';
+ for (const f of [...winListeners.get('ov:display')]) f();
+ assert.strictEqual(sent()[0].tokens['--accent'], '#ff006e', 'a display change repaints and is sent too');
+ for (const f of [...frameListeners.get('load')]) f();
+ assert.strictEqual(posted.filter(([m]) => m.type === 'ov-bot-theme').length, 4, 'the frame load sends it again');
+ stop();
+ assert.ok(!winListeners.get('ov:theme').size && !winListeners.get('ov:display').size && !frameListeners.get('load').size, 'stop() removes them');
+ });
+
+ await check('listen(): the panel\'s height sets the frame between 160 and 720 px, and only from its own frame', async () => {
+ const listeners = new Set();
+ const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };
+ const card = { style: {} };
+ const panel = { postMessage: () => {} };
+ const frame = { contentWindow: panel, style: {}, closest: (sel) => (sel === '#ch-bot-container' ? card : null) };
+ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => ({}) });
+ const send = async (data, { origin = 'https://openvibe.bot', source = panel } = {}) => {
+ for (const f of [...listeners]) await f({ data, origin, source });
+ };
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 4000 });
+ assert.deepStrictEqual([card.style.height, card.style.resize], ['720px', 'none'], 'a tall panel is capped, and the card stops being hand-resized');
+ assert.strictEqual(frame.style.height, '100%', 'the frame fills the card');
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 20 });
+ assert.strictEqual(card.style.height, '160px', 'a short panel keeps the controls reachable');
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 340 });
+ assert.strictEqual(card.style.height, '340px');
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 340.6 });
+ assert.strictEqual(card.style.height, '341px', 'rounded to a whole pixel');
+ for (const bad of [0, -5, NaN, Infinity, 'tall', null]) {
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: bad });
+ assert.strictEqual(card.style.height, '341px', `a non-height (${String(bad)}) changes nothing`);
+ }
+ await send({ type: 'ov-bot-size', robot_id: 'rob_other', height: 500 });
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 500 }, { origin: 'https://evil.example' });
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', height: 500 }, { source: {} });
+ assert.strictEqual(card.style.height, '341px', 'another robot, origin or window cannot size it');
+ await send({ type: 'ov-bot-size', height: 300 }, {});
+ assert.strictEqual(card.style.height, '300px', 'the size needs no robot id (the origin and window are checked)');
+ stop();
+ assert.strictEqual(listeners.size, 0);
+ });
+
await check('listen(): while the panel drives, the keys it names and the gamepad go to it; never typing, never after', async () => {
await check('listen(): while the panel drives, the keys it names and the gamepad go to it; never typing, never after', async () => {
const on = {};
const on = {};
const add = (t, f) => { (on[t] = on[t] || new Set()).add(f); };
const add = (t, f) => { (on[t] = on[t] || new Set()).add(f); };