From f155d16399e2f47bb9fe825c00ddcb175264d589 Mon Sep 17 00:00:00 2001 From: Nik Rozman Date: Sun, 1 Mar 2026 20:45:23 +0100 Subject: [PATCH] Add configuration slider for double tapping --- source/ble_config.cpp | 35 +++++++++--- source/config.h | 24 +++++++- source/main.cpp | 3 +- source/tap.cpp | 129 +++++++++++++++++++++++++++++++++--------- source/tap.h | 1 + web/app.js | 69 +++++++++++++++++----- web/index.html | 32 +++++++++++ web/style.css | 7 +++ 8 files changed, 248 insertions(+), 52 deletions(-) diff --git a/source/ble_config.cpp b/source/ble_config.cpp index 2a811b5..d52ff04 100644 --- a/source/ble_config.cpp +++ b/source/ble_config.cpp @@ -1,4 +1,5 @@ #include "ble_config.h" +#include "tap.h" #include #include @@ -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) { diff --git a/source/config.h b/source/config.h index 329aa97..9dfcc48 100644 --- a/source/config.h +++ b/source/config.h @@ -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; // 1–31 → 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] 1–31 + 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 diff --git a/source/main.cpp b/source/main.cpp index 5522e01..5502269 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -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 ───────────────────────────────────────────────────── diff --git a/source/tap.cpp b/source/tap.cpp index 3187de7..725121a 100644 --- a/source/tap.cpp +++ b/source/tap.cpp @@ -7,42 +7,115 @@ extern BLEHidAdafruit blehid; // ─── Tap detection setup ────────────────────────────────────────────────────── +// REG_TAP_THS_6D bits[4:0] = tapThreshold (1–31); 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); + } } } diff --git a/source/tap.h b/source/tap.h index 8165638..74a6811 100644 --- a/source/tap.h +++ b/source/tap.h @@ -3,5 +3,6 @@ #ifdef FEATURE_TAP_DETECTION void setupTapDetection(); +void applyTapThreshold(); void processTaps(unsigned long now); #endif diff --git a/web/app.js b/web/app.js index c0c8c9f..4e17dc1 100644 --- a/web/app.js +++ b/web/app.js @@ -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); diff --git a/web/index.html b/web/index.html index 76f21ca..336e9e6 100644 --- a/web/index.html +++ b/web/index.html @@ -89,6 +89,38 @@ + +
+
+
Tap Threshold
Impact force needed · 1 LSB ≈ 62.5 mg at ±2g
+ +
750 mg
+
+
+
Double-Tap Action
What a double-tap sends
+
+ + + + +
+
+ +
+
diff --git a/web/style.css b/web/style.css index 5c2da5e..c9876b9 100644 --- a/web/style.css +++ b/web/style.css @@ -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%); }