export interface FaviconBadgeOptions { /** * Badge fill color. Default Facebook-ish red. */ badgeColor?: string; /** * Text/number color. Default white. */ textColor?: string; /** * `true` → paint the count inside the badge (caps at 99+). * `false` → flat dot. Default `false` — Facebook-style restraint. */ showCount?: boolean; /** * Optional explicit base favicon URL. If omitted, we read the current * `` href; if there is none, we paint onto a blank * canvas so something useful still appears on the tab. */ baseUrl?: string; /** * Canvas size in CSS px. 32 is the standard favicon hi-DPI size. */ size?: number; /** * Pulse the badge by alternating between the badged frame and the * bare base favicon. Default `true` — periphery-vision cue that * makes the dot findable in a tab bar of dozens of icons. Set * `false` for a static dot (quieter; matches enterprise tastes). */ pulse?: boolean; /** * Milliseconds the badge stays visible per cycle. Default 600. */ pulseOnMs?: number; /** * Milliseconds the badge is hidden per cycle. Default 400. * Asymmetric on/off (default 600/400) gives a Slack/FB-style * heartbeat — the badge is the dominant state, the gap is brief. */ pulseOffMs?: number; } interface BadgeHandle { set(count: number): void; clear(): void; } const LINK_REL_VARIANTS = ['icon', 'shortcut icon'] as const; const MANAGED_ATTR = 'data-chat-favicon-badge'; /** * Paints a small badge over the page's favicon using a hidden canvas * and swaps the resulting data URL into ``. SSR-safe. * * Notes / gotchas (encoded as invariants): * - SVG favicons can't be rastered via `` reliably across * browsers without inlining. We attempt anyway; on failure we paint * a blank background + the badge so the tab still signals unread. * - We never mutate the host's original ``. We add a managed * `` ahead of it; on clear * we remove that managed node. This is the cleanest way to handle * hosts that swap their own favicons (e.g. Next.js dynamic icons). * - Cross-origin favicons need CORS to draw; if drawing throws we * fall back to a CORS-less canvas (badge only, no base image). */ export function createFaviconBadge(opts: FaviconBadgeOptions = {}): BadgeHandle { if (typeof document === 'undefined') { return { set() {}, clear() {} }; } const badgeColor = opts.badgeColor ?? '#ef4444'; const textColor = opts.textColor ?? '#ffffff'; const showCount = opts.showCount ?? false; const size = opts.size ?? 32; const pulse = opts.pulse ?? true; const pulseOnMs = opts.pulseOnMs ?? 600; const pulseOffMs = opts.pulseOffMs ?? 400; let baseImg: HTMLImageElement | null = null; let baseLoaded = false; let lastCount = -1; // Pulse animation state. let frameOn: string | null = null; // badged frame data URL let frameOff: string | null = null; // bare base frame data URL (or empty) let pulseTimer: ReturnType | null = null; let pulsePhase: 'on' | 'off' = 'on'; const detectBaseUrl = (): string | null => { if (opts.baseUrl) return opts.baseUrl; for (const rel of LINK_REL_VARIANTS) { const link = document.querySelector( `link[rel="${rel}"]:not([${MANAGED_ATTR}])`, ); if (link?.href) return link.href; } return null; }; const loadBase = () => { if (baseImg) return; const url = detectBaseUrl(); if (!url) return; const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { baseLoaded = true; // If a `set()` came in before load completed, re-render now. if (lastCount >= 0) renderFrames(lastCount); }; img.onerror = () => { // Network/CORS failure: leave baseLoaded=false; we'll paint badge // on a blank canvas. baseLoaded = false; }; img.src = url; baseImg = img; }; const removeManagedLinks = () => { document .querySelectorAll(`link[${MANAGED_ATTR}]`) .forEach((node) => node.remove()); }; const applyDataUrl = (dataUrl: string) => { removeManagedLinks(); const link = document.createElement('link'); link.rel = 'icon'; link.type = 'image/png'; link.href = dataUrl; link.setAttribute(MANAGED_ATTR, 'true'); document.head.appendChild(link); }; // Paint the off-phase frame. If we have a usable base favicon, this // is the bare base; otherwise a transparent canvas. Returning a // transparent frame (rather than `null` + removing the link) is // load-bearing: Chrome only re-rasters the tab favicon when the // managed ``'s href *changes*, so off-phase MUST swap to a // different data URL — not vanish — for the pulse to be visible. const renderOffFrame = (): string | null => { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) return null; if (baseImg && baseLoaded) { try { ctx.drawImage(baseImg, 0, 0, size, size); } catch { // Tainted canvas → fall through to the empty frame. ctx.clearRect(0, 0, size, size); } } // If no base, the canvas stays fully transparent — Chrome falls // back to its default tab glyph, which still gives a visible // on/off contrast against the red badge. try { return canvas.toDataURL('image/png'); } catch { return null; } }; // Paint the badged frame. Reused for both the static (no-pulse) case // and the `on` phase of the pulse loop. const renderBadged = (count: number): string | null => { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) return null; if (baseImg && baseLoaded) { try { ctx.drawImage(baseImg, 0, 0, size, size); } catch { ctx.clearRect(0, 0, size, size); } } const r = size * 0.28; const cx = size - r - size * 0.04; const cy = size - r - size * 0.04; ctx.fillStyle = badgeColor; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); if (showCount) { const label = count > 99 ? '99+' : String(count); ctx.fillStyle = textColor; const fontSize = label.length >= 3 ? r * 0.85 : r * 1.25; ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, cx, cy + fontSize * 0.06); } try { return canvas.toDataURL('image/png'); } catch { // Tainted-canvas fallback: badge only, no base. const fb = document.createElement('canvas'); fb.width = size; fb.height = size; const fctx = fb.getContext('2d'); if (!fctx) return null; fctx.fillStyle = badgeColor; fctx.beginPath(); fctx.arc(cx, cy, r, 0, Math.PI * 2); fctx.fill(); return fb.toDataURL('image/png'); } }; const stopPulse = () => { if (pulseTimer !== null) { clearTimeout(pulseTimer); pulseTimer = null; } }; const tickPulse = () => { if (frameOn === null) return; // Always swap the managed 's href between two distinct data // URLs — Chrome / Safari skip the redraw if the href is identical // to what was just set, so removing-and-readding the node isn't // enough on its own (`renderOffFrame` guarantees a non-null off // frame whenever frameOn exists). if (pulsePhase === 'on') { applyDataUrl(frameOn); pulsePhase = 'off'; pulseTimer = setTimeout(tickPulse, pulseOnMs); } else { applyDataUrl(frameOff ?? frameOn); pulsePhase = 'on'; pulseTimer = setTimeout(tickPulse, pulseOffMs); } }; const renderFrames = (count: number) => { frameOn = renderBadged(count); frameOff = renderOffFrame(); if (frameOn === null) return; if (!pulse) { applyDataUrl(frameOn); return; } // (Re-)start the pulse loop. If a previous loop was running we // tear it down first so the cadence doesn't double up. stopPulse(); pulsePhase = 'on'; tickPulse(); }; return { set(count) { lastCount = count; loadBase(); // If base not loaded yet, the onload handler will call // renderFrames(lastCount) once it resolves. renderFrames(count); }, clear() { lastCount = -1; stopPulse(); frameOn = null; frameOff = null; removeManagedLinks(); }, }; }