Clean up UI windows and strengthen settings workflows
This commit is contained in:
+216
-16
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
@@ -21,13 +21,14 @@ function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{cons
|
||||
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
|
||||
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
|
||||
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
|
||||
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750;
|
||||
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
||||
window.canDrive=canDrive;
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
|
||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||
window.showToast=toast;
|
||||
const manualPorts=new Map();
|
||||
function populatePorts(ports){
|
||||
const p=$('#port');
|
||||
if(!p)return;
|
||||
@@ -50,6 +51,7 @@ function populatePorts(ports){
|
||||
};
|
||||
|
||||
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||
manualPorts.forEach(addPort);
|
||||
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||
|
||||
let matched=false;
|
||||
@@ -94,7 +96,7 @@ function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.ve
|
||||
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)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();});
|
||||
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(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});
|
||||
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
|
||||
@@ -111,28 +113,62 @@ addEventListener('focus',()=>focusChanged(!document.hidden));
|
||||
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
|
||||
addEventListener('pagehide',()=>focusChanged(false));
|
||||
|
||||
function setFeedback(el,message=''){
|
||||
if(!el)return;
|
||||
el.textContent=message;el.hidden=!message;
|
||||
}
|
||||
function openModal(id){
|
||||
const el=typeof id==='string'?document.getElementById(id):id;
|
||||
if(!el)return;
|
||||
if(!el||el.open)return;
|
||||
resetInputs();
|
||||
el._returnFocus=document.activeElement;
|
||||
if(el.id==='battery-modal')fillBatteryForm();
|
||||
if(el.id==='link-modal'){
|
||||
fillLinkForm();
|
||||
$('#custom-port-status').textContent='';
|
||||
$('#custom-port-input').value='';
|
||||
}
|
||||
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
|
||||
el.classList.remove('closing');
|
||||
if(!el.open)el.showModal();
|
||||
el.showModal();
|
||||
}
|
||||
function closeModal(dialog){
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
|
||||
dialog.classList.add('closing');
|
||||
let done=false;
|
||||
const finish=()=>{
|
||||
if(done)return;
|
||||
done=true;
|
||||
dialog.removeEventListener('animationend',finish);
|
||||
dialog.removeEventListener('animationend',onEnd);
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
|
||||
};
|
||||
dialog.addEventListener('animationend',finish,{once:true});
|
||||
setTimeout(finish,300);
|
||||
const onEnd=e=>{if(e.target===dialog)finish()};
|
||||
dialog.addEventListener('animationend',onEnd);
|
||||
setTimeout(finish,180);
|
||||
}
|
||||
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
|
||||
$('#link-quality').onclick=()=>openModal('link-quality-modal');
|
||||
let heavyActionBusy=false;
|
||||
async function requestHeavyTest(action){
|
||||
if(heavyActionBusy)return;
|
||||
heavyActionBusy=true;
|
||||
setFeedback($('#heavy-quality-error'));
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
try{
|
||||
const test=await api(action);
|
||||
vehicleSnapshot.heavy_quality=test;
|
||||
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
|
||||
finally{
|
||||
heavyActionBusy=false;
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}
|
||||
}
|
||||
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
|
||||
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
|
||||
$$('dialog').forEach(d=>{
|
||||
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||
d.addEventListener('click',e=>{
|
||||
@@ -255,7 +291,7 @@ $('#connect').onclick=async()=>{
|
||||
recordActivity(`Connecting to ${port}…`);
|
||||
toast(`Connecting to ${port}…`);
|
||||
try{
|
||||
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
||||
const confirmedState=await api('connect',port,+linkConfig.baud||57600,+linkConfig.control_hz||20);
|
||||
if(confirmedState)render(confirmedState);
|
||||
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
|
||||
}catch(e){
|
||||
@@ -270,7 +306,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
|
||||
if(customPortBtn&&customPortInput){
|
||||
const applyCustomPort=()=>{
|
||||
const val=customPortInput.value.trim();
|
||||
if(!val)return;
|
||||
if(!val){$('#custom-port-status').textContent='Enter a port first.';customPortInput.focus();return;}
|
||||
if(vehicleSnapshot.connected)return;
|
||||
manualPorts.set(val,{device:val,label:`Custom (${val})`});
|
||||
const p=$('#port');
|
||||
let exists=false;
|
||||
for(const opt of p.options){
|
||||
@@ -280,8 +318,7 @@ if(customPortBtn&&customPortInput){
|
||||
p.add(new Option(`Custom (${val})`, val), 0);
|
||||
p.value=val;
|
||||
}
|
||||
closeModal($('#link-modal'));
|
||||
toast(`Selected port: ${val}`);
|
||||
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
|
||||
};
|
||||
customPortBtn.onclick=applyCustomPort;
|
||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||
@@ -380,6 +417,34 @@ if(engageButton){
|
||||
}
|
||||
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
|
||||
let consoleRefreshBusy=false,consolePaused=false;
|
||||
$('#console-pause').onclick=()=>{
|
||||
consolePaused=!consolePaused;
|
||||
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
|
||||
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
|
||||
$('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages';
|
||||
if(!consolePaused)refreshConsole();
|
||||
};
|
||||
async function refreshConsole(){
|
||||
if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return;
|
||||
consoleRefreshBusy=true;
|
||||
try{
|
||||
const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36;
|
||||
const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36;
|
||||
const sides=await api('read_console',150);
|
||||
if(consolePaused||!consoleDialog.open)return;
|
||||
consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…';
|
||||
consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…';
|
||||
if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight;
|
||||
if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight;
|
||||
}catch(error){
|
||||
$('#console-state').textContent=`Console unavailable: ${error.message}`;
|
||||
}
|
||||
finally{consoleRefreshBusy=false}
|
||||
}
|
||||
$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()};
|
||||
setInterval(refreshConsole,750);
|
||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||
@@ -391,9 +456,108 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
|
||||
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
|
||||
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
|
||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||
async function saveSettings(form,method,commit,message){
|
||||
if(form.dataset.busy==='true'||!form.reportValidity())return;
|
||||
const data=Object.fromEntries(new FormData(form));
|
||||
const button=form.querySelector('[type="submit"]'),label=button.textContent;
|
||||
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
|
||||
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
|
||||
setFeedback(form.querySelector('.form-feedback'));
|
||||
let saved=false;
|
||||
try{commit(await api(method,data));saved=true;toast(message)}
|
||||
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
|
||||
finally{
|
||||
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
|
||||
if(saved)closeModal(form.closest('dialog'));
|
||||
}
|
||||
}
|
||||
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
|
||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
|
||||
$('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
|
||||
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
|
||||
const motorEdits=new Map();
|
||||
async function requestMotorEdit(servo,kind,value){
|
||||
if(motorEdits.has(servo))return;
|
||||
const s=vehicleSnapshot.servos?.[String(servo)]||{};
|
||||
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
|
||||
setFeedback($('#motor-feedback'));
|
||||
if(demoActive){
|
||||
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
|
||||
else s.direction=expected;
|
||||
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
|
||||
}
|
||||
motorEdits.set(servo,{kind,expected,started:performance.now()});
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
try{
|
||||
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
|
||||
}catch(error){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
}
|
||||
}
|
||||
function renderMotors(servos){
|
||||
for(const [servo,edit] of motorEdits){
|
||||
const s=servos[String(servo)]||{};
|
||||
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
|
||||
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
|
||||
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
|
||||
}
|
||||
}
|
||||
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
|
||||
const assigned=Object.values(servos).filter(s=>s.motor).length;
|
||||
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
|
||||
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
|
||||
if(signature===servoSignature)return;
|
||||
servoSignature=signature;
|
||||
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
|
||||
list.replaceChildren();
|
||||
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
|
||||
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
|
||||
for(let i=1;i<=16;i++){
|
||||
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
|
||||
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
|
||||
const select=row.querySelector('select');
|
||||
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
|
||||
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
|
||||
select.value=String(s.motor||0);
|
||||
select.disabled=!editable||!!pending||s.function==null;
|
||||
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
|
||||
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
|
||||
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
|
||||
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
|
||||
reverse.onclick=()=>requestMotorEdit(i,'direction');
|
||||
(s.motor?list:other).append(row);
|
||||
}
|
||||
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
|
||||
if(assigned<16)list.append(other);
|
||||
}
|
||||
function renderHeavyQuality(test,connected,armed){
|
||||
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
|
||||
if(!status||!results||!run||!stop)return;
|
||||
const quality=demoActive?null:vehicleSnapshot.link_quality;
|
||||
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
|
||||
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
|
||||
const running=!!test?.running;
|
||||
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
|
||||
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
|
||||
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
|
||||
stop.hidden=!running;
|
||||
stop.disabled=heavyActionBusy;
|
||||
results.replaceChildren();
|
||||
for(const row of test?.results||[]){
|
||||
const line=document.createElement('div');line.className='heavy-result-row';
|
||||
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
|
||||
const stats=document.createElement('span');
|
||||
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
|
||||
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
|
||||
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
|
||||
line.append(rate,stats);results.append(line);
|
||||
}
|
||||
}
|
||||
function render(s,confirmed=false){
|
||||
vehicleSnapshot=s;
|
||||
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||
@@ -402,10 +566,13 @@ function render(s,confirmed=false){
|
||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
|
||||
document.body.dataset.appState=appState;
|
||||
document.body.dataset.controlsEnabled=String(view.drive);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#port').disabled=!!s.connected;
|
||||
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -456,6 +623,37 @@ function render(s,confirmed=false){
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#battery-fill').style.width='0';
|
||||
}
|
||||
const quality=s.link_quality,qualityCard=$('#link-quality');
|
||||
if(qualityCard){
|
||||
if(demoActive){
|
||||
qualityCard.dataset.grade='offline';
|
||||
$('#link-quality-grade').textContent='Demo';
|
||||
$('#link-quality-roundtrip').textContent='no hardware link test';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
renderMotors(s.servos||{});
|
||||
return;
|
||||
}
|
||||
const grade=quality?.grade||(s.connected?'testing':'offline');
|
||||
qualityCard.dataset.grade=grade;
|
||||
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
|
||||
if(quality&&quality.probe_samples){
|
||||
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
|
||||
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
|
||||
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
|
||||
const downLoss=percent(quality.rx_sequence_loss_percent);
|
||||
const badFrames=quality.rx_corrupt_frames_20s??0;
|
||||
const badBytes=quality.rx_corrupt_bytes_20s??0;
|
||||
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
|
||||
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
|
||||
if(quality.probe_samples<10||quality.rx_packets_30s<10){
|
||||
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
|
||||
}
|
||||
}else{
|
||||
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
}
|
||||
}
|
||||
renderMotors(s.servos||{});
|
||||
}
|
||||
|
||||
@@ -564,8 +762,10 @@ async function boot(){
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
linkConfig=init.link_config||{};
|
||||
populatePorts(init.ports);
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
|
||||
+62
-6
@@ -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-model-layout-v14">
|
||||
<link rel="stylesheet" href="styles.css?v=20260930-dialogs-v1">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
@@ -50,13 +50,19 @@
|
||||
<b><em id="battery-fill"></em></b>
|
||||
</button>
|
||||
|
||||
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
|
||||
<span class="link-quality-dot" aria-hidden="true"></span>
|
||||
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
|
||||
<i><span id="link-quality-roundtrip">waiting for connection</span><span id="link-quality-downlink"></span></i>
|
||||
</button>
|
||||
|
||||
<div class="deck-status-bar">
|
||||
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||
<li id="activity-oldest" hidden></li>
|
||||
<li id="activity-previous" hidden></li>
|
||||
<li id="status-text">Not connected</li>
|
||||
</ol>
|
||||
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
|
||||
<header class="activity-heading"><span>Recent activity</span><span class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></span></header>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -146,14 +152,64 @@
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Modals -->
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||
<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>
|
||||
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link settings</h2>
|
||||
<form id="link-form">
|
||||
<section class="settings-section"><h3>Serial connection</h3>
|
||||
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
|
||||
<p class="note">Baud changes apply on the next connection.</p>
|
||||
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
|
||||
<p id="custom-port-status" class="note" role="status"></p>
|
||||
</section>
|
||||
<section class="settings-section"><h3>Live updates</h3>
|
||||
<div class="form-grid two">
|
||||
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
</div>
|
||||
<p class="note">Lower rates leave more bandwidth for controls. Vehicle telemetry rates reset after a reboot.</p>
|
||||
</section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="quality-title">Link quality</h2>
|
||||
<p class="note">The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.</p>
|
||||
<div class="quality-summary"><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
|
||||
<section class="heavy-test-panel"><h3>Extended test</h3><p class="note">Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.</p>
|
||||
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
|
||||
<div id="heavy-quality-results" class="heavy-quality-results"></div>
|
||||
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Power</small><h2 id="battery-title-modal">Battery settings</h2><p class="note">Charge is estimated from voltage. Capacity and C rating describe your battery.</p>
|
||||
<form id="battery-form">
|
||||
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
|
||||
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Configuration</small><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
|
||||
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
|
||||
</dialog>
|
||||
<dialog id="console-modal" class="modal console-modal" aria-labelledby="console-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Local session</small><h2 id="console-title">Live console</h2>
|
||||
<div class="console-toolbar"><p id="console-state" class="note" role="status">Live · follows new messages</p><button id="console-pause" type="button" class="button subtle" aria-pressed="false">Pause</button></div>
|
||||
<div class="console-split"><section><header><i class="rx-dot"></i><span>RX</span><small>Vehicle → PC</small></header><pre id="console-rx-output" tabindex="0" aria-label="Received messages">Waiting for vehicle data…</pre></section><section><header><i class="tx-dot"></i><span>TX</span><small>PC → Vehicle</small></header><pre id="console-tx-output" tabindex="0" aria-label="Sent messages">Waiting for transmitted data…</pre></section></div>
|
||||
</dialog>
|
||||
|
||||
<script src="model-viewer.js?v=20260826-controls-v2"></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-keyboard-v7"></script>
|
||||
<script src="app.js?v=20260930-dialogs-v1"></script>
|
||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+167
@@ -539,6 +539,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
margin-left: auto;
|
||||
}
|
||||
.logs-link:hover { color: #f1f4f2; }
|
||||
.activity-actions { display:flex; align-items:center; gap:12px; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
PRIMARY CONTROL ZONE (Workspace)
|
||||
@@ -554,6 +555,59 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Active MAVLink link test ── */
|
||||
.link-quality {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 3px 0;
|
||||
color: #eef2f0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 9px;
|
||||
transition: background-color .18s ease;
|
||||
}
|
||||
.link-quality:hover,.link-quality:focus-visible { background:rgba(255,255,255,.055); outline:none; }
|
||||
.link-quality-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #667074;
|
||||
}
|
||||
.link-quality span:not(.link-quality-dot) { display:flex; flex-direction:column; gap:1px; min-width:0; }
|
||||
.link-quality small { color:#8d979a; font-size:10px; font-weight:400; }
|
||||
.link-quality strong { font-size:14px; font-weight:500; line-height:1.15; }
|
||||
.link-quality i {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
color: #aab2b4;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.link-quality i span { display:block; }
|
||||
.link-quality[data-grade="excellent"] .link-quality-dot { background:#bce96c; box-shadow:0 0 8px rgba(188,233,108,.35); }
|
||||
.link-quality[data-grade="good"] .link-quality-dot { background:#85c7e8; }
|
||||
.link-quality[data-grade="fair"] .link-quality-dot { background:#e7bf69; }
|
||||
.link-quality[data-grade="poor"] .link-quality-dot { background:#f07f78; box-shadow:0 0 8px rgba(240,127,120,.3); }
|
||||
.link-quality[data-grade="testing"] .link-quality-dot { background:#aab3b6; }
|
||||
.quality-modal { width:min(520px,calc(100vw - 32px)); }
|
||||
.heavy-test-panel { margin-top:18px; padding-top:14px; border-top:1px solid rgba(128,145,145,.2); }
|
||||
.heavy-quality-status { font-size:14px; font-weight:500; }
|
||||
.heavy-quality-results { display:grid; gap:6px; margin-top:12px; }
|
||||
.heavy-result-row { display:flex; justify-content:space-between; gap:12px; align-items:baseline; padding:7px 10px; border-radius:10px; background:rgba(120,140,140,.08); font-size:12px; }
|
||||
.heavy-result-row strong { flex:none; }
|
||||
.heavy-result-row span { text-align:right; color:var(--muted); }
|
||||
.primary-control-zone {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -1025,6 +1079,37 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.console-modal {
|
||||
width: min(1040px, calc(100vw - 64px));
|
||||
max-width: 1040px;
|
||||
}
|
||||
.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; }
|
||||
.console-split section { min-width:0; }
|
||||
.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; }
|
||||
.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; }
|
||||
.console-split header .rx-dot { background:#79c8eb; }
|
||||
.console-split header .tx-dot { background:#bce96c; }
|
||||
.console-split header span { color:#303836; font-size:12px; font-weight:600; }
|
||||
.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; }
|
||||
.console-split pre {
|
||||
height: min(55vh, 480px);
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
border: 1px solid #d9ddda;
|
||||
border-radius: 12px;
|
||||
background: #171b1d;
|
||||
color: #dce4e1;
|
||||
font: 11px/1.45 Consolas, "SFMono-Regular", monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.console-split { grid-template-columns:1fr; }
|
||||
.console-split pre { height:220px; }
|
||||
}
|
||||
|
||||
/* A pending or unknown armed state must stay legible, even without a link. */
|
||||
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1131,3 +1216,85 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
.telemetry-zone { gap:12px; }
|
||||
.activity-history { gap:4px; }
|
||||
}
|
||||
|
||||
/* Settings windows: readable fields, predictable spacing, one action hierarchy. */
|
||||
.modal {
|
||||
color-scheme: light;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid #dedfdf;
|
||||
border-radius: 20px;
|
||||
background: #f6f6f6;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,.24);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.modal[open] { animation: dialog-in .16s ease-out; }
|
||||
.modal.closing { animation: dialog-out .12s ease-in forwards; }
|
||||
.modal::backdrop { background: rgba(15,19,22,.4); backdrop-filter: blur(3px); }
|
||||
.modal > small { display:block; color:#73787b; font-size:12px; font-weight:400; margin:0 42px 6px 0; }
|
||||
.modal h2 { margin:0 40px 18px 0; font-size:28px; letter-spacing:-.025em; font-weight:400; }
|
||||
.modal h3 { margin:0 0 10px; font-size:14px; font-weight:500; color:#454b4e; }
|
||||
.modal .note { font-size:13px; line-height:1.5; margin:8px 0 0; color:#676e71; }
|
||||
.modal-close { top:16px; right:16px; width:36px; height:36px; display:grid; place-items:center; border-radius:50%; background:#e9ebec; color:#555c60; font-size:25px; line-height:1; }
|
||||
.modal-close:hover { background:#dfe3e5; }
|
||||
.settings-section { margin-top:22px; }
|
||||
.settings-section + .settings-section { border-top:1px solid #e0e3e4; padding-top:20px; }
|
||||
.modal .form-grid { gap:16px; margin-top:10px; }
|
||||
.modal .form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.modal label { min-width:0; font-size:13px; line-height:1.35; font-weight:400; color:#50585c; }
|
||||
.modal label > span { display:block; margin-bottom:7px; }
|
||||
.modal input,.modal select { width:100%; min-width:0; height:42px; padding:0 13px; border:1px solid #d7dcde; border-radius:999px; background:#fff; color:#272c2f; font-size:14px; font-weight:400; }
|
||||
.modal select { padding-right:28px; }
|
||||
.modal :is(button,input,select,summary,pre):focus-visible { outline:2px solid #6598b5; outline-offset:3px; }
|
||||
.modal .number-pill { height:42px; grid-template-columns:36px minmax(0,1fr) 36px; background:#fff; border:1px solid #d7dcde; }
|
||||
.modal .number-pill button { width:36px; height:40px; font-size:24px; color:#5b666b; }
|
||||
.modal .number-pill input { border:0; padding:0; height:40px; appearance:textfield; -moz-appearance:textfield; }
|
||||
.modal .number-pill input::-webkit-inner-spin-button { appearance:none; }
|
||||
.modal .number-pill:focus-within { border-color:#6598b5; }
|
||||
.custom-port-label { display:block; margin-top:16px; }
|
||||
.custom-port-row { display:flex; gap:8px; }
|
||||
.custom-port-row input { flex:1; }
|
||||
.modal .button { min-height:42px; height:auto; padding:10px 18px; font-size:14px; font-weight:400; border-radius:999px; white-space:normal; }
|
||||
.modal .button.subtle { background:#e9ebec; border:0; color:#424b50; }
|
||||
.modal .modal-primary { background:#333e44; color:#fff; border:0; }
|
||||
.modal .modal-primary:hover { background:#455660; }
|
||||
.modal .modal-actions { margin-top:24px; gap:10px; align-items:center; }
|
||||
.modal .form-feedback { margin:16px 0 0; padding:10px 12px; border-radius:12px; font-size:13px; line-height:1.5; color:#9b392e; background:#f5e6e3; }
|
||||
.modal [hidden] { display:none !important; }
|
||||
.modal [data-busy="true"] { cursor:progress; }
|
||||
.motor-modal { width:min(650px,calc(100vw - 32px)); }
|
||||
.modal .motor-head,.modal .motor-row { grid-template-columns:78px minmax(100px,1fr) 94px 86px; font-size:13px; gap:10px; padding:10px 0; }
|
||||
.modal .motor-head { margin-top:18px; color:#727b80; font-size:12px; font-weight:400; }
|
||||
.motor-row strong { font-size:12px; font-weight:500; }
|
||||
.motor-row > span { font-size:12px; color:#727b80; }
|
||||
.modal .motor-row .button { min-height:36px; font-size:12px; padding:8px 12px; }
|
||||
.motor-other { margin-top:16px; }
|
||||
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; }
|
||||
.quality-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:18px; }
|
||||
.quality-summary div { border-radius:14px; background:#e9ebec; padding:14px 12px; min-width:0; }
|
||||
.quality-summary span { display:block; font-size:12px; color:#697278; margin-bottom:8px; }
|
||||
.quality-summary strong { font-size:20px; font-weight:400; font-variant-numeric:tabular-nums; }
|
||||
.modal .heavy-quality-status { padding:12px; background:#e9ebec; border-radius:12px; font-size:14px; margin-top:14px; }
|
||||
.modal .heavy-result-row { font-size:13px; padding:10px 12px; font-variant-numeric:tabular-nums; }
|
||||
.console-modal { width:min(1020px,calc(100vw - 32px)); }
|
||||
.console-toolbar { display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
||||
.console-toolbar .note { margin:0; }
|
||||
.modal .console-split header small { font-size:12px; }
|
||||
.modal .console-split pre { height:clamp(180px,48vh,430px); padding:14px; font-size:12px; line-height:1.55; border-radius:12px; }
|
||||
@keyframes dialog-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
|
||||
@keyframes dialog-out { to { opacity:0; transform:translateY(5px); } }
|
||||
@media (max-width:520px) {
|
||||
.modal { padding:22px 18px; }
|
||||
.modal .form-grid.two { grid-template-columns:1fr; }
|
||||
.modal .motor-head,.modal .motor-row { grid-template-columns:56px minmax(80px,1fr) 74px 66px; gap:5px; font-size:12px; }
|
||||
.modal .motor-row .button { padding:8px 6px; }
|
||||
.quality-summary { gap:6px; }
|
||||
.quality-summary div { padding:12px 8px; }
|
||||
.quality-summary strong { font-size:17px; }
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
.modal[open],.modal.closing { animation:none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user