diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 771bdbf..9acb4ca 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -248,6 +248,7 @@ async function pollSessions() { renderSidebar(sessions, _viewingSession); handleBellTransitions(prev, sessions); updateSessionPill(sessions); + updateFaviconBadge(); } catch (err) { _pollFailCount++; setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); @@ -815,6 +816,61 @@ function updatePillBell() { if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden'); } +// --------------------------------------------------------------------------- +// Dynamic favicon — activity dot overlay +// --------------------------------------------------------------------------- + +var _originalFavicon = null; // cached original favicon href + +/** + * Update the favicon with an activity dot if any session has unseen bells. + * Uses a 32x32 canvas to draw the original favicon + a colored circle overlay. + * Restores the original favicon when there are no unseen bells. + */ +function updateFaviconBadge() { + var hasActivity = _currentSessions && _currentSessions.some(function (s) { + return s.bell && s.bell.unseen_count > 0; + }); + + var link = document.querySelector('link[rel="icon"][sizes="32x32"]') || + document.querySelector('link[rel="icon"]'); + if (!link) return; + + // Cache the original favicon on first call + if (!_originalFavicon) _originalFavicon = link.href; + + if (!hasActivity) { + // Restore original favicon when no activity + if (link.href !== _originalFavicon) link.href = _originalFavicon; + return; + } + + // Draw favicon + activity dot on canvas + var canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + var ctx = canvas.getContext('2d'); + if (!ctx) return; + + var img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = function () { + ctx.drawImage(img, 0, 0, 32, 32); + + // Activity dot — brand amber (same as bell indicator) + ctx.beginPath(); + ctx.arc(24, 8, 7, 0, 2 * Math.PI); // top-right area + ctx.fillStyle = '#F1A640'; // var(--bell-color) + ctx.fill(); + ctx.strokeStyle = '#0D1117'; // var(--bg) — border for contrast + ctx.lineWidth = 2; + ctx.stroke(); + + link.href = canvas.toDataURL('image/png'); + }; + img.src = _originalFavicon; +} + // ─── Session open / close ──────────────────────────────────────────────────── /** diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index f0dc75f..11918b5 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2040,3 +2040,26 @@ test('applyDisplaySettings calls window._setTerminalFontSize when available', () }); + +// --- Issue: Dynamic favicon badge with activity dot --- + +test('updateFaviconBadge function exists in app.js', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok( + source.includes('function updateFaviconBadge'), + 'app.js must define updateFaviconBadge function' + ); +}); + +test('pollSessions calls updateFaviconBadge', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const pollStart = source.indexOf('async function pollSessions()'); + assert.ok(pollStart !== -1, 'pollSessions must exist'); + // Find the closing brace of pollSessions (next line starting with "}") + const pollEnd = source.indexOf('\n}', pollStart); + const pollBody = source.substring(pollStart, pollEnd + 2); + assert.ok( + pollBody.includes('updateFaviconBadge'), + 'pollSessions must call updateFaviconBadge — update favicon on every poll cycle' + ); +});