openvibers/openvibe.live Commits

Robot panel embed: the page's theme tokens and the panel's height (#769)

OP OpenVibers committed 3 days ago · d25cb3c171 · 2 files, +145 −2

* Robot panel: hand the embed the page's theme and follow its height

The bot-embed listener posts ov-bot-theme, read from the page's computed
style, to Bot's frame on load and on the shared theme loader's ov:theme (and
ov:display). It accepts ov-bot-size from that frame and sets the frame to the
reported height, clamped to 160-720 px, turning off the hand resize once a
height arrives. The frame fills its container.

* Merge remote-tracking branch 'origin/main' into live-bot-embed-theme-height

* Robot panel: size the card around the frame

#ch-bot-container is the card (channel.css): its reported height sets the card, clamped to
160-720 px, and turns off its resize handle. The frame keeps filling the card.

Changes

ddiff --git a/public/js/bot-embed.js b/public/js/bot-embed.js
iindex bd2578e6..a9ba7645 100644
---- a/public/js/bot-embed.js
++++ b/public/js/bot-embed.js
@@@ -20,6 +20,10 @@
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/⌘.
++ 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 };
});
ddiff --git a/test/bot-embed-page.test.js b/test/bot-embed-page.test.js
iindex d162fea1..af1df0ca 100644
---- a/test/bot-embed-page.test.js
++++ b/test/bot-embed-page.test.js
@@@ -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); };