Make tap freezing configurable, add toggles to other functions, minor UI changes

This commit is contained in:
Nik Rozman
2026-03-03 08:49:22 +01:00
parent 8f63d7c0b5
commit dcc50150b8
6 changed files with 97 additions and 41 deletions
+7 -4
View File
@@ -72,10 +72,11 @@ void pushConfigBlob() {
b.chargeMode = (uint8_t)cfg.chargeMode; b.chargeMode = (uint8_t)cfg.chargeMode;
b.tapThreshold = cfg.tapThreshold; b.tapThreshold = cfg.tapThreshold;
b.tapAction = (uint8_t)cfg.tapAction; b.tapAction = (uint8_t)cfg.tapAction;
b.tapKey = cfg.tapKey; b.tapKey = cfg.tapKey;
b.tapMod = cfg.tapMod; b.tapMod = cfg.tapMod;
b._pad = 0; b.tapFreezeEnabled = cfg.tapFreezeEnabled;
b.jerkThreshold = cfg.jerkThreshold; b.jerkThreshold = cfg.jerkThreshold;
b.featureFlags = cfg.featureFlags;
cfgBlob.write((uint8_t*)&b, sizeof(b)); cfgBlob.write((uint8_t*)&b, sizeof(b));
} }
#endif #endif
@@ -118,7 +119,9 @@ void onConfigBlobWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l)
cfg.tapKey = b->tapKey; cfg.tapKey = b->tapKey;
cfg.tapMod = b->tapMod; cfg.tapMod = b->tapMod;
#endif #endif
cfg.tapFreezeEnabled = b->tapFreezeEnabled ? 1 : 0;
if (b->jerkThreshold >= 100.0f && b->jerkThreshold <= 50000.0f) cfg.jerkThreshold = b->jerkThreshold; if (b->jerkThreshold >= 100.0f && b->jerkThreshold <= 50000.0f) cfg.jerkThreshold = b->jerkThreshold;
cfg.featureFlags = b->featureFlags & (FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG_AUTO_RECAL_ENABLED);
saveConfig(); saveConfig();
Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0); Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0);
Serial.print(" dz="); Serial.print(cfg.deadZone,3); Serial.print(" dz="); Serial.print(cfg.deadZone,3);
+27 -16
View File
@@ -53,7 +53,15 @@
// ─── Persistence ────────────────────────────────────────────────────────────── // ─── Persistence ──────────────────────────────────────────────────────────────
#define CONFIG_FILENAME "/imu_mouse_cfg.bin" #define CONFIG_FILENAME "/imu_mouse_cfg.bin"
#define CONFIG_MAGIC 0xDEAD123AUL #define CONFIG_MAGIC 0xDEAD123CUL
// ─── Runtime feature-override flags (cfg.featureFlags bitmask) ───────────────
// These mirror the compile-time FEATURE_* defines but can be toggled at runtime
// via the web UI and persisted in flash. Bits not listed here are reserved = 0.
#define FLAG_TAP_ENABLED 0x01 // Tap detection active (requires restart)
#define FLAG_TEMP_COMP_ENABLED 0x02 // Temperature gyro-drift compensation
#define FLAG_AUTO_RECAL_ENABLED 0x04 // Auto-recalibrate after long idle
#define FLAG_ALL_DEFAULT (FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG_AUTO_RECAL_ENABLED)
// ─── Enums ──────────────────────────────────────────────────────────────────── // ─── Enums ────────────────────────────────────────────────────────────────────
enum CurveType : uint8_t { CURVE_LINEAR=0, CURVE_SQUARE=1, CURVE_SQRT=2 }; enum CurveType : uint8_t { CURVE_LINEAR=0, CURVE_SQUARE=1, CURVE_SQRT=2 };
@@ -83,27 +91,30 @@ struct Config {
TapAction tapAction; // what a double-tap does TapAction tapAction; // what a double-tap does
uint8_t tapKey; // HID keycode (used when tapAction == TAP_ACTION_KEY) uint8_t tapKey; // HID keycode (used when tapAction == TAP_ACTION_KEY)
uint8_t tapMod; // HID modifier byte (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 float jerkThreshold; // jerk² threshold for tap-freeze detection
uint8_t tapFreezeEnabled; // 1 = enable jerk-based cursor freeze during taps
uint8_t featureFlags; // bitmask of FLAG_* — runtime feature overrides
}; };
extern Config cfg; extern Config cfg;
extern const Config CFG_DEFAULTS; extern const Config CFG_DEFAULTS;
// ─── ConfigBlob (over BLE, 20 bytes) ───────────────────────────────────────── // ─── ConfigBlob (over BLE, 25 bytes) ─────────────────────────────────────────
struct __attribute__((packed)) ConfigBlob { struct __attribute__((packed)) ConfigBlob {
float sensitivity; // [0] float sensitivity; // [0]
float deadZone; // [4] float deadZone; // [4]
float accelStrength; // [8] float accelStrength; // [8]
uint8_t curve; // [12] uint8_t curve; // [12]
uint8_t axisFlip; // [13] uint8_t axisFlip; // [13]
uint8_t chargeMode; // [14] uint8_t chargeMode; // [14]
uint8_t tapThreshold; // [15] 131 uint8_t tapThreshold; // [15] 131
uint8_t tapAction; // [16] TapAction enum uint8_t tapAction; // [16] TapAction enum
uint8_t tapKey; // [17] HID keycode uint8_t tapKey; // [17] HID keycode
uint8_t tapMod; // [18] HID modifier uint8_t tapMod; // [18] HID modifier
uint8_t _pad; // [19] uint8_t tapFreezeEnabled; // [19] 1 = enable jerk-based cursor freeze during taps
float jerkThreshold; // [20] jerk² tap-freeze threshold float jerkThreshold; // [20] jerk² tap-freeze threshold
uint8_t featureFlags; // [24] FLAG_* bitmask — runtime feature overrides
}; };
static_assert(sizeof(ConfigBlob) == 24, "ConfigBlob must be 24 bytes"); static_assert(sizeof(ConfigBlob) == 25, "ConfigBlob must be 25 bytes");
// ─── TelemetryPacket (24 bytes) ─────────────────────────────────────────────── // ─── TelemetryPacket (24 bytes) ───────────────────────────────────────────────
#ifdef FEATURE_TELEMETRY #ifdef FEATURE_TELEMETRY
+12 -15
View File
@@ -60,7 +60,7 @@ Config cfg;
const Config CFG_DEFAULTS = { 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, /*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0,
/*jerkThreshold=*/2000.0f /*jerkThreshold=*/2000.0f, /*tapFreezeEnabled=*/1, /*featureFlags=*/FLAG_ALL_DEFAULT
}; };
// ─── Telemetry definition ───────────────────────────────────────────────────── // ─── Telemetry definition ─────────────────────────────────────────────────────
@@ -221,7 +221,7 @@ void setup() {
Serial.println("[OK] IMU ready"); Serial.println("[OK] IMU ready");
#ifdef FEATURE_TAP_DETECTION #ifdef FEATURE_TAP_DETECTION
setupTapDetection(); if (cfg.featureFlags & FLAG_TAP_ENABLED) setupTapDetection();
#endif #endif
cachedTempC = readIMUTemp(); cachedTempC = readIMUTemp();
@@ -320,7 +320,7 @@ void loop() {
#endif #endif
#ifdef FEATURE_TAP_DETECTION #ifdef FEATURE_TAP_DETECTION
processTaps(now); if (cfg.featureFlags & FLAG_TAP_ENABLED) processTaps(now);
#endif #endif
if (now - lastTime < (unsigned long)LOOP_RATE_MS) return; if (now - lastTime < (unsigned long)LOOP_RATE_MS) return;
@@ -340,17 +340,14 @@ void loop() {
#endif #endif
// Gyro reads with optional temperature compensation // Gyro reads with optional temperature compensation
float gx, gy, gz; float correction = 0.0f;
#ifdef FEATURE_TEMP_COMPENSATION #ifdef FEATURE_TEMP_COMPENSATION
float correction = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC); if (cfg.featureFlags & FLAG_TEMP_COMP_ENABLED)
gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f); correction = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC);
gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f);
gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f);
#else
gx = (imu.readFloatGyroX() - biasGX) * (PI/180.0f);
gy = (imu.readFloatGyroY() - biasGY) * (PI/180.0f);
gz = (imu.readFloatGyroZ() - biasGZ) * (PI/180.0f);
#endif #endif
float gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f);
float gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f);
float gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f);
float ax = imu.readFloatAccelX(); float ax = imu.readFloatAccelX();
float ay = imu.readFloatAccelY(); float ay = imu.readFloatAccelY();
@@ -362,8 +359,8 @@ void loop() {
float jx = (ax - prevAx) / dt, jy = (ay - prevAy) / dt, jz = (az - prevAz) / dt; float jx = (ax - prevAx) / dt, jy = (ay - prevAy) / dt, jz = (az - prevAz) / dt;
float jerkSq = jx*jx + jy*jy + jz*jz; float jerkSq = jx*jx + jy*jy + jz*jz;
prevAx = ax; prevAy = ay; prevAz = az; prevAx = ax; prevAy = ay; prevAz = az;
bool shocked = (jerkSq > cfg.jerkThreshold) || (now < shockFreezeUntil); bool shocked = cfg.tapFreezeEnabled && ((jerkSq > cfg.jerkThreshold) || (now < shockFreezeUntil));
if (jerkSq > cfg.jerkThreshold) shockFreezeUntil = now + SHOCK_FREEZE_MS; if (cfg.tapFreezeEnabled && jerkSq > cfg.jerkThreshold) shockFreezeUntil = now + SHOCK_FREEZE_MS;
// Complementary filter — gx=pitch axis, gz=yaw axis on this board layout // Complementary filter — gx=pitch axis, gz=yaw axis on this board layout
// During shock: gyro-only integration to avoid accel spike corrupting angles // During shock: gyro-only integration to avoid accel spike corrupting angles
@@ -427,7 +424,7 @@ void loop() {
bool idle = (idleFrames >= IDLE_FRAMES); bool idle = (idleFrames >= IDLE_FRAMES);
#ifdef FEATURE_AUTO_RECAL #ifdef FEATURE_AUTO_RECAL
if (idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) { if ((cfg.featureFlags & FLAG_AUTO_RECAL_ENABLED) && idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) {
Serial.println("[AUTO-CAL] Long idle — recalibrating..."); Serial.println("[AUTO-CAL] Long idle — recalibrating...");
idleStartMs = 0; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); return; idleStartMs = 0; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); return;
} }
+28 -4
View File
@@ -9,9 +9,16 @@ const CHR = {
gitHash: '00001239-0000-1000-8000-00805f9b34fb', // GitHash R 8 bytes gitHash: '00001239-0000-1000-8000-00805f9b34fb', // GitHash R 8 bytes
}; };
// Runtime feature-override flag bitmask constants (mirror firmware FLAG_* defines)
const FLAG_TAP_ENABLED = 0x01;
const FLAG_TEMP_COMP_ENABLED = 0x02;
const FLAG_AUTO_RECAL_ENABLED = 0x04;
const FLAG_ALL_DEFAULT = FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG_AUTO_RECAL_ENABLED;
// Local shadow of the current config (kept in sync with device) // 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, tapFreezeEnabled:1, jerkThreshold:2000 }; tapThreshold:12, tapAction:0, tapKey:0, tapMod:0, tapFreezeEnabled:1, jerkThreshold:2000,
featureFlags:FLAG_ALL_DEFAULT };
let device=null, server=null, chars={}, userDisconnected=false; let device=null, server=null, chars={}, userDisconnected=false;
let currentChargeStatus=0, currentBattPct=null, currentBattVoltage=null; let currentChargeStatus=0, currentBattPct=null, currentBattVoltage=null;
@@ -200,11 +207,11 @@ async function checkHashMatch() {
} }
// ── ConfigBlob read / write ────────────────────────────────────────────────── // ── ConfigBlob read / write ──────────────────────────────────────────────────
// ConfigBlob layout (24 bytes LE): // ConfigBlob layout (25 bytes LE):
// float sensitivity [0], float deadZone [4], float accelStrength [8] // float sensitivity [0], float deadZone [4], float accelStrength [8]
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14] // uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14]
// uint8 tapThreshold [15], uint8 tapAction [16], uint8 tapKey [17], uint8 tapMod [18], uint8 tapFreezeEnabled [19] // uint8 tapThreshold [15], uint8 tapAction [16], uint8 tapKey [17], uint8 tapMod [18], uint8 tapFreezeEnabled [19]
// float jerkThreshold [20] // float jerkThreshold [20], uint8 featureFlags [24]
async function readConfigBlob() { async function readConfigBlob() {
if (!chars.configBlob) return; if (!chars.configBlob) return;
@@ -227,6 +234,11 @@ async function readConfigBlob() {
if (view.byteLength >= 24) { if (view.byteLength >= 24) {
config.jerkThreshold = view.getFloat32(20, true); config.jerkThreshold = view.getFloat32(20, true);
} }
if (view.byteLength >= 25) {
config.featureFlags = view.getUint8(24);
} else {
config.featureFlags = FLAG_ALL_DEFAULT; // old firmware — assume all on
}
applyConfigToUI(); applyConfigToUI();
log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} tapThr=${config.tapThreshold}`,'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'); } } catch(e) { log(`Config read error: ${e.message}`,'err'); }
@@ -255,6 +267,9 @@ function applyConfigToUI() {
document.getElementById('tapModShift').checked = !!(config.tapMod & 0x02); document.getElementById('tapModShift').checked = !!(config.tapMod & 0x02);
document.getElementById('tapModAlt').checked = !!(config.tapMod & 0x04); document.getElementById('tapModAlt').checked = !!(config.tapMod & 0x04);
document.getElementById('tapModGui').checked = !!(config.tapMod & 0x08); document.getElementById('tapModGui').checked = !!(config.tapMod & 0x08);
document.getElementById('capTapEnabled').checked = !!(config.featureFlags & FLAG_TAP_ENABLED);
document.getElementById('capTempComp').checked = !!(config.featureFlags & FLAG_TEMP_COMP_ENABLED);
document.getElementById('capAutoRecal').checked = !!(config.featureFlags & FLAG_AUTO_RECAL_ENABLED);
} }
let _writeConfigTimer = null; let _writeConfigTimer = null;
@@ -278,9 +293,12 @@ async function _doWriteConfigBlob() {
| (document.getElementById('tapModGui').checked ? 0x08 : 0); | (document.getElementById('tapModGui').checked ? 0x08 : 0);
config.tapFreezeEnabled = document.getElementById('tapFreezeEnabled').checked ? 1 : 0; config.tapFreezeEnabled = document.getElementById('tapFreezeEnabled').checked ? 1 : 0;
config.jerkThreshold = +document.getElementById('slJerkThreshold').value; config.jerkThreshold = +document.getElementById('slJerkThreshold').value;
config.featureFlags = (document.getElementById('capTapEnabled').checked ? FLAG_TAP_ENABLED : 0)
| (document.getElementById('capTempComp').checked ? FLAG_TEMP_COMP_ENABLED : 0)
| (document.getElementById('capAutoRecal').checked ? FLAG_AUTO_RECAL_ENABLED : 0);
// config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly // config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly
const buf = new ArrayBuffer(24); const buf = new ArrayBuffer(25);
const view = new DataView(buf); const view = new DataView(buf);
view.setFloat32(0, config.sensitivity, true); view.setFloat32(0, config.sensitivity, true);
view.setFloat32(4, config.deadZone, true); view.setFloat32(4, config.deadZone, true);
@@ -294,6 +312,7 @@ async function _doWriteConfigBlob() {
view.setUint8(18, config.tapMod); view.setUint8(18, config.tapMod);
view.setUint8(19, config.tapFreezeEnabled); view.setUint8(19, config.tapFreezeEnabled);
view.setFloat32(20, config.jerkThreshold, true); view.setFloat32(20, config.jerkThreshold, true);
view.setUint8(24, config.featureFlags);
try { try {
await gattWrite(chars.configBlob, buf); await gattWrite(chars.configBlob, buf);
@@ -330,6 +349,11 @@ function setChargeModeUI(val) {
document.getElementById('ciMode').textContent = ['Off (0mA)','50 mA','100 mA'][val] ?? '--'; document.getElementById('ciMode').textContent = ['Off (0mA)','50 mA','100 mA'][val] ?? '--';
} }
function onCapTapChange(enabled) {
writeConfigBlob();
log('Tap detection ' + (enabled ? 'enabled' : 'disabled') + ' — restart device to apply', 'warn');
}
function onTapFreezeChange(enabled) { function onTapFreezeChange(enabled) {
config.tapFreezeEnabled = enabled ? 1 : 0; config.tapFreezeEnabled = enabled ? 1 : 0;
updateTapFreezeUI(enabled); updateTapFreezeUI(enabled);
+19
View File
@@ -155,6 +155,25 @@
</div> </div>
</div> </div>
<div class="section-label">Device Capabilities</div>
<div class="card">
<div class="flip-row">
<div class="flip-label">Tap Detection</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Double-tap click action &nbsp;<span class="restart-note">· restart to apply</span></div>
<label class="toggle"><input type="checkbox" id="capTapEnabled" onchange="onCapTapChange(this.checked)" disabled><div class="toggle-track"></div><div class="toggle-thumb"></div></label>
</div>
<div class="flip-row">
<div class="flip-label">Temp Compensation</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Gyro drift correction by temperature</div>
<label class="toggle"><input type="checkbox" id="capTempComp" onchange="writeConfigBlob()" disabled><div class="toggle-track"></div><div class="toggle-thumb"></div></label>
</div>
<div class="flip-row" style="border-bottom:none">
<div class="flip-label">Auto Recalibration</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Recalibrate gyro after long idle period</div>
<label class="toggle"><input type="checkbox" id="capAutoRecal" onchange="writeConfigBlob()" disabled><div class="toggle-track"></div><div class="toggle-thumb"></div></label>
</div>
</div>
<div class="section-label">Device Commands</div> <div class="section-label">Device Commands</div>
<div class="cmd-grid"> <div class="cmd-grid">
<button class="cmd-btn calibrate" id="btnCal" onclick="sendCalibrate()" disabled> <button class="cmd-btn calibrate" id="btnCal" onclick="sendCalibrate()" disabled>
+4 -2
View File
@@ -110,7 +110,7 @@
.logo-sub { font-size:10px; color:var(--label); letter-spacing:0.25em; text-transform:uppercase; margin-top:3px; } .logo-sub { font-size:10px; color:var(--label); letter-spacing:0.25em; text-transform:uppercase; margin-top:3px; }
.header-right { margin-left:auto; display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; } .header-right { margin-left:auto; display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
.status-pill { display:flex; align-items:center; gap:8px; padding:8px 14px; border:1px solid var(--border); font-size:11px; letter-spacing:0.15em; text-transform:uppercase; color:var(--label); transition:all 0.3s; white-space:nowrap; } .status-pill { display:flex; align-items:center; gap:8px; height:34px; padding:0 14px; border:1px solid var(--border); font-size:11px; letter-spacing:0.15em; text-transform:uppercase; color:var(--label); transition:all 0.3s; white-space:nowrap; }
.status-pill.connected { border-color:var(--ok); color:var(--ok); } .status-pill.connected { border-color:var(--ok); color:var(--ok); }
.status-pill.connecting { border-color:var(--warn); color:var(--warn); } .status-pill.connecting { border-color:var(--warn); color:var(--warn); }
.dot { width:7px; height:7px; border-radius:50%; background:var(--dim); flex-shrink:0; } .dot { width:7px; height:7px; border-radius:50%; background:var(--dim); flex-shrink:0; }
@@ -131,7 +131,7 @@
.btn:disabled { border-color:var(--dim); color:var(--dim); cursor:not-allowed; } .btn:disabled { border-color:var(--dim); color:var(--dim); cursor:not-allowed; }
.btn:disabled::before { display:none; } .btn:disabled::before { display:none; }
.btn:disabled:hover { color:var(--dim); } .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 { border:1px solid var(--dim); color:var(--label); min-width:52px; text-align:center; font-size:10px; height:34px; padding:0 10px; }
.btn-debug::before { background:var(--accent); } .btn-debug::before { background:var(--accent); }
.btn-theme { border:1px solid var(--dim); color:var(--label); min-width:72px; text-align:center; } .btn-theme { border:1px solid var(--dim); color:var(--label); min-width:72px; text-align:center; }
.btn-theme::before { background:var(--text); } .btn-theme::before { background:var(--text); }
@@ -303,6 +303,8 @@
.mod-btn input:checked + span { background:var(--accent); color:var(--bg); border-color:var(--accent); font-weight:bold; } .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; } .mod-btn input:disabled + span { opacity:0.35; cursor:not-allowed; }
.restart-note { color:var(--warn); font-family:var(--mono); font-size:9px; }
.tap-flash { position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity 0.25s; } .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.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%); } .tap-flash.right { background:radial-gradient(circle at center, var(--tap-right) 0%, transparent 70%); }