Add configuration slider for double tapping

This commit is contained in:
2026-03-01 20:45:23 +01:00
parent ef97d8f32a
commit f155d16399
8 changed files with 248 additions and 52 deletions

View File

@@ -1,4 +1,5 @@
#include "ble_config.h"
#include "tap.h"
#include <Adafruit_LittleFS.h>
#include <InternalFileSystem.h>
@@ -8,7 +9,7 @@ extern File cfgFile;
// ─── BLE Config Service objects ───────────────────────────────────────────────
#ifdef FEATURE_CONFIG_SERVICE
BLEService cfgService (0x1234);
BLECharacteristic cfgBlob (0x1235); // ConfigBlob R/W 16 bytes
BLECharacteristic cfgBlob (0x1235); // ConfigBlob R/W 20 bytes
BLECharacteristic cfgCommand (0x1236); // Command W 1 byte
#ifdef FEATURE_TELEMETRY
BLECharacteristic cfgTelemetry(0x1237); // Telemetry R/N 24 bytes
@@ -54,9 +55,17 @@ void saveConfig() {
#ifdef FEATURE_CONFIG_SERVICE
void pushConfigBlob() {
ConfigBlob b;
b.sensitivity = cfg.sensitivity; b.deadZone = cfg.deadZone;
b.accelStrength = cfg.accelStrength; b.curve = (uint8_t)cfg.curve;
b.axisFlip = cfg.axisFlip; b.chargeMode = (uint8_t)cfg.chargeMode; b._pad = 0;
b.sensitivity = cfg.sensitivity;
b.deadZone = cfg.deadZone;
b.accelStrength = cfg.accelStrength;
b.curve = (uint8_t)cfg.curve;
b.axisFlip = cfg.axisFlip;
b.chargeMode = (uint8_t)cfg.chargeMode;
b.tapThreshold = cfg.tapThreshold;
b.tapAction = (uint8_t)cfg.tapAction;
b.tapKey = cfg.tapKey;
b.tapMod = cfg.tapMod;
b._pad = 0;
cfgBlob.write((uint8_t*)&b, sizeof(b));
}
#endif
@@ -64,6 +73,9 @@ void pushConfigBlob() {
void factoryReset() {
cfg = CFG_DEFAULTS; saveConfig();
applyChargeMode(cfg.chargeMode);
#ifdef FEATURE_TAP_DETECTION
applyTapThreshold();
#endif
#ifdef FEATURE_CONFIG_SERVICE
if (!safeMode) pushConfigBlob();
#endif
@@ -84,14 +96,23 @@ void onConfigBlobWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l)
cfg.sensitivity = b->sensitivity;
cfg.deadZone = b->deadZone;
cfg.accelStrength = b->accelStrength;
if (b->curve <= 2) cfg.curve = (CurveType)b->curve;
if (b->curve <= 2) cfg.curve = (CurveType)b->curve;
cfg.axisFlip = b->axisFlip;
if (b->chargeMode <= 2) { cfg.chargeMode = (ChargeMode)b->chargeMode; applyChargeMode(cfg.chargeMode); }
#ifdef FEATURE_TAP_DETECTION
if (b->tapThreshold >= 1 && b->tapThreshold <= 31) {
cfg.tapThreshold = b->tapThreshold;
applyTapThreshold();
}
if (b->tapAction <= 3) cfg.tapAction = (TapAction)b->tapAction;
cfg.tapKey = b->tapKey;
cfg.tapMod = b->tapMod;
#endif
saveConfig();
Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0);
Serial.print(" dz="); Serial.print(cfg.deadZone,3);
Serial.print(" curve="); Serial.print(cfg.curve);
Serial.print(" chg="); Serial.println(cfg.chargeMode);
Serial.print(" tapThr="); Serial.print(cfg.tapThreshold);
Serial.print(" tapAction="); Serial.println(cfg.tapAction);
}
void onCommandWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) {

View File

@@ -60,6 +60,16 @@ enum CurveType : uint8_t { CURVE_LINEAR=0, CURVE_SQUARE=1, CURVE_SQRT=2 };
enum ChargeMode : uint8_t { CHARGE_OFF=0, CHARGE_SLOW=1, CHARGE_FAST=2 };
enum ChargeStatus: uint8_t { CHGSTAT_DISCHARGING=0, CHGSTAT_CHARGING=1, CHGSTAT_FULL=2 };
// ─── Tap action types ─────────────────────────────────────────────────────────
// TAP_ACTION_KEY: fires a raw HID keycode (tapKey) with optional modifier (tapMod).
// Modifier byte: bit0=Ctrl, bit1=Shift, bit2=Alt, bit3=GUI (same as HID modifier byte).
enum TapAction : uint8_t {
TAP_ACTION_LEFT = 0,
TAP_ACTION_RIGHT = 1,
TAP_ACTION_MIDDLE = 2,
TAP_ACTION_KEY = 3,
};
// ─── Config (stored in flash) ─────────────────────────────────────────────────
struct Config {
uint32_t magic;
@@ -69,11 +79,15 @@ struct Config {
CurveType curve;
uint8_t axisFlip;
ChargeMode chargeMode;
uint8_t tapThreshold; // 131 → REG_TAP_THS_6D bits[4:0]; 1 LSB = 62.5 mg at ±2g
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)
};
extern Config cfg;
extern const Config CFG_DEFAULTS;
// ─── ConfigBlob (over BLE, 16 bytes) ─────────────────────────────────────────
// ─── ConfigBlob (over BLE, 20 bytes) ─────────────────────────────────────────
struct __attribute__((packed)) ConfigBlob {
float sensitivity; // [0]
float deadZone; // [4]
@@ -81,9 +95,13 @@ struct __attribute__((packed)) ConfigBlob {
uint8_t curve; // [12]
uint8_t axisFlip; // [13]
uint8_t chargeMode; // [14]
uint8_t _pad; // [15]
uint8_t tapThreshold; // [15] 131
uint8_t tapAction; // [16] TapAction enum
uint8_t tapKey; // [17] HID keycode
uint8_t tapMod; // [18] HID modifier
uint8_t _pad; // [19]
};
static_assert(sizeof(ConfigBlob) == 16, "ConfigBlob must be 16 bytes");
static_assert(sizeof(ConfigBlob) == 20, "ConfigBlob must be 20 bytes");
// ─── TelemetryPacket (24 bytes) ───────────────────────────────────────────────
#ifdef FEATURE_TELEMETRY

View File

@@ -58,7 +58,8 @@ File cfgFile(InternalFS);
// ─── Config definitions ───────────────────────────────────────────────────────
Config cfg;
const Config CFG_DEFAULTS = {
CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW
CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW,
/*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0
};
// ─── Telemetry definition ─────────────────────────────────────────────────────

View File

@@ -7,42 +7,115 @@
extern BLEHidAdafruit blehid;
// ─── Tap detection setup ──────────────────────────────────────────────────────
// REG_TAP_THS_6D bits[4:0] = tapThreshold (131); 1 LSB = FS/32 = 62.5 mg at ±2g.
// REG_INT_DUR2 at ODR=416 Hz:
// SHOCK[7:6] = 2 → 38 ms max tap duration
// QUIET[5:4] = 2 → 19 ms refractory after tap
// DUR[3:0] = 6 → 115 ms max inter-tap window for double detection
void applyTapThreshold() {
uint8_t thr = cfg.tapThreshold;
if (thr < 1) thr = 1;
if (thr > 31) thr = 31;
imuWriteReg(REG_TAP_THS_6D, thr & 0x1F);
}
void setupTapDetection() {
imuWriteReg(REG_CTRL1_XL, 0x60); // ODR=416Hz, FS=±2g
imuWriteReg(REG_TAP_CFG, 0x8E); // INT_EN + LIR + TAP_Z/Y/X
imuWriteReg(REG_TAP_THS_6D, 0x0C); // threshold 750 mg (was 500 mg — too easy to false-trigger)
imuWriteReg(REG_INT_DUR2, 0x7A); // DUR=7(538ms), QUIET=2(19ms), SHOCK=2(38ms)
imuWriteReg(REG_WAKE_UP_THS, 0x80); // enable double-tap
imuWriteReg(REG_MD1_CFG, 0x48); // route taps to INT1
Serial.println("[TAP] Engine configured — single=LEFT, double=RIGHT");
imuWriteReg(REG_CTRL1_XL, 0x60); // ODR=416 Hz, FS=±2g
imuWriteReg(REG_TAP_CFG, 0x8E); // TIMER_EN + LIR + TAP_Z/Y/X enabled
applyTapThreshold();
imuWriteReg(REG_INT_DUR2, 0x62); // SHOCK=2(38ms), QUIET=2(19ms), DUR=6(115ms)
imuWriteReg(REG_WAKE_UP_THS, 0x80); // bit7=1 → single + double tap both enabled
imuWriteReg(REG_MD1_CFG, 0x48); // route single-tap(0x08) + double-tap(0x40) → INT1
Serial.print("[TAP] threshold="); Serial.print(cfg.tapThreshold);
Serial.print(" (~"); Serial.print(cfg.tapThreshold * 62.5f, 0); Serial.println(" mg)");
}
// ─── Tap processing ───────────────────────────────────────────────────────────
void processTaps(unsigned long now) {
// Release held button after CLICK_HOLD_MS
if (clickButtonDown && (now - clickDownMs >= CLICK_HOLD_MS)) {
blehid.mouseButtonPress(clickButton, false);
clickButtonDown = false; clickButton = 0;
// Only double-tap is mapped to an action. Single-tap is ignored (it always fires
// before the double is confirmed and cannot be reliably disambiguated on this
// hardware without an unacceptable latency penalty).
//
// The LSM6DS3 sets SINGLE_TAP immediately on first contact — we wait until
// DOUBLE_TAP is set (within the hardware DUR window of 115 ms) before acting.
// An additional TAP_CONFIRM_MS guard ensures the TAP_SRC register has settled.
//
// IMPORTANT: call mouseButtonPress(bitmask) — single arg only. The two-arg
// overload takes (conn_hdl, buttons) and sends the wrong button value.
static enum { TAP_IDLE, TAP_PENDING, TAP_EXECUTING } tapState = TAP_IDLE;
static unsigned long tapPendingMs = 0;
static uint8_t pendingButton = 0; // 0 = key action pending
// After DOUBLE_TAP fires we add a small settle guard before committing.
static const unsigned long TAP_CONFIRM_MS = 20;
static void fireTapAction(unsigned long now) {
switch (cfg.tapAction) {
case TAP_ACTION_LEFT:
blehid.mouseButtonPress(MOUSE_BUTTON_LEFT);
pendingButton = MOUSE_BUTTON_LEFT;
Serial.println("[TAP] Double → LEFT click");
statLeftClicks++;
break;
case TAP_ACTION_RIGHT:
blehid.mouseButtonPress(MOUSE_BUTTON_RIGHT);
pendingButton = MOUSE_BUTTON_RIGHT;
Serial.println("[TAP] Double → RIGHT click");
statRightClicks++;
break;
case TAP_ACTION_MIDDLE:
blehid.mouseButtonPress(MOUSE_BUTTON_MIDDLE);
pendingButton = MOUSE_BUTTON_MIDDLE;
Serial.println("[TAP] Double → MIDDLE click");
statLeftClicks++;
break;
case TAP_ACTION_KEY: {
uint8_t keys[6] = {cfg.tapKey, 0, 0, 0, 0, 0};
blehid.keyboardReport(cfg.tapMod, keys);
pendingButton = 0;
Serial.print("[TAP] Double → KEY 0x"); Serial.println(cfg.tapKey, HEX);
statLeftClicks++;
break;
}
}
if (clickButtonDown) return; // Don't start a new click while one is held
clickButtonDown = true; clickDownMs = now;
tapState = TAP_EXECUTING;
}
// The LSM6DS3 (with D_TAP_EN) already disambiguates at hardware level:
// SINGLE_TAP is only set after the DUR window expires with no second tap.
// DOUBLE_TAP is set immediately when the second tap arrives within DUR.
// We trust this directly — no software delay needed.
void processTaps(unsigned long now) {
// ── Release ───────────────────────────────────────────────────────────────
if (tapState == TAP_EXECUTING) {
if (now - clickDownMs >= CLICK_HOLD_MS) {
if (pendingButton) {
blehid.mouseButtonRelease();
} else {
// Key action: release all keys
uint8_t noKeys[6] = {};
blehid.keyboardReport(0, noKeys);
}
clickButton = 0; clickButtonDown = false;
tapState = TAP_IDLE;
}
return;
}
// ── Poll TAP_SRC ──────────────────────────────────────────────────────────
uint8_t tapSrc = imuReadReg(REG_TAP_SRC);
if (!(tapSrc & 0x40)) return; // TAP_IA not set — no event
bool tapIA = !!(tapSrc & 0x40);
bool doubleTap = !!(tapSrc & 0x10);
if (tapSrc & 0x10) { // DOUBLE_TAP → right click
Serial.println("[TAP] Double → RIGHT");
blehid.mouseButtonPress(MOUSE_BUTTON_RIGHT, true);
clickButton = MOUSE_BUTTON_RIGHT; clickButtonDown = true; clickDownMs = now;
statRightClicks++;
} else if (tapSrc & 0x20) { // SINGLE_TAP → left click
Serial.println("[TAP] Single → LEFT");
blehid.mouseButtonPress(MOUSE_BUTTON_LEFT, true);
clickButton = MOUSE_BUTTON_LEFT; clickButtonDown = true; clickDownMs = now;
statLeftClicks++;
if (tapState == TAP_IDLE) {
if (tapIA && doubleTap) {
tapPendingMs = now;
tapState = TAP_PENDING;
}
return;
}
if (tapState == TAP_PENDING) {
if (now - tapPendingMs >= TAP_CONFIRM_MS) {
fireTapAction(now);
}
}
}

View File

@@ -3,5 +3,6 @@
#ifdef FEATURE_TAP_DETECTION
void setupTapDetection();
void applyTapThreshold();
void processTaps(unsigned long now);
#endif

View File

@@ -9,7 +9,8 @@ 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 };
const config = { sensitivity:600, deadZone:0.06, accelStrength:0.08, curve:0, axisFlip:0, chargeMode:1,
tapThreshold:12, tapAction:0, tapKey:0, tapMod:0 };
let device=null, server=null, chars={}, userDisconnected=false;
let currentChargeStatus=0, currentBattPct=null;
@@ -104,23 +105,30 @@ async function discoverServices() {
}
// ── ConfigBlob read / write ──────────────────────────────────────────────────
// ConfigBlob layout (16 bytes LE):
// ConfigBlob layout (20 bytes LE):
// float sensitivity [0], float deadZone [4], float accelStrength [8]
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14], uint8 pad [15]
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14]
// uint8 tapThreshold [15], uint8 tapAction [16], uint8 tapKey [17], uint8 tapMod [18], uint8 pad [19]
async function readConfigBlob() {
if (!chars.configBlob) return;
try {
const dv = await chars.configBlob.readValue();
const view = new DataView(dv.buffer ?? dv);
const view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
config.sensitivity = view.getFloat32(0, true);
config.deadZone = view.getFloat32(4, true);
config.accelStrength = view.getFloat32(8, true);
config.curve = view.getUint8(12);
config.axisFlip = view.getUint8(13);
config.chargeMode = view.getUint8(14);
if (view.byteLength >= 20) {
config.tapThreshold = view.getUint8(15);
config.tapAction = view.getUint8(16);
config.tapKey = view.getUint8(17);
config.tapMod = view.getUint8(18);
}
applyConfigToUI();
log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)}`,'ok');
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'); }
}
@@ -135,6 +143,14 @@ function applyConfigToUI() {
document.getElementById('flipX').checked = !!(config.axisFlip & 1);
document.getElementById('flipY').checked = !!(config.axisFlip & 2);
setChargeModeUI(config.chargeMode);
document.getElementById('slTapThreshold').value = config.tapThreshold;
updateDisplay('tapThreshold', config.tapThreshold);
setTapActionUI(config.tapAction);
document.getElementById('tapKeyHex').value = config.tapKey ? config.tapKey.toString(16).padStart(2,'0') : '';
document.getElementById('tapModCtrl').checked = !!(config.tapMod & 0x01);
document.getElementById('tapModShift').checked = !!(config.tapMod & 0x02);
document.getElementById('tapModAlt').checked = !!(config.tapMod & 0x04);
document.getElementById('tapModGui').checked = !!(config.tapMod & 0x08);
}
async function writeConfigBlob() {
@@ -146,9 +162,14 @@ async function writeConfigBlob() {
config.accelStrength = +document.getElementById('slAccel').value;
config.axisFlip = (document.getElementById('flipX').checked ? 1 : 0)
| (document.getElementById('flipY').checked ? 2 : 0);
// config.curve and config.chargeMode are updated directly by setCurve/setChargeMode
config.tapThreshold = +document.getElementById('slTapThreshold').value;
config.tapMod = (document.getElementById('tapModCtrl').checked ? 0x01 : 0)
| (document.getElementById('tapModShift').checked ? 0x02 : 0)
| (document.getElementById('tapModAlt').checked ? 0x04 : 0)
| (document.getElementById('tapModGui').checked ? 0x08 : 0);
// config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly
const buf = new ArrayBuffer(16);
const buf = new ArrayBuffer(20);
const view = new DataView(buf);
view.setFloat32(0, config.sensitivity, true);
view.setFloat32(4, config.deadZone, true);
@@ -156,11 +177,15 @@ async function writeConfigBlob() {
view.setUint8(12, config.curve);
view.setUint8(13, config.axisFlip);
view.setUint8(14, config.chargeMode);
view.setUint8(15, 0);
view.setUint8(15, config.tapThreshold);
view.setUint8(16, config.tapAction);
view.setUint8(17, config.tapKey);
view.setUint8(18, config.tapMod);
view.setUint8(19, 0);
try {
await chars.configBlob.writeValue(buf);
log(`Config written — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} curve=${config.curve} chg=${config.chargeMode}`,'ok');
log(`Config written — sens=${config.sensitivity.toFixed(0)} tapThr=${config.tapThreshold} tapAction=${config.tapAction}`,'ok');
} catch(e) { log(`Config write failed: ${e.message}`,'err'); }
}
@@ -193,6 +218,23 @@ function setChargeModeUI(val) {
document.getElementById('ciMode').textContent = ['Off (0mA)','50 mA','100 mA'][val] ?? '--';
}
async function setTapAction(val) {
config.tapAction = val;
setTapActionUI(val);
await writeConfigBlob();
}
function setTapActionUI(val) {
['tapActLeft','tapActRight','tapActMiddle','tapActKey'].forEach((id,i) =>
document.getElementById(id).classList.toggle('active', i===val));
document.getElementById('tapKeyRow').style.display = val===3 ? 'flex' : 'none';
}
function onTapKeyInput() {
const hex = document.getElementById('tapKeyHex').value.trim();
const code = parseInt(hex, 16);
config.tapKey = (isNaN(code) || code < 0 || code > 255) ? 0 : code;
writeConfigBlob();
}
async function sendCalibrate() {
if (!chars.command) return;
try { await chars.command.writeValue(new Uint8Array([0x01])); log('Calibration sent — hold still!','warn'); }
@@ -289,9 +331,10 @@ function updateChargeUI() {
// ── 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)],
sensitivity: ['valSensitivity', v=>parseFloat(v).toFixed(0)],
deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)],
accel: ['valAccel', v=>parseFloat(v).toFixed(2)],
tapThreshold: ['valTapThreshold', v=>(parseFloat(v)*62.5).toFixed(0)+' mg'],
};
const [id,fmt] = map[key];
document.getElementById(id).textContent = fmt(val);
@@ -304,7 +347,7 @@ function setStatus(state) {
pill.className='status-pill '+state;
document.body.className=state;
const cBtn=document.getElementById('connectBtn'), dBtn=document.getElementById('disconnectBtn');
const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn');
const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn,#tapKeyHex,.mod-btn input');
if (state==='connected') {
cBtn.style.display='none'; dBtn.style.display='';
inputs.forEach(el=>el.disabled=false);

View File

@@ -89,6 +89,38 @@
</div>
</div>
<div class="section-label">Tap Configuration</div>
<div class="card">
<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"
oninput="updateDisplay('tapThreshold',this.value)" onchange="writeConfigBlob()">
<div class="param-value" id="valTapThreshold">750 mg</div>
</div>
<div class="param" style="border-bottom:none;padding-bottom:0">
<div><div class="param-label">Double-Tap Action</div><div class="param-desc">What a double-tap sends</div></div>
<div class="segmented" style="grid-column:2/4">
<button class="seg-btn active" id="tapActLeft" onclick="setTapAction(0)" disabled>LEFT</button>
<button class="seg-btn" id="tapActRight" onclick="setTapAction(1)" disabled>RIGHT</button>
<button class="seg-btn" id="tapActMiddle" onclick="setTapAction(2)" disabled>MIDDLE</button>
<button class="seg-btn" id="tapActKey" onclick="setTapAction(3)" disabled>KEY</button>
</div>
</div>
<div class="tap-key-row" id="tapKeyRow" style="display:none">
<div class="param-label" style="font-size:11px">HID Keycode (hex)</div>
<input type="text" id="tapKeyHex" placeholder="e.g. 28 = Enter" maxlength="4"
style="font-family:var(--mono);font-size:12px;background:var(--bg);color:var(--text);border:1px solid var(--border);padding:4px 8px;width:110px"
oninput="onTapKeyInput()" disabled>
<div class="param-label" style="font-size:11px;margin-left:12px">Modifier</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<label class="mod-btn"><input type="checkbox" id="tapModCtrl" onchange="writeConfigBlob()" disabled><span>Ctrl</span></label>
<label class="mod-btn"><input type="checkbox" id="tapModShift" onchange="writeConfigBlob()" disabled><span>Shift</span></label>
<label class="mod-btn"><input type="checkbox" id="tapModAlt" onchange="writeConfigBlob()" disabled><span>Alt</span></label>
<label class="mod-btn"><input type="checkbox" id="tapModGui" onchange="writeConfigBlob()" disabled><span>GUI</span></label>
</div>
</div>
</div>
<div class="section-label">Axis Configuration</div>
<div class="card">
<div class="flip-row">

View File

@@ -266,6 +266,13 @@
body.disconnected .card { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
body.disconnected .cmd-grid { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
.tap-key-row { display:flex; align-items:center; gap:10px; padding-top:12px; flex-wrap:wrap; }
.mod-btn { display:flex; align-items:center; gap:4px; cursor:pointer; font-family:var(--mono); font-size:10px; color:var(--label); user-select:none; }
.mod-btn input { display:none; }
.mod-btn span { padding:4px 8px; border:1px solid var(--border); background:transparent; transition:all 0.15s; }
.mod-btn input:checked + span { background:var(--accent); color:var(--bg); border-color:var(--accent); font-weight:bold; }
.mod-btn input:disabled + span { opacity:0.35; cursor:not-allowed; }
.tap-flash { position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity 0.25s; }
.tap-flash.left { background:radial-gradient(circle at center, var(--tap-left) 0%, transparent 70%); }
.tap-flash.right { background:radial-gradient(circle at center, var(--tap-right) 0%, transparent 70%); }