Merge pull request #7 from osias-trade/fix/blur-icon-and-ws-keepalive

Fix blurred lock icon overlay; add WS ping keepalive
This commit is contained in:
2026-09-08 21:09:15 -04:00
committed by GitHub
2 changed files with 46 additions and 7 deletions
+23 -1
View File
@@ -32,7 +32,22 @@ interface WireError {
message: string; message: string;
} }
type WireMessage = WireLockState | WireAck | WireError; interface WirePong {
type: 'pong';
}
type WireMessage = WireLockState | WireAck | WireError | WirePong;
// JSON-level ping, per the protocol's "ping (optional fallback)" section —
// used here specifically (not relying on the browser's native WS ping/pong
// alone) because this client runs in a MV3 background service worker, which
// Chromium can idle-kill after ~30s with no activity reaching its own
// onMessage-style handlers; a plain protocol-level ping/pong frame isn't
// guaranteed to count as that activity, but a JSON message our own
// 'message' listener below processes is. Sending well under that 30s
// budget (not waiting until the deadline) keeps the socket (and the
// worker) alive continuously rather than trying to time a wakeup.
const PING_INTERVAL_MS = 20_000;
const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = { const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = {
buy_only: 'buy-only', buy_only: 'buy-only',
@@ -95,10 +110,14 @@ export function connectWsClient(options: WsClientOptions): WsClientController {
const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`); const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`);
currentSocket = socket; currentSocket = socket;
let authFailure = false; let authFailure = false;
let pingTimer: ReturnType<typeof setInterval> | undefined;
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
reconnectAttempt = 0; reconnectAttempt = 0;
options.onStatusChange('connected'); options.onStatusChange('connected');
pingTimer = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' }));
}, PING_INTERVAL_MS);
}); });
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
@@ -129,9 +148,12 @@ export function connectWsClient(options: WsClientOptions): WsClientController {
// 'ack' has nothing to react to yet — the client doesn't send // 'ack' has nothing to react to yet — the client doesn't send
// unlock_request/escalate_request until lock-decision logic exists // unlock_request/escalate_request until lock-decision logic exists
// server-side (see backend/CLAUDE.md non-goals for this milestone). // server-side (see backend/CLAUDE.md non-goals for this milestone).
// 'pong' likewise needs no handling — sending the ping is the whole
// point (see PING_INTERVAL_MS above), receiving a reply isn't acted on.
}); });
socket.addEventListener('close', (event) => { socket.addEventListener('close', (event) => {
if (pingTimer) clearInterval(pingTimer);
if (currentSocket === socket) currentSocket = undefined; if (currentSocket === socket) currentSocket = undefined;
if (manuallyDisconnected) { if (manuallyDisconnected) {
@@ -20,19 +20,38 @@ const INTERCEPTED_EVENT_TYPES = ['pointerdown', 'mousedown', 'mouseup', 'click']
const LOCK_ICON_SVG = const LOCK_ICON_SVG =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>'; '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>';
// `backdrop-filter` (not `filter`) is deliberate: `filter` on the container
// would recomposite the container's entire subtree — including the lock
// icon overlay appended below as its child — as one filtered unit, blurring
// the icon along with the button (filter/opacity are compositing
// properties, so a child's own `filter:none` can't opt back out once an
// ancestor sets one). `backdrop-filter` on the overlay instead only blurs
// what's painted *behind* the overlay; the overlay's own children (the
// icon) paint on top of that blurred backdrop unaffected, no DOM
// restructuring of the site's own (React-owned) nodes required.
const OVERLAY_STYLE = [ const OVERLAY_STYLE = [
'position:absolute', 'position:absolute',
'inset:0', // 4px on every side instead of a flush `inset:0`, so the overlay reads as
// slightly larger than the pill it's covering rather than exactly
// clipped to it.
'top:-4px',
'bottom:-4px',
'left:-4px',
'right:-4px',
'display:flex', 'display:flex',
'align-items:center', 'align-items:center',
'justify-content:center', 'justify-content:center',
'pointer-events:auto', 'pointer-events:auto',
'cursor:not-allowed', 'cursor:not-allowed',
'z-index:2147483000', 'z-index:2147483000',
'filter:none', // A light blur (vs. the earlier 3px) avoids the pixelated look on small
'opacity:1', // pill-sized targets; darkening + desaturating the backdrop is what
// actually communicates "disabled" here, the blur alone was carrying too
// much of that job.
'backdrop-filter:blur(1px) grayscale(0.6) brightness(0.55)',
'-webkit-backdrop-filter:blur(1px) grayscale(0.6) brightness(0.55)',
'background:rgba(0,0,0,0.25)',
'color:#fff', 'color:#fff',
'background:transparent',
].join(';'); ].join(';');
/** /**
@@ -50,8 +69,6 @@ export const blurDisable: Intervention = {
const originalStyleAttr = container.getAttribute('style'); const originalStyleAttr = container.getAttribute('style');
const computedPosition = getComputedStyle(container).position; const computedPosition = getComputedStyle(container).position;
container.style.filter = 'blur(3px)';
container.style.opacity = '0.5';
container.style.cursor = 'not-allowed'; container.style.cursor = 'not-allowed';
container.style.pointerEvents = 'none'; container.style.pointerEvents = 'none';
if (computedPosition === 'static') { if (computedPosition === 'static') {