openvibers/openvibe.bot Commits
Overlay controls back in their corners, at their own size (#401)
Changes
nnew file mode 100644@@@ -0,0 +1 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.6" stroke-linecap="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>@@@ -671,11 +671,14 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:controls) and says so with a small lock at its corner. */#panel[data-role="watcher"] [data-command] input:disabled,#panel[data-role="watcher"] [data-command] button:disabled { pointer-events: none; }--#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .bot-slider, .widget-drive .drive-pad) { cursor: pointer; position: relative; }--#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .widget-drive .drive-pad)::after {++#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .bot-slider, .widget-drive .drive-pad) { cursor: pointer; }++/* Nothing here moves a control: the overlay's stick and slider are placed (absolute), and making them relative dropped++ them out of their corners to the frame's top (Maticus, 11-11: "shows only the top left corner of the virtual++ joystick"). The lock sits on the stick itself (relative already), clear of the pad's arrangement icon. */++#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .widget-drive .drive-pad > .stick)::after {content: ""; position: absolute; right: 6%; top: 6%; width: 18px; height: 18px; border-radius: 50%; pointer-events: none;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("/panel/lock.svg");}/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:@@@ -683,11 +686,11 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:framed anywhere: the stick has its four marks and a knob that lights when it drives; Stop is a ringed red discbeside it (on the hand this person chose); a slider says its value with − and + to step it; battery and latencyare 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 {++#panel:not(.bot-surface) .widget-drive { position: relative; }++#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }++#panel:not(.bot-surface) .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }++#panel:not(.bot-surface)[data-hand="left"] .drive-pad .stop { order: -1; }++#panel:not(.bot-surface) .stick {--stick: clamp(8.5rem, 44vw, 12.5rem);width: var(--stick);background:@@@ -696,12 +699,12 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: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 {++#panel:not(.bot-surface)[data-pad="s"] .stick { --stick: clamp(7rem, 34vw, 9.5rem); }++#panel:not(.bot-surface)[data-pad="l"] .stick { --stick: clamp(10rem, 58vw, 15rem); }++#panel:not(.bot-surface) .stick-ring { inset: 22%; border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); }++#panel:not(.bot-surface) .stick-cross { inset: 30%; opacity: 0.35; }++#panel:not(.bot-surface) .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:not(.bot-surface) .stick-knob {width: 38%;background:radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.28), transparent 42%),@@@ -709,12 +712,12 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: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:not(.bot-surface) .stick.held, #panel:not(.bot-surface) .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:not(.bot-surface) .stick.held .stick-dirs, #panel:not(.bot-surface) .stick.active .stick-dirs { fill: color-mix(in srgb, var(--accent) 70%, var(--text)); }++#panel:not(.bot-surface) .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:not(.bot-surface) .stick[aria-disabled="true"] { opacity: 0.5; filter: saturate(0.4); }--#panel .drive-pad .stop {++#panel:not(.bot-surface) .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);@@@ -723,69 +726,74 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: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); }++#panel:not(.bot-surface) .drive-pad .stop .icon { width: 1.35em; height: 1.35em; fill: var(--danger); stroke: none; }++#panel:not(.bot-surface) .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:not(.bot-surface) .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }++#panel:not(.bot-surface) .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; }++#panel:not(.bot-surface) .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:not(.bot-surface) .pad-prefs-btn:hover, #panel:not(.bot-surface) .pad-prefs-btn[aria-expanded="true"] { color: var(--text); background: var(--raised); }++#panel:not(.bot-surface) .pad-prefs-btn .icon { width: 1.15rem; height: 1.15rem; }++#panel:not(.bot-surface) .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:not(.bot-surface) .pad-prefs[hidden] { display: none; }++#panel:not(.bot-surface) .pad-pref { display: grid; gap: 0.3rem; }++#panel:not(.bot-surface) .pad-pref-l { font-size: 0.72rem; font-weight: 600; color: var(--muted); }++#panel:not(.bot-surface) .pad-pref-opts { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 3px; border-radius: 10px; background: var(--bg); }++#panel:not(.bot-surface) .pad-pref label { position: relative; }++#panel:not(.bot-surface) .pad-pref input { position: absolute; inset: 0; opacity: 0; margin: 0; width: 100%; height: 100%; min-height: 0; cursor: pointer; }++#panel:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .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"] {++#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }++#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }++#panel:not(.bot-surface) .slider-head .control-name { min-width: 0; color: var(--text); font-weight: 600; font-size: 0.9rem; }++#panel:not(.bot-surface) .control-value { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }++#panel:not(.bot-surface) .slider-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; }++#panel:not(.bot-surface) .slider-row .step { width: 40px; height: 40px; min-width: 40px; min-height: 40px; padding: 0; border-radius: 50%; display: grid; place-items: center; }++#panel:not(.bot-surface) .slider-row .step .icon { width: 1.1rem; height: 1.1rem; }++#panel:not(.bot-surface) .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; }++#panel:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .slider-row input[type="range"]::-moz-range-progress { height: 8px; border-radius: 999px; background: var(--accent); }++#panel:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .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; }++#panel:not(.bot-surface) .widget-battery { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; }++#panel:not(.bot-surface) .widget-battery > h2 { margin: 0; }++#panel:not(.bot-surface) .battery { gap: 0.6rem; }++#panel:not(.bot-surface) .battery meter { height: 0.55rem; border-radius: 999px; border: 0; background: color-mix(in srgb, var(--line) 80%, var(--text) 8%); }++#panel:not(.bot-surface) .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:not(.bot-surface) .battery meter::-webkit-meter-optimum-value { border-radius: 999px; background: var(--ok); }++#panel:not(.bot-surface) .battery meter::-webkit-meter-suboptimum-value { border-radius: 999px; background: var(--warn); }++#panel:not(.bot-surface) .battery meter::-webkit-meter-even-less-good-value { border-radius: 999px; background: var(--danger); }++#panel:not(.bot-surface) .battery .readout { font-size: 0.95rem; min-width: 3.2ch; text-align: right; }++#panel:not(.bot-surface) .widget-latency { padding: 0.6rem 0.9rem 0.75rem; }++#panel:not(.bot-surface) .widget-latency > h2 { margin-bottom: 0.45rem; }++#panel:not(.bot-surface) .stats { gap: 0.4rem; }++#panel:not(.bot-surface) .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:not(.bot-surface) .stat .readout { font-size: 1rem; }++#panel:not(.bot-surface) .stat-label { font-size: 0.68rem; line-height: 1.2; }++#panel:not(.bot-surface) .readout:is(:empty) { color: var(--muted); }++#panel:not(.bot-surface)[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; }++#panel:not(.bot-surface) .notice[data-device-fault], #panel:not(.bot-surface) .notice[data-notice] { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; line-height: 1.35; }++#panel:not(.bot-surface) .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; }++@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .drive-pad .stop, #panel:not(.bot-surface) .stick-dirs { transition: none; } }++#panel:not(.bot-surface) .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; }++#panel:not(.bot-surface) .widgets { align-items: start; }++++/* The overlay and the one-row controls keep the look they had before Controls v2 (Maticus, 11-11: "the previous size++ and position was perfect"): Controls v2 is the full panel's (#panel:not(.bot-surface) above); their stick draws its++ dots, not the v2 direction marks. */++.bot-surface .stick-dirs { display: none; }@@@ -229,6 +229,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', cache.assetHeaders(req.path, { hashed: false })); res.type(type).sendFile(path.join(PUBLIC, file)); };r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));r.get('/panel/panel.css', asset('panel.css', 'text/css'));++ r.get('/panel/lock.svg', asset('lock.svg', 'image/svg+xml')); // the locked-control badge (a data: image is refused by default-src 'self')r.get('/panel/embed-frame.js', asset('embed-frame.js', 'application/javascript'));r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++// The controls over a stream keep their places and their look (Maticus, 2026-11-11: "the on screen controls look++// janky all of the sudden… shows only the top left corner of the virtual joystick. the previous size and position was++// perfect"). Two rules broke them: the locked-control badge made the overlay's stick and slider position: relative++// (#391), which dropped them from their corners to the frame's top; and Controls v2 (#393) styled every #panel stick,++// the overlay's too, at the full panel's size inside its 132 px box. The badge's lock was also a data: image, which the++// panel pages' default-src 'self' refuses (a bare blue dot).++// node test/overlay-controls-css.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.css'), 'utf8');++const rules = [...css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ sel: m[1].trim(), body: m[2] }));++++// 1. Nothing outside the overlay's own placement rules gives its stick or slider a position.++const placed = rules.filter((r) => /\.bot-(stick|slider)\b(?![\w-])/.test(r.sel) && /(^|;)\s*position\s*:/.test(r.body));++for (const r of placed) {++ const value = (r.body.match(/position\s*:\s*([a-z]+)/) || [])[1];++ assert.ok(['absolute', 'static'].includes(value), `the overlay's stick and slider stay placed: "${r.sel}" sets position: ${value}`);++}++++// 2. Controls v2 is the full panel's: every #panel selector after its heading leaves the overlay and the one row out.++const v2 = css.slice(css.indexOf('/* ── Controls v2'));++assert.ok(v2.length > 100, 'the Controls v2 block is there');++const v2rules = [...v2.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{[^{}]*\}/g)].map((m) => m[1]);++const loose = v2rules.flatMap((sel) => sel.split(',')).map((s) => s.trim()).filter((s) => /#panel(?!:not\(\.bot-surface\))/.test(s));++assert.deepStrictEqual(loose, [], 'Controls v2 selectors are scoped to #panel:not(.bot-surface)');++assert.ok(/\.bot-surface \.stick-dirs\s*\{\s*display:\s*none/.test(css), 'the overlay stick draws its dots, not the v2 marks');++++// 3. No data: images: the panel pages run under default-src 'self'.++assert.ok(!/url\(\s*["']?data:/.test(css), 'no data: URL in panel.css (default-src \'self\' refuses it)');++assert.ok(css.includes('url("/panel/lock.svg")'), 'the lock badge is a same-origin file');++const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'web', 'routes.js'), 'utf8');++assert.ok(routes.includes("r.get('/panel/lock.svg', asset('lock.svg', 'image/svg+xml'))"), 'and the panel serves it');++assert.ok(fs.existsSync(path.join(__dirname, '..', 'public', 'lock.svg')), 'the file exists');++++console.log('overlay controls css: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.6" stroke-linecap="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>
@@ -671,11 +671,14 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -671,11 +671,14 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
controls) and says so with a small lock at its corner. */
controls) and says so with a small lock at its corner. */
#panel[data-role="watcher"] [data-command] input:disabled,
#panel[data-role="watcher"] [data-command] input:disabled,
#panel[data-role="watcher"] [data-command] button:disabled { pointer-events: none; }
#panel[data-role="watcher"] [data-command] button:disabled { pointer-events: none; }
-#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .bot-slider, .widget-drive .drive-pad) { cursor: pointer; position: relative; }
-#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .widget-drive .drive-pad)::after {
+#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .bot-slider, .widget-drive .drive-pad) { cursor: pointer; }
+/* Nothing here moves a control: the overlay's stick and slider are placed (absolute), and making them relative dropped
+ them out of their corners to the frame's top (Maticus, 11-11: "shows only the top left corner of the virtual
+ joystick"). The lock sits on the stick itself (relative already), clear of the pad's arrangement icon. */
+#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .widget-drive .drive-pad > .stick)::after {
content: ""; position: absolute; right: 6%; top: 6%; width: 18px; height: 18px; border-radius: 50%; pointer-events: none;
content: ""; position: absolute; right: 6%; top: 6%; width: 18px; height: 18px; border-radius: 50%; pointer-events: none;
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("/panel/lock.svg");
}
}
/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:
/* ── Controls v2 (ask 368, 2026-11-11: the owner circled the pad, the Stop, the head slider and the readouts:
@@ -683,11 +686,11 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -683,11 +686,11 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
framed anywhere: the stick has its four marks and a knob that lights when it drives; Stop is a ringed red disc
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
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. ─── */
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 {
+#panel:not(.bot-surface) .widget-drive { position: relative; }
+#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }
+#panel:not(.bot-surface) .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }
+#panel:not(.bot-surface)[data-hand="left"] .drive-pad .stop { order: -1; }
+#panel:not(.bot-surface) .stick {
--stick: clamp(8.5rem, 44vw, 12.5rem);
--stick: clamp(8.5rem, 44vw, 12.5rem);
width: var(--stick);
width: var(--stick);
background:
background:
@@ -696,12 +699,12 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -696,12 +699,12 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
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);
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;
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 {
+#panel:not(.bot-surface)[data-pad="s"] .stick { --stick: clamp(7rem, 34vw, 9.5rem); }
+#panel:not(.bot-surface)[data-pad="l"] .stick { --stick: clamp(10rem, 58vw, 15rem); }
+#panel:not(.bot-surface) .stick-ring { inset: 22%; border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); }
+#panel:not(.bot-surface) .stick-cross { inset: 30%; opacity: 0.35; }
+#panel:not(.bot-surface) .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:not(.bot-surface) .stick-knob {
width: 38%;
width: 38%;
background:
background:
radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.28), transparent 42%),
radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.28), transparent 42%),
@@ -709,12 +712,12 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -709,12 +712,12 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
border: 1px solid color-mix(in srgb, var(--line) 50%, var(--text) 30%);
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);
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:not(.bot-surface) .stick.held, #panel:not(.bot-surface) .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:not(.bot-surface) .stick.held .stick-dirs, #panel:not(.bot-surface) .stick.active .stick-dirs { fill: color-mix(in srgb, var(--accent) 70%, var(--text)); }
+#panel:not(.bot-surface) .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:not(.bot-surface) .stick[aria-disabled="true"] { opacity: 0.5; filter: saturate(0.4); }
-#panel .drive-pad .stop {
+#panel:not(.bot-surface) .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;
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;
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);
font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em; color: var(--text);
@@ -723,69 +726,74 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -723,69 +726,74 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 10%, transparent), 0 6px 14px rgba(0, 0, 0, 0.35);
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;
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); }
+#panel:not(.bot-surface) .drive-pad .stop .icon { width: 1.35em; height: 1.35em; fill: var(--danger); stroke: none; }
+#panel:not(.bot-surface) .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:not(.bot-surface) .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }
+#panel:not(.bot-surface) .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. */
/* 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; }
+#panel:not(.bot-surface) .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:not(.bot-surface) .pad-prefs-btn:hover, #panel:not(.bot-surface) .pad-prefs-btn[aria-expanded="true"] { color: var(--text); background: var(--raised); }
+#panel:not(.bot-surface) .pad-prefs-btn .icon { width: 1.15rem; height: 1.15rem; }
+#panel:not(.bot-surface) .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:not(.bot-surface) .pad-prefs[hidden] { display: none; }
+#panel:not(.bot-surface) .pad-pref { display: grid; gap: 0.3rem; }
+#panel:not(.bot-surface) .pad-pref-l { font-size: 0.72rem; font-weight: 600; color: var(--muted); }
+#panel:not(.bot-surface) .pad-pref-opts { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 3px; border-radius: 10px; background: var(--bg); }
+#panel:not(.bot-surface) .pad-pref label { position: relative; }
+#panel:not(.bot-surface) .pad-pref input { position: absolute; inset: 0; opacity: 0; margin: 0; width: 100%; height: 100%; min-height: 0; cursor: pointer; }
+#panel:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .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. */
/* 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"] {
+#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }
+#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }
+#panel:not(.bot-surface) .slider-head .control-name { min-width: 0; color: var(--text); font-weight: 600; font-size: 0.9rem; }
+#panel:not(.bot-surface) .control-value { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }
+#panel:not(.bot-surface) .slider-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; }
+#panel:not(.bot-surface) .slider-row .step { width: 40px; height: 40px; min-width: 40px; min-height: 40px; padding: 0; border-radius: 50%; display: grid; place-items: center; }
+#panel:not(.bot-surface) .slider-row .step .icon { width: 1.1rem; height: 1.1rem; }
+#panel:not(.bot-surface) .slider-row input[type="range"] {
-webkit-appearance: none; appearance: none; min-width: 0; height: 28px; min-height: 0; margin: 0; background: transparent; cursor: pointer;
-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; }
+#panel:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .slider-row input[type="range"]::-moz-range-progress { height: 8px; border-radius: 999px; background: var(--accent); }
+#panel:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .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:not(.bot-surface) .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. */
/* 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; }
+#panel:not(.bot-surface) .widget-battery { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; }
+#panel:not(.bot-surface) .widget-battery > h2 { margin: 0; }
+#panel:not(.bot-surface) .battery { gap: 0.6rem; }
+#panel:not(.bot-surface) .battery meter { height: 0.55rem; border-radius: 999px; border: 0; background: color-mix(in srgb, var(--line) 80%, var(--text) 8%); }
+#panel:not(.bot-surface) .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:not(.bot-surface) .battery meter::-webkit-meter-optimum-value { border-radius: 999px; background: var(--ok); }
+#panel:not(.bot-surface) .battery meter::-webkit-meter-suboptimum-value { border-radius: 999px; background: var(--warn); }
+#panel:not(.bot-surface) .battery meter::-webkit-meter-even-less-good-value { border-radius: 999px; background: var(--danger); }
+#panel:not(.bot-surface) .battery .readout { font-size: 0.95rem; min-width: 3.2ch; text-align: right; }
+#panel:not(.bot-surface) .widget-latency { padding: 0.6rem 0.9rem 0.75rem; }
+#panel:not(.bot-surface) .widget-latency > h2 { margin-bottom: 0.45rem; }
+#panel:not(.bot-surface) .stats { gap: 0.4rem; }
+#panel:not(.bot-surface) .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:not(.bot-surface) .stat .readout { font-size: 1rem; }
+#panel:not(.bot-surface) .stat-label { font-size: 0.68rem; line-height: 1.2; }
+#panel:not(.bot-surface) .readout:is(:empty) { color: var(--muted); }
+#panel:not(.bot-surface)[data-readouts="off"] :is(.widget-battery, .widget-latency, .widget-telemetry) { display: none; }
/* The offline notice: a calm line, not an alarm. */
/* 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; }
+#panel:not(.bot-surface) .notice[data-device-fault], #panel:not(.bot-surface) .notice[data-notice] { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; line-height: 1.35; }
+#panel:not(.bot-surface) .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; }
+@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .drive-pad .stop, #panel:not(.bot-surface) .stick-dirs { transition: none; } }
+#panel:not(.bot-surface) .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). */
/* 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; }
+#panel:not(.bot-surface) .widgets { align-items: start; }
+
+/* The overlay and the one-row controls keep the look they had before Controls v2 (Maticus, 11-11: "the previous size
+ and position was perfect"): Controls v2 is the full panel's (#panel:not(.bot-surface) above); their stick draws its
+ dots, not the v2 direction marks. */
+.bot-surface .stick-dirs { display: none; }
@@ -229,6 +229,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -229,6 +229,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', cache.assetHeaders(req.path, { hashed: false })); res.type(type).sendFile(path.join(PUBLIC, file)); };
const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', cache.assetHeaders(req.path, { hashed: false })); res.type(type).sendFile(path.join(PUBLIC, file)); };
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
+ r.get('/panel/lock.svg', asset('lock.svg', 'image/svg+xml')); // the locked-control badge (a data: image is refused by default-src 'self')
r.get('/panel/embed-frame.js', asset('embed-frame.js', 'application/javascript'));
r.get('/panel/embed-frame.js', asset('embed-frame.js', 'application/javascript'));
r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));
r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+// The controls over a stream keep their places and their look (Maticus, 2026-11-11: "the on screen controls look
+// janky all of the sudden… shows only the top left corner of the virtual joystick. the previous size and position was
+// perfect"). Two rules broke them: the locked-control badge made the overlay's stick and slider position: relative
+// (#391), which dropped them from their corners to the frame's top; and Controls v2 (#393) styled every #panel stick,
+// the overlay's too, at the full panel's size inside its 132 px box. The badge's lock was also a data: image, which the
+// panel pages' default-src 'self' refuses (a bare blue dot).
+// node test/overlay-controls-css.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.css'), 'utf8');
+const rules = [...css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ sel: m[1].trim(), body: m[2] }));
+
+// 1. Nothing outside the overlay's own placement rules gives its stick or slider a position.
+const placed = rules.filter((r) => /\.bot-(stick|slider)\b(?![\w-])/.test(r.sel) && /(^|;)\s*position\s*:/.test(r.body));
+for (const r of placed) {
+ const value = (r.body.match(/position\s*:\s*([a-z]+)/) || [])[1];
+ assert.ok(['absolute', 'static'].includes(value), `the overlay's stick and slider stay placed: "${r.sel}" sets position: ${value}`);
+}
+
+// 2. Controls v2 is the full panel's: every #panel selector after its heading leaves the overlay and the one row out.
+const v2 = css.slice(css.indexOf('/* ── Controls v2'));
+assert.ok(v2.length > 100, 'the Controls v2 block is there');
+const v2rules = [...v2.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{[^{}]*\}/g)].map((m) => m[1]);
+const loose = v2rules.flatMap((sel) => sel.split(',')).map((s) => s.trim()).filter((s) => /#panel(?!:not\(\.bot-surface\))/.test(s));
+assert.deepStrictEqual(loose, [], 'Controls v2 selectors are scoped to #panel:not(.bot-surface)');
+assert.ok(/\.bot-surface \.stick-dirs\s*\{\s*display:\s*none/.test(css), 'the overlay stick draws its dots, not the v2 marks');
+
+// 3. No data: images: the panel pages run under default-src 'self'.
+assert.ok(!/url\(\s*["']?data:/.test(css), 'no data: URL in panel.css (default-src \'self\' refuses it)');
+assert.ok(css.includes('url("/panel/lock.svg")'), 'the lock badge is a same-origin file');
+const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'web', 'routes.js'), 'utf8');
+assert.ok(routes.includes("r.get('/panel/lock.svg', asset('lock.svg', 'image/svg+xml'))"), 'and the panel serves it');
+assert.ok(fs.existsSync(path.join(__dirname, '..', 'public', 'lock.svg')), 'the file exists');
+
+console.log('overlay controls css: all checks passed');