UI improvements, and control softening

This commit is contained in:
2026-08-27 20:11:37 +02:00
parent 84ec93543b
commit 73c20a0ff2
14 changed files with 374 additions and 38 deletions
+93
View File
@@ -5,6 +5,99 @@ const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
const {present}=require('../web/control-state.js');
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
test('model viewport fills the available space above battery status',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.rov-stage \{ flex:1; height:auto; min-height:0; width:100%/);
assert.match(css,/\.telemetry-zone \.model-card \{ flex: 1; min-height: 0;/);
});
test('white wash stays behind controls; right panel is gray and edges reach 50 percent',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.workspace-light \{[^}]*z-index:-1/);
assert.match(css,/\.controls-workspace \{[^}]*isolation:isolate/);
assert.match(css,/\.controls-workspace \{[^}]*background:#ededed/);
assert.match(css,/\.sidebar-deck \{[^}]*background:linear-gradient\(160deg,#333,#242424\)/);
assert.match(css,/\.cursor-light::after \{[^}]*rgba\(255,255,255,\.50\)/);
});
test('keyboard easing publishes intermediate demands to the real Python bridge',async()=>{
const h=harness();h.state();h.run('bridgeReady=true');
keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');
for(let i=0;i<10;i++){h.advance(50);await Promise.resolve();await Promise.resolve();await Promise.resolve();}
const sent=h.calls.filter(call=>call[0]==='axes');
assert.ok(sent.some(call=>call[1]>0&&call[1]<1),'surge ramp is transmitted, not just drawn');
assert.ok(sent.some(call=>call[3]>0&&call[3]<1),'vertical ramp is transmitted, not just drawn');
assert.ok(sent.every(call=>call[1]>=0&&call[1]<=1&&call[3]>=0&&call[3]<=1));
});
test('cursor lighting stays decorative and arrows use centered geometry',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.doesNotMatch(js,/set_axes|setJoyControl|setHeave|requestControl/);
assert.match(js,/workspace\.append\(wash\)/);
assert.match(js,/pointerleave',clear/);
assert.match(css,/\.cursor-light \{[^}]*pointer-events:none/);
assert.match(css,/range-pill button \{[^}]*place-items:center/);
assert.match(css,/#engage\.danger \.engage-label \{ color:#777/);
});
test('edge lighting uses angle; only visible pills get blue and sidebar is excluded',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.match(js,/Math\.atan2/);
assert.match(js,/!host\.closest\('\.sidebar-deck,#engage'\)/);
assert.match(js,/host\.matches\(pillSelector\)/);
assert.match(css,/\.cursor-light\.pill-light::before \{ background:radial-gradient/);
assert.match(css,/\.cursor-light::after \{[^}]*linear-gradient\(var\(--light-angle/);
assert.doesNotMatch(css,/\.cursor-light::after \{[^}]*radial-gradient/);
assert.match(css,/\.rov-stage \{[^}]*border-radius:31px; overflow:hidden/);
});
test('all interface typography uses Helvetica with inherited control fonts',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const families=[...css.matchAll(/font-family\s*:\s*([^;}]+)/g)].map(match=>match[1].trim());
assert.ok(families.includes('"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.ok(families.every(family=>family==='inherit'||family==='"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.match(css,/button, select, input \{ font: inherit; \}/);
});
function keyEvent(h,type,key,repeat=false){h.fire(type,{key,repeat,target:{tagName:'BODY'},preventDefault(){}})}
test('keyboard ramps joystick and vertical together, then eases back on release',()=>{
const h=harness();h.state();
keyEvent(h,'keydown','w');keyEvent(h,'keydown','d');keyEvent(h,'keydown','r');
assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
h.advance(180);
for(const axis of ['forward','turn','vertical'])assert.ok(h.run(`control.${axis}`)>0&&h.run(`control.${axis}`)<1);
keyEvent(h,'keydown','w',true);h.advance(240);
assert.ok(Math.abs(h.run('control.forward')-Math.SQRT1_2)<1e-6);
assert.ok(Math.abs(h.run('control.turn')-Math.SQRT1_2)<1e-6);
assert.equal(h.run('control.vertical'),1);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','d');keyEvent(h,'keyup','r');
h.advance(180);assert.ok(h.run('control.forward')>0);assert.ok(h.run('control.vertical')>0);
h.advance(250);assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('keyboard reversal is continuous and focus loss cancels its ramp',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(420);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','r');keyEvent(h,'keydown','s');keyEvent(h,'keydown','f');
assert.equal(h.run('control.forward'),1);assert.equal(h.run('control.vertical'),1);
h.advance(200);assert.ok(Math.abs(h.run('control.forward'))<.3);assert.ok(Math.abs(h.run('control.vertical'))<.3);
h.advance(220);assert.equal(h.run('control.forward'),-1);assert.equal(h.run('control.vertical'),-1);
keyEvent(h,'keyup','s');keyEvent(h,'keyup','f');h.fire('blur');h.advance(500);
assert.equal(h.run('control.forward+control.vertical'),0);
});
test('touch takes ownership from a keyboard ramp without later keyboard overwrite',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(100);
h.el('#joystick-canvas').emit('pointerdown',{clientX:300,clientY:160});
h.el('#heave').emit('pointerdown',{pointerId:2,clientY:300});
keyEvent(h,'keydown','a');h.advance(450);
assert.equal(h.run('control.turn'),1);assert.equal(Math.abs(h.run('control.forward')),0);assert.equal(h.run('control.vertical'),-1);
});
test('hold fill and pending engagement share the same green and glow',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.engage-progress-label \{ color:var\(--engage-green\).*filter:var\(--engage-glow\)/);
assert.match(css,/#engage\.pending \.engage-label \{ color:var\(--engage-green\); filter:var\(--engage-glow\)/);
});
test('native pywebview result wrapper can execute alongside app globals',()=>{
const h=harness();
// Window.evaluate_js declares these variables in the page's global scope.
+9
View File
@@ -112,6 +112,15 @@ class WorkerWatchdogTests(unittest.TestCase):
self.assertNeutral()
self.assertEqual(self.worker.axes, Axes())
def test_intermediate_demands_reach_mavlink_with_output_limit(self):
with patch("app.time.monotonic", return_value=10):
self.worker.last_control_at = 9.9
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
self.worker._send_control()
# 25% input at the default 20% limit: 5% command, not 100%.
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
(1, 50, 0, 525, 0, 0))
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
with patch("app.time.monotonic", return_value=13.1):
self.worker.set_axes(Axes(forward=1), 1)
+2 -2
View File
@@ -90,10 +90,10 @@ function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.m
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
+42
View File
@@ -0,0 +1,42 @@
/* Decorative only: no control demands or vehicle state are accessed here. */
(() => {
const workspace=document.querySelector('.controls-workspace');
if(!workspace)return;
const wash=document.createElement('span');
wash.className='workspace-light';wash.setAttribute('aria-hidden','true');workspace.append(wash);
// Explicit visual surfaces, not every semantic button. The entire sidebar
// and text-only Engage control are excluded from local lighting.
const pillSelector='.config-btn,.modal .button,.range-pill,.number-pill';
const shapeSelector=`${pillSelector},.heave-track,.heave-knob,.joystick,.modal`;
const layers=[...document.querySelectorAll(shapeSelector)].filter(host=>!host.closest('.sidebar-deck,#engage')).map(host=>{
host.classList.add('light-surface');
const layer=document.createElement('span');layer.className='cursor-light';
layer.setAttribute('aria-hidden','true');
layer.classList.toggle('pill-light',host.matches(pillSelector));
host.append(layer);return {host,layer};
});
let frame=0,point=null;
const clear=()=>{point=null;cancelAnimationFrame(frame);frame=0;wash.classList.remove('lit');layers.forEach(({layer})=>layer.classList.remove('lit','near'))};
function paint(){
frame=0;if(!point)return;
const {x,y}=point,r=workspace.getBoundingClientRect();
wash.style.setProperty('--light-x',`${x-r.left}px`);wash.style.setProperty('--light-y',`${y-r.top}px`);
wash.classList.toggle('lit',x>=r.left&&x<=r.right&&y>=r.top&&y<=r.bottom);
for(const {host,layer} of layers){
const b=host.getBoundingClientRect();
layer.style.setProperty('--light-x',`${x-b.left}px`);layer.style.setProperty('--light-y',`${y-b.top}px`);
const inside=x>=b.left&&x<=b.right&&y>=b.top&&y<=b.bottom;
// CSS gradient angles start at up; atan2 is measured from the x axis.
const angle=Math.atan2(y-(b.top+b.height/2),x-(b.left+b.width/2))*180/Math.PI+90;
layer.style.setProperty('--light-angle',`${angle}deg`);
layer.classList.toggle('lit',inside);layer.classList.add('near');
}
}
document.addEventListener('pointermove',event=>{
if(event.pointerType==='touch'){clear();return}
point={x:event.clientX,y:event.clientY};if(!frame)frame=requestAnimationFrame(paint);
},{passive:true});
document.documentElement.addEventListener('pointerleave',clear);
document.addEventListener('pointercancel',clear);window.addEventListener('blur',clear);
document.addEventListener('visibilitychange',()=>{if(document.hidden)clear()});
})();
+6 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
<link rel="stylesheet" href="styles.css?v=20260827-model-layout-v14">
</head>
<body>
<main class="app-shell">
@@ -132,9 +132,9 @@
<label class="output-limit">
<span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill">
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
</label>
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
@@ -151,8 +151,9 @@
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20260827-polish-v3"></script>
<script src="joystick-3d.js?v=20260827-keyboard-v7"></script>
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260827-bridge-v6"></script>
<script src="app.js?v=20260827-keyboard-v7"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+9 -2
View File
@@ -30,7 +30,14 @@
function publish(){window.setJoyControl?.(x,y)}
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
function easeTo(nextX,nextY){
cancelAnimationFrame(returnFrame);
const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}
const startX=x,startY=y,start=performance.now();
const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX+(nextX-startX)*smooth,startY+(nextY-startY)*smooth);if(p<1)returnFrame=requestAnimationFrame(tick)};
returnFrame=requestAnimationFrame(tick);
}
function returnToCenter(){easeTo(0,0)}
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
@@ -38,5 +45,5 @@
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
addEventListener('resize',resize);resize();
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
})();
+26 -10
View File
@@ -1,5 +1,19 @@
/* SUB Control UI – Desktop Layout */
/* Isolated pointer lighting: no layout or input interception. */
.controls-workspace { position:relative; isolation:isolate; }
.workspace-light { position:absolute; inset:0; z-index:-1; pointer-events:none; border-radius:inherit; opacity:0; background:radial-gradient(600px circle at var(--light-x) var(--light-y),rgba(255,255,255,.35),transparent 75%); transition:opacity .35s ease-out; }
.workspace-light.lit { opacity:1; }
.light-surface { position:relative; }
.cursor-light { position:absolute; inset:0; z-index:3; pointer-events:none; border-radius:inherit; }
.cursor-light::before,.cursor-light::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; opacity:0; transition:opacity .35s ease-out; }
.cursor-light.pill-light::before { background:radial-gradient(240px circle at var(--light-x) var(--light-y),rgba(110,190,235,.08),transparent 80%); }
.cursor-light::after { padding:1px; background:linear-gradient(var(--light-angle,315deg),transparent 30%,rgba(255,255,255,.13) 65%,rgba(255,255,255,.50) 100%); -webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0); -webkit-mask-composite:xor; mask-composite:exclude; }
.cursor-light.lit::before,.cursor-light.near::after { opacity:1; }
.joystick > .cursor-light { inset:7%; border-radius:50%; }
#engage > .cursor-light::after { display:none; }
@media (prefers-reduced-motion:reduce) { .workspace-light,.cursor-light::before,.cursor-light::after { transition:none; } }
:root {
color-scheme: dark;
--bg: #0c1012;
@@ -14,7 +28,7 @@
--amber: #f2c675;
--red: #f0958b;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
font-family: Inter, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 300;
}
@@ -1039,7 +1053,7 @@ body #connect, body #connect:hover, body #connect.is-connected, body #connect.is
}
body #connect .btn-label { animation:none; }
.telemetry-zone { gap:20px; }
.rov-stage { height:clamp(180px,32vh,250px); min-height:180px; touch-action:none; }
.rov-stage { flex:1; height:auto; min-height:0; width:100%; touch-action:none; border-radius:31px; overflow:hidden; background:transparent; }
.model-card { justify-content:flex-start; }
.rov-stage::before { display:none; }
.model-tools button { font-size:12px; padding:7px 11px; }
@@ -1052,24 +1066,25 @@ body #connect .btn-label { animation:none; }
.activity-history li { font-size:12px; font-weight:300; line-height:1.25; color:#999; overflow-wrap:anywhere; }
.activity-history #status-text { color:#fff; font-size:12px; font-weight:300; }
.activity-history li[hidden] { display:none; }
.controls-workspace { padding:8px 20px 16px; background:#f6f6f6; }
.controls-workspace { padding:8px 20px 16px; background:#ededed; }
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) 52px; }
.engage-header-zone { display:block; position:relative; height:120px; min-height:0; margin:0; padding:0; }
body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding:0 20px; border:0; border-radius:0; background:transparent; box-shadow:none; }
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
#engage::before, #engage::after { display:none; }
body #engage { align-items:flex-end; padding-bottom:12px; }
.engage-face > span { font-family:"Segoe UI Variable Display","Segoe UI",Arial,sans-serif; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
.engage-face > span { font-family:inherit; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
#engage { --engage-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
#engage .engage-label { color:#777; background:none; animation:none; }
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
#engage:is(.arm,.danger):not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-sheen-label { opacity:1; animation:engage-sweep 2.2s linear infinite; }
@keyframes engage-sweep { from {mask-position:100% 0;-webkit-mask-position:100% 0} to {mask-position:0% 0;-webkit-mask-position:0% 0} }
.engage-progress-label { color:#4cce73; clip-path:none; filter:drop-shadow(0 0 2px rgba(76,206,115,.3)); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
.engage-progress-label { color:var(--engage-green); clip-path:none; filter:var(--engage-glow); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
#engage.charged .engage-progress-label { mask-image:none; -webkit-mask-image:none; }
#engage.pending .engage-label { color:#5a8753; background:none; }
#engage.danger .engage-label { color:#171717; }
#engage.pending .engage-label { color:var(--engage-green); filter:var(--engage-glow); background:none; }
#engage.danger .engage-label { color:#777; }
#engage:focus-visible { outline:2px solid #8d9991; outline-offset:-6px; }
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:#f6f6f6; }
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:transparent; }
.control-status[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) {
.engage-sheen-label { display:none; }
@@ -1094,10 +1109,11 @@ body #engage { align-items:flex-end; padding-bottom:12px; }
.heave-fill { left:22px; right:22px; background:#999; }
.control-toolbar { display:flex; justify-content:center; align-items:center; gap:16px; padding-top:12px; }
.control-toolbar .output-limit { width:auto; flex-direction:row; align-items:center; font-size:13px; gap:12px; color:#777; }
.control-toolbar .output-limit span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
.control-toolbar .output-limit > span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
.control-toolbar .output-limit b { display:inline-block; flex:0 0 4ch; width:4ch; text-align:right; color:#333; font-variant-numeric:tabular-nums; }
.control-toolbar .range-pill { width:175px; grid-template-columns:28px 1fr 28px; height:40px; background:#e7e7e7; border:0; }
.control-toolbar .range-pill button { width:28px; height:40px; color:#666; font-size:22px; }
.control-toolbar .range-pill button { width:28px; height:40px; padding:0; display:grid; place-items:center; color:#666; line-height:1; }
.control-toolbar .range-pill button svg { width:24px; height:24px; display:block; }
.control-toolbar .range-pill input[type="range"] { background:#c4c4c4; height:20px; background-clip:content-box; padding:8px 0; }
.control-toolbar .range-pill input[type="range"]::-webkit-slider-thumb { width:18px; height:18px; background:#858585; }
.control-toolbar .config-btn { width:auto; height:40px; padding:0 18px; font-size:13px; background:#e7e7e7; color:#444; border:0; border-radius:999px; }
+93
View File
@@ -5,6 +5,99 @@ const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
const {present}=require('../web/control-state.js');
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
test('model viewport fills the available space above battery status',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.rov-stage \{ flex:1; height:auto; min-height:0; width:100%/);
assert.match(css,/\.telemetry-zone \.model-card \{ flex: 1; min-height: 0;/);
});
test('white wash stays behind controls; right panel is gray and edges reach 50 percent',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.workspace-light \{[^}]*z-index:-1/);
assert.match(css,/\.controls-workspace \{[^}]*isolation:isolate/);
assert.match(css,/\.controls-workspace \{[^}]*background:#ededed/);
assert.match(css,/\.sidebar-deck \{[^}]*background:linear-gradient\(160deg,#333,#242424\)/);
assert.match(css,/\.cursor-light::after \{[^}]*rgba\(255,255,255,\.50\)/);
});
test('keyboard easing publishes intermediate demands to the real Python bridge',async()=>{
const h=harness();h.state();h.run('bridgeReady=true');
keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');
for(let i=0;i<10;i++){h.advance(50);await Promise.resolve();await Promise.resolve();await Promise.resolve();}
const sent=h.calls.filter(call=>call[0]==='axes');
assert.ok(sent.some(call=>call[1]>0&&call[1]<1),'surge ramp is transmitted, not just drawn');
assert.ok(sent.some(call=>call[3]>0&&call[3]<1),'vertical ramp is transmitted, not just drawn');
assert.ok(sent.every(call=>call[1]>=0&&call[1]<=1&&call[3]>=0&&call[3]<=1));
});
test('cursor lighting stays decorative and arrows use centered geometry',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.doesNotMatch(js,/set_axes|setJoyControl|setHeave|requestControl/);
assert.match(js,/workspace\.append\(wash\)/);
assert.match(js,/pointerleave',clear/);
assert.match(css,/\.cursor-light \{[^}]*pointer-events:none/);
assert.match(css,/range-pill button \{[^}]*place-items:center/);
assert.match(css,/#engage\.danger \.engage-label \{ color:#777/);
});
test('edge lighting uses angle; only visible pills get blue and sidebar is excluded',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.match(js,/Math\.atan2/);
assert.match(js,/!host\.closest\('\.sidebar-deck,#engage'\)/);
assert.match(js,/host\.matches\(pillSelector\)/);
assert.match(css,/\.cursor-light\.pill-light::before \{ background:radial-gradient/);
assert.match(css,/\.cursor-light::after \{[^}]*linear-gradient\(var\(--light-angle/);
assert.doesNotMatch(css,/\.cursor-light::after \{[^}]*radial-gradient/);
assert.match(css,/\.rov-stage \{[^}]*border-radius:31px; overflow:hidden/);
});
test('all interface typography uses Helvetica with inherited control fonts',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const families=[...css.matchAll(/font-family\s*:\s*([^;}]+)/g)].map(match=>match[1].trim());
assert.ok(families.includes('"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.ok(families.every(family=>family==='inherit'||family==='"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.match(css,/button, select, input \{ font: inherit; \}/);
});
function keyEvent(h,type,key,repeat=false){h.fire(type,{key,repeat,target:{tagName:'BODY'},preventDefault(){}})}
test('keyboard ramps joystick and vertical together, then eases back on release',()=>{
const h=harness();h.state();
keyEvent(h,'keydown','w');keyEvent(h,'keydown','d');keyEvent(h,'keydown','r');
assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
h.advance(180);
for(const axis of ['forward','turn','vertical'])assert.ok(h.run(`control.${axis}`)>0&&h.run(`control.${axis}`)<1);
keyEvent(h,'keydown','w',true);h.advance(240);
assert.ok(Math.abs(h.run('control.forward')-Math.SQRT1_2)<1e-6);
assert.ok(Math.abs(h.run('control.turn')-Math.SQRT1_2)<1e-6);
assert.equal(h.run('control.vertical'),1);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','d');keyEvent(h,'keyup','r');
h.advance(180);assert.ok(h.run('control.forward')>0);assert.ok(h.run('control.vertical')>0);
h.advance(250);assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('keyboard reversal is continuous and focus loss cancels its ramp',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(420);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','r');keyEvent(h,'keydown','s');keyEvent(h,'keydown','f');
assert.equal(h.run('control.forward'),1);assert.equal(h.run('control.vertical'),1);
h.advance(200);assert.ok(Math.abs(h.run('control.forward'))<.3);assert.ok(Math.abs(h.run('control.vertical'))<.3);
h.advance(220);assert.equal(h.run('control.forward'),-1);assert.equal(h.run('control.vertical'),-1);
keyEvent(h,'keyup','s');keyEvent(h,'keyup','f');h.fire('blur');h.advance(500);
assert.equal(h.run('control.forward+control.vertical'),0);
});
test('touch takes ownership from a keyboard ramp without later keyboard overwrite',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(100);
h.el('#joystick-canvas').emit('pointerdown',{clientX:300,clientY:160});
h.el('#heave').emit('pointerdown',{pointerId:2,clientY:300});
keyEvent(h,'keydown','a');h.advance(450);
assert.equal(h.run('control.turn'),1);assert.equal(Math.abs(h.run('control.forward')),0);assert.equal(h.run('control.vertical'),-1);
});
test('hold fill and pending engagement share the same green and glow',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.engage-progress-label \{ color:var\(--engage-green\).*filter:var\(--engage-glow\)/);
assert.match(css,/#engage\.pending \.engage-label \{ color:var\(--engage-green\); filter:var\(--engage-glow\)/);
});
test('native pywebview result wrapper can execute alongside app globals',()=>{
const h=harness();
// Window.evaluate_js declares these variables in the page's global scope.
+9
View File
@@ -112,6 +112,15 @@ class WorkerWatchdogTests(unittest.TestCase):
self.assertNeutral()
self.assertEqual(self.worker.axes, Axes())
def test_intermediate_demands_reach_mavlink_with_output_limit(self):
with patch("app.time.monotonic", return_value=10):
self.worker.last_control_at = 9.9
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
self.worker._send_control()
# 25% input at the default 20% limit: 5% command, not 100%.
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
(1, 50, 0, 525, 0, 0))
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
with patch("app.time.monotonic", return_value=13.1):
self.worker.set_axes(Axes(forward=1), 1)
+2 -2
View File
@@ -90,10 +90,10 @@ function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.m
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
+42
View File
@@ -0,0 +1,42 @@
/* Decorative only: no control demands or vehicle state are accessed here. */
(() => {
const workspace=document.querySelector('.controls-workspace');
if(!workspace)return;
const wash=document.createElement('span');
wash.className='workspace-light';wash.setAttribute('aria-hidden','true');workspace.append(wash);
// Explicit visual surfaces, not every semantic button. The entire sidebar
// and text-only Engage control are excluded from local lighting.
const pillSelector='.config-btn,.modal .button,.range-pill,.number-pill';
const shapeSelector=`${pillSelector},.heave-track,.heave-knob,.joystick,.modal`;
const layers=[...document.querySelectorAll(shapeSelector)].filter(host=>!host.closest('.sidebar-deck,#engage')).map(host=>{
host.classList.add('light-surface');
const layer=document.createElement('span');layer.className='cursor-light';
layer.setAttribute('aria-hidden','true');
layer.classList.toggle('pill-light',host.matches(pillSelector));
host.append(layer);return {host,layer};
});
let frame=0,point=null;
const clear=()=>{point=null;cancelAnimationFrame(frame);frame=0;wash.classList.remove('lit');layers.forEach(({layer})=>layer.classList.remove('lit','near'))};
function paint(){
frame=0;if(!point)return;
const {x,y}=point,r=workspace.getBoundingClientRect();
wash.style.setProperty('--light-x',`${x-r.left}px`);wash.style.setProperty('--light-y',`${y-r.top}px`);
wash.classList.toggle('lit',x>=r.left&&x<=r.right&&y>=r.top&&y<=r.bottom);
for(const {host,layer} of layers){
const b=host.getBoundingClientRect();
layer.style.setProperty('--light-x',`${x-b.left}px`);layer.style.setProperty('--light-y',`${y-b.top}px`);
const inside=x>=b.left&&x<=b.right&&y>=b.top&&y<=b.bottom;
// CSS gradient angles start at up; atan2 is measured from the x axis.
const angle=Math.atan2(y-(b.top+b.height/2),x-(b.left+b.width/2))*180/Math.PI+90;
layer.style.setProperty('--light-angle',`${angle}deg`);
layer.classList.toggle('lit',inside);layer.classList.add('near');
}
}
document.addEventListener('pointermove',event=>{
if(event.pointerType==='touch'){clear();return}
point={x:event.clientX,y:event.clientY};if(!frame)frame=requestAnimationFrame(paint);
},{passive:true});
document.documentElement.addEventListener('pointerleave',clear);
document.addEventListener('pointercancel',clear);window.addEventListener('blur',clear);
document.addEventListener('visibilitychange',()=>{if(document.hidden)clear()});
})();
+6 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
<link rel="stylesheet" href="styles.css?v=20260827-model-layout-v14">
</head>
<body>
<main class="app-shell">
@@ -132,9 +132,9 @@
<label class="output-limit">
<span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill">
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
</label>
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
@@ -151,8 +151,9 @@
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20260827-polish-v3"></script>
<script src="joystick-3d.js?v=20260827-keyboard-v7"></script>
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260827-bridge-v6"></script>
<script src="app.js?v=20260827-keyboard-v7"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+9 -2
View File
@@ -30,7 +30,14 @@
function publish(){window.setJoyControl?.(x,y)}
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
function easeTo(nextX,nextY){
cancelAnimationFrame(returnFrame);
const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}
const startX=x,startY=y,start=performance.now();
const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX+(nextX-startX)*smooth,startY+(nextY-startY)*smooth);if(p<1)returnFrame=requestAnimationFrame(tick)};
returnFrame=requestAnimationFrame(tick);
}
function returnToCenter(){easeTo(0,0)}
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
@@ -38,5 +45,5 @@
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
addEventListener('resize',resize);resize();
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
})();
+26 -10
View File
@@ -1,5 +1,19 @@
/* SUB Control UI – Desktop Layout */
/* Isolated pointer lighting: no layout or input interception. */
.controls-workspace { position:relative; isolation:isolate; }
.workspace-light { position:absolute; inset:0; z-index:-1; pointer-events:none; border-radius:inherit; opacity:0; background:radial-gradient(600px circle at var(--light-x) var(--light-y),rgba(255,255,255,.35),transparent 75%); transition:opacity .35s ease-out; }
.workspace-light.lit { opacity:1; }
.light-surface { position:relative; }
.cursor-light { position:absolute; inset:0; z-index:3; pointer-events:none; border-radius:inherit; }
.cursor-light::before,.cursor-light::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; opacity:0; transition:opacity .35s ease-out; }
.cursor-light.pill-light::before { background:radial-gradient(240px circle at var(--light-x) var(--light-y),rgba(110,190,235,.08),transparent 80%); }
.cursor-light::after { padding:1px; background:linear-gradient(var(--light-angle,315deg),transparent 30%,rgba(255,255,255,.13) 65%,rgba(255,255,255,.50) 100%); -webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0); -webkit-mask-composite:xor; mask-composite:exclude; }
.cursor-light.lit::before,.cursor-light.near::after { opacity:1; }
.joystick > .cursor-light { inset:7%; border-radius:50%; }
#engage > .cursor-light::after { display:none; }
@media (prefers-reduced-motion:reduce) { .workspace-light,.cursor-light::before,.cursor-light::after { transition:none; } }
:root {
color-scheme: dark;
--bg: #0c1012;
@@ -14,7 +28,7 @@
--amber: #f2c675;
--red: #f0958b;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
font-family: Inter, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 300;
}
@@ -1039,7 +1053,7 @@ body #connect, body #connect:hover, body #connect.is-connected, body #connect.is
}
body #connect .btn-label { animation:none; }
.telemetry-zone { gap:20px; }
.rov-stage { height:clamp(180px,32vh,250px); min-height:180px; touch-action:none; }
.rov-stage { flex:1; height:auto; min-height:0; width:100%; touch-action:none; border-radius:31px; overflow:hidden; background:transparent; }
.model-card { justify-content:flex-start; }
.rov-stage::before { display:none; }
.model-tools button { font-size:12px; padding:7px 11px; }
@@ -1052,24 +1066,25 @@ body #connect .btn-label { animation:none; }
.activity-history li { font-size:12px; font-weight:300; line-height:1.25; color:#999; overflow-wrap:anywhere; }
.activity-history #status-text { color:#fff; font-size:12px; font-weight:300; }
.activity-history li[hidden] { display:none; }
.controls-workspace { padding:8px 20px 16px; background:#f6f6f6; }
.controls-workspace { padding:8px 20px 16px; background:#ededed; }
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) 52px; }
.engage-header-zone { display:block; position:relative; height:120px; min-height:0; margin:0; padding:0; }
body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding:0 20px; border:0; border-radius:0; background:transparent; box-shadow:none; }
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
#engage::before, #engage::after { display:none; }
body #engage { align-items:flex-end; padding-bottom:12px; }
.engage-face > span { font-family:"Segoe UI Variable Display","Segoe UI",Arial,sans-serif; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
.engage-face > span { font-family:inherit; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
#engage { --engage-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
#engage .engage-label { color:#777; background:none; animation:none; }
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
#engage:is(.arm,.danger):not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-sheen-label { opacity:1; animation:engage-sweep 2.2s linear infinite; }
@keyframes engage-sweep { from {mask-position:100% 0;-webkit-mask-position:100% 0} to {mask-position:0% 0;-webkit-mask-position:0% 0} }
.engage-progress-label { color:#4cce73; clip-path:none; filter:drop-shadow(0 0 2px rgba(76,206,115,.3)); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
.engage-progress-label { color:var(--engage-green); clip-path:none; filter:var(--engage-glow); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
#engage.charged .engage-progress-label { mask-image:none; -webkit-mask-image:none; }
#engage.pending .engage-label { color:#5a8753; background:none; }
#engage.danger .engage-label { color:#171717; }
#engage.pending .engage-label { color:var(--engage-green); filter:var(--engage-glow); background:none; }
#engage.danger .engage-label { color:#777; }
#engage:focus-visible { outline:2px solid #8d9991; outline-offset:-6px; }
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:#f6f6f6; }
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:transparent; }
.control-status[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) {
.engage-sheen-label { display:none; }
@@ -1094,10 +1109,11 @@ body #engage { align-items:flex-end; padding-bottom:12px; }
.heave-fill { left:22px; right:22px; background:#999; }
.control-toolbar { display:flex; justify-content:center; align-items:center; gap:16px; padding-top:12px; }
.control-toolbar .output-limit { width:auto; flex-direction:row; align-items:center; font-size:13px; gap:12px; color:#777; }
.control-toolbar .output-limit span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
.control-toolbar .output-limit > span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
.control-toolbar .output-limit b { display:inline-block; flex:0 0 4ch; width:4ch; text-align:right; color:#333; font-variant-numeric:tabular-nums; }
.control-toolbar .range-pill { width:175px; grid-template-columns:28px 1fr 28px; height:40px; background:#e7e7e7; border:0; }
.control-toolbar .range-pill button { width:28px; height:40px; color:#666; font-size:22px; }
.control-toolbar .range-pill button { width:28px; height:40px; padding:0; display:grid; place-items:center; color:#666; line-height:1; }
.control-toolbar .range-pill button svg { width:24px; height:24px; display:block; }
.control-toolbar .range-pill input[type="range"] { background:#c4c4c4; height:20px; background-clip:content-box; padding:8px 0; }
.control-toolbar .range-pill input[type="range"]::-webkit-slider-thumb { width:18px; height:18px; background:#858585; }
.control-toolbar .config-btn { width:auto; height:40px; padding:0 18px; font-size:13px; background:#e7e7e7; color:#444; border:0; border-radius:999px; }