openvibers/openvibe.bot Commits

The framed panel fits a phone: one head row, stick and Stop side by side, a row view that works (#389)

OP OpenVibers committed 2 days ago · d113b55e0e · 6 files, +152 −11

On a phone the panel a channel frames was 950 px tall (an e-stop bar saying "clear", a header with an
icon and three pills, a 240 px stick over a full-width Stop, then four cards), and it pushed the
stream's chat off the screen.

- The framed panel (?mode=panel) is compact at any width: the e-stop bar shows only when it says
  something or holds the button; the name, its state, Drive and an open-in-new icon share one row;
  the stick and a round Stop sit side by side; one-control cards use the control's name as title;
  battery and latency are short rows; 2-3 columns once the frame is wide. None of it reads the
  frame's height or orientation (the frame's height follows its content).
- The surfaces (overlay, compact): the stick filled nothing (its pad had no size), so it showed as a
  dot. It fills its circle now. A watcher there had no way to drive: a Drive button takes the
  controls (the e-stop waits, hidden, for the join), and a line shows what the page answered.
- The row (compact) is a small remote: stick at the left, name over the slider, Drive or the
  e-stop over "more"; one line when wide. It takes the host card's colours, with no second box.
- embed-frame.js re-sends the overlay's hit boxes when a control is shown or hidden.

Ask 353.

Changes

ddiff --git a/public/embed-frame.js b/public/embed-frame.js
iindex 58384e2..1df9a60 100644
---- a/public/embed-frame.js
++++ b/public/embed-frame.js
@@@ -186,6 +186,13 @@
if (doc.body) observer.observe(doc.body);
observer.observe(html);
}
++ // Over a picture the body never changes size, but its controls do: Drive gives way to the e-stop, a line
++ // appears under them. Their boxes are the page's clip, so a control shown or hidden is sent again.
++ let watcher = null;
++ if (view() === 'overlay' && typeof win.MutationObserver === 'function' && doc.body) {
++ watcher = new win.MutationObserver(onResize);
++ watcher.observe(doc.body, { attributes: true, subtree: true, childList: true, characterData: true, attributeFilter: ['hidden'] });
++ }
send(true);
return () => {
@@@ -194,6 +201,7 @@
win.removeEventListener('resize', onResize);
if (timer) { win.clearTimeout(timer); timer = 0; }
if (observer) observer.disconnect();
++ if (watcher) watcher.disconnect();
};
}
ddiff --git a/public/panel.css b/public/panel.css
iindex 37fe7ec..f05d62b 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -548,3 +548,121 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
/* The e-stop reads as one: a red ring and glyph on the same scrim. */
.bot-estop { border-color: color-mix(in srgb, var(--danger) 70%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
.bot-pill[data-state="estop"] { border-color: color-mix(in srgb, var(--danger) 55%, rgba(255, 255, 255, 0.14)); }
++
++/* ── The panel in a frame, compact at any size (ask 353, 2026-11-11: on a phone the framed panel was 950 px of
++ banner, header, pills, a 240 px stick and four cards, and it pushed the stream's chat off the screen) ──────────
++ A frame's height follows its content (embed-frame.js), so nothing here may depend on the frame's height or
++ orientation: those media queries above are for the panel's own page. The frame's width decides the columns. */
++html.embed-page[data-bot-view="panel"] main#panel:not(.bot-surface) { padding: 0.6rem 0.7rem calc(0.7rem + env(safe-area-inset-bottom)); }
++/* The e-stop bar only when it says something (latched) or holds the button (you drive): clear and watching, the
++ state is the dot in the head's pill. */
++html.embed-page[data-bot-view="panel"] .estop-banner[data-latched="false"]:not(:has(.estop:not([hidden]))) { display: none; }
++html.embed-page[data-bot-view="panel"] .estop-bar { padding: 0.35rem 0.7rem; gap: 0.5rem; }
++html.embed-page[data-bot-view="panel"] .estop-state { font-size: 0.9rem; }
++html.embed-page[data-bot-view="panel"] .estop { min-height: 44px; min-width: 0; padding: 0 0.85rem; border-radius: 12px; font-size: 0.85rem; box-shadow: 0 3px 0 var(--danger-deep); }
++/* The head: one row with the name, its state, Drive and the way to the robot's own page. */
++html.embed-page[data-bot-view="panel"] #panel > .panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.5rem; margin: 0 0 0.6rem; }
++html.embed-page[data-bot-view="panel"] .panel-head .ovk-page-head { margin: 0; flex: 0 1 auto; min-width: 0; padding: 0; }
++html.embed-page[data-bot-view="panel"] .panel-head .ovk-page-head-row { gap: 0; min-height: 0; }
++html.embed-page[data-bot-view="panel"] .panel-head .ovk-heading-icon { display: none; }
++html.embed-page[data-bot-view="panel"] .panel-head h1 { margin: 0; font-size: 1.05rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++html.embed-page[data-bot-view="panel"] .panel-head .meta { flex: 1 1 0; min-width: 0; margin: 0; gap: 0.3rem; flex-wrap: nowrap; overflow: hidden; }
++html.embed-page[data-bot-view="panel"] .panel-head .meta .pill { min-height: 1.6rem; padding: 0 0.5rem; font-size: 0.75rem; }
++html.embed-page[data-bot-view="panel"] .embed-sign-in { display: flex; align-items: center; gap: 0.4rem; margin: 0 0 0 auto; }
++html.embed-page[data-bot-view="panel"] .embed-sign-in .button { min-height: 40px; padding: 0 0.85rem; font-size: 0.9rem; }
++html.embed-page[data-bot-view="panel"] .embed-open { width: 40px; min-width: 40px; padding: 0; }
++html.embed-page[data-bot-view="panel"] .embed-open span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++html.embed-page[data-bot-view="panel"] .panel-head .turn-strip, html.embed-page[data-bot-view="panel"] .panel-head > :is(.notice, .turn) { flex-basis: 100%; }
++/* Narrow: the name, its state and the actions; the profile and "Watching" (Drive says it) step aside. */
++@media (max-width: 30rem) { html.embed-page[data-bot-view="panel"] .panel-head .meta .pill:not(.online) { display: none; } }
++/* The cards: tighter, and a card with one control lets that control's name be its title. */
++html.embed-page[data-bot-view="panel"] .widgets { gap: 0.5rem; grid-template-columns: minmax(0, 1fr); }
++html.embed-page[data-bot-view="panel"] .widget { padding: 0.7rem; border-radius: 12px; }
++html.embed-page[data-bot-view="panel"] .widget h2 { margin: 0 0 0.45rem; font-size: 0.68rem; }
++html.embed-page[data-bot-view="panel"] .widget:has(> .controls > .control:only-child) > h2,
++html.embed-page[data-bot-view="panel"] .widget-drive > h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++html.embed-page[data-bot-view="panel"] :is(.widget-camera, .widget-video_click, .widget-drive) { grid-column: auto; grid-row: auto; position: static; box-shadow: none; }
++/* The stick and Stop side by side: a thumb on each. */
++html.embed-page[data-bot-view="panel"] .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.25rem; }
++html.embed-page[data-bot-view="panel"] .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }
++html.embed-page[data-bot-view="panel"] .drive-pad .stick { width: clamp(7.5rem, 40vw, 11rem); }
++html.embed-page[data-bot-view="panel"] .drive-pad .stop { width: 4.75rem; max-width: none; height: 4.75rem; min-height: 0; padding: 0; border-radius: 50%; flex-direction: column; gap: 0.15rem; font-size: 0.8rem; }
++html.embed-page[data-bot-view="panel"] .widget-drive .hint { margin: 0.55rem 0 0; }
++html.embed-page[data-bot-view="panel"] .stick--buttons, html.embed-page[data-bot-view="panel"] .stick--ptz { width: clamp(7.5rem, 40vw, 11rem); }
++/* Readouts as one short row each. */
++html.embed-page[data-bot-view="panel"] .widget-battery { display: flex; align-items: center; gap: 0.6rem; padding-block: 0.5rem; }
++html.embed-page[data-bot-view="panel"] .widget-battery > h2 { margin: 0; }
++html.embed-page[data-bot-view="panel"] .widget-battery .battery { flex: 1; gap: 0.5rem; }
++html.embed-page[data-bot-view="panel"] .widget-battery .readout { font-size: 0.95rem; }
++html.embed-page[data-bot-view="panel"] .widget-latency { padding-block: 0.5rem; }
++html.embed-page[data-bot-view="panel"] .widget-latency .stats { gap: 0.35rem; }
++html.embed-page[data-bot-view="panel"] .widget-latency .stat { padding: 0.3rem 0.5rem; }
++html.embed-page[data-bot-view="panel"] .widget-latency .stat .readout { font-size: 0.95rem; }
++html.embed-page[data-bot-view="panel"] .widget-latency .stat-label { font-size: 0.66rem; }
++/* Wider frames: the stick in its own column, the rest beside it. */
++@media (min-width: 34rem) {
++ html.embed-page[data-bot-view="panel"] .widgets { grid-template-columns: minmax(15rem, 0.9fr) minmax(0, 1fr); align-items: start; }
++ html.embed-page[data-bot-view="panel"] .widget-drive { grid-row: span 4; }
++ html.embed-page[data-bot-view="panel"] :is(.widget-camera, .widget-video_click) { grid-column: 1 / -1; }
++}
++@media (min-width: 60rem) {
++ html.embed-page[data-bot-view="panel"] .widgets { grid-template-columns: minmax(15rem, 0.8fr) repeat(2, minmax(0, 1fr)); }
++}
++html.embed-page[data-bot-view="panel"] .hint { display: block; }
++@media (hover: none) and (pointer: coarse) { html.embed-page[data-bot-view="panel"] .hint { display: none; } html.embed-page[data-bot-view="panel"] #panel[data-gamepad="true"] .hint { display: block; } }
++
++/* The stick on the surfaces fills its scrim (the pad around it had no size, so the stick was a dot). */
++.bot-stick .drive-pad, .bot-stick .ptz-pad { display: grid; grid-template: 100% / 100%; width: 100%; height: 100%; gap: 0; }
++.bot-stick .bot-widget > .stick, .bot-stick :is(.drive-pad, .ptz-pad) > .stick { width: 100%; height: 100%; }
++.bot-stick .stick[aria-disabled="true"] { opacity: 0.55; }
++.bot-slider input:disabled { opacity: 0.55; }
++
++/* A watcher's Drive: where the e-stop goes once they have the controls; and the page's answer under it. */
++.bot-drive {
++ position: absolute; top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right));
++ height: 44px; min-height: 44px; padding: 0 14px 0 12px; gap: 6px; border-radius: 22px; font-size: 13px; font-weight: 700;
++ background: var(--fill-accent); border-color: color-mix(in srgb, var(--fill-accent) 70%, #fff); color: var(--on-fill-accent);
++}
++.bot-drive .icon { width: 18px; height: 18px; }
++.bot-notice {
++ position: absolute; left: 50%; top: calc(16px + env(safe-area-inset-top)); transform: translateX(-50%);
++ max-width: min(22rem, calc(100% - 32px)); margin: 0; padding: 6px 12px; border-radius: 12px; font-size: 12px; line-height: 1.35; text-align: center;
++}
++.bot-overlay .bot-notice { top: auto; bottom: calc(16px + env(safe-area-inset-bottom)); }
++@media (max-height: 300px) {
++ .bot-drive { top: calc(var(--bot-gap) + env(safe-area-inset-top)); right: calc(12px + 44px + 8px + env(safe-area-inset-right)); }
++ .bot-overlay .bot-notice { bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); max-width: calc(100% - 2 * var(--bot-sz) - 24px); }
++}
++
++/* The one row, as a small remote: the stick at the left, the name over the slider, Drive or the e-stop over "more".
++ A wide frame puts it all on one line. */
++body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }
++.bot-compact {
++ display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; grid-template-areas: "stick pill act" "stick slider more" "note note note";
++ align-items: center; gap: 6px 12px; min-height: 112px; padding: 8px 10px; border-radius: 16px;
++}
++.bot-compact[data-no-slider] { grid-template-areas: "stick pill act" "stick pill more" "note note note"; }
++.bot-compact .bot-stick { grid-area: stick; }
++.bot-compact .bot-pill { grid-area: pill; height: 32px; padding: 0 4px; font-size: 13px; }
++.bot-compact .bot-slider { grid-area: slider; width: 100%; max-width: 260px; height: 44px; padding: 0 10px; }
++.bot-compact .bot-slider .bot-widget, .bot-compact .bot-slider label { width: 100%; }
++.bot-compact .bot-slider input[type="range"] { width: 100%; height: 28px; }
++.bot-compact :is(.bot-estop, .bot-drive) { grid-area: act; justify-self: end; }
++.bot-compact .bot-more { grid-area: more; justify-self: end; }
++.bot-compact .bot-drive { position: static; }
++.bot-compact .bot-notice { grid-area: note; position: static; transform: none; max-width: none; text-align: left; }
++@media (min-width: 36rem) {
++ .bot-compact { grid-template-columns: minmax(0, 1fr) 96px minmax(8rem, 16rem) auto auto; grid-template-areas: "pill stick slider act more" "note note note note note"; }
++ .bot-compact[data-no-slider] { grid-template-areas: "pill stick stick act more" "note note note note note"; }
++}
++/* The row sits in the host's own card (Live's robot card): no second box, and the page's colours, so it reads on a
++ light card as well as a dark one. Drive keeps its fill; the e-stop its red ring. */
++.bot-compact { background: transparent; border: 0; border-radius: 0; -webkit-backdrop-filter: none; backdrop-filter: none; padding: 6px 8px; }
++body.bot-mode-compact .bot-compact .bot-control { color: var(--text); }
++body.bot-mode-compact .bot-compact .bot-pill-state { color: var(--muted); }
++body.bot-mode-compact .bot-compact .bot-stick { background: color-mix(in srgb, var(--text) 6%, transparent); border-color: var(--line); }
++body.bot-mode-compact .bot-compact .bot-stick .stick-ring { border-color: var(--line); }
++body.bot-mode-compact .bot-compact :is(.bot-more, .bot-estop) { background: color-mix(in srgb, var(--text) 7%, transparent); }
++body.bot-mode-compact .bot-compact .bot-estop { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 70%, transparent); }
++body.bot-mode-compact .bot-compact .bot-drive { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }
++body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); }
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 6737ded..1e61364 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -57,6 +57,7 @@ const PATHS = {
plug: '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
gamepad: '<path d="M7 7h10a4 4 0 0 1 4 4l.8 5a2.6 2.6 0 0 1-4.6 2L15 15H9l-2.2 3a2.6 2.6 0 0 1-4.6-2L3 11a4 4 0 0 1 4-4z"/><path d="M7.5 10v3M6 11.5h3M16 11h.01M18 13h.01"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
++ external: '<path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>',
};
const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;
/** Hold-button icons by axis: [negative, positive]. */
@@@ -626,23 +627,32 @@ function overlaySlider(profile, allowed) {
}
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>` : '');
++// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel; the e-stop waits,
++// hidden, for the join that makes them an operator (public/panel.js onFrame), and the line under the controls says
++// what the page around answered (sign in there, or the owner decides who drives). Ask 353, 2026-11-11: on a phone over
++// the stream there was no way to start driving at all.
++const botEstop = (role, canDrive = false) => (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>`
++ : canDrive ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit aria-label="E-stop" title="E-stop" hidden>${icon('stop')}</button>` : '');
++const botDrive = (canDrive) => (canDrive
++ ? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');
++const botNotice = '<p class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden></p>';
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 = [] }) {
++function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResendMs = 150, view = 'overlay', parentOrigins = [], signedIn = true }) {
const stick = overlayStickMarkup(profile, allowed_commands);
const slider = overlaySlider(profile, allowed_commands);
-- const stop = botEstop(role);
++ const canDrive = role === 'watcher' && !signedIn;
++ const stop = botEstop(role, canDrive) + botDrive(canDrive);
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>`;
++ ? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'}>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}${botNotice}</div>`
++ : `<div class="bot-overlay" data-bot-overlay>${botPill(robot)}${stickMarkup}${sliderMarkup}${stop}${botMore}${botNotice}</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>`;
}
@@@ -651,7 +661,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
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 }), {
++ return page(robot.name, renderBotSurface({ robot, profile, role, allowed_commands, holdResendMs, view: botView, parentOrigins, signedIn }), {
scripts: panelScripts(profile, false, true),
bodyClass: `embed-page bot-mode-${botView}`,
htmlClass: `embed-page bot-mode-${botView}`,
@@@ -678,9 +688,9 @@ ${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') :
${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>
${turnStrip(robot, role)}
++${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in"><button type="button" class="button primary" data-drive data-drive-bar>${icon('gamepad')}<span>Drive</span></button> <a class="button embed-open" href="/panel/${esc(robot.id)}" target="_blank" rel="noopener" title="Open on openvibe.bot">${icon('external')}<span>Open on openvibe.bot</span></a></p>` : ''}
</div>
${tryIt ? tryIntro() : ''}
--${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}
<p class="notice" data-device-fault role="status" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
ddiff --git a/test/bot-views.test.js b/test/bot-views.test.js
iindex 17c85ac..9d51f18 100644
---- a/test/bot-views.test.js
++++ b/test/bot-views.test.js
@@@ -52,7 +52,11 @@ const render = (over = {}) => renderPanel({
};
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-estop'), 'a signed-in watcher has no e-stop and no Drive here');
++ const anon = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay', signedIn: false });
++ assert.ok(/<button[^>]*class="bot-drive bot-control"[^>]*data-drive data-drive-bar data-bot-hit/.test(anon), 'the anonymous embed takes the controls with Drive (ask 353)');
++ assert.ok(/<button[^>]*class="bot-estop bot-control"[^>]*data-estop[^>]*hidden>/.test(anon), 'its e-stop waits, hidden, for the join');
++ assert.ok(anon.includes('class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden'), 'and the page\'s answer has a line');
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] } } } },
ddiff --git a/test/embed-drive.test.js b/test/embed-drive.test.js
iindex 43abc76..0d58925 100644
---- a/test/embed-drive.test.js
++++ b/test/embed-drive.test.js
@@@ -35,7 +35,7 @@ const { renderPanel } = require('../server/web/render');
await check('the framed watcher gets a Drive button and a hidden e-stop; the client takes tickets from its own popup only', () => {
const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };
const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });
-- assert.ok(html.includes('data-drive>') && html.includes('data-drive-bar'));
++ assert.ok(html.includes('data-drive data-drive-bar>'), 'Drive is its own bar: it goes when the controls are taken');
assert.ok(html.includes('data-estop hidden>'), 'the e-stop waits for an owner or operator join');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
// Only its own popup, or the allowed page around the frame (stream-buttons.test.js), hands it a ticket.
ddiff --git a/test/embed-panel.test.js b/test/embed-panel.test.js
iindex 5076520..efe8776 100644
---- a/test/embed-panel.test.js
++++ b/test/embed-panel.test.js
@@@ -24,7 +24,8 @@ const { renderPanel } = require('../server/web/render');
assert.ok(!watcher.includes('data-estop>') && !watcher.includes('data-estop-clear') && !watcher.includes('data-embed-form'));
assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));
assert.ok(!/<button(?![^>]*disabled)[^>]*data-(hold|stop)/.test(watcher), 'a control is enabled for a watcher');
-- assert.match(watcher, /<a class="button" href="\/panel\/rob_e1" target="_blank" rel="noopener">Open on openvibe.bot<\/a>/);
++ // Ask 353 (2026-11-11): in the head's row, as an icon with its words for screen readers.
++ assert.match(watcher, /<a class="button embed-open" href="\/panel\/rob_e1" target="_blank" rel="noopener" title="Open on openvibe.bot"><svg[^>]*>.*?<\/svg><span>Open on openvibe.bot<\/span><\/a>/);
for (const a of watcher.match(/<a [^>]*>/g)) assert.ok(a.includes('target="_blank"') && a.includes('rel="noopener"'), a);
const signedIn = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: true });
assert.ok(!signedIn.includes('Open on openvibe.bot'));