openvibers/openvibe.bot Commits

Embeddable panel: overlay and compact views for robot controls (#378)

OP OpenVibers committed 3 days ago · e7fc324534 · 8 files, +549 −21

The embed route takes ?mode=overlay|compact: the overlay is a transparent page drawing only
the first stick, first slider, e-stop, state pill and a more button, with safe-area placement
and a 4 s idle fade; compact is the same subset in one row. Both come from the robot's profile.
The size message now carries mode/minWidth/minHeight (and the overlay's control rectangles for
click-through), the host can switch views with ov-bot-mode, and docs/controls-embed.md tells
any OpenVibe site how to frame it, including the clip-path needed for presses to reach a video.

Changes

ddiff --git a/docs/controls-embed.md b/docs/controls-embed.md
nnew file mode 100644
iindex 0000000..c416cbe
---- /dev/null
++++ b/docs/controls-embed.md
@@@ -0,0 +1,86 @@
++# Put a robot's controls on a page or over a video
++
++The embeddable panel is `GET /panel/<rob_…>/embed`. It is a full page in an iframe, framed only by an origin the
++robot's site allows (`BOT_EMBED_ORIGINS`; the robot's `data-parent-origins` lists them). A page can ask for three views:
++
++| view | query | what it draws |
++|---|---|---|
++| `panel` | `/embed` or `?mode=panel` | the whole panel: header, camera, every widget, the e-stop and the readouts. This is the default and what existing pages get. |
++| `overlay` | `?mode=overlay` | a transparent page that fills its frame and draws only the live controls: the robot's name and state (online, your turn, someone else driving, e-stop on) as one pill at the top left, the first stick in a 132 px circle at the bottom left, the first slider as a 44 × 132 vertical track at the bottom right, the e-stop as a 44 px round button at the top right, and a “more” button under it. Safe-area insets are applied. |
++| `compact` | `?mode=compact` | one row for a narrow card: the same pill, a 96 px stick, the same slider across, the e-stop and “more”. |
++
++Which stick and slider exist comes from the robot's profile (`server/profiles/*.json`): the first `drive`, `ptz` or
++direction-button widget with a stick, and the first `number` actuator a widget drives. A robot with neither still gets
++the pill, the e-stop (for its owner and operators) and “more”. A viewer sees the pill and “more” until they take the
++controls the same way the full panel lets them.
++
++Typical iframe:
++
++```html
++<iframe
++ src="https://openvibe.bot/panel/rob_abc123/embed?mode=overlay"
++ title="Robot controls"
++ allow="gamepad"
++ sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms"
++ referrerpolicy="strict-origin-when-cross-origin"></iframe>
++```
++
++Do not add `allow-top-navigation`, and do not widen the sandbox. The panel needs scripts, its own origin (the
++OpenVibe session cookie), forms and popups (Drive opens its own page). Commands go from the panel to OpenVibe.Bot as
++the signed-in viewer; the page around the frame never sends a command and cannot.
++
++## The messages
++
++Every message the frame sends also carries `robot_id` when it knows it. The page accepts a message only when all three
++match: `e.origin` is the Bot origin, `e.source` is the iframe's `contentWindow`, and `robot_id` is the robot the iframe
++was given. Anything else is ignored.
++
++| direction | type | fields | meaning |
++|---|---|---|---|
++| frame → page | `ov-bot-size` | `height`, `mode`, `minWidth`, `minHeight`, `hit?` | its content height (the full panel card grows to it), the current view, the smallest size that view stays usable in (`panel` 320×480, `overlay` 280×196, `compact` 320×112), and, for `overlay`, the rectangles the controls occupy: `hit: [{ x, y, w, h }, …]` in frame pixels. Sent on load and on change, at most five times a second. |
++| frame → page | `ov-bot-open-panel` | — | the overlay's “more” was pressed; the page should show the full panel (`panel` view, under the video, or in a dialog). |
++| frame → page | `ov-bot-blur-controls` | — | Escape was pressed in the overlay; focus should go back to the page (for a player, focus the video box). |
++| page → frame | `ov-bot-mode` | `mode: 'panel' \| 'overlay' \| 'compact'` | switch view. A view is rendered on the server (its controls come from the profile), so the frame reloads itself in the new view; the page does not rebuild the iframe. |
++| page → frame | `ov-bot-activity` | — | the pointer moved over the page, outside the frame's controls: the overlay wakes from its 45 % idle state. |
++| page → frame | `ov-bot-theme` | `tokens` | the page's colours (see `public/embed-frame.js`); optional. |
++| page → frame | `ov-bot-key` / `ov-bot-pad` | key or axes | optional: the page's keyboard and gamepad while the panel drives, after the frame's `ov-bot-controls` says it has the focus. |
++
++## Over a video: let the presses through
++
++The overlay page is transparent and its page is `pointer-events: none`; only the controls take a pointer. But a press
++on an iframe never crosses into the page underneath, even when the iframe's own document is click-through. The page
++that puts the controls over a picture therefore clips the iframe to the control rectangles from `ov-bot-size.hit`:
++
++```js
++function clipToControls(frame, hit) {
++ if (!Array.isArray(hit) || !hit.length) return; // no boxes: leave the frame whole
++ const rects = hit.filter((r) => r && [r.x, r.y, r.w, r.h].every(Number.isFinite) && r.w > 0 && r.h > 0).slice(0, 12);
++ if (!rects.length) return;
++ const path = rects.map((r) => `M${r.x} ${r.y} H${r.x + r.w} V${r.y + r.h} H${r.x} Z`).join(' ');
++ frame.style.clipPath = `path("${path}")`;
++}
++
++window.addEventListener('message', (e) => {
++ if (e.origin !== 'https://openvibe.bot' || e.source !== frame.contentWindow) return;
++ const d = e.data || {};
++ if (d.type === 'ov-bot-size' && d.mode === 'overlay') clipToControls(frame, d.hit);
++});
++```
++
++`clip-path` participates in hit testing: a press outside every rectangle reaches the video (or whatever is behind the
++frame) and the player's own controls keep working. Put the frame in a layer above the video and **below** the player's
++controls bar, unmute pill and chat button, and leave the bottom bar's height out of the layer, e.g.
++`position:absolute; inset:0 0 var(--player-bar-height, 64px) 0; z-index:4`. The overlay's e-stop and pill then sit
++above the picture but under those page controls. In full screen, keep the frame inside the element that goes full
++screen (`requestFullscreen` on the player box), so the controls stay over the picture.
++
++Do not use `pointer-events: none` on the iframe element itself as a click-through trick: it turns the controls off too.
++
++## Security
++
++- The frame is sandboxed exactly as above and accepts parent messages only from `data-parent-origins`.
++- The page accepts frame messages only from the Bot origin **and** that exact iframe, and only the shapes below.
++- A page never sends `estop`, `drive`, `actuator` or any other command to the frame: only OpenVibe.Bot's panel sends
++ commands, through Bot's own gate, as the viewer. Chat commands on a bound OpenVibe.Live channel go through the
++ channel route in Live, never through this protocol.
++- Treat `hit` as layout data only: clamp it and ignore anything malformed.
ddiff --git a/public/embed-frame.js b/public/embed-frame.js
iindex 8d372b5..58384e2 100644
---- a/public/embed-frame.js
++++ b/public/embed-frame.js
@@@ -8,8 +8,12 @@
* 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).
++ * ov-bot-size { height, mode, minWidth, minHeight, hit? } ← the embed's own content height, its view, the size
++ * it needs, and for an overlay the rectangles the controls occupy (so the page can let presses
++ * through everywhere else), sent on load and when it changes, at most five times a second (a
++ * trailing edge, so the last size always goes).
++ * ov-bot-mode { mode: 'panel' | 'overlay' | 'compact' } → the frame reloads itself in that view, so a host can
++ * offer the same switch the query (?mode=) does without rebuilding the frame itself.
*
* 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.
@@@ -28,6 +32,9 @@
// 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
++ const MODES = ['panel', 'overlay', 'compact'];
++ // The smallest frame each view stays usable in; the server also writes these to <html> (data-bot-min-*).
++ const MIN_SIZE = { panel: { minWidth: 320, minHeight: 480 }, overlay: { minWidth: 280, minHeight: 196 }, compact: { minWidth: 320, minHeight: 112 } };
function validColor(value) {
return typeof value === 'string' && value.length <= MAX_LEN && COLOR_RE.test(value.trim());
@@@ -38,6 +45,11 @@
return value === 'dark' || value === 'light' ? value : null;
}
++ /** 'panel' | 'overlay' | 'compact', or null. */
++ function validMode(value) {
++ return MODES.includes(value) ? value : null;
++ }
++
/** The tokens worth applying: only the known names, only plain colours, never inherited keys. */
function sanitizeTokens(tokens) {
const out = {};
@@@ -70,6 +82,8 @@
let stopAt = 0;
let timer = 0;
let lastHeight = -1;
++ let lastMode = null;
++ let lastHit = '';
const measure = () => {
const body = doc.body;
@@@ -80,6 +94,30 @@
} catch { /* a document going away */ }
return Math.max(0, Math.ceil(height));
};
++ const attr = (name) => {
++ try { return html.getAttribute ? html.getAttribute(name) : null; } catch { return null; }
++ };
++ const view = () => validMode(attr('data-bot-view')) || 'panel';
++ const sizes = (mode) => {
++ const fallback = MIN_SIZE[mode] || MIN_SIZE.panel;
++ const w = Number(attr('data-bot-min-width'));
++ const h = Number(attr('data-bot-min-height'));
++ return { minWidth: Number.isFinite(w) && w > 0 ? w : fallback.minWidth, minHeight: Number.isFinite(h) && h > 0 ? h : fallback.minHeight };
++ };
++ // The controls' own boxes, so a page can clip the transparent frame to them and let every other press
++ // reach the picture underneath. Only the overlay has anything worth reporting.
++ const hitAreas = (mode) => {
++ if (mode !== 'overlay' || !doc.querySelectorAll) return [];
++ const out = [];
++ for (const el of doc.querySelectorAll('[data-bot-hit]')) {
++ let r;
++ try { r = el.getBoundingClientRect(); } catch { continue; }
++ if (!r || !(r.width > 0) || !(r.height > 0)) continue;
++ out.push({ x: Math.max(0, Math.floor(r.left)), y: Math.max(0, Math.floor(r.top)), w: Math.ceil(r.width), h: Math.ceil(r.height) });
++ if (out.length >= 12) break;
++ }
++ return out;
++ };
const post = (message) => {
for (const origin of targets) {
try { win.parent.postMessage(message, origin); } catch { /* not that origin */ }
@@@ -94,10 +132,17 @@
}
if (timer) { win.clearTimeout(timer); timer = 0; }
const height = measure();
-- if (!force && height === lastHeight) return;
++ const mode = view();
++ const { minWidth, minHeight } = sizes(mode);
++ const hit = hitAreas(mode);
++ const hitKey = JSON.stringify(hit);
++ if (!force && height === lastHeight && mode === lastMode && hitKey === lastHit) return;
stopAt = now;
lastHeight = height;
-- post(robot ? { type: 'ov-bot-size', robot_id: robot, height } : { type: 'ov-bot-size', height });
++ lastMode = mode;
++ lastHit = hitKey;
++ const message = { ...(robot ? { robot_id: robot } : {}), type: 'ov-bot-size', height, mode, minWidth, minHeight, ...(hit.length ? { hit } : {}) };
++ post(message);
};
const onLoad = () => send(true);
@@@ -105,8 +150,23 @@
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 (!d || typeof d !== 'object') return;
if (robot && typeof d.robot_id === 'string' && d.robot_id !== robot) return;
++ if (d.type === 'ov-bot-mode') {
++ const mode = validMode(d.mode);
++ if (!mode || mode === view()) return;
++ // A view is server-rendered (its controls come from the profile), so the frame reloads itself in it:
++ // the page around it does not have to rebuild the iframe to switch modes.
++ try {
++ const loc = win.location;
++ if (!loc || typeof loc.href !== 'string') return;
++ const u = new win.URL(loc.href);
++ u.searchParams.set('mode', mode);
++ loc.replace(u.href);
++ } catch { /* no location (a test's fake window) */ }
++ return;
++ }
++ if (d.type !== 'ov-bot-theme') 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']);
@@@ -137,5 +197,5 @@
};
}
-- return { TOKENS, validColor, validScheme, sanitizeTokens, validOrigin, start };
++ return { TOKENS, MODES, MIN_SIZE, validColor, validScheme, validMode, sanitizeTokens, validOrigin, start };
});
ddiff --git a/public/panel.css b/public/panel.css
iindex 89d5cdc..3256ba8 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -437,3 +437,80 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.share-screen.share-using { outline: 2px solid var(--accent); outline-offset: 2px; cursor: crosshair; }
.share-controls { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; }
.share-use { display: inline-flex; gap: 8px; align-items: center; }
++
++/* ── The controls on their own: over a picture (mode=overlay) or in one row (mode=compact) ───────────
++ The framed page is transparent and never takes a press outside a control: a page that puts it over a
++ stream clips the frame to the controls (the rectangles in ov-bot-size.hit) so everything else reaches the
++ picture. Each control carries its own soft dark scrim so it reads on any picture, and uses the panel's
++ existing tokens (the scrim, line and blur are the one shared recipe; no new palette). */
++html.bot-mode-overlay, html.bot-mode-overlay body,
++html.bot-mode-compact, html.bot-mode-compact body { background: transparent; }
++body.bot-mode-overlay, body.bot-mode-compact { margin: 0; min-height: 0; overflow: hidden; pointer-events: none; }
++body.bot-mode-overlay main#panel.bot-surface { position: fixed; inset: 0; margin: 0; padding: 0; max-width: none; background: transparent; }
++body.bot-mode-compact main#panel.bot-surface { margin: 0; padding: 0; max-width: none; background: transparent; display: grid; align-items: center; }
++.bot-surface:focus { outline: none; }
++.bot-overlay, .bot-compact { pointer-events: none; }
++.bot-control {
++ pointer-events: auto; color: #fff; background: rgba(8, 12, 22, 0.42);
++ border: 1px solid rgba(255, 255, 255, 0.14);
++ -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
++}
++.bot-overlay { position: absolute; inset: 0; transition: opacity 0.25s ease; }
++.bot-overlay.is-idle { opacity: 0.45; }
++@media (prefers-reduced-motion: reduce) { .bot-overlay { transition: none; } }
++
++/* The robot's name and state, one small pill at the top left. */
++.bot-pill {
++ position: absolute; top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left));
++ display: inline-flex; align-items: center; gap: 8px; max-width: calc(100% - 152px); height: 36px; padding: 0 12px;
++ border-radius: 999px; font-size: 12px; line-height: 1; white-space: nowrap;
++}
++.bot-pill .dot { width: 8px; height: 8px; }
++.bot-pill-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
++.bot-pill-state { color: rgba(255, 255, 255, 0.78); overflow: hidden; text-overflow: ellipsis; }
++.bot-pill[data-state="online"] .dot { background: var(--ok); }
++.bot-pill[data-state="turn"] .dot { background: var(--accent); }
++.bot-pill[data-state="busy"] .dot { background: var(--warn); }
++.bot-pill[data-state="offline"] .dot, .bot-pill[data-state="estop"] .dot { background: var(--danger); }
++
++/* The first stick, 132 px at the bottom left. The scrim is the circle; the stick itself is transparent. */
++.bot-stick {
++ position: absolute; left: calc(16px + env(safe-area-inset-left)); bottom: calc(16px + env(safe-area-inset-bottom));
++ width: 132px; height: 132px; border-radius: 50%; display: grid; place-items: center;
++}
++.bot-stick .bot-widget { width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; display: grid; place-items: center; min-width: 0; }
++.bot-stick .stick { width: 100%; height: 100%; background: transparent; border: 0; box-shadow: none; }
++.bot-stick .stick-ring { inset: 12%; border-color: rgba(255, 255, 255, 0.18); }
++
++/* The first slider, a 44 × 132 vertical track at the bottom right. */
++.bot-slider {
++ position: absolute; right: calc(16px + env(safe-area-inset-right)); bottom: calc(16px + env(safe-area-inset-bottom));
++ width: 44px; height: 132px; border-radius: 22px; display: grid; place-items: center; padding: 8px 0;
++}
++.bot-slider .bot-widget { width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; display: grid; place-items: center; min-width: 0; }
++.bot-slider input[type="range"] {
++ writing-mode: vertical-lr; direction: rtl; width: 28px; height: 112px; min-width: 0; min-height: 0;
++ margin: 0; padding: 0; accent-color: var(--accent);
++}
++
++/* The e-stop, 44 px at the top right, and the way to the full panel under it. */
++.bot-estop, .bot-more {
++ position: absolute; right: calc(16px + env(safe-area-inset-right)); width: 44px; height: 44px; min-width: 44px; min-height: 44px;
++ padding: 0; border-radius: 50%; justify-content: center; font-size: 15px; font-weight: 700; letter-spacing: 0.04em;
++}
++.bot-estop { top: calc(16px + env(safe-area-inset-top)); color: var(--danger); }
++.bot-estop .icon { width: 22px; height: 22px; stroke-width: 2.4; }
++.bot-more { top: calc(68px + env(safe-area-inset-top)); font-size: 16px; line-height: 1; }
++
++/* One row for a narrow card: the pill, a 96 px stick, the slider across, the e-stop and more. */
++.bot-compact {
++ display: flex; align-items: center; gap: 8px; min-height: 64px; padding: 8px 10px; border-radius: 16px;
++ background: rgba(8, 12, 22, 0.42); border: 1px solid rgba(255, 255, 255, 0.14);
++ -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
++}
++body.bot-mode-compact .bot-compact .bot-control { background: transparent; border-color: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
++.bot-compact .bot-pill { position: static; flex: 1 1 auto; min-width: 0; max-width: none; height: 36px; padding: 0 6px; }
++.bot-compact .bot-stick { position: static; flex: none; width: 96px; height: 96px; border-radius: 50%; }
++.bot-compact .bot-slider { position: static; flex: none; width: 108px; height: 44px; border-radius: 22px; padding: 0 8px; }
++.bot-compact .bot-slider input[type="range"] { writing-mode: initial; direction: ltr; width: 92px; height: 28px; }
++.bot-compact .bot-estop, .bot-compact .bot-more { position: static; flex: none; width: 44px; height: 44px; }
ddiff --git a/public/panel.js b/public/panel.js
iindex 4530760..9c51181 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -277,6 +277,22 @@
let myRole = null; // the role the joined frame gave this panel (owner, operator, viewer, queue)
let retry = 0;
let gone = false; // refused for good (no access): no reconnect
++ // The embeddable panel's two compact views (?mode=overlay|compact): only the live controls, over a picture or
++ // in one row. The controls fade after four seconds without a touch, and come back on the first one.
++ const BOT_VIEW = main.dataset.botView || 'panel';
++ const botSurface = $('[data-bot-overlay], [data-bot-compact]');
++ let botIdle = 0;
++ function botWake() {
++ if (BOT_VIEW !== 'overlay' || !botSurface) return;
++ botSurface.classList.remove('is-idle');
++ clearTimeout(botIdle);
++ botIdle = setTimeout(() => botSurface.classList.add('is-idle'), 4000);
++ }
++ if (BOT_VIEW === 'overlay' && botSurface) {
++ for (const ev of ['pointermove', 'pointerdown', 'touchstart', 'keydown']) document.addEventListener(ev, botWake, { passive: true });
++ botWake();
++ }
++ if (BOT_VIEW !== 'panel') main.addEventListener('pointerdown', () => { try { main.focus({ preventScroll: true }); } catch { /* older browser */ } });
const commandOf = (el) => { const s = el.closest('[data-command]'); return s ? JSON.parse(s.dataset.command) : null; };
const kindOf = (el) => { const c = commandOf(el); return c ? c.kind : null; };
@@@ -646,6 +662,13 @@
$$('[data-button-key]').find((el) => !el.disabled && (el.dataset.buttonKey === e.code || el.dataset.buttonKey.toLowerCase() === e.key.toLowerCase()));
function onKeyDown(e) {
if (typing(e)) return;
++ // Escape hands the keyboard back to the page (Live focuses its player), so the overlay never traps it.
++ if (e.code === 'Escape' && BOT_VIEW !== 'panel') {
++ releaseAll();
++ try { main.blur(); if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } catch { /* */ }
++ for (const origin of PARENTS) { try { window.parent.postMessage({ type: 'ov-bot-blur-controls', robot_id: ROBOT_ID }, origin); } catch { /* not that origin */ } }
++ return;
++ }
const focusedButton = e.target.closest && e.target.closest('[data-button-name]');
if (focusedButton && (e.code === 'Enter' || e.code === 'Space')) {
e.preventDefault(); if (!e.repeat) pressProfileButton(focusedButton, `key:${e.code}`); return;
@@@ -939,6 +962,22 @@
const online = $('[data-online-label]');
if (online) online.textContent = label;
}
++ /** The overlay's one-line state: e-stop, offline, your turn, someone else's turn, or just online. */
++ function paintBotPill(state) {
++ const pill = $('[data-bot-pill]');
++ if (!pill) return;
++ const latched = !!(state.estop && state.estop.latched);
++ const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);
++ const queue = state.queue;
++ let label = 'Online'; let dataState = 'online';
++ if (latched || held) { label = 'E-stop on'; dataState = 'estop'; }
++ else if (!state.online) { label = 'Offline'; dataState = 'offline'; }
++ else if (queue && queue.active) { label = 'Your turn'; dataState = 'turn'; }
++ else if (queue && queue.turn_ends_at) { label = 'Someone else driving'; dataState = 'busy'; }
++ const text = $('[data-bot-state]');
++ if (text) text.textContent = label;
++ pill.dataset.state = dataState;
++ }
function paint(state) {
if (!state) return;
const latched = !!(state.estop && state.estop.latched);
@@@ -964,6 +1003,7 @@
if (latched || held) releaseAll();
paintFaults(state);
setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
++ paintBotPill(state);
main.dataset.deviceOnline = String(!!state.online);
if (state.telemetry) lastTelemetryAt = performance.now();
else if (!state.online) lastTelemetryAt = 0;
@@@ -1111,6 +1151,14 @@
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 });
++ // The overlay's "more" asks the page for the full panel (Live switches the card to under the video).
++ for (const el of $$('[data-bot-more]')) {
++ el.addEventListener('click', () => {
++ for (const origin of PARENTS) {
++ try { window.parent.postMessage({ type: 'ov-bot-open-panel', robot_id: ROBOT_ID }, origin); } catch { /* not that origin */ }
++ }
++ });
++ }
let parentSilent = false;
let asked = null;
let popup = null;
@@@ -1135,6 +1183,8 @@
const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);
if (!fromPopup && !fromParent) return;
const d = e.data || {};
++ // The page says the pointer moved on it (the empty parts of the overlay are click-through there): wake.
++ if (fromParent && d.type === 'ov-bot-activity') { botWake(); return; }
// The page's keys and gamepad while this panel drives (announceControls): the same handlers as this frame's own.
// A key's release always goes through, so nothing stays held when driving ends between press and release.
if (fromParent && d.robot_id === ROBOT_ID && d.type === 'ov-bot-key' && typeof d.code === 'string') {
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 473769d..7854936 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -63,9 +63,9 @@ 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, htmlClass = '', colorScheme = 'dark' } = {}) {
++function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null, htmlClass = '', htmlAttrs = '', colorScheme = 'dark' } = {}) {
return `<!doctype html>
--<html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}>
++<html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}${htmlAttrs}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@@ -145,14 +145,18 @@ const humanName = (name) => { const s = String(name).replace(/_/g, ' '); return
const HOLD_REPEAT_MS = { looi: { head: 90 } };
const holdRepeatOf = (profileId, name) => { const v = (HOLD_REPEAT_MS[profileId] || {})[name]; return v ? Math.min(250, Math.max(75, v)) : 0; };
--function actuatorControl(name, a, enabled, profileId = '') {
++function numberRange(name, a, enabled, profileId = '') {
const repeat = a.type === 'number' ? holdRepeatOf(profileId, name) : 0;
++ const [lo, hi] = a.range;
++ const start = Math.min(hi, Math.max(lo, 0));
++ return `<input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${repeat ? ` data-hold-repeat="${esc(repeat)}"` : ''}${disabledUnless(enabled)}>`;
++}
++
++function actuatorControl(name, a, enabled, profileId = '') {
const off = disabledUnless(enabled);
const label = humanName(name);
if (a.type === 'number') {
-- const [lo, hi] = a.range;
-- const start = Math.min(hi, Math.max(lo, 0));
-- return `<label class="control slider"><span class="control-name">${esc(label)}</span> <input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${repeat ? ` data-hold-repeat="${esc(repeat)}"` : ''}${off}></label>`;
++ return `<label class="control slider"><span class="control-name">${esc(label)}</span> ${numberRange(name, a, enabled, profileId)}</label>`;
}
if (a.type === 'rgb') {
return `<div class="control colour"><label><span class="control-name">${esc(label)}</span> <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`
@@@ -555,8 +559,104 @@ ${field('max_turn', 'Top turn', own.max_turn != null ? own.max_turn : limits.max
<button type="submit" class="primary">Commission this robot</button></form></section>`;
}
--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null }) {
++
++// ── The same controls over a picture ({mode=overlay}) or in a narrow card ({mode=compact}) ───────────────
++// The embeddable panel normally draws the whole page. Over a stream it must draw only the live controls:
++// the first stick, the first slider, the e-stop and a way to the full panel. Which ones exist comes from the
++// robot's profile (commands + widgets), exactly as the full panel does; nothing here is robot-specific.
++const BOT_VIEWS = new Set(['panel', 'overlay', 'compact']);
++const botViewOf = (v) => (BOT_VIEWS.has(v) ? v : 'panel');
++const BOT_VIEW_MIN = { panel: [320, 480], overlay: [280, 196], compact: [320, 112] };
++
++/** The first stick the robot has: a drive joystick, a pan-tilt camera's, or a direction-button robot's. */
++function overlayStickMarkup(profile, allowed) {
++ const commands = profile.commands || {};
++ for (const w of profile.widgets || []) {
++ const kind = w.command ? w.command.kind : null;
++ const enabled = !!kind && allowed.includes(kind);
++ if (w.type === 'drive') {
++ const axes = (commands[kind] && commands[kind].axes) || {};
++ const x = STICK_X.find((a) => axes[a]);
++ const y = STICK_Y.find((a) => axes[a]);
++ if (!x && !y) continue;
++ const stick = {};
++ if (x) stick.x = x;
++ if (y) stick.y = y;
++ const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');
++ return `<section class="widget widget-drive bot-widget" data-widget="drive" data-command="${data(w.command)}">`
++ + `<div class="drive-pad" data-axes="${data(axes)}"><div class="stick" data-joystick="${data(stick)}" role="img" aria-label="${esc(`Joystick: ${label}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`
++ + '<span class="stick-ring"></span><span class="stick-cross"></span><span class="stick-knob" data-knob></span></div></div></section>';
++ }
++ if (w.type === 'ptz') {
++ const axes = (commands[kind] && commands[kind].axes) || {};
++ if (!axes.pan || !axes.tilt) continue;
++ return `<section class="widget widget-ptz bot-widget" data-widget="ptz" data-command="${data(w.command)}">`
++ + `<div class="ptz-pad"><div class="stick stick--ptz" data-ptz-stick="${data({ x: 'pan', y: 'tilt' })}" data-axes="${data(axes)}" role="img" aria-label="Joystick: pan and tilt. Drag and hold to move the camera."${enabled ? '' : ' aria-disabled="true"'}>`
++ + '<span class="stick-ring"></span><span class="stick-cross"></span><span class="stick-knob" data-knob></span></div></div></section>';
++ }
++ if (w.type === 'buttons') {
++ const names = (commands.button && commands.button.names) || {};
++ const dirs = DIRECTION_BUTTONS.filter((d) => d.names.some((n) => names[n]));
++ if (dirs.length < 2) continue;
++ return `<section class="widget widget-buttons bot-widget" data-widget="buttons" data-command="${data(w.command || { kind: 'button' })}">`
++ + `<div class="stick stick--buttons" data-button-stick role="img" aria-label="${esc(`Joystick: presses ${dirs.map((d) => d.label).join(', ')}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`
++ + '<span class="stick-ring"></span><span class="stick-cross"></span><span class="stick-knob" data-knob></span></div></section>';
++ }
++ }
++ return '';
++}
++
++/** The first actuator slider (a number range) any widget drives, e.g. the LOOI's head. */
++function overlaySlider(profile, allowed) {
++ const commands = profile.commands || {};
++ const names = (commands.actuator && commands.actuator.names) || {};
++ for (const w of profile.widgets || []) {
++ if (!['pan-tilt', 'servo', 'head', 'lift', 'lights', 'horn'].includes(w.type)) continue;
++ const kind = w.command ? w.command.kind : null;
++ for (const name of (w.command && w.command.names) || []) {
++ const a = names[name];
++ if (a && a.type === 'number') return { widget: w, name, actuator: a, enabled: !!kind && allowed.includes(kind) };
++ }
++ }
++ return null;
++}
++
++const botPill = (robot) => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;
++const botEstop = (role) => (ESTOP_ROLES.has(role)
++ ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit aria-label="E-stop" title="E-stop">${icon('stop')}</button>` : '');
++const botMore = '<button type="button" class="bot-more bot-control" data-bot-more data-bot-hit aria-label="More controls" title="Open the full panel"><span aria-hidden="true">•••</span></button>';
++
++/** The overlay/compact page: a transparent surface with only the controls, no topbar, header or widgets. */
++function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResendMs = 150, view = 'overlay', parentOrigins = [] }) {
++ const stick = overlayStickMarkup(profile, allowed_commands);
++ const slider = overlaySlider(profile, allowed_commands);
++ const stop = botEstop(role);
++ const sliderLabel = slider ? esc(humanName(slider.name)) : '';
++ const sliderMarkup = slider
++ ? `<div class="bot-slider bot-control" data-bot-hit><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`
++ : '';
++ const stickMarkup = stick ? `<div class="bot-stick bot-control" data-bot-hit>${stick}</div>` : '';
++ const body = view === 'compact'
++ ? `<div class="bot-compact" data-bot-compact>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}</div>`
++ : `<div class="bot-overlay" data-bot-overlay>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}</div>`;
++ return `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}</main>`;
++}
++
++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null }) {
const embed = mode === 'embed';
++ const botView = embed ? botViewOf(view) : 'panel';
++ if (embed && botView !== 'panel') {
++ const [minWidth, minHeight] = BOT_VIEW_MIN[botView];
++ return page(robot.name, renderBotSurface({ robot, profile, role, allowed_commands, holdResendMs, view: botView, parentOrigins }), {
++ scripts: panelScripts(profile, false, true),
++ bodyClass: `embed-page bot-mode-${botView}`,
++ htmlClass: `embed-page bot-mode-${botView}`,
++ htmlAttrs: ` data-bot-view="${botView}" data-bot-min-width="${minWidth}" data-bot-min-height="${minHeight}"`,
++ colorScheme: 'dark light',
++ });
++ }
++ const minSize = BOT_VIEW_MIN.panel;
++ const htmlAttrs = embed ? ` data-bot-view="panel" data-bot-min-width="${minSize[0]}" data-bot-min-height="${minSize[1]}"` : '';
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
@@@ -569,7 +669,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
</header>
--<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>
++<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''} data-bot-view="${esc(botView)}"${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>
<div class="panel-head">
${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
@@@ -586,7 +686,7 @@ ${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',
-- htmlClass: embed ? 'embed-page' : '', colorScheme: embed ? 'dark light' : 'dark',
++ htmlClass: embed ? 'embed-page' : '', htmlAttrs, colorScheme: embed ? 'dark light' : 'dark',
description: tryIt ? TRY_DESCRIPTION : null, canonical: tryIt ? '/try' : null,
});
}
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 51f8640..ea17c42 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -910,7 +910,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const role = await domain.members.roleOf(robot.id, me.subject);
return role || (robot.access_policy === 'queue' ? 'queue' : null);
}
-- async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}) {
++ async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}, view = 'panel') {
await attachInProcess(robot, profile);
const effective = domain.control.effectiveLimits(robot, profile);
const { maxCommandMs } = effective;
@@@ -943,7 +943,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
}
const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
res.type('html').send(renderPanel({
-- robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
++ robot: presentForPage(robot), profile, role, mode, view, signedIn, streaming, people, signaling_url,
profileForm, automation, limitsInfo,
// The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put
// the panel under their Live stream.
@@@ -1079,7 +1079,11 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
if (!role) return res.status(403).type('html').send(renderEmbedRefused({ robotId: robot.id, parentOrigins: (config.embed && config.embed.origins) || [] }));
const profile = await profileOf(robot);
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
-- return sendPanel(res, robot, profile, role, 'embed', !!req.viewer);
++ // ?mode=overlay|compact draws just the controls (a transparent surface for a page that puts it over a
++ // picture, or one row for a narrow card); panel is the full embed and the default. Anything else is panel.
++ const asked = String(req.query.mode || '').toLowerCase();
++ const view = ['overlay', 'compact'].includes(asked) ? asked : 'panel';
++ return sendPanel(res, robot, profile, role, 'embed', !!req.viewer, {}, view);
}));
return r;
ddiff --git a/test/bot-views.test.js b/test/bot-views.test.js
nnew file mode 100644
iindex 0000000..17c85ac
---- /dev/null
++++ b/test/bot-views.test.js
@@@ -0,0 +1,128 @@
++'use strict';
++// The embeddable panel's three views (?mode=panel|overlay|compact): the full panel as before, a transparent
++// overlay that draws only the live controls (the first stick, the first slider, the e-stop and "more"), and one
++// row for a narrow card. The controls come from the robot's own profile; a host can change the view by message.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++const { renderPanel } = require('../server/web/render');
++
++const DRIVE = {
++ id: 'looi.demo', name: 'LOOI',
++ commands: {
++ drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } },
++ actuator: { names: { head: { type: 'number', range: [-1, 1] }, horn: { type: 'tone', hz: [220, 880] } } },
++ },
++ widgets: [
++ { type: 'camera', label: 'Camera', order: 0 },
++ { type: 'drive', label: 'Drive', order: 1, command: { kind: 'drive' } },
++ { type: 'head', label: 'Head', order: 2, command: { kind: 'actuator', names: ['head', 'horn'] } },
++ ],
++};
++const ROBOT = { id: 'rob_view1', name: 'LOOI', profile_id: 'looi.demo', estop: { latched: false } };
++const render = (over = {}) => renderPanel({
++ robot: ROBOT, profile: DRIVE, role: 'owner', allowed_commands: ['drive', 'actuator', 'halt'],
++ mode: 'embed', signedIn: true, parentOrigins: ['https://openvibe.live'], ...over,
++});
++
++(async () => {
++ await check('overlay: transparent page, only the live controls, the state pill, and no full-panel chrome', () => {
++ const html = render({ view: 'overlay' });
++ assert.ok(html.includes('<html lang="en" class="embed-page bot-mode-overlay" data-bot-view="overlay" data-bot-min-width="280" data-bot-min-height="196">'));
++ assert.ok(html.includes('class="embed-page bot-mode-overlay"'));
++ assert.ok(html.includes('data-bot-overlay'));
++ assert.ok(!html.includes('class="topbar"') && !html.includes('data-estop-banner') && !html.includes('class="widget-camera"'));
++ assert.ok(html.includes('data-bot-pill') && html.includes('data-bot-state') && html.includes('>LOOI<'));
++ assert.ok(html.includes('data-joystick') && html.includes('data-actuator="head"'));
++ assert.ok(html.includes('data-estop') && html.includes('data-bot-more'));
++ const hits = html.match(/data-bot-hit/g) || [];
++ assert.ok(hits.length >= 5, `pill, stick, slider, e-stop and more each report a hit box (${hits.length})`);
++ assert.ok(!html.includes('data-hold=') && !html.includes('data-stop'), 'no pad buttons or stop row in the overlay');
++ });
++
++ await check('overlay: the controls a robot has decide what it draws (first stick, first slider, e-stop by role)', () => {
++ const ptz = {
++ id: 'camera.demo', name: 'Camera',
++ commands: { ptz: { axes: { pan: [-1, 1], tilt: [-1, 1], zoom: [-1, 1] } }, actuator: { names: { zoom: { type: 'number', range: [0, 3] } } } },
++ widgets: [
++ { type: 'ptz', label: 'Pan / tilt', order: 1, command: { kind: 'ptz' } },
++ { type: 'pan-tilt', label: 'Zoom', order: 2, command: { kind: 'actuator', names: ['zoom'] } },
++ ],
++ };
++ const cam = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay' });
++ assert.ok(cam.includes('data-ptz-stick') && cam.includes('data-actuator="zoom"'));
++ assert.ok(!cam.includes('data-estop'), 'a watcher has no e-stop until they take the controls');
++ assert.ok(cam.includes('data-bot-more'), 'but there is always a way to the full panel');
++ const hornOnly = {
++ id: 'horn.demo', name: 'Horn', commands: { actuator: { names: { horn: { type: 'tone', hz: [220, 880] } } } },
++ widgets: [{ type: 'horn', label: 'Horn', command: { kind: 'actuator', names: ['horn'] } }],
++ };
++ const quiet = renderPanel({ robot: ROBOT, profile: hornOnly, role: 'owner', allowed_commands: ['actuator'], mode: 'embed', view: 'overlay' });
++ assert.ok(!quiet.includes('data-actuator') && !quiet.includes('data-joystick'), 'a tone is not a slider, and there is no stick');
++ assert.ok(quiet.includes('data-estop') && quiet.includes('data-bot-more'));
++ });
++
++ await check('compact: one row with the pill, a 96 px stick, the slider across, the e-stop and more', () => {
++ const html = render({ view: 'compact' });
++ assert.ok(html.includes('class="embed-page bot-mode-compact" data-bot-view="compact" data-bot-min-width="320" data-bot-min-height="112"'));
++ assert.ok(html.includes('class="bot-compact"') && !html.includes('data-bot-overlay'));
++ assert.ok(html.includes('data-bot-pill') && html.includes('data-joystick') && html.includes('data-actuator="head"'));
++ assert.ok(html.includes('data-estop') && html.includes('data-bot-more'));
++ });
++
++ await check('panel is the default view: an unknown mode falls back to the full panel and its old markup', () => {
++ const html = render({});
++ assert.ok(html.includes('data-bot-view="panel"') && html.includes('data-bot-min-width="320"') && html.includes('data-bot-min-height="480"'));
++ assert.ok(html.includes('data-estop-banner') && html.includes('class="widgets"'));
++ const weird = render({ view: '../../etc/passwd' });
++ assert.ok(weird.includes('data-bot-view="panel"') && weird.includes('class="widgets"'));
++ });
++
++ await check('the sheet keeps the overlay transparent, click-through, scrimmed and at the sizes the page promises', () => {
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.css'), 'utf8');
++ assert.ok(/html\.bot-mode-overlay,\s*html\.bot-mode-overlay body,[\s\S]*?background:\s*transparent/.test(css));
++ assert.match(css, /body\.bot-mode-overlay,\s*body\.bot-mode-compact \{[^}]*pointer-events:\s*none/);
++ assert.match(css, /\.bot-control \{[^}]*pointer-events:\s*auto[^}]*rgba\(8,\s*12,\s*22,\s*0\.42\)[^}]*rgba\(255,\s*255,\s*255,\s*0\.14\)[^}]*blur\(6px\)/);
++ assert.match(css, /\.bot-stick \{[^}]*width:\s*132px;\s*height:\s*132px/);
++ assert.match(css, /\.bot-slider \{[^}]*width:\s*44px;\s*height:\s*132px/);
++ assert.match(css, /\.bot-overlay\.is-idle \{\s*opacity:\s*0\.45/);
++ assert.match(css, /prefers-reduced-motion: reduce[^}]*\{[^}]*\.bot-overlay \{\s*transition:\s*none/);
++ assert.match(css, /\.bot-compact \.bot-stick \{[^}]*width:\s*96px/);
++ });
++
++ await check('the client fades the controls, wakes on a touch, and hands focus back on Escape', () => {
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
++ assert.match(js, /setTimeout\(\(\) => botSurface\.classList\.add\('is-idle'\), 4000\)/);
++ assert.match(js, /'pointermove', 'pointerdown', 'touchstart', 'keydown'/);
++ assert.match(js, /if \(e\.code === 'Escape' && BOT_VIEW !== 'panel'\)/);
++ assert.match(js, /type: 'ov-bot-blur-controls'/);
++ assert.match(js, /type: 'ov-bot-open-panel'/);
++ assert.match(js, /ov-bot-activity/);
++ assert.match(js, /paintBotPill/);
++ });
++
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const get = (p) => fetch(t.base + p, { redirect: 'manual' });
++ const { robot } = await t.robot(alex, { name: 'Overlay rover' });
++ await t.domain.robots.setEmbedPublic(robot.id, true);
++
++ await check('GET /panel/:id/embed?mode=… draws each view, and anything else is the panel', async () => {
++ const overlay = await get(`/panel/${robot.id}/embed?mode=overlay`);
++ assert.strictEqual(overlay.status, 200);
++ const over = await overlay.text();
++ assert.ok(over.includes('data-bot-view="overlay"') && over.includes('class="embed-page bot-mode-overlay"'), 'overlay view');
++ const compact = await (await get(`/panel/${robot.id}/embed?mode=compact`)).text();
++ assert.ok(compact.includes('data-bot-view="compact"'), 'compact view');
++ const panel = await (await get(`/panel/${robot.id}/embed`)).text();
++ assert.ok(panel.includes('data-bot-view="panel"'), 'the default is the full panel');
++ const odd = await (await get(`/panel/${robot.id}/embed?mode=constructor`)).text();
++ assert.ok(odd.includes('data-bot-view="panel"'), 'an unknown mode never reaches the markup');
++ const closed = await (await get(`/panel/${robot.id}/embed?mode=overlay`)).status;
++ assert.strictEqual(closed, 200);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/embed-frame.test.js b/test/embed-frame.test.js
iindex e9a4ab0..00b3f8a 100644
---- a/test/embed-frame.test.js
++++ b/test/embed-frame.test.js
@@@ -28,6 +28,7 @@ function fakeWin({ height = 300 } = {}) {
const win = {
document: { documentElement: html, body },
parent,
++ URL,
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; },
@@@ -79,7 +80,7 @@ const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id:
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('<html lang="en" class="embed-page" data-bot-view="panel" data-bot-min-width="320" data-bot-min-height="480">'), 'the embed follows prefers-color-scheme before the first token message, and names its view');
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"'));
@@@ -94,7 +95,7 @@ const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id:
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');
++ assert.deepStrictEqual(win.__posted, [[{ robot_id: 'rob_x', type: 'ov-bot-size', height: 321, mode: 'panel', minWidth: 320, minHeight: 480 }, PARENT]], 'one size and view 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' }) });
@@@ -125,7 +126,7 @@ const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id:
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.deepStrictEqual(win.__posted.at(-1)[0], { type: 'ov-bot-size', robot_id: 'rob_x', height: 420, mode: 'panel', minWidth: 320, minHeight: 480 });
assert.ok(win.__posted.length <= 3, `at most about five a second, got ${win.__posted.length}`);
win.__setHeight(300);
win.__fire('resize', {});
@@@ -137,6 +138,28 @@ const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id:
assert.strictEqual(win.__listeners.get('resize').size, 0);
});
++ await check('start(): the view and its minimum size come from the page; a host message changes the view by reload', () => {
++ const win = fakeWin({ height: 220 });
++ win.__attrs['data-bot-view'] = 'overlay';
++ win.__attrs['data-bot-min-width'] = '280';
++ win.__attrs['data-bot-min-height'] = '196';
++ win.location = { href: 'https://openvibe.bot/panel/rob_x/embed?mode=overlay', replaced: [], replace(u) { this.replaced.push(u); } };
++ const stop = EmbedFrame.start({ win, parents: [PARENT], robotId: 'rob_x' });
++ assert.deepStrictEqual(win.__posted[0][0], { robot_id: 'rob_x', type: 'ov-bot-size', height: 220, mode: 'overlay', minWidth: 280, minHeight: 196 }, 'the overlay names its view and wanted size');
++ // A stranger cannot reload it, another robot cannot, and an unknown mode is ignored.
++ win.__fire('message', { origin: 'https://evil.example', source: win.parent, data: { type: 'ov-bot-mode', mode: 'panel', robot_id: 'rob_x' } });
++ win.__fire('message', { origin: PARENT, source: {}, data: { type: 'ov-bot-mode', mode: 'panel', robot_id: 'rob_x' } });
++ win.__fire('message', { origin: PARENT, source: win.parent, data: { type: 'ov-bot-mode', mode: 'panel', robot_id: 'rob_other' } });
++ win.__fire('message', { origin: PARENT, source: win.parent, data: { type: 'ov-bot-mode', mode: 'huge', robot_id: 'rob_x' } });
++ assert.deepStrictEqual(win.location.replaced, []);
++ // The page that frames it switches to the full panel: the frame reloads itself in that view (no host rebuild).
++ win.__fire('message', { origin: PARENT, source: win.parent, data: { type: 'ov-bot-mode', mode: 'panel', robot_id: 'rob_x' } });
++ assert.deepStrictEqual(win.location.replaced, ['https://openvibe.bot/panel/rob_x/embed?mode=panel']);
++ win.__fire('message', { origin: PARENT, source: win.parent, data: { type: 'ov-bot-mode', mode: 'overlay', robot_id: 'rob_x' } });
++ assert.strictEqual(win.location.replaced.length, 1, 'the current view is not reloaded');
++ stop();
++ });
++
await check('start(): an unframed page or one with no trusted parents does nothing', () => {
const unframed = fakeWin();
unframed.parent = unframed;