openvibers/openvibe.bot Commits
Controls v2: the pad, Stop, the head slider and the readouts redesigned, arranged per person (#393)
Changes
@@@ -448,7 +448,7 @@ resumes it at once.```json{ "v": 1, "seq": 3, "ts": 1738065600206, "type": "command_result", "id": "cmd_01J8Z4F…",-- "result": "refused", "code": "bot.device_offline", "reason": "the device is offline; commands are never queued" }++ "result": "refused", "code": "bot.device_offline", "reason": "The robot is offline. Nothing you press waits for it: it moves only while it is connected." }``````json@@@ -677,3 +677,115 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:background: color-mix(in srgb, var(--fill-accent) 85%, transparent) center / 11px 11px no-repeaturl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round'%3E%3Crect x='5' y='11' width='14' height='9' rx='2'/%3E%3Cpath d='M8 11V8a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E");}++++/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:++ "redesign … polish … more consistent … per user"). One family on the full panel, in a channel's Robot tab and++ framed anywhere: the stick has its four marks and a knob that lights when it drives; Stop is a ringed red disc++ beside it (on the hand this person chose); a slider says its value with − and + to step it; battery and latency++ are slim readout rows. The pad's arrangement menu (the sliders icon) keeps the hand, the size and the readouts. ─── */++#panel .widget-drive { position: relative; }++#panel .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }++#panel .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }++#panel[data-hand="left"] .drive-pad .stop { order: -1; }++#panel .stick {++ --stick: clamp(8.5rem, 44vw, 12.5rem);++ width: var(--stick);++ background:++ radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--surface) 70%, var(--text) 8%) 0%, color-mix(in srgb, var(--bg) 92%, #000) 72%);++ border: 1px solid color-mix(in srgb, var(--line) 80%, var(--text) 20%);++ box-shadow: inset 0 2px 14px rgba(0, 0, 0, 0.55), inset 0 -1px 0 rgba(255, 255, 255, 0.04), 0 1px 0 rgba(255, 255, 255, 0.05);++ transition: box-shadow 0.18s ease, border-color 0.18s ease;++}++#panel[data-pad="s"] .stick { --stick: clamp(7rem, 34vw, 9.5rem); }++#panel[data-pad="l"] .stick { --stick: clamp(10rem, 58vw, 15rem); }++#panel .stick-ring { inset: 22%; border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); }++#panel .stick-cross { inset: 30%; opacity: 0.35; }++#panel .stick-dirs { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; fill: color-mix(in srgb, var(--text) 32%, transparent); transition: fill 0.18s ease; }++#panel .stick-knob {++ width: 38%;++ background:++ radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.28), transparent 42%),++ radial-gradient(circle at 50% 55%, color-mix(in srgb, var(--raised) 85%, var(--text) 15%), color-mix(in srgb, var(--bg) 70%, #000) 78%);++ border: 1px solid color-mix(in srgb, var(--line) 50%, var(--text) 30%);++ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.12);++}++#panel .stick.held, #panel .stick.active { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); box-shadow: inset 0 2px 14px rgba(0, 0, 0, 0.5), 0 0 0 4px color-mix(in srgb, var(--accent) 16%, transparent); }++#panel .stick.held .stick-dirs, #panel .stick.active .stick-dirs { fill: color-mix(in srgb, var(--accent) 70%, var(--text)); }++#panel .stick.active .stick-knob { background: radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.4), transparent 42%), radial-gradient(circle at 50% 55%, #6ea8ff, var(--accent) 75%); border-color: #9cc2ff; box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 22%, transparent), 0 10px 22px rgba(0, 0, 0, 0.45); }++#panel .stick[aria-disabled="true"] { opacity: 0.5; filter: saturate(0.4); }++++#panel .drive-pad .stop {++ width: calc(var(--stick, 11rem) * 0.46); height: calc(var(--stick, 11rem) * 0.46); min-width: 4.25rem; min-height: 4.25rem; max-width: 6rem; max-height: 6rem;++ padding: 0; border-radius: 50%; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.2rem;++ font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em; color: var(--text);++ background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 22%, var(--surface)), color-mix(in srgb, var(--fill-danger) 10%, var(--bg)));++ border: 2px solid color-mix(in srgb, var(--danger) 70%, transparent);++ box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 10%, transparent), 0 6px 14px rgba(0, 0, 0, 0.35);++ transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease;++}++#panel .drive-pad .stop .icon { width: 1.35em; height: 1.35em; fill: var(--danger); stroke: none; }++#panel .drive-pad .stop:hover:not(:disabled) { background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 36%, var(--surface)), color-mix(in srgb, var(--fill-danger) 18%, var(--bg))); }++#panel .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }++#panel .drive-pad .stop:disabled { opacity: 0.5; filter: saturate(0.5); }++++/* The arrangement menu: a quiet icon in the drive card's corner, its choices as segmented rows. */++#panel .pad-prefs-btn { position: absolute; top: 0.45rem; right: 0.45rem; min-width: 36px; min-height: 36px; width: 36px; height: 36px; padding: 0; border-radius: 10px; display: grid; place-items: center; background: transparent; border-color: transparent; color: var(--muted); }++#panel .pad-prefs-btn:hover, #panel .pad-prefs-btn[aria-expanded="true"] { color: var(--text); background: var(--raised); }++#panel .pad-prefs-btn .icon { width: 1.15rem; height: 1.15rem; }++#panel .pad-prefs { position: absolute; top: 2.9rem; right: 0.45rem; z-index: 5; display: grid; gap: 0.6rem; width: min(17rem, calc(100% - 0.9rem)); padding: 0.75rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 16px 36px -12px rgba(0, 0, 0, 0.6); }++#panel .pad-prefs[hidden] { display: none; }++#panel .pad-pref { display: grid; gap: 0.3rem; }++#panel .pad-pref-l { font-size: 0.72rem; font-weight: 600; color: var(--muted); }++#panel .pad-pref-opts { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 3px; border-radius: 10px; background: var(--bg); }++#panel .pad-pref label { position: relative; }++#panel .pad-pref input { position: absolute; inset: 0; opacity: 0; margin: 0; width: 100%; height: 100%; min-height: 0; cursor: pointer; }++#panel .pad-pref span { display: grid; place-items: center; min-height: 34px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; color: var(--muted); }++#panel .pad-pref input:checked + span { background: var(--raised); color: var(--text); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05), 0 2px 6px rgba(0, 0, 0, 0.25); }++#panel .pad-pref input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }++++/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */++#panel .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }++#panel .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }++#panel .slider-head .control-name { min-width: 0; color: var(--text); font-weight: 600; font-size: 0.9rem; }++#panel .control-value { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }++#panel .slider-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; }++#panel .slider-row .step { width: 40px; height: 40px; min-width: 40px; min-height: 40px; padding: 0; border-radius: 50%; display: grid; place-items: center; }++#panel .slider-row .step .icon { width: 1.1rem; height: 1.1rem; }++#panel .slider-row input[type="range"] {++ -webkit-appearance: none; appearance: none; min-width: 0; height: 28px; min-height: 0; margin: 0; background: transparent; cursor: pointer;++}++#panel .slider-row input[type="range"]::-webkit-slider-runnable-track { height: 8px; border-radius: 999px; background: linear-gradient(90deg, var(--accent) 0 var(--p, 50%), color-mix(in srgb, var(--line) 80%, var(--text) 10%) var(--p, 50%) 100%); }++#panel .slider-row input[type="range"]::-moz-range-track { height: 8px; border-radius: 999px; background: color-mix(in srgb, var(--line) 80%, var(--text) 10%); }++#panel .slider-row input[type="range"]::-moz-range-progress { height: 8px; border-radius: 999px; background: var(--accent); }++#panel .slider-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; margin-top: -8px; border-radius: 50%; background: #fff; border: 0; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent), 0 2px 6px rgba(0, 0, 0, 0.4); transition: box-shadow 0.15s ease; }++#panel .slider-row input[type="range"]::-moz-range-thumb { width: 24px; height: 24px; border-radius: 50%; background: #fff; border: 0; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent), 0 2px 6px rgba(0, 0, 0, 0.4); }++#panel .slider-row input[type="range"]:active::-webkit-slider-thumb { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 30%, transparent), 0 2px 6px rgba(0, 0, 0, 0.4); }++#panel .slider-row input[type="range"]:disabled { opacity: 0.45; cursor: not-allowed; }++++/* Readouts: battery and latency as slim rows (the heading is the row's label), folded when this person folds them. */++#panel .widget-battery { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; }++#panel .widget-battery > h2 { margin: 0; }++#panel .battery { gap: 0.6rem; }++#panel .battery meter { height: 0.55rem; border-radius: 999px; border: 0; background: color-mix(in srgb, var(--line) 80%, var(--text) 8%); }++#panel .battery meter::-webkit-meter-bar { height: 0.55rem; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--line) 80%, var(--text) 8%); }++#panel .battery meter::-webkit-meter-optimum-value { border-radius: 999px; background: var(--ok); }++#panel .battery meter::-webkit-meter-suboptimum-value { border-radius: 999px; background: var(--warn); }++#panel .battery meter::-webkit-meter-even-less-good-value { border-radius: 999px; background: var(--danger); }++#panel .battery .readout { font-size: 0.95rem; min-width: 3.2ch; text-align: right; }++#panel .widget-latency { padding: 0.6rem 0.9rem 0.75rem; }++#panel .widget-latency > h2 { margin-bottom: 0.45rem; }++#panel .stats { gap: 0.4rem; }++#panel .stat { gap: 0.1rem; padding: 0.4rem 0.55rem; border-radius: 10px; border-color: transparent; background: color-mix(in srgb, var(--bg) 70%, var(--surface)); }++#panel .stat .readout { font-size: 1rem; }++#panel .stat-label { font-size: 0.68rem; line-height: 1.2; }++#panel .readout:is(:empty) { color: var(--muted); }++#panel[data-readouts="off"] :is(.widget-battery, .widget-latency, .widget-telemetry) { display: none; }++++/* The offline notice: a calm line, not an alarm. */++#panel .notice[data-device-fault], #panel .notice[data-notice] { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; line-height: 1.35; }++#panel .notice[hidden] { display: none; }++++@media (prefers-reduced-motion: reduce) { #panel .stick, #panel .drive-pad .stop, #panel .stick-dirs { transition: none; } }++#panel .pad-prefs { text-align: left; }++/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */++#panel .widgets { align-items: start; }@@@ -879,6 +879,49 @@});for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop);});++ // Controls v2: a slider says its value and fills its track to it (--p); − and + beside it step it a tenth of its++ // range, the same input a drag makes, so the same command goes (and the same throttle holds).++ const fmt = (v) => (Math.abs(v) >= 10 ? String(Math.round(v)) : String(Math.round(v * 100) / 100));++ $$('input[type="range"][data-actuator]').forEach((el) => {++ const out = document.querySelector(`[data-value-of="${CSS.escape(el.dataset.actuator)}"]`);++ const paint = () => { const lo = Number(el.min), hi = Number(el.max), v = Number(el.value); el.style.setProperty('--p', `${((v - lo) / ((hi - lo) || 1)) * 100}%`); if (out) out.textContent = fmt(v); };++ el.addEventListener('input', paint);++ el.addEventListener('change', paint);++ paint();++ });++ $$('[data-step-for]').forEach((b) => b.addEventListener('click', () => {++ const el = document.querySelector(`input[data-actuator="${CSS.escape(b.dataset.stepFor)}"]`);++ if (!el || el.disabled) return;++ const lo = Number(el.min), hi = Number(el.max);++ el.value = String(Math.min(hi, Math.max(lo, Number(el.value) + ((hi - lo) / 10) * Number(b.dataset.step))));++ el.dispatchEvent(new Event('input', { bubbles: true }));++ el.dispatchEvent(new Event('change', { bubbles: true }));++ }));++ // The pad's arrangement, this person's (kept in this browser): the Stop's hand, the pad's size, the readouts.++ (() => {++ const KEY = 'ov-bot-pad-prefs';++ const panelEl = document.getElementById('panel');++ const btn = document.querySelector('[data-pad-prefs]');++ const menu = document.querySelector('[data-pad-prefs-menu]');++ if (!panelEl) return;++ let prefs = { hand: 'right', size: 'm', readouts: 'on' };++ try { prefs = { ...prefs, ...(JSON.parse(localStorage.getItem(KEY) || '{}') || {}) }; } catch { /* private mode */ }++ const apply = () => {++ panelEl.dataset.hand = prefs.hand; panelEl.dataset.pad = prefs.size; panelEl.dataset.readouts = prefs.readouts;++ $$('[data-pad-pref]').forEach((r) => { r.checked = prefs[r.dataset.padPref] === r.value; });++ };++ apply();++ if (!btn || !menu) return;++ const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };++ btn.addEventListener('click', (e) => { e.stopPropagation(); const open = menu.hidden; menu.hidden = !open; btn.setAttribute('aria-expanded', String(open)); });++ document.addEventListener('click', (e) => { if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close(); });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !menu.hidden) { close(); btn.focus(); } });++ $$('[data-pad-pref]').forEach((r) => r.addEventListener('change', () => {++ prefs[r.dataset.padPref] = r.value;++ try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch { /* */ }++ apply();++ }));++ })();const stopRepeats = () => { [...repeats].forEach((s) => s()); };window.addEventListener('blur', stopRepeats);document.addEventListener('visibilitychange', () => { if (document.hidden) stopRepeats(); });@@@ -943,7 +986,8 @@function enableControls() {for (const section of $$('[data-command]')) {const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);-- for (const el of $$('button, input, select', section)) el.disabled = !ok;++ // The pad's arrangement is this person's own and works offline: it is never a control of the robot.++ for (const el of $$('button, input, select', section)) if (!el.closest('[data-pad-prefs-menu]') && !el.matches('[data-pad-prefs]')) el.disabled = !ok;for (const el of $$('[data-joystick], [data-button-stick], [data-ptz-stick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }}announceControls();@@@ -1458,7 +1458,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}const allow = allowedFor(robot, role, profile);if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };-- if (!online) return { ok: false, code: 'bot.device_offline', reason: 'the device is offline; commands are never queued', robot, role };++ if (!online) return { ok: false, code: 'bot.device_offline', reason: 'The robot is offline. Nothing you press waits for it: it moves only while it is connected.', robot, role };const eff = effectiveLimits(robot, profile);const release = kind === 'button' && value && value.state === 'up';@@@ -48,6 +48,7 @@ const PATHS = {cw: '<path d="M20 4v6h-6"/><path d="M18.5 15a7.5 7.5 0 1 1-1.8-7.8L20 10"/>',plus: '<path d="M12 5v14M5 12h14"/>',minus: '<path d="M5 12h14"/>',++ sliders: '<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>',stop: '<path d="M8.2 2h7.6L22 8.2v7.6L15.8 22H8.2L2 15.8V8.2z"/><path d="M8 12h8"/>',square: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',camera: '<path d="M3 7h3l2-3h8l2 3h3v12H3z"/><circle cx="12" cy="13" r="3.5"/>',@@@ -127,11 +128,28 @@ function drivePad(axes, enabled) {const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');const joystick = x || y? `<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-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg>'+ `<span class="stick-knob" data-knob></span></div>`: '';return `<div class="drive-pad" data-axes="${data(axes)}">${joystick}${Object.keys(rest).length ? `<div class="pad">${axisButtons(rest, enabled)}</div>` : ''}`-- + `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;++ + `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`++ + padPrefs();++}++++/**++ * How this person likes the pad (controls v2, ask 368: "make it … per user"): the hand the Stop sits on, the pad's size++ * and the readouts shown or folded. public/panel.js keeps the choice in this browser and sets data-hand, data-pad and++ * data-readouts on #panel; nothing here changes what the robot does.++ */++function padPrefs() {++ const group = (name, label, opts) => `<div class="pad-pref" role="radiogroup" aria-label="${esc(label)}"><span class="pad-pref-l">${esc(label)}</span><div class="pad-pref-opts">${opts.map(([v, t]) =>++ `<label><input type="radio" name="pad-${esc(name)}" value="${esc(v)}" data-pad-pref="${esc(name)}"><span>${esc(t)}</span></label>`).join('')}</div></div>`;++ return `<button type="button" class="pad-prefs-btn" data-pad-prefs aria-expanded="false" aria-controls="pad-prefs" title="Arrange the controls" aria-label="Arrange the controls">${icon('sliders')}</button>`++ + `<div class="pad-prefs" id="pad-prefs" data-pad-prefs-menu hidden>`++ + group('hand', 'Stop on the', [['right', 'Right'], ['left', 'Left']])++ + group('size', 'Pad', [['s', 'Small'], ['m', 'Medium'], ['l', 'Large']])++ + group('readouts', 'Readouts', [['on', 'Shown'], ['off', 'Folded']])++ + '</div>';}/** An actuator's name as a label: backpack_lights → Backpack lights. */@@@ -157,7 +175,10 @@ function actuatorControl(name, a, enabled, profileId = '') {const off = disabledUnless(enabled);const label = humanName(name);if (a.type === 'number') {-- return `<label class="control slider"><span class="control-name">${esc(label)}</span> ${numberRange(name, a, enabled, profileId)}</label>`;++ return `<div class="control slider"><label class="slider-head"><span class="control-name">${esc(label)}</span><output class="control-value" data-value-of="${esc(name)}">0</output></label>`++ + `<div class="slider-row"><button type="button" class="step" data-step-for="${esc(name)}" data-step="-1" aria-label="${esc(`${label} down`)}"${off}>${icon('minus')}</button>`++ + `${numberRange(name, a, enabled, profileId)}`++ + `<button type="button" class="step" data-step-for="${esc(name)}" data-step="1" aria-label="${esc(`${label} up`)}"${off}>${icon('plus')}</button></div></div>`;}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>`@@@ -234,7 +255,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =// sim … would work killer for LOOI or any PTZ"): drag to move, let go to stop; zoom stays on its buttons.const ptzStick = axes.pan && axes.tilt? `<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>'++ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div>': '';inner = `<div class="ptz-pad">${ptzStick}<div class="pad">${axisButtons(axes, enabled)}</div></div>`;break;@@@ -283,7 +304,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =const hasDrive = (profile.widgets || []).some((x) => x.type === 'drive');const buttonStick = dirs.length >= 2 && !hasDrive? `<div class="stick stick--buttons" data-button-stick role="img" aria-label="Joystick: presses ${esc(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>'++ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div>'+ `<p class="hint"><kbd>W A S D</kbd> or arrows press ${esc(dirs.map((d) => d.label).join(', '))}. <span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad's stick and D-pad do too.</span></p>`: '';inner = `${buttonStick}<div class="button-grid">${Object.entries(names).map(([name, button]) =>@@@ -590,14 +611,14 @@ function overlayStickMarkup(profile, allowed) {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>';++ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><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>';++ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div></div></section>';}if (w.type === 'buttons') {const names = (commands.button && commands.button.names) || {};@@@ -605,7 +626,7 @@ function overlayStickMarkup(profile, allowed) {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>';++ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div></section>';}}return '';
@@ -448,7 +448,7 @@ resumes it at once.
@@ -448,7 +448,7 @@ resumes it at once.
```json
```json
{ "v": 1, "seq": 3, "ts": 1738065600206, "type": "command_result", "id": "cmd_01J8Z4F…",
{ "v": 1, "seq": 3, "ts": 1738065600206, "type": "command_result", "id": "cmd_01J8Z4F…",
- "result": "refused", "code": "bot.device_offline", "reason": "the device is offline; commands are never queued" }
+ "result": "refused", "code": "bot.device_offline", "reason": "The robot is offline. Nothing you press waits for it: it moves only while it is connected." }
```
```
```json
```json
@@ -677,3 +677,115 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -677,3 +677,115 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
background: color-mix(in srgb, var(--fill-accent) 85%, transparent) center / 11px 11px no-repeat
background: color-mix(in srgb, var(--fill-accent) 85%, transparent) center / 11px 11px no-repeat
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round'%3E%3Crect x='5' y='11' width='14' height='9' rx='2'/%3E%3Cpath d='M8 11V8a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E");
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round'%3E%3Crect x='5' y='11' width='14' height='9' rx='2'/%3E%3Cpath d='M8 11V8a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E");
}
}
+
+/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:
+ "redesign … polish … more consistent … per user"). One family on the full panel, in a channel's Robot tab and
+ framed anywhere: the stick has its four marks and a knob that lights when it drives; Stop is a ringed red disc
+ beside it (on the hand this person chose); a slider says its value with − and + to step it; battery and latency
+ are slim readout rows. The pad's arrangement menu (the sliders icon) keeps the hand, the size and the readouts. ─── */
+#panel .widget-drive { position: relative; }
+#panel .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }
+#panel .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }
+#panel[data-hand="left"] .drive-pad .stop { order: -1; }
+#panel .stick {
+ --stick: clamp(8.5rem, 44vw, 12.5rem);
+ width: var(--stick);
+ background:
+ radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--surface) 70%, var(--text) 8%) 0%, color-mix(in srgb, var(--bg) 92%, #000) 72%);
+ border: 1px solid color-mix(in srgb, var(--line) 80%, var(--text) 20%);
+ box-shadow: inset 0 2px 14px rgba(0, 0, 0, 0.55), inset 0 -1px 0 rgba(255, 255, 255, 0.04), 0 1px 0 rgba(255, 255, 255, 0.05);
+ transition: box-shadow 0.18s ease, border-color 0.18s ease;
+}
+#panel[data-pad="s"] .stick { --stick: clamp(7rem, 34vw, 9.5rem); }
+#panel[data-pad="l"] .stick { --stick: clamp(10rem, 58vw, 15rem); }
+#panel .stick-ring { inset: 22%; border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); }
+#panel .stick-cross { inset: 30%; opacity: 0.35; }
+#panel .stick-dirs { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; fill: color-mix(in srgb, var(--text) 32%, transparent); transition: fill 0.18s ease; }
+#panel .stick-knob {
+ width: 38%;
+ background:
+ radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.28), transparent 42%),
+ radial-gradient(circle at 50% 55%, color-mix(in srgb, var(--raised) 85%, var(--text) 15%), color-mix(in srgb, var(--bg) 70%, #000) 78%);
+ border: 1px solid color-mix(in srgb, var(--line) 50%, var(--text) 30%);
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+#panel .stick.held, #panel .stick.active { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); box-shadow: inset 0 2px 14px rgba(0, 0, 0, 0.5), 0 0 0 4px color-mix(in srgb, var(--accent) 16%, transparent); }
+#panel .stick.held .stick-dirs, #panel .stick.active .stick-dirs { fill: color-mix(in srgb, var(--accent) 70%, var(--text)); }
+#panel .stick.active .stick-knob { background: radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.4), transparent 42%), radial-gradient(circle at 50% 55%, #6ea8ff, var(--accent) 75%); border-color: #9cc2ff; box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 22%, transparent), 0 10px 22px rgba(0, 0, 0, 0.45); }
+#panel .stick[aria-disabled="true"] { opacity: 0.5; filter: saturate(0.4); }
+
+#panel .drive-pad .stop {
+ width: calc(var(--stick, 11rem) * 0.46); height: calc(var(--stick, 11rem) * 0.46); min-width: 4.25rem; min-height: 4.25rem; max-width: 6rem; max-height: 6rem;
+ padding: 0; border-radius: 50%; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.2rem;
+ font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em; color: var(--text);
+ background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 22%, var(--surface)), color-mix(in srgb, var(--fill-danger) 10%, var(--bg)));
+ border: 2px solid color-mix(in srgb, var(--danger) 70%, transparent);
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 10%, transparent), 0 6px 14px rgba(0, 0, 0, 0.35);
+ transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease;
+}
+#panel .drive-pad .stop .icon { width: 1.35em; height: 1.35em; fill: var(--danger); stroke: none; }
+#panel .drive-pad .stop:hover:not(:disabled) { background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 36%, var(--surface)), color-mix(in srgb, var(--fill-danger) 18%, var(--bg))); }
+#panel .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }
+#panel .drive-pad .stop:disabled { opacity: 0.5; filter: saturate(0.5); }
+
+/* The arrangement menu: a quiet icon in the drive card's corner, its choices as segmented rows. */
+#panel .pad-prefs-btn { position: absolute; top: 0.45rem; right: 0.45rem; min-width: 36px; min-height: 36px; width: 36px; height: 36px; padding: 0; border-radius: 10px; display: grid; place-items: center; background: transparent; border-color: transparent; color: var(--muted); }
+#panel .pad-prefs-btn:hover, #panel .pad-prefs-btn[aria-expanded="true"] { color: var(--text); background: var(--raised); }
+#panel .pad-prefs-btn .icon { width: 1.15rem; height: 1.15rem; }
+#panel .pad-prefs { position: absolute; top: 2.9rem; right: 0.45rem; z-index: 5; display: grid; gap: 0.6rem; width: min(17rem, calc(100% - 0.9rem)); padding: 0.75rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 16px 36px -12px rgba(0, 0, 0, 0.6); }
+#panel .pad-prefs[hidden] { display: none; }
+#panel .pad-pref { display: grid; gap: 0.3rem; }
+#panel .pad-pref-l { font-size: 0.72rem; font-weight: 600; color: var(--muted); }
+#panel .pad-pref-opts { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 3px; border-radius: 10px; background: var(--bg); }
+#panel .pad-pref label { position: relative; }
+#panel .pad-pref input { position: absolute; inset: 0; opacity: 0; margin: 0; width: 100%; height: 100%; min-height: 0; cursor: pointer; }
+#panel .pad-pref span { display: grid; place-items: center; min-height: 34px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; color: var(--muted); }
+#panel .pad-pref input:checked + span { background: var(--raised); color: var(--text); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05), 0 2px 6px rgba(0, 0, 0, 0.25); }
+#panel .pad-pref input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
+
+/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */
+#panel .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }
+#panel .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }
+#panel .slider-head .control-name { min-width: 0; color: var(--text); font-weight: 600; font-size: 0.9rem; }
+#panel .control-value { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }
+#panel .slider-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; }
+#panel .slider-row .step { width: 40px; height: 40px; min-width: 40px; min-height: 40px; padding: 0; border-radius: 50%; display: grid; place-items: center; }
+#panel .slider-row .step .icon { width: 1.1rem; height: 1.1rem; }
+#panel .slider-row input[type="range"] {
+ -webkit-appearance: none; appearance: none; min-width: 0; height: 28px; min-height: 0; margin: 0; background: transparent; cursor: pointer;
+}
+#panel .slider-row input[type="range"]::-webkit-slider-runnable-track { height: 8px; border-radius: 999px; background: linear-gradient(90deg, var(--accent) 0 var(--p, 50%), color-mix(in srgb, var(--line) 80%, var(--text) 10%) var(--p, 50%) 100%); }
+#panel .slider-row input[type="range"]::-moz-range-track { height: 8px; border-radius: 999px; background: color-mix(in srgb, var(--line) 80%, var(--text) 10%); }
+#panel .slider-row input[type="range"]::-moz-range-progress { height: 8px; border-radius: 999px; background: var(--accent); }
+#panel .slider-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; margin-top: -8px; border-radius: 50%; background: #fff; border: 0; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent), 0 2px 6px rgba(0, 0, 0, 0.4); transition: box-shadow 0.15s ease; }
+#panel .slider-row input[type="range"]::-moz-range-thumb { width: 24px; height: 24px; border-radius: 50%; background: #fff; border: 0; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent), 0 2px 6px rgba(0, 0, 0, 0.4); }
+#panel .slider-row input[type="range"]:active::-webkit-slider-thumb { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 30%, transparent), 0 2px 6px rgba(0, 0, 0, 0.4); }
+#panel .slider-row input[type="range"]:disabled { opacity: 0.45; cursor: not-allowed; }
+
+/* Readouts: battery and latency as slim rows (the heading is the row's label), folded when this person folds them. */
+#panel .widget-battery { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; }
+#panel .widget-battery > h2 { margin: 0; }
+#panel .battery { gap: 0.6rem; }
+#panel .battery meter { height: 0.55rem; border-radius: 999px; border: 0; background: color-mix(in srgb, var(--line) 80%, var(--text) 8%); }
+#panel .battery meter::-webkit-meter-bar { height: 0.55rem; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--line) 80%, var(--text) 8%); }
+#panel .battery meter::-webkit-meter-optimum-value { border-radius: 999px; background: var(--ok); }
+#panel .battery meter::-webkit-meter-suboptimum-value { border-radius: 999px; background: var(--warn); }
+#panel .battery meter::-webkit-meter-even-less-good-value { border-radius: 999px; background: var(--danger); }
+#panel .battery .readout { font-size: 0.95rem; min-width: 3.2ch; text-align: right; }
+#panel .widget-latency { padding: 0.6rem 0.9rem 0.75rem; }
+#panel .widget-latency > h2 { margin-bottom: 0.45rem; }
+#panel .stats { gap: 0.4rem; }
+#panel .stat { gap: 0.1rem; padding: 0.4rem 0.55rem; border-radius: 10px; border-color: transparent; background: color-mix(in srgb, var(--bg) 70%, var(--surface)); }
+#panel .stat .readout { font-size: 1rem; }
+#panel .stat-label { font-size: 0.68rem; line-height: 1.2; }
+#panel .readout:is(:empty) { color: var(--muted); }
+#panel[data-readouts="off"] :is(.widget-battery, .widget-latency, .widget-telemetry) { display: none; }
+
+/* The offline notice: a calm line, not an alarm. */
+#panel .notice[data-device-fault], #panel .notice[data-notice] { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; line-height: 1.35; }
+#panel .notice[hidden] { display: none; }
+
+@media (prefers-reduced-motion: reduce) { #panel .stick, #panel .drive-pad .stop, #panel .stick-dirs { transition: none; } }
+#panel .pad-prefs { text-align: left; }
+/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */
+#panel .widgets { align-items: start; }
@@ -879,6 +879,49 @@
@@ -879,6 +879,49 @@
});
});
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop);
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop);
});
});
+ // Controls v2: a slider says its value and fills its track to it (--p); − and + beside it step it a tenth of its
+ // range, the same input a drag makes, so the same command goes (and the same throttle holds).
+ const fmt = (v) => (Math.abs(v) >= 10 ? String(Math.round(v)) : String(Math.round(v * 100) / 100));
+ $$('input[type="range"][data-actuator]').forEach((el) => {
+ const out = document.querySelector(`[data-value-of="${CSS.escape(el.dataset.actuator)}"]`);
+ const paint = () => { const lo = Number(el.min), hi = Number(el.max), v = Number(el.value); el.style.setProperty('--p', `${((v - lo) / ((hi - lo) || 1)) * 100}%`); if (out) out.textContent = fmt(v); };
+ el.addEventListener('input', paint);
+ el.addEventListener('change', paint);
+ paint();
+ });
+ $$('[data-step-for]').forEach((b) => b.addEventListener('click', () => {
+ const el = document.querySelector(`input[data-actuator="${CSS.escape(b.dataset.stepFor)}"]`);
+ if (!el || el.disabled) return;
+ const lo = Number(el.min), hi = Number(el.max);
+ el.value = String(Math.min(hi, Math.max(lo, Number(el.value) + ((hi - lo) / 10) * Number(b.dataset.step))));
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ }));
+ // The pad's arrangement, this person's (kept in this browser): the Stop's hand, the pad's size, the readouts.
+ (() => {
+ const KEY = 'ov-bot-pad-prefs';
+ const panelEl = document.getElementById('panel');
+ const btn = document.querySelector('[data-pad-prefs]');
+ const menu = document.querySelector('[data-pad-prefs-menu]');
+ if (!panelEl) return;
+ let prefs = { hand: 'right', size: 'm', readouts: 'on' };
+ try { prefs = { ...prefs, ...(JSON.parse(localStorage.getItem(KEY) || '{}') || {}) }; } catch { /* private mode */ }
+ const apply = () => {
+ panelEl.dataset.hand = prefs.hand; panelEl.dataset.pad = prefs.size; panelEl.dataset.readouts = prefs.readouts;
+ $$('[data-pad-pref]').forEach((r) => { r.checked = prefs[r.dataset.padPref] === r.value; });
+ };
+ apply();
+ if (!btn || !menu) return;
+ const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };
+ btn.addEventListener('click', (e) => { e.stopPropagation(); const open = menu.hidden; menu.hidden = !open; btn.setAttribute('aria-expanded', String(open)); });
+ document.addEventListener('click', (e) => { if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close(); });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !menu.hidden) { close(); btn.focus(); } });
+ $$('[data-pad-pref]').forEach((r) => r.addEventListener('change', () => {
+ prefs[r.dataset.padPref] = r.value;
+ try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch { /* */ }
+ apply();
+ }));
+ })();
const stopRepeats = () => { [...repeats].forEach((s) => s()); };
const stopRepeats = () => { [...repeats].forEach((s) => s()); };
window.addEventListener('blur', stopRepeats);
window.addEventListener('blur', stopRepeats);
document.addEventListener('visibilitychange', () => { if (document.hidden) stopRepeats(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) stopRepeats(); });
@@ -943,7 +986,8 @@
@@ -943,7 +986,8 @@
function enableControls() {
function enableControls() {
for (const section of $$('[data-command]')) {
for (const section of $$('[data-command]')) {
const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);
const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);
- for (const el of $$('button, input, select', section)) el.disabled = !ok;
+ // The pad's arrangement is this person's own and works offline: it is never a control of the robot.
+ for (const el of $$('button, input, select', section)) if (!el.closest('[data-pad-prefs-menu]') && !el.matches('[data-pad-prefs]')) el.disabled = !ok;
for (const el of $$('[data-joystick], [data-button-stick], [data-ptz-stick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
for (const el of $$('[data-joystick], [data-button-stick], [data-ptz-stick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
}
}
announceControls();
announceControls();
@@ -1458,7 +1458,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1458,7 +1458,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
const allow = allowedFor(robot, role, profile);
const allow = allowedFor(robot, role, profile);
if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };
if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };
- if (!online) return { ok: false, code: 'bot.device_offline', reason: 'the device is offline; commands are never queued', robot, role };
+ if (!online) return { ok: false, code: 'bot.device_offline', reason: 'The robot is offline. Nothing you press waits for it: it moves only while it is connected.', robot, role };
const eff = effectiveLimits(robot, profile);
const eff = effectiveLimits(robot, profile);
const release = kind === 'button' && value && value.state === 'up';
const release = kind === 'button' && value && value.state === 'up';
@@ -48,6 +48,7 @@ const PATHS = {
@@ -48,6 +48,7 @@ const PATHS = {
cw: '<path d="M20 4v6h-6"/><path d="M18.5 15a7.5 7.5 0 1 1-1.8-7.8L20 10"/>',
cw: '<path d="M20 4v6h-6"/><path d="M18.5 15a7.5 7.5 0 1 1-1.8-7.8L20 10"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
minus: '<path d="M5 12h14"/>',
minus: '<path d="M5 12h14"/>',
+ sliders: '<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>',
stop: '<path d="M8.2 2h7.6L22 8.2v7.6L15.8 22H8.2L2 15.8V8.2z"/><path d="M8 12h8"/>',
stop: '<path d="M8.2 2h7.6L22 8.2v7.6L15.8 22H8.2L2 15.8V8.2z"/><path d="M8 12h8"/>',
square: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
square: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
camera: '<path d="M3 7h3l2-3h8l2 3h3v12H3z"/><circle cx="12" cy="13" r="3.5"/>',
camera: '<path d="M3 7h3l2-3h8l2 3h3v12H3z"/><circle cx="12" cy="13" r="3.5"/>',
@@ -127,11 +128,28 @@ function drivePad(axes, enabled) {
@@ -127,11 +128,28 @@ function drivePad(axes, enabled) {
const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');
const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');
const joystick = x || y
const joystick = x || y
? `<div class="stick" data-joystick="${data(stick)}" role="img" aria-label="${esc(`Joystick: ${label}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`
? `<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-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg>'
+ `<span class="stick-knob" data-knob></span></div>`
+ `<span class="stick-knob" data-knob></span></div>`
: '';
: '';
return `<div class="drive-pad" data-axes="${data(axes)}">${joystick}${Object.keys(rest).length ? `<div class="pad">${axisButtons(rest, enabled)}</div>` : ''}`
return `<div class="drive-pad" data-axes="${data(axes)}">${joystick}${Object.keys(rest).length ? `<div class="pad">${axisButtons(rest, enabled)}</div>` : ''}`
- + `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;
+ + `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`
+ + padPrefs();
+}
+
+/**
+ * How this person likes the pad (controls v2, ask 368: "make it … per user"): the hand the Stop sits on, the pad's size
+ * and the readouts shown or folded. public/panel.js keeps the choice in this browser and sets data-hand, data-pad and
+ * data-readouts on #panel; nothing here changes what the robot does.
+ */
+function padPrefs() {
+ const group = (name, label, opts) => `<div class="pad-pref" role="radiogroup" aria-label="${esc(label)}"><span class="pad-pref-l">${esc(label)}</span><div class="pad-pref-opts">${opts.map(([v, t]) =>
+ `<label><input type="radio" name="pad-${esc(name)}" value="${esc(v)}" data-pad-pref="${esc(name)}"><span>${esc(t)}</span></label>`).join('')}</div></div>`;
+ return `<button type="button" class="pad-prefs-btn" data-pad-prefs aria-expanded="false" aria-controls="pad-prefs" title="Arrange the controls" aria-label="Arrange the controls">${icon('sliders')}</button>`
+ + `<div class="pad-prefs" id="pad-prefs" data-pad-prefs-menu hidden>`
+ + group('hand', 'Stop on the', [['right', 'Right'], ['left', 'Left']])
+ + group('size', 'Pad', [['s', 'Small'], ['m', 'Medium'], ['l', 'Large']])
+ + group('readouts', 'Readouts', [['on', 'Shown'], ['off', 'Folded']])
+ + '</div>';
}
}
/** An actuator's name as a label: backpack_lights → Backpack lights. */
/** An actuator's name as a label: backpack_lights → Backpack lights. */
@@ -157,7 +175,10 @@ function actuatorControl(name, a, enabled, profileId = '') {
@@ -157,7 +175,10 @@ function actuatorControl(name, a, enabled, profileId = '') {
const off = disabledUnless(enabled);
const off = disabledUnless(enabled);
const label = humanName(name);
const label = humanName(name);
if (a.type === 'number') {
if (a.type === 'number') {
- return `<label class="control slider"><span class="control-name">${esc(label)}</span> ${numberRange(name, a, enabled, profileId)}</label>`;
+ return `<div class="control slider"><label class="slider-head"><span class="control-name">${esc(label)}</span><output class="control-value" data-value-of="${esc(name)}">0</output></label>`
+ + `<div class="slider-row"><button type="button" class="step" data-step-for="${esc(name)}" data-step="-1" aria-label="${esc(`${label} down`)}"${off}>${icon('minus')}</button>`
+ + `${numberRange(name, a, enabled, profileId)}`
+ + `<button type="button" class="step" data-step-for="${esc(name)}" data-step="1" aria-label="${esc(`${label} up`)}"${off}>${icon('plus')}</button></div></div>`;
}
}
if (a.type === 'rgb') {
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>`
return `<div class="control colour"><label><span class="control-name">${esc(label)}</span> <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`
@@ -234,7 +255,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -234,7 +255,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
// sim … would work killer for LOOI or any PTZ"): drag to move, let go to stop; zoom stays on its buttons.
// sim … would work killer for LOOI or any PTZ"): drag to move, let go to stop; zoom stays on its buttons.
const ptzStick = axes.pan && axes.tilt
const ptzStick = axes.pan && axes.tilt
? `<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"'}>`
? `<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>'
+ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div>'
: '';
: '';
inner = `<div class="ptz-pad">${ptzStick}<div class="pad">${axisButtons(axes, enabled)}</div></div>`;
inner = `<div class="ptz-pad">${ptzStick}<div class="pad">${axisButtons(axes, enabled)}</div></div>`;
break;
break;
@@ -283,7 +304,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -283,7 +304,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
const hasDrive = (profile.widgets || []).some((x) => x.type === 'drive');
const hasDrive = (profile.widgets || []).some((x) => x.type === 'drive');
const buttonStick = dirs.length >= 2 && !hasDrive
const buttonStick = dirs.length >= 2 && !hasDrive
? `<div class="stick stick--buttons" data-button-stick role="img" aria-label="Joystick: presses ${esc(dirs.map((d) => d.label).join(', '))}. Drag and hold to drive."${enabled ? '' : ' aria-disabled="true"'}>`
? `<div class="stick stick--buttons" data-button-stick role="img" aria-label="Joystick: presses ${esc(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>'
+ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div>'
+ `<p class="hint"><kbd>W A S D</kbd> or arrows press ${esc(dirs.map((d) => d.label).join(', '))}. <span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad's stick and D-pad do too.</span></p>`
+ `<p class="hint"><kbd>W A S D</kbd> or arrows press ${esc(dirs.map((d) => d.label).join(', '))}. <span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad's stick and D-pad do too.</span></p>`
: '';
: '';
inner = `${buttonStick}<div class="button-grid">${Object.entries(names).map(([name, button]) =>
inner = `${buttonStick}<div class="button-grid">${Object.entries(names).map(([name, button]) =>
@@ -590,14 +611,14 @@ function overlayStickMarkup(profile, allowed) {
@@ -590,14 +611,14 @@ function overlayStickMarkup(profile, allowed) {
const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');
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)}">`
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"'}>`
+ `<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>';
+ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div></div></section>';
}
}
if (w.type === 'ptz') {
if (w.type === 'ptz') {
const axes = (commands[kind] && commands[kind].axes) || {};
const axes = (commands[kind] && commands[kind].axes) || {};
if (!axes.pan || !axes.tilt) continue;
if (!axes.pan || !axes.tilt) continue;
return `<section class="widget widget-ptz bot-widget" data-widget="ptz" data-command="${data(w.command)}">`
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"'}>`
+ `<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>';
+ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div></div></section>';
}
}
if (w.type === 'buttons') {
if (w.type === 'buttons') {
const names = (commands.button && commands.button.names) || {};
const names = (commands.button && commands.button.names) || {};
@@ -605,7 +626,7 @@ function overlayStickMarkup(profile, allowed) {
@@ -605,7 +626,7 @@ function overlayStickMarkup(profile, allowed) {
if (dirs.length < 2) continue;
if (dirs.length < 2) continue;
return `<section class="widget widget-buttons bot-widget" data-widget="buttons" data-command="${data(w.command || { kind: 'button' })}">`
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"'}>`
+ `<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>';
+ + '<span class="stick-ring"></span><span class="stick-cross"></span><svg class="stick-dirs" viewBox="0 0 100 100" aria-hidden="true" focusable="false"><path d="M50 5l6 7H44zM95 50l-7 6V44zM50 95l-6-7h12zM5 50l7-6v12z"/></svg><span class="stick-knob" data-knob></span></div></section>';
}
}
}
}
return '';
return '';