Attempt to add jerk correction
This commit is contained in:
@@ -75,6 +75,7 @@ void pushConfigBlob() {
|
||||
b.tapKey = cfg.tapKey;
|
||||
b.tapMod = cfg.tapMod;
|
||||
b._pad = 0;
|
||||
b.jerkThreshold = cfg.jerkThreshold;
|
||||
cfgBlob.write((uint8_t*)&b, sizeof(b));
|
||||
}
|
||||
#endif
|
||||
@@ -117,6 +118,7 @@ void onConfigBlobWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l)
|
||||
cfg.tapKey = b->tapKey;
|
||||
cfg.tapMod = b->tapMod;
|
||||
#endif
|
||||
if (b->jerkThreshold >= 100.0f && b->jerkThreshold <= 50000.0f) cfg.jerkThreshold = b->jerkThreshold;
|
||||
saveConfig();
|
||||
Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0);
|
||||
Serial.print(" dz="); Serial.print(cfg.deadZone,3);
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
#define CONFIG_FILENAME "/imu_mouse_cfg.bin"
|
||||
#define CONFIG_MAGIC 0xDEAD1239UL
|
||||
#define CONFIG_MAGIC 0xDEAD123AUL
|
||||
|
||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||
enum CurveType : uint8_t { CURVE_LINEAR=0, CURVE_SQUARE=1, CURVE_SQRT=2 };
|
||||
@@ -83,6 +83,7 @@ struct Config {
|
||||
TapAction tapAction; // what a double-tap does
|
||||
uint8_t tapKey; // HID keycode (used when tapAction == TAP_ACTION_KEY)
|
||||
uint8_t tapMod; // HID modifier byte (used when tapAction == TAP_ACTION_KEY)
|
||||
float jerkThreshold; // jerk² threshold for tap-freeze detection
|
||||
};
|
||||
extern Config cfg;
|
||||
extern const Config CFG_DEFAULTS;
|
||||
@@ -100,8 +101,9 @@ struct __attribute__((packed)) ConfigBlob {
|
||||
uint8_t tapKey; // [17] HID keycode
|
||||
uint8_t tapMod; // [18] HID modifier
|
||||
uint8_t _pad; // [19]
|
||||
float jerkThreshold; // [20] jerk² tap-freeze threshold
|
||||
};
|
||||
static_assert(sizeof(ConfigBlob) == 20, "ConfigBlob must be 20 bytes");
|
||||
static_assert(sizeof(ConfigBlob) == 24, "ConfigBlob must be 24 bytes");
|
||||
|
||||
// ─── TelemetryPacket (24 bytes) ───────────────────────────────────────────────
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
|
||||
@@ -59,7 +59,8 @@ File cfgFile(InternalFS);
|
||||
Config cfg;
|
||||
const Config CFG_DEFAULTS = {
|
||||
CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW,
|
||||
/*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0
|
||||
/*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0,
|
||||
/*jerkThreshold=*/2000.0f
|
||||
};
|
||||
|
||||
// ─── Telemetry definition ─────────────────────────────────────────────────────
|
||||
@@ -128,6 +129,14 @@ uint32_t loopStalls = 0; // loop iterations where dt > 20ms (behind sch
|
||||
bool pendingCal = false;
|
||||
bool pendingReset = false;
|
||||
|
||||
// ── Jerk-based shock detection — freeze cursor during tap impacts ────────────
|
||||
// Jerk = da/dt (rate of change of acceleration). Normal mouse rotation produces
|
||||
// smooth accel changes (low jerk); a tap is a sharp impulse (very high jerk).
|
||||
// This cleanly separates taps from any intentional motion regardless of speed.
|
||||
unsigned long shockFreezeUntil = 0;
|
||||
float prevAx = 0, prevAy = 0, prevAz = 0; // previous frame's accel for Δa
|
||||
const unsigned long SHOCK_FREEZE_MS = 80; // hold freeze after last spike
|
||||
|
||||
ChargeStatus lastChargeStatus = CHGSTAT_DISCHARGING;
|
||||
|
||||
int idleFrames = 0;
|
||||
@@ -223,6 +232,8 @@ void setup() {
|
||||
#endif
|
||||
|
||||
calibrateGyroBias();
|
||||
// Seed previous-accel for jerk detection so first frame doesn't spike
|
||||
prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ();
|
||||
|
||||
bledis.setManufacturer("Seeed Studio");
|
||||
bledis.setModel("XIAO nRF52840 Sense");
|
||||
@@ -294,7 +305,7 @@ void loop() {
|
||||
if (cmd == 'r') { Serial.println("[SERIAL] Reset"); pendingReset = true; }
|
||||
}
|
||||
|
||||
if (pendingCal) { pendingCal = false; calibrateGyroBias(); }
|
||||
if (pendingCal) { pendingCal = false; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); }
|
||||
if (pendingReset) { pendingReset = false; factoryReset(); }
|
||||
|
||||
// Heartbeat LED
|
||||
@@ -345,19 +356,37 @@ void loop() {
|
||||
float ay = imu.readFloatAccelY();
|
||||
float az = imu.readFloatAccelZ();
|
||||
|
||||
// ── Jerk-based shock detection — freeze cursor during tap impacts ────────
|
||||
// Jerk = da/dt. Normal rotation = smooth accel changes (low jerk);
|
||||
// a tap is a sharp impulse (very high jerk).
|
||||
float jx = (ax - prevAx) / dt, jy = (ay - prevAy) / dt, jz = (az - prevAz) / dt;
|
||||
float jerkSq = jx*jx + jy*jy + jz*jz;
|
||||
prevAx = ax; prevAy = ay; prevAz = az;
|
||||
bool shocked = (jerkSq > cfg.jerkThreshold) || (now < shockFreezeUntil);
|
||||
if (jerkSq > cfg.jerkThreshold) shockFreezeUntil = now + SHOCK_FREEZE_MS;
|
||||
|
||||
// Complementary filter — gx=pitch axis, gz=yaw axis on this board layout
|
||||
angleX = ALPHA*(angleX + gx*dt) + (1.0f - ALPHA)*atan2f(ax, sqrtf(ay*ay + az*az));
|
||||
angleY = ALPHA*(angleY + gz*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az));
|
||||
// During shock: gyro-only integration to avoid accel spike corrupting angles
|
||||
if (shocked) {
|
||||
angleX += gx * dt;
|
||||
angleY += gz * dt;
|
||||
} else {
|
||||
angleX = ALPHA*(angleX + gx*dt) + (1.0f - ALPHA)*atan2f(ax, sqrtf(ay*ay + az*az));
|
||||
angleY = ALPHA*(angleY + gz*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az));
|
||||
}
|
||||
|
||||
// ── Gravity-based axis decomposition ──────────────────────────────────────
|
||||
// Low-pass filter accel to get a stable gravity estimate in device frame.
|
||||
// This lets us project angular velocity onto world-aligned axes regardless
|
||||
// of how the device is rolled. Device forward (pointing) axis = X.
|
||||
// Confirmed by diagnostics: GX=roll, GY=nod, GZ=pan in user's hold.
|
||||
// Skip update during shock to protect the gravity estimate from tap spikes.
|
||||
const float GRAV_LP = 0.05f;
|
||||
gravX += GRAV_LP * (ax - gravX);
|
||||
gravY += GRAV_LP * (ay - gravY);
|
||||
gravZ += GRAV_LP * (az - gravZ);
|
||||
if (!shocked) {
|
||||
gravX += GRAV_LP * (ax - gravX);
|
||||
gravY += GRAV_LP * (ay - gravY);
|
||||
gravZ += GRAV_LP * (az - gravZ);
|
||||
}
|
||||
|
||||
float gN = sqrtf(gravX*gravX + gravY*gravY + gravZ*gravZ);
|
||||
if (gN < 0.3f) gN = 1.0f;
|
||||
@@ -400,14 +429,18 @@ void loop() {
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
if (idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) {
|
||||
Serial.println("[AUTO-CAL] Long idle — recalibrating...");
|
||||
idleStartMs = 0; calibrateGyroBias(); return;
|
||||
idleStartMs = 0; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); return;
|
||||
}
|
||||
#endif
|
||||
|
||||
int8_t moveX = 0, moveY = 0;
|
||||
uint8_t flags = 0;
|
||||
|
||||
if (idle) {
|
||||
if (shocked) {
|
||||
// Shock freeze — discard accumulated sub-pixel motion and suppress output
|
||||
accumX = accumY = 0.0f;
|
||||
flags |= 0x08; // bit3 = shock freeze active
|
||||
} else if (idle) {
|
||||
accumX = accumY = 0.0f;
|
||||
flags |= 0x01;
|
||||
} else {
|
||||
|
||||
112
web/app.js
112
web/app.js
@@ -11,7 +11,7 @@ const CHR = {
|
||||
|
||||
// Local shadow of the current config (kept in sync with device)
|
||||
const config = { sensitivity:600, deadZone:0.06, accelStrength:0.08, curve:0, axisFlip:0, chargeMode:1,
|
||||
tapThreshold:12, tapAction:0, tapKey:0, tapMod:0 };
|
||||
tapThreshold:12, tapAction:0, tapKey:0, tapMod:0, jerkThreshold:2000 };
|
||||
|
||||
let device=null, server=null, chars={}, userDisconnected=false;
|
||||
let currentChargeStatus=0, currentBattPct=null, currentBattVoltage=null;
|
||||
@@ -222,6 +222,9 @@ async function readConfigBlob() {
|
||||
config.tapKey = view.getUint8(17);
|
||||
config.tapMod = view.getUint8(18);
|
||||
}
|
||||
if (view.byteLength >= 24) {
|
||||
config.jerkThreshold = view.getFloat32(20, true);
|
||||
}
|
||||
applyConfigToUI();
|
||||
log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} tapThr=${config.tapThreshold}`,'ok');
|
||||
} catch(e) { log(`Config read error: ${e.message}`,'err'); }
|
||||
@@ -238,6 +241,8 @@ function applyConfigToUI() {
|
||||
document.getElementById('flipX').checked = !!(config.axisFlip & 1);
|
||||
document.getElementById('flipY').checked = !!(config.axisFlip & 2);
|
||||
setChargeModeUI(config.chargeMode);
|
||||
document.getElementById('slJerkThreshold').value = config.jerkThreshold;
|
||||
updateDisplay('jerkThreshold', config.jerkThreshold);
|
||||
document.getElementById('slTapThreshold').value = config.tapThreshold;
|
||||
updateDisplay('tapThreshold', config.tapThreshold);
|
||||
setTapActionUI(config.tapAction);
|
||||
@@ -267,9 +272,10 @@ async function _doWriteConfigBlob() {
|
||||
| (document.getElementById('tapModShift').checked ? 0x02 : 0)
|
||||
| (document.getElementById('tapModAlt').checked ? 0x04 : 0)
|
||||
| (document.getElementById('tapModGui').checked ? 0x08 : 0);
|
||||
config.jerkThreshold = +document.getElementById('slJerkThreshold').value;
|
||||
// config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly
|
||||
|
||||
const buf = new ArrayBuffer(20);
|
||||
const buf = new ArrayBuffer(24);
|
||||
const view = new DataView(buf);
|
||||
view.setFloat32(0, config.sensitivity, true);
|
||||
view.setFloat32(4, config.deadZone, true);
|
||||
@@ -282,6 +288,7 @@ async function _doWriteConfigBlob() {
|
||||
view.setUint8(17, config.tapKey);
|
||||
view.setUint8(18, config.tapMod);
|
||||
view.setUint8(19, 0);
|
||||
view.setFloat32(20, config.jerkThreshold, true);
|
||||
|
||||
try {
|
||||
await gattWrite(chars.configBlob, buf);
|
||||
@@ -440,16 +447,111 @@ function toggleAdvanced(on) {
|
||||
advancedMode = on;
|
||||
localStorage.setItem('advanced', on);
|
||||
document.getElementById('ciVoltItem').style.display = on ? '' : 'none';
|
||||
document.getElementById('debugBtn').style.display = on ? '' : 'none';
|
||||
// Switch charge-info grid between 3 and 4 columns
|
||||
document.getElementById('chargeInfo').style.gridTemplateColumns = on ? '1fr 1fr 1fr 1fr' : '1fr 1fr 1fr';
|
||||
}
|
||||
|
||||
// ── IMU Debug Recorder ────────────────────────────────────────────────────────
|
||||
let debugModalOpen = false;
|
||||
let debugRecording = false;
|
||||
let debugBuffer = [];
|
||||
const DEBUG_LIVE_ROWS = 40;
|
||||
let debugLiveRing = [];
|
||||
let debugT0 = 0;
|
||||
|
||||
function openDebugModal() {
|
||||
debugModalOpen = true;
|
||||
debugT0 = Date.now();
|
||||
debugLiveRing = [];
|
||||
document.getElementById('debugOverlay').classList.add('show');
|
||||
// Auto-start IMU stream if not already running
|
||||
if (!imuSubscribed && chars.imuStream) vizSetPaused(false);
|
||||
}
|
||||
function closeDebugModal() {
|
||||
debugModalOpen = false;
|
||||
document.getElementById('debugOverlay').classList.remove('show');
|
||||
}
|
||||
|
||||
function feedDebugRow(gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags) {
|
||||
if (!debugModalOpen) return;
|
||||
const ms = Date.now() - debugT0;
|
||||
const row = { ms, gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags };
|
||||
|
||||
// Live ring buffer
|
||||
debugLiveRing.push(row);
|
||||
if (debugLiveRing.length > DEBUG_LIVE_ROWS) debugLiveRing.shift();
|
||||
|
||||
// Recording buffer
|
||||
if (debugRecording) {
|
||||
debugBuffer.push(row);
|
||||
document.getElementById('debugRecCount').textContent = debugBuffer.length + ' samples';
|
||||
}
|
||||
|
||||
// Shock indicator
|
||||
const shocked = !!(flags & 0x08);
|
||||
const badge = document.getElementById('debugShockBadge');
|
||||
badge.classList.toggle('active', shocked);
|
||||
|
||||
// Render live table
|
||||
const tbody = document.getElementById('debugRows');
|
||||
tbody.innerHTML = '';
|
||||
for (const r of debugLiveRing) {
|
||||
const f = [];
|
||||
if (r.flags & 0x01) f.push('idle');
|
||||
if (r.flags & 0x02) f.push('tap1');
|
||||
if (r.flags & 0x04) f.push('tap2');
|
||||
if (r.flags & 0x08) f.push('shock');
|
||||
const tr = document.createElement('tr');
|
||||
if (r.flags & 0x08) tr.className = 'shock-row';
|
||||
tr.innerHTML =
|
||||
`<td>${r.ms}</td><td>${r.gyroX}</td><td>${r.gyroZ}</td>` +
|
||||
`<td>${r.accelX}</td><td>${r.accelY}</td><td>${r.accelZ}</td>` +
|
||||
`<td>${r.moveX}</td><td>${r.moveY}</td><td>${f.join(' ')}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
tbody.parentElement.parentElement.scrollTop = tbody.parentElement.parentElement.scrollHeight;
|
||||
}
|
||||
|
||||
function toggleDebugRec() {
|
||||
debugRecording = !debugRecording;
|
||||
const btn = document.getElementById('debugRecBtn');
|
||||
btn.classList.toggle('recording', debugRecording);
|
||||
btn.textContent = debugRecording ? '■ STOP' : '● REC';
|
||||
if (debugRecording) { debugBuffer = []; debugT0 = Date.now(); }
|
||||
document.getElementById('debugRecCount').textContent = debugBuffer.length + ' samples';
|
||||
}
|
||||
|
||||
function saveDebugCSV() {
|
||||
if (!debugBuffer.length) { log('No recorded data to save','warn'); return; }
|
||||
const header = 'ms,gyroX_mDPS,gyroZ_mDPS,accelX_mg,accelY_mg,accelZ_mg,moveX,moveY,flags\n';
|
||||
const rows = debugBuffer.map(r =>
|
||||
`${r.ms},${r.gyroX},${r.gyroZ},${r.accelX},${r.accelY},${r.accelZ},${r.moveX},${r.moveY},${r.flags}`
|
||||
).join('\n');
|
||||
const blob = new Blob([header + rows], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `imu_debug_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`;
|
||||
a.click(); URL.revokeObjectURL(url);
|
||||
log(`Saved ${debugBuffer.length} samples as CSV`,'ok');
|
||||
}
|
||||
|
||||
function clearDebugRec() {
|
||||
debugBuffer = [];
|
||||
debugRecording = false;
|
||||
const btn = document.getElementById('debugRecBtn');
|
||||
btn.classList.remove('recording');
|
||||
btn.textContent = '● REC';
|
||||
document.getElementById('debugRecCount').textContent = '0 samples';
|
||||
}
|
||||
|
||||
// ── Param display ─────────────────────────────────────────────────────────────
|
||||
function updateDisplay(key, val) {
|
||||
const map = {
|
||||
sensitivity: ['valSensitivity', v=>parseFloat(v).toFixed(0)],
|
||||
deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)],
|
||||
accel: ['valAccel', v=>parseFloat(v).toFixed(2)],
|
||||
jerkThreshold:['valJerkThreshold',v=>parseFloat(v).toFixed(0)],
|
||||
tapThreshold: ['valTapThreshold', v=>(parseFloat(v)*62.5).toFixed(0)+' mg'],
|
||||
};
|
||||
const [id,fmt] = map[key];
|
||||
@@ -601,7 +703,6 @@ async function vizSetPaused(paused) {
|
||||
}
|
||||
|
||||
function parseImuStream(dv) {
|
||||
if (vizPaused) return;
|
||||
let view;
|
||||
try {
|
||||
view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
|
||||
@@ -625,6 +726,11 @@ function parseImuStream(dv) {
|
||||
moveY = view.getInt8(11);
|
||||
flags = view.getUint8(12);
|
||||
} catch(e) { log(`parseImuStream: parse error — ${e.message}`,'err'); return; }
|
||||
|
||||
// Feed debug recorder (even when viz is paused)
|
||||
feedDebugRow(gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags);
|
||||
|
||||
if (vizPaused) return;
|
||||
const idle = !!(flags & 0x01);
|
||||
const single = !!(flags & 0x02);
|
||||
const dbl = !!(flags & 0x04);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="batt-cells" id="battCells"></div>
|
||||
<span id="battPct">--%</span>
|
||||
</div>
|
||||
<button class="btn btn-debug" id="debugBtn" onclick="openDebugModal()" style="display:none" title="IMU Debug Recorder"><span>DBG</span></button>
|
||||
<button class="btn btn-theme" id="themeBtn" onclick="cycleTheme()"><span>AUTO</span></button>
|
||||
<div class="status-pill" id="statusPill"><div class="dot"></div><span id="statusText">DISCONNECTED</span></div>
|
||||
<button class="btn btn-connect" id="connectBtn" onclick="doConnect()"><span>Connect</span></button>
|
||||
@@ -100,6 +101,12 @@
|
||||
|
||||
<div class="section-label">Tap Configuration</div>
|
||||
<div class="card">
|
||||
<div class="param">
|
||||
<div><div class="param-label">Tap Freeze Sensitivity</div><div class="param-desc">Jerk² threshold — lower = more aggressive cursor freeze during taps</div></div>
|
||||
<input type="range" id="slJerkThreshold" min="500" max="10000" step="100" value="2000"
|
||||
oninput="updateDisplay('jerkThreshold',this.value)" onchange="writeConfigBlob()">
|
||||
<div class="param-value" id="valJerkThreshold">2000</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<div><div class="param-label">Tap Threshold</div><div class="param-desc">Impact force needed · 1 LSB ≈ 62.5 mg at ±2g</div></div>
|
||||
<input type="range" id="slTapThreshold" min="1" max="31" step="1" value="12"
|
||||
@@ -223,6 +230,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="debugOverlay">
|
||||
<div class="modal debug-modal">
|
||||
<div class="debug-header">
|
||||
<h3>IMU Debug Recorder</h3>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span class="debug-shock-badge" id="debugShockBadge">SHOCK</span>
|
||||
<button class="debug-close" onclick="closeDebugModal()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-live" id="debugLive">
|
||||
<table class="debug-table">
|
||||
<thead><tr>
|
||||
<th>ms</th><th>gX</th><th>gZ</th><th>aX</th><th>aY</th><th>aZ</th><th>mX</th><th>mY</th><th>flags</th>
|
||||
</tr></thead>
|
||||
<tbody id="debugRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="debug-controls">
|
||||
<button class="debug-rec-btn" id="debugRecBtn" onclick="toggleDebugRec()">● REC</button>
|
||||
<span class="debug-rec-count" id="debugRecCount">0 samples</span>
|
||||
<button class="debug-ctrl-btn" onclick="saveDebugCSV()">Save CSV</button>
|
||||
<button class="debug-ctrl-btn" onclick="clearDebugRec()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -131,6 +131,8 @@
|
||||
.btn:disabled { border-color:var(--dim); color:var(--dim); cursor:not-allowed; }
|
||||
.btn:disabled::before { display:none; }
|
||||
.btn:disabled:hover { color:var(--dim); }
|
||||
.btn-debug { border:1px solid var(--dim); color:var(--label); min-width:52px; text-align:center; font-size:10px; padding:6px 10px; }
|
||||
.btn-debug::before { background:var(--accent); }
|
||||
.btn-theme { border:1px solid var(--dim); color:var(--label); min-width:72px; text-align:center; }
|
||||
.btn-theme::before { background:var(--text); }
|
||||
|
||||
@@ -267,6 +269,29 @@
|
||||
.btn-confirm { border-color:var(--accent2); color:var(--accent2); }
|
||||
.btn-confirm:hover { background:var(--accent2); color:var(--bg); }
|
||||
|
||||
/* ── Debug modal ────────────────────────────────────────────────────────── */
|
||||
.debug-modal { max-width:720px; padding:20px; border-color:var(--accent); }
|
||||
.debug-modal h3 { color:var(--accent); margin-bottom:0; }
|
||||
.debug-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; }
|
||||
.debug-close { background:none; border:1px solid var(--border); color:var(--label); font-size:14px; cursor:pointer; padding:2px 8px; font-family:var(--mono); }
|
||||
.debug-close:hover { border-color:var(--text); color:var(--text); }
|
||||
.debug-live { max-height:340px; overflow-y:auto; border:1px solid var(--border); margin-bottom:12px; }
|
||||
.debug-table { width:100%; border-collapse:collapse; font-family:var(--mono); font-size:10px; }
|
||||
.debug-table thead { position:sticky; top:0; background:var(--panel); z-index:1; }
|
||||
.debug-table th { padding:4px 6px; text-align:right; color:var(--label); font-weight:400; border-bottom:1px solid var(--border); letter-spacing:0.1em; text-transform:uppercase; }
|
||||
.debug-table td { padding:2px 6px; text-align:right; color:var(--text); border-bottom:1px solid color-mix(in srgb, var(--border) 40%, transparent); white-space:nowrap; }
|
||||
.debug-table td:last-child { text-align:left; color:var(--label); }
|
||||
.debug-table .shock-row td { color:var(--accent2); }
|
||||
.debug-controls { display:flex; align-items:center; gap:10px; }
|
||||
.debug-rec-btn { font-family:var(--mono); font-size:12px; font-weight:700; padding:6px 14px; cursor:pointer; border:1px solid var(--accent2); color:var(--accent2); background:transparent; letter-spacing:0.1em; }
|
||||
.debug-rec-btn.recording { background:var(--accent2); color:var(--bg); animation:rec-pulse 1s infinite; }
|
||||
@keyframes rec-pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
|
||||
.debug-rec-count { font-family:var(--mono); font-size:10px; color:var(--label); }
|
||||
.debug-ctrl-btn { font-family:var(--mono); font-size:10px; padding:5px 10px; cursor:pointer; border:1px solid var(--border); color:var(--label); background:transparent; }
|
||||
.debug-ctrl-btn:hover { border-color:var(--text); color:var(--text); }
|
||||
.debug-shock-badge { font-family:var(--mono); font-size:9px; letter-spacing:0.15em; padding:2px 8px; border:1px solid var(--border); color:var(--border); opacity:0.3; transition:all 0.15s; }
|
||||
.debug-shock-badge.active { border-color:var(--accent2); color:var(--accent2); opacity:1; }
|
||||
|
||||
.no-ble { grid-column:1/-1; text-align:center; padding:80px 24px; }
|
||||
.no-ble h2 { font-family:var(--sans); font-size:28px; font-weight:700; color:var(--accent2); margin-bottom:12px; }
|
||||
.no-ble p { font-size:13px; color:var(--label); line-height:1.8; }
|
||||
|
||||
Reference in New Issue
Block a user