diff --git a/frontend/index.html b/frontend/index.html
index 216d532..37f1246 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -9,8 +9,8 @@
RemoteTerm for MeshCore
-
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2b43110..3e5aef1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -14,6 +14,8 @@ import {
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
+ useFaviconBadge,
+ useUnreadTitle,
useRawPacketStatsSession,
} from './hooks';
import { AppShell } from './components/AppShell';
@@ -259,6 +261,8 @@ export function App() {
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
+ useFaviconBadge(unreadCounts, mentions, favorites);
+ useUnreadTitle(unreadCounts, favorites);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index 828b573..a79a475 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -10,4 +10,5 @@ export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';
+export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
diff --git a/frontend/src/hooks/useFaviconBadge.ts b/frontend/src/hooks/useFaviconBadge.ts
new file mode 100644
index 0000000..2bfade6
--- /dev/null
+++ b/frontend/src/hooks/useFaviconBadge.ts
@@ -0,0 +1,196 @@
+import { useEffect, useMemo, useRef } from 'react';
+
+import type { Favorite } from '../types';
+import { getStateKey } from '../utils/conversationState';
+
+const APP_TITLE = 'RemoteTerm for MeshCore';
+const UNREAD_APP_TITLE = 'RemoteTerm';
+const BASE_FAVICON_PATH = '/favicon.svg';
+const GREEN_BADGE_FILL = '#16a34a';
+const RED_BADGE_FILL = '#dc2626';
+const BADGE_CENTER = 750;
+const BADGE_OUTER_RADIUS = 220;
+const BADGE_INNER_RADIUS = 180;
+
+let baseFaviconSvgPromise: Promise | null = null;
+
+export type FaviconBadgeState = 'none' | 'green' | 'red';
+
+function getUnreadDirectMessageCount(unreadCounts: Record): number {
+ return Object.entries(unreadCounts).reduce(
+ (sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0),
+ 0
+ );
+}
+
+function getUnreadFavoriteChannelCount(
+ unreadCounts: Record,
+ favorites: Favorite[]
+): number {
+ return favorites.reduce(
+ (sum, favorite) =>
+ sum +
+ (favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
+ 0
+ );
+}
+
+export function getTotalUnreadCount(unreadCounts: Record): number {
+ return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
+}
+
+export function getFavoriteUnreadCount(
+ unreadCounts: Record,
+ favorites: Favorite[]
+): number {
+ return favorites.reduce((sum, favorite) => {
+ const stateKey = getStateKey(favorite.type, favorite.id);
+ return sum + (unreadCounts[stateKey] || 0);
+ }, 0);
+}
+
+export function getUnreadTitle(
+ unreadCounts: Record,
+ favorites: Favorite[]
+): string {
+ const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
+ if (unreadCount <= 0) {
+ return APP_TITLE;
+ }
+
+ const label = unreadCount > 99 ? '99+' : String(unreadCount);
+ return `(${label}) ${UNREAD_APP_TITLE}`;
+}
+
+export function deriveFaviconBadgeState(
+ unreadCounts: Record,
+ mentions: Record,
+ favorites: Favorite[]
+): FaviconBadgeState {
+ if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
+ return 'red';
+ }
+
+ if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
+ return 'green';
+ }
+
+ return 'none';
+}
+
+export function buildBadgedFaviconSvg(baseSvg: string, badgeFill: string): string {
+ const closingTagIndex = baseSvg.lastIndexOf('');
+ if (closingTagIndex === -1) {
+ return baseSvg;
+ }
+
+ const badge = `
+
+
+ `;
+ return `${baseSvg.slice(0, closingTagIndex)}${badge}`;
+}
+
+async function loadBaseFaviconSvg(): Promise {
+ if (!baseFaviconSvgPromise) {
+ baseFaviconSvgPromise = fetch(BASE_FAVICON_PATH, { cache: 'force-cache' })
+ .then(async (response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to load favicon SVG: ${response.status}`);
+ }
+ return response.text();
+ })
+ .catch((error) => {
+ baseFaviconSvgPromise = null;
+ throw error;
+ });
+ }
+
+ return baseFaviconSvgPromise;
+}
+
+function upsertFaviconLinks(rel: 'icon' | 'shortcut icon', href: string): void {
+ const links = Array.from(document.head.querySelectorAll(`link[rel="${rel}"]`));
+ const targets = links.length > 0 ? links : [document.createElement('link')];
+
+ for (const link of targets) {
+ if (!link.parentNode) {
+ link.rel = rel;
+ document.head.appendChild(link);
+ }
+ link.type = 'image/svg+xml';
+ link.href = href;
+ }
+}
+
+function applyFaviconHref(href: string): void {
+ upsertFaviconLinks('icon', href);
+ upsertFaviconLinks('shortcut icon', href);
+}
+
+export function useUnreadTitle(unreadCounts: Record, favorites: Favorite[]): void {
+ const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
+
+ useEffect(() => {
+ document.title = title;
+
+ return () => {
+ document.title = APP_TITLE;
+ };
+ }, [title]);
+}
+
+export function useFaviconBadge(
+ unreadCounts: Record,
+ mentions: Record,
+ favorites: Favorite[]
+): void {
+ const objectUrlRef = useRef(null);
+ const badgeState = useMemo(
+ () => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
+ [favorites, mentions, unreadCounts]
+ );
+
+ useEffect(() => {
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ objectUrlRef.current = null;
+ }
+
+ if (badgeState === 'none') {
+ applyFaviconHref(BASE_FAVICON_PATH);
+ return;
+ }
+
+ const badgeFill = badgeState === 'red' ? RED_BADGE_FILL : GREEN_BADGE_FILL;
+ let cancelled = false;
+
+ void loadBaseFaviconSvg()
+ .then((baseSvg) => {
+ if (cancelled) {
+ return;
+ }
+
+ const objectUrl = URL.createObjectURL(
+ new Blob([buildBadgedFaviconSvg(baseSvg, badgeFill)], {
+ type: 'image/svg+xml',
+ })
+ );
+ objectUrlRef.current = objectUrl;
+ applyFaviconHref(objectUrl);
+ })
+ .catch(() => {
+ if (!cancelled) {
+ applyFaviconHref(BASE_FAVICON_PATH);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ objectUrlRef.current = null;
+ }
+ };
+ }, [badgeState]);
+}
diff --git a/frontend/src/test/useFaviconBadge.test.ts b/frontend/src/test/useFaviconBadge.test.ts
new file mode 100644
index 0000000..29070b3
--- /dev/null
+++ b/frontend/src/test/useFaviconBadge.test.ts
@@ -0,0 +1,255 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ buildBadgedFaviconSvg,
+ deriveFaviconBadgeState,
+ getFavoriteUnreadCount,
+ getUnreadTitle,
+ getTotalUnreadCount,
+ useFaviconBadge,
+ useUnreadTitle,
+} from '../hooks/useFaviconBadge';
+import type { Favorite } from '../types';
+import { getStateKey } from '../utils/conversationState';
+
+function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
+ return (
+ document.head.querySelector(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
+ );
+}
+
+describe('useFaviconBadge', () => {
+ const baseSvg =
+ '';
+ const originalCreateObjectURL = URL.createObjectURL;
+ const originalRevokeObjectURL = URL.revokeObjectURL;
+ let objectUrlCounter = 0;
+ let fetchMock: ReturnType;
+ let createObjectURLMock: ReturnType;
+ let revokeObjectURLMock: ReturnType;
+
+ beforeEach(() => {
+ document.head.innerHTML = `
+
+
+ `;
+ document.title = 'RemoteTerm for MeshCore';
+ objectUrlCounter = 0;
+ fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ text: async () => baseSvg,
+ });
+ createObjectURLMock = vi.fn(() => `blob:generated-${++objectUrlCounter}`);
+ revokeObjectURLMock = vi.fn();
+
+ vi.stubGlobal('fetch', fetchMock);
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ writable: true,
+ value: createObjectURLMock,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ writable: true,
+ value: revokeObjectURLMock,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ writable: true,
+ value: originalCreateObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ writable: true,
+ value: originalRevokeObjectURL,
+ });
+ });
+
+ it('derives badge priority from unread counts, mentions, and favorites', () => {
+ const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
+
+ expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
+ expect(
+ deriveFaviconBadgeState(
+ {
+ [getStateKey('channel', 'fav-chan')]: 3,
+ },
+ {},
+ favorites
+ )
+ ).toBe('green');
+ expect(
+ deriveFaviconBadgeState(
+ {
+ [getStateKey('contact', 'abc')]: 12,
+ },
+ {},
+ favorites
+ )
+ ).toBe('red');
+ expect(
+ deriveFaviconBadgeState(
+ {
+ [getStateKey('channel', 'fav-chan')]: 1,
+ },
+ {
+ [getStateKey('channel', 'fav-chan')]: true,
+ },
+ favorites
+ )
+ ).toBe('red');
+ });
+
+ it('builds a dot-only badge into the base svg markup', () => {
+ const svg = buildBadgedFaviconSvg(baseSvg, '#16a34a');
+
+ expect(svg).toContain('');
+ expect(svg).toContain('');
+ expect(svg).not.toContain(' {
+ expect(getTotalUnreadCount({})).toBe(0);
+ expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
+ expect(getFavoriteUnreadCount({}, [])).toBe(0);
+ expect(
+ getFavoriteUnreadCount(
+ {
+ [getStateKey('channel', 'fav-chan')]: 7,
+ [getStateKey('contact', 'fav-contact')]: 3,
+ [getStateKey('channel', 'other-chan')]: 9,
+ },
+ [
+ { type: 'channel', id: 'fav-chan' },
+ { type: 'contact', id: 'fav-contact' },
+ ]
+ )
+ ).toBe(10);
+ expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
+ expect(
+ getUnreadTitle(
+ {
+ [getStateKey('channel', 'fav-chan')]: 7,
+ [getStateKey('channel', 'other-chan')]: 9,
+ },
+ [{ type: 'channel', id: 'fav-chan' }]
+ )
+ ).toBe('(7) RemoteTerm');
+ expect(
+ getUnreadTitle(
+ {
+ [getStateKey('channel', 'fav-chan')]: 120,
+ },
+ [{ type: 'channel', id: 'fav-chan' }]
+ )
+ ).toBe('(99+) RemoteTerm');
+ });
+
+ it('switches between the base favicon and generated blob badges', async () => {
+ const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
+ const { rerender } = renderHook(
+ ({
+ unreadCounts,
+ mentions,
+ currentFavorites,
+ }: {
+ unreadCounts: Record;
+ mentions: Record;
+ currentFavorites: Favorite[];
+ }) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
+ {
+ initialProps: {
+ unreadCounts: {},
+ mentions: {},
+ currentFavorites: favorites,
+ },
+ }
+ );
+
+ await waitFor(() => {
+ expect(getIconHref('icon')).toBe('/favicon.svg');
+ expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
+ });
+
+ rerender({
+ unreadCounts: {
+ [getStateKey('channel', 'fav-chan')]: 1,
+ },
+ mentions: {},
+ currentFavorites: favorites,
+ });
+
+ await waitFor(() => {
+ expect(getIconHref('icon')).toBe('blob:generated-1');
+ expect(getIconHref('shortcut icon')).toBe('blob:generated-1');
+ });
+
+ rerender({
+ unreadCounts: {
+ [getStateKey('contact', 'dm-key')]: 12,
+ },
+ mentions: {},
+ currentFavorites: favorites,
+ });
+
+ await waitFor(() => {
+ expect(getIconHref('icon')).toBe('blob:generated-2');
+ expect(getIconHref('shortcut icon')).toBe('blob:generated-2');
+ });
+
+ rerender({
+ unreadCounts: {},
+ mentions: {},
+ currentFavorites: favorites,
+ });
+
+ await waitFor(() => {
+ expect(getIconHref('icon')).toBe('/favicon.svg');
+ expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(createObjectURLMock).toHaveBeenCalledTimes(2);
+ expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-1');
+ expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-2');
+ });
+
+ it('writes unread counts into the page title', () => {
+ const { rerender, unmount } = renderHook(
+ ({
+ unreadCounts,
+ favorites,
+ }: {
+ unreadCounts: Record;
+ favorites: Favorite[];
+ }) => useUnreadTitle(unreadCounts, favorites),
+ {
+ initialProps: {
+ unreadCounts: {},
+ favorites: [{ type: 'channel', id: 'fav-chan' }],
+ },
+ }
+ );
+
+ expect(document.title).toBe('RemoteTerm for MeshCore');
+
+ rerender({
+ unreadCounts: {
+ [getStateKey('channel', 'fav-chan')]: 4,
+ [getStateKey('contact', 'dm-key')]: 2,
+ },
+ favorites: [{ type: 'channel', id: 'fav-chan' }],
+ });
+
+ expect(document.title).toBe('(4) RemoteTerm');
+
+ unmount();
+
+ expect(document.title).toBe('RemoteTerm for MeshCore');
+ });
+});