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)
Changes
@@@ -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();};}@@@ -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); }@@@ -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 = [], holdResendMsconst 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">@@@ -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] } } } },@@@ -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.@@@ -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'));
@@ -186,6 +186,13 @@
@@ -186,6 +186,13 @@
if (doc.body) observer.observe(doc.body);
if (doc.body) observer.observe(doc.body);
observer.observe(html);
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);
send(true);
return () => {
return () => {
@@ -194,6 +201,7 @@
@@ -194,6 +201,7 @@
win.removeEventListener('resize', onResize);
win.removeEventListener('resize', onResize);
if (timer) { win.clearTimeout(timer); timer = 0; }
if (timer) { win.clearTimeout(timer); timer = 0; }
if (observer) observer.disconnect();
if (observer) observer.disconnect();
+ if (watcher) watcher.disconnect();
};
};
}
}
@@ -548,3 +548,121 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
@@ -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. */
/* 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-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)); }
.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); }
@@ -57,6 +57,7 @@ const PATHS = {
@@ -57,6 +57,7 @@ const PATHS = {
plug: '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
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"/>',
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"/>',
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>`;
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]. */
/** Hold-button icons by axis: [negative, positive]. */
@@ -626,23 +627,32 @@ function overlaySlider(profile, allowed) {
@@ -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 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>';
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. */
/** 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 stick = overlayStickMarkup(profile, allowed_commands);
const slider = overlaySlider(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 sliderLabel = slider ? esc(humanName(slider.name)) : '';
const sliderMarkup = slider
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>`
? `<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 stickMarkup = stick ? `<div class="bot-stick bot-control" data-bot-hit>${stick}</div>` : '';
const body = view === 'compact'
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>`;
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
@@ -651,7 +661,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
const botView = embed ? botViewOf(view) : 'panel';
const botView = embed ? botViewOf(view) : 'panel';
if (embed && botView !== 'panel') {
if (embed && botView !== 'panel') {
const [minWidth, minHeight] = BOT_VIEW_MIN[botView];
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),
scripts: panelScripts(profile, false, true),
bodyClass: `embed-page bot-mode-${botView}`,
bodyClass: `embed-page bot-mode-${botView}`,
htmlClass: `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>', '/') :
@@ -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' } })}
${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>
<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)}
${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>
</div>
${tryIt ? tryIntro() : ''}
${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-device-fault role="status" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
<div class="widgets">
@@ -52,7 +52,11 @@ const render = (over = {}) => renderPanel({
@@ -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' });
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-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');
assert.ok(cam.includes('data-bot-more'), 'but there is always a way to the full panel');
const hornOnly = {
const hornOnly = {
id: 'horn.demo', name: 'Horn', commands: { actuator: { names: { horn: { type: 'tone', hz: [220, 880] } } } },
id: 'horn.demo', name: 'Horn', commands: { actuator: { names: { horn: { type: 'tone', hz: [220, 880] } } } },
@@ -35,7 +35,7 @@ const { renderPanel } = require('../server/web/render');
@@ -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', () => {
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 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 });
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');
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');
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.
// Only its own popup, or the allowed page around the frame (stream-buttons.test.js), hands it a ticket.
@@ -24,7 +24,8 @@ const { renderPanel } = require('../server/web/render');
@@ -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-estop>') && !watcher.includes('data-estop-clear') && !watcher.includes('data-embed-form'));
assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));
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.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);
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 });
const signedIn = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: true });
assert.ok(!signedIn.includes('Open on openvibe.bot'));
assert.ok(!signedIn.includes('Open on openvibe.bot'));