Save pre-V2 motor controls and UI checkpoint
This commit is contained in:
+104
-55
@@ -6,7 +6,8 @@ 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={}, 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 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,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
@@ -448,31 +449,50 @@ if(customPortBtn&&customPortInput){
|
||||
}
|
||||
const engageButton=$('#engage');
|
||||
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
|
||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
|
||||
const ENGAGE_HOLD_MS=1400;
|
||||
let engageLabelValue=$('#engage-label')?.textContent||'engage';
|
||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
|
||||
const ENGAGE_HOLD_MS=600;
|
||||
|
||||
function applyEngageVisual(next){
|
||||
if(!engageButton)return;
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Interaction updates immediately; the short fade never gates emergency access.
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging','DISARMING…':'disengaging','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Update the state label without delaying the requested control action.
|
||||
engageButton.disabled=!next.action;
|
||||
engageButton.setAttribute('aria-busy',String(next.pending));
|
||||
engageButton.setAttribute('aria-label',label);
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 0.6 seconds to engage (F2)':next.message;
|
||||
const key=`${next.label}/${next.tone}`;
|
||||
if(engageVisualState===key)return;
|
||||
const previous=engageVisualState;engageVisualState=key;
|
||||
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
|
||||
clearTimeout(engageTransitionTimer);
|
||||
engageButton.classList.remove('arm','danger','subtle','charged','pending');
|
||||
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
|
||||
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
|
||||
$('#engage-label').textContent=label;
|
||||
const mainLabel=$('#engage-label'),previousLabel=engageLabelValue;
|
||||
const appended=previousLabel==='engage'&&label==='engaging'||previousLabel==='disengage'&&label==='disengaging';
|
||||
let suffix=null;
|
||||
if(appended){
|
||||
const previousWidth=mainLabel.getBoundingClientRect().width;
|
||||
const stem=previousLabel.slice(0,-1);
|
||||
mainLabel.replaceChildren(document.createTextNode(stem));
|
||||
suffix=document.createElement('span');suffix.className='engage-suffix';suffix.textContent=label.slice(stem.length);mainLabel.append(suffix);
|
||||
const appendedWidth=mainLabel.getBoundingClientRect().width;
|
||||
mainLabel.style.setProperty('--engage-append-shift',`${(appendedWidth-previousWidth)/2}px`);
|
||||
mainLabel.classList.add('is-appending');
|
||||
}else{
|
||||
mainLabel.classList.remove('is-appending');
|
||||
mainLabel.style.removeProperty('--engage-append-shift');
|
||||
mainLabel.textContent=label;
|
||||
}
|
||||
engageLabelValue=label;
|
||||
if(engageProgressLabel)engageProgressLabel.textContent=label;
|
||||
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
|
||||
if(previous){
|
||||
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
|
||||
engageButton.classList.add('state-transition');
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
|
||||
if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible'));
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200);
|
||||
}
|
||||
}
|
||||
function completeEngage(){
|
||||
@@ -592,7 +612,22 @@ async function refreshConsole(){
|
||||
finally{consoleRefreshBusy=false}
|
||||
}
|
||||
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)}
|
||||
function syncMotorScalingUI(){
|
||||
const independent=!!motorSettings.independent;
|
||||
document.body.dataset.motorScaling=independent?'independent':'shared';
|
||||
const toggle=$('#motor-scale-independent');if(toggle)toggle.checked=independent;
|
||||
const gain=$('#gain'),down=$('#gain-down'),up=$('#gain-up');
|
||||
if(gain){gain.disabled=independent;gain.title=independent?'Set motor scales in Motor setup':'Shared limit for every motor';}
|
||||
if(down)down.disabled=independent;if(up)up.disabled=independent;
|
||||
}
|
||||
function pushMotorSettings(persist=true){
|
||||
if(!bridgeReady)return Promise.resolve(motorSettings);
|
||||
const payload=JSON.parse(JSON.stringify(motorSettings));
|
||||
motorSettingsQueue=motorSettingsQueue.catch(()=>{}).then(()=>api('save_motor_settings',payload,persist)).then(saved=>{motorSettings=saved;syncMotorScalingUI();if(persist){servoSignature='';renderMotors(vehicleSnapshot.servos||{});toast('Motor settings saved')}return saved}).catch(error=>{setFeedback($('#motor-feedback'),error.message||'Could not save motor settings.');throw error});
|
||||
return motorSettingsQueue;
|
||||
}
|
||||
function motorScale(motor){return motorSettings.independent?(motorSettings.motors[String(motor)]?.scale??100):+$('#gain').value}
|
||||
function setGain(value){if(motorSettings.independent)return;const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;servoSignature='';renderMotors(vehicleSnapshot.servos||{});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
|
||||
// in the embedded window, without depending on native range drag defaults.
|
||||
@@ -623,6 +658,10 @@ function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){con
|
||||
$('#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();
|
||||
$('#motor-scale-independent').onchange=e=>{
|
||||
if(e.target.checked&&!motorSettings.customized){for(let motor=1;motor<=12;motor++)motorSettings.motors[String(motor)].scale=+$('#gain').value;motorSettings.customized=true}
|
||||
motorSettings={...motorSettings,independent:e.target.checked};syncMotorScalingUI();servoSignature='';renderMotors(vehicleSnapshot.servos||{});pushMotorSettings(true).catch(()=>{});
|
||||
};
|
||||
async function requestMotorEdit(servo,kind,value){
|
||||
if(motorEdits.has(servo))return;
|
||||
const s=vehicleSnapshot.servos?.[String(servo)]||{};
|
||||
@@ -645,41 +684,40 @@ async function requestMotorEdit(servo,kind,value){
|
||||
}
|
||||
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 state=servos[String(servo)]||{};
|
||||
if((edit.kind==='function'?state.function:state.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.`)}
|
||||
}
|
||||
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);
|
||||
const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor);
|
||||
const servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}]));
|
||||
$('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`;
|
||||
const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b);
|
||||
const structure=JSON.stringify([assignments.map(([port,state])=>[port,state.function,state.motor,state.direction]),editable,[...motorEdits],motorSettings.independent,shownMotors,Object.values(motorSettings.motors).map(m=>m.name)]);
|
||||
if(structure!==servoSignature){
|
||||
servoSignature=structure;const list=$('#motor-list');list.replaceChildren();
|
||||
for(const motor of shownMotors){
|
||||
const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor);
|
||||
row.innerHTML='<input class="motor-name" type="text" maxlength="24" autocomplete="off" aria-label="Motor name"><input class="motor-scale" type="range" min="0" max="100" step="1" aria-label="Motor output scale"><output class="motor-scale-value"></output><button type="button" class="button subtle motor-reverse">Reverse</button><span class="motor-channel"></span>';
|
||||
const name=row.querySelector('.motor-name');name.onchange=()=>{motorSettings.motors[String(motor)].name=name.value.trim().slice(0,24)||`Motor ${motor}`;name.value=motorSettings.motors[String(motor)].name;pushMotorSettings(true).catch(()=>{})};
|
||||
const scale=row.querySelector('.motor-scale');scale.oninput=()=>{motorSettings.customized=true;motorSettings.motors[String(motor)].scale=+scale.value;row.querySelector('output').textContent=`${scale.value}%`;pushMotorSettings(false).catch(()=>{})};scale.onchange=()=>pushMotorSettings(true).catch(()=>{});
|
||||
row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row);
|
||||
}
|
||||
const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary);
|
||||
for(let i=1;i<=16;i++){
|
||||
const state=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-map-row';row.innerHTML='<span class="motor-map-port"></span><select aria-label="Servo output motor assignment"></select><span class="motor-map-direction"></span>';
|
||||
row.querySelector('.motor-map-port').textContent=`SERVO${i}`;const select=row.querySelector('select');select.add(new Option(state.function==null?'Reading…':'Unassigned','0'));for(let motor=1;motor<=12;motor++)select.add(new Option(motorSettings.motors[String(motor)]?.name||`Motor ${motor}`,String(motor)));select.value=String(state.motor||0);select.disabled=!editable||!!pending||state.function==null;select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
|
||||
row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row);
|
||||
}
|
||||
list.append(details);
|
||||
}
|
||||
for(const motor of shownMotors){
|
||||
const row=$(`#motor-list .motor-row[data-motor="${motor}"]`),state=servoByMotor.get(String(motor))||{},settings=motorSettings.motors[String(motor)]||{name:`Motor ${motor}`,scale:20};if(!row)continue;
|
||||
const servo=state.servo||null,name=row.querySelector('.motor-name');if(document.activeElement!==name)name.value=settings.name;
|
||||
const scale=row.querySelector('.motor-scale');scale.disabled=!motorSettings.independent;if(document.activeElement!==scale)scale.value=motorScale(motor);
|
||||
row.querySelector('.motor-scale-value').textContent=`${motorScale(motor)}%`;row.querySelector('.motor-channel').textContent=servo?`SERVO${servo} · ${state.output_us||'—'} µs`:'Unassigned';
|
||||
const reverse=row.querySelector('.motor-reverse');reverse.disabled=!editable||!servo||state.direction==null||motorEdits.has(servo);reverse.textContent=state.direction===-1?'Restore':'Reverse';reverse.setAttribute('aria-label',`${reverse.textContent} ${settings.name}`);
|
||||
}
|
||||
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');
|
||||
@@ -716,7 +754,8 @@ function render(s,confirmed=false){
|
||||
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);
|
||||
const driveEnabled=view.drive;
|
||||
document.body.dataset.controlsEnabled=String(driveEnabled);
|
||||
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';
|
||||
@@ -751,7 +790,7 @@ function render(s,confirmed=false){
|
||||
controlStatus.classList.toggle('is-error',view.error);
|
||||
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||
if(motorBtn)motorBtn.disabled=false;
|
||||
|
||||
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
|
||||
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
|
||||
@@ -762,16 +801,25 @@ function render(s,confirmed=false){
|
||||
updateReadouts();
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}%`;
|
||||
$('#battery-title').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
|
||||
$('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
|
||||
$('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
|
||||
$('#battery-live-current').textContent=Number.isFinite(b.current_a)?`${b.current_a.toFixed(1)} A`:'Unavailable';
|
||||
$('#battery-live-consumed').textContent=Number.isFinite(b.consumed_mah)?`${Math.round(b.consumed_mah).toLocaleString()} mAh`:'Unavailable';
|
||||
$('#battery-live-consumed-source').textContent=b.consumed_source||'';
|
||||
$('#battery-live-remaining').textContent=Number.isFinite(b.remaining_mah)?`${Math.round(b.remaining_mah).toLocaleString()} mAh`:Number.isFinite(b.remaining_percent)?`${Math.round(b.remaining_percent)}%`:'Unavailable';
|
||||
$('#battery-live-note').textContent=s.battery_stale?'Battery telemetry is stale. Check the telemetry rate and battery monitor.':b.charge_source==='Voltage estimate'?'Charge percentage is estimated from voltage. Add a calibrated current sensor for consumption readings.':'Live values come from the flight controller; accuracy depends on its battery monitor setup.';
|
||||
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
|
||||
$('.battery-card').title=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
||||
$('#battery-fill').style.width=`${b.percent}%`;
|
||||
$('.battery-card').title=`${b.charge_source||'Battery status'} · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
||||
$('#battery-fill').style.width=`${Number.isFinite(b.percent)?b.percent:0}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
|
||||
$('.battery-card').dataset.low=String(!!b.low);
|
||||
}else{
|
||||
$('#battery-title').textContent='No voltage';
|
||||
$('#battery-percent').textContent='—';
|
||||
$('#battery-live-voltage').textContent='—';$('#battery-live-current').textContent='—';
|
||||
$('#battery-live-consumed').textContent='—';$('#battery-live-consumed-source').textContent='';
|
||||
$('#battery-live-remaining').textContent='—';$('#battery-live-note').textContent='Waiting for battery telemetry.';
|
||||
$('.battery-card').dataset.stale='false';
|
||||
$('.battery-card').title='Waiting for battery voltage · Battery settings';
|
||||
$('#battery-fill').style.width='0';
|
||||
@@ -912,20 +960,22 @@ async function boot(){
|
||||
if(booted||booting||!hasDesktopApi())return;
|
||||
booting=true;clearTimeout(bootRetry);
|
||||
try{
|
||||
// OS port discovery must not depend on model/config initialization succeeding.
|
||||
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);
|
||||
motorSettings=init.motor_settings||motorSettings;
|
||||
syncMotorScalingUI();
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
// Populate the serial selector before starting the optional model load.
|
||||
// A large cached mesh must never delay an operator seeing USB COM ports.
|
||||
loadDefaultModel();
|
||||
// COM discovery can block on Windows drivers; finish the first paint first.
|
||||
setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0);
|
||||
// Let the controls paint before fetching and parsing the large STL mesh.
|
||||
const startModelLoad=()=>{void loadDefaultModel()};
|
||||
if('requestIdleCallback' in window)requestIdleCallback(startModelLoad,{timeout:2500});
|
||||
else setTimeout(startModelLoad,1500);
|
||||
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
|
||||
if(appState==='disconnected'||demoActive){
|
||||
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
|
||||
@@ -949,9 +999,8 @@ function waitForBridge(){
|
||||
addEventListener('pywebviewready',boot);
|
||||
render(vehicleSnapshot);
|
||||
if(document.readyState==='loading'){
|
||||
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||
addEventListener('DOMContentLoaded',waitForBridge);
|
||||
}else{
|
||||
loadDefaultModel();
|
||||
waitForBridge();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||
} else if (known && online) {
|
||||
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
|
||||
action=focused?'arm':null; message='Disarmed · hold 0.6 seconds to engage.';
|
||||
}
|
||||
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||
const failure=error || state.command_error;
|
||||
|
||||
+17
-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=20261002-spacing-motion-v2">
|
||||
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
@@ -140,7 +140,7 @@
|
||||
<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"><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">
|
||||
<input id="gain" aria-label="Shared motor output limit" type="range" min="0" max="100" step="1" value="20">
|
||||
<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>
|
||||
@@ -172,6 +172,7 @@
|
||||
<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>Battery detail · Hz</span><select name="battery_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>
|
||||
@@ -194,7 +195,16 @@
|
||||
</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"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery</h2>
|
||||
<section class="battery-live" aria-label="Live battery readings">
|
||||
<div class="battery-live-grid">
|
||||
<div><span>Pack voltage</span><strong id="battery-live-voltage">—</strong></div>
|
||||
<div><span>Current draw</span><strong id="battery-live-current">—</strong></div>
|
||||
<div><span>Charge used</span><strong id="battery-live-consumed">—</strong><small id="battery-live-consumed-source"></small></div>
|
||||
<div><span>Capacity left</span><strong id="battery-live-remaining">—</strong></div>
|
||||
</div>
|
||||
<p id="battery-live-note" class="note">Waiting for battery telemetry.</p>
|
||||
</section>
|
||||
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
|
||||
<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>
|
||||
@@ -203,9 +213,10 @@
|
||||
</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"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Offline</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>
|
||||
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
|
||||
<div id="motor-list" class="motor-list"></div>
|
||||
</dialog>
|
||||
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
|
||||
@@ -228,7 +239,7 @@
|
||||
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||
<script src="controller-input.js?v=20261001-controller-v1"></script>
|
||||
<script src="console-view.js?v=20261002-console-v2"></script>
|
||||
<script src="app.js?v=20261002-spacing-motion-v2"></script>
|
||||
<script src="app.js?v=20261017-engage-center-v2"></script>
|
||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+76
-29
@@ -713,23 +713,27 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
pointer-events: none;
|
||||
}
|
||||
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; }
|
||||
.engage-face { position: relative; display: grid; place-items: center; min-width: 74px; }
|
||||
.engage-face > span { grid-area: 1/1; font-size: 11px; font-weight: 650; letter-spacing: .12em; }
|
||||
.engage-face { position:relative; display:grid; place-items:center; width:7.5em; height:1em; min-width:0; text-align:center; font-size:clamp(52px,11cqw,90px); }
|
||||
.engage-face > span { grid-area:1/1; justify-self:center; align-self:start; font-size:11px; font-weight:650; letter-spacing:.12em; white-space:nowrap; }
|
||||
.engage-label {
|
||||
position:relative;
|
||||
color: transparent;
|
||||
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
|
||||
background-size: 250% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
text-align:center;
|
||||
transition:transform 180ms cubic-bezier(.77,0,.175,1);
|
||||
}
|
||||
#engage .engage-label.is-appending { transform:translateX(var(--engage-append-shift,0px)); }
|
||||
.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; }
|
||||
.engage-suffix { display:inline-block; opacity:0; clip-path:inset(0 100% 0 0); transform:translateX(-.08em); transition:clip-path 180ms cubic-bezier(.77,0,.175,1),opacity 120ms ease-out,transform 180ms cubic-bezier(.77,0,.175,1); }
|
||||
.state-transition .engage-suffix,.engage-suffix.is-visible { opacity:1; clip-path:inset(0); transform:translateX(0); }
|
||||
.engage-progress-label {
|
||||
color: #a9ed72;
|
||||
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
|
||||
transition: opacity .18s ease;
|
||||
}
|
||||
.state-transition .engage-face { animation: state-label .24s ease-out; }
|
||||
@keyframes state-label { from {opacity:.35} to {opacity:1} }
|
||||
@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
|
||||
.engage-button:hover { transform:none; }
|
||||
.engage-button.pending { background:#405346; border-color:#516e56; }
|
||||
@@ -1015,21 +1019,14 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
color: #7a8280;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.motor-modal { width: min(600px, calc(100vw - 40px)); }
|
||||
.motor-head, .motor-row {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 80px 75px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #e0e4df;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.motor-head {
|
||||
font-weight: 500;
|
||||
color: #727a7c;
|
||||
border-bottom: 1.5px solid #c8cec6;
|
||||
}
|
||||
.motor-modal { width: min(860px, calc(100vw - 40px)); }
|
||||
.battery-live { margin:14px 0 20px; padding:14px; border-radius:16px; background:#f0f3ef; }
|
||||
.battery-live-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
|
||||
.battery-live-grid > div { min-width:0; padding:10px 11px; border-radius:12px; background:#fff; }
|
||||
.battery-live-grid span,.battery-live-grid small { display:block; color:#727b77; font-size:11px; }
|
||||
.battery-live-grid strong { display:block; margin-top:7px; color:#303936; font-size:18px; font-weight:500; font-variant-numeric:tabular-nums; }
|
||||
.battery-live-grid small { margin-top:4px; }
|
||||
.battery-live .note { margin:10px 0 0; line-height:1.5; font-size:12px; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast {
|
||||
@@ -1158,7 +1155,7 @@ body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding
|
||||
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:inherit; 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(52px,11cqw,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; }
|
||||
@@ -1173,6 +1170,9 @@ body #engage { align-items:flex-end; padding-bottom:12px; }
|
||||
.control-status[hidden] { display:none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.engage-sheen-label { display:none; }
|
||||
#engage .engage-label.is-appending { transition:none; transform:none; }
|
||||
.engage-suffix { transition:opacity var(--ui-duration,180ms) ease-out; }
|
||||
.state-transition .engage-suffix { clip-path:inset(0); transform:none; }
|
||||
}
|
||||
.controls-grid {
|
||||
--joy-size:min(54vh,calc(100vw - 480px),460px);
|
||||
@@ -1265,12 +1265,8 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
.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-modal { width:min(860px,calc(100vw - 32px)); }
|
||||
@media (max-width:650px) { .battery-live-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
.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; }
|
||||
@@ -1289,8 +1285,6 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
@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; }
|
||||
@@ -1385,7 +1379,6 @@ body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
|
||||
.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); }
|
||||
.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); }
|
||||
.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); }
|
||||
.modal .motor-row .button { min-height:var(--pill-sm); padding:6px 12px; }
|
||||
.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; }
|
||||
|
||||
/* Fixed SVG geometry avoids font baselines shifting the close icon. */
|
||||
@@ -1472,3 +1465,57 @@ body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
|
||||
@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } }
|
||||
.controller-status { transition:opacity var(--ui-duration) ease-out; }
|
||||
.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; }
|
||||
|
||||
/* Motor scaling and control-plane fit: keep interaction areas inside their rows. */
|
||||
.primary-control-zone { grid-template-rows:clamp(76px,14vh,108px) minmax(0,1fr) auto; }
|
||||
.engage-header-zone { height:auto; min-height:0; align-self:stretch; }
|
||||
body #engage { height:100%; min-height:0; }
|
||||
.controls-grid { --joy-size:min(40vh,calc(100cqw - 168px),380px); min-height:0; align-content:center; }
|
||||
.joystick { min-width:0; min-height:0; max-width:100%; max-height:100%; }
|
||||
.joystick-pad,.heave-pad { min-width:0; min-height:0; flex:1 1 auto; overflow:hidden; }
|
||||
.control-card { min-width:0; min-height:0; overflow:hidden; }
|
||||
.control-toolbar { flex-wrap:nowrap; min-height:48px; padding:5px 0 0; gap:clamp(8px,1.5vw,18px); }
|
||||
.control-toolbar .output-limit { min-width:0; }
|
||||
.control-toolbar .output-limit > span { flex:0 1 120px; width:120px; }
|
||||
.control-toolbar .range-pill { width:clamp(112px,20cqw,190px); flex:0 1 190px; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
|
||||
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
|
||||
@media (max-height:610px) {
|
||||
.primary-control-zone { grid-template-rows:clamp(64px,12vh,82px) minmax(0,1fr) auto; }
|
||||
.controls-grid { --joy-size:min(34vh,calc(100cqw - 168px),300px); }
|
||||
}
|
||||
.motor-modal { width:min(720px,calc(100vw - 36px)); max-height:calc(100dvh - 28px); padding:clamp(18px,3vw,28px); overflow:auto; }
|
||||
.motor-modal > h2 { margin:0 0 2px; }
|
||||
.motor-scale-heading { display:flex; align-items:center; justify-content:space-between; padding:12px 0 7px; border-bottom:1px solid #e7eae7; color:#56635b; font-size:12px; }
|
||||
.switch-control { display:flex; align-items:center; gap:9px; cursor:pointer; color:#34423a; font-size:12px; }
|
||||
.switch-control input { position:absolute; opacity:0; width:1px; height:1px; }
|
||||
.switch-track { position:relative; display:block; width:34px; height:20px; border-radius:999px; background:#d8ddda; transition:background-color 160ms ease-out; }
|
||||
.switch-track::after { content:""; position:absolute; width:14px; height:14px; left:3px; top:3px; border-radius:50%; background:#fff; box-shadow:0 1px 2px #0002; transition:transform 160ms ease-out; }
|
||||
.switch-control input:checked + .switch-track { background:#4d8767; }
|
||||
.switch-control input:checked + .switch-track::after { transform:translateX(14px); }
|
||||
.switch-control input:focus-visible + .switch-track { outline:2px solid #6598b5; outline-offset:3px; }
|
||||
.motor-list { display:grid; }
|
||||
.motor-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(100px,1.5fr) 42px 76px; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid #e7eae7; }
|
||||
.motor-name { min-width:0; height:34px; padding:0 8px; border:1px solid transparent; border-radius:6px; background:transparent; font-size:13px; }
|
||||
.motor-name:hover,.motor-name:focus { border-color:#d8dfda; background:#fff; }
|
||||
.motor-row .motor-scale { width:100%; min-width:0; height:22px; padding:0; border:0; background:transparent; accent-color:#4d8767; }
|
||||
.motor-scale-value { color:#344b3c; font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; text-align:right; }
|
||||
.motor-reverse { min-height:30px; padding:5px 9px; font-size:11px; }
|
||||
.motor-reverse:disabled { opacity:.42; cursor:not-allowed; }
|
||||
.motor-channel { grid-column:1/-1; margin-top:-8px; color:#87918a; font-size:10px; }
|
||||
.motor-mapping { margin-top:8px; }
|
||||
.motor-mapping summary { padding:9px 0; color:#657168; font-size:12px; cursor:pointer; }
|
||||
.motor-map-row { display:grid; grid-template-columns:65px minmax(0,1fr) 72px; align-items:center; gap:9px; padding:6px 0; border-bottom:1px solid #edf0ed; }
|
||||
.motor-map-port { color:#4b5a50; font:11px ui-monospace,Consolas,monospace; }
|
||||
.motor-map-row select { height:32px; font-size:12px; }
|
||||
.motor-map-direction { color:#77827b; font-size:11px; text-align:right; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
|
||||
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
|
||||
.controls-grid { align-content:start; }
|
||||
.joystick-pad,.heave-pad { padding-top:0; }
|
||||
.joystick { transform:translateY(-7px); }
|
||||
@media (max-width:640px) { .motor-row { grid-template-columns:minmax(0,1fr) minmax(75px,1.3fr) 38px 70px; gap:7px; } .motor-map-row { grid-template-columns:54px minmax(0,1fr) 64px; gap:6px; } }
|
||||
@media (max-width:520px) { .motor-modal { padding:18px 14px; } .motor-row { grid-template-columns:minmax(0,1fr) minmax(64px,1fr) 36px 66px; gap:5px; } .motor-name { font-size:12px; } .motor-reverse { padding:5px 6px; } }
|
||||
@media (prefers-reduced-motion:reduce) { .switch-track,.switch-track::after { transition:none; } }
|
||||
|
||||
Reference in New Issue
Block a user