feat: show unread count badge on favicon

Closes #560.

Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.

The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.

Notes on the approach:

- The badge link is an *additional* icon link that we append and mark as
  ours; we never remove or mutate a link we did not create. Next's metadata
  icons are rendered by React, which keeps a fiber pointing at that DOM node,
  so removing it would leave React holding a detached node and throw
  "Cannot read properties of null (reading 'removeChild')" on the next
  commit that deletes the fiber. Appending instead means the last-declared
  icon wins, and non-SVG fallback links survive with their type/sizes intact.
  (The usual recipe for this feature — assign canvas.toDataURL() to the
  existing link's href — does both of the things that break here.)

- Every change of state is an *insertion* of a fresh link of ours, never a
  mutation or a removal, because that is the only signal a browser reliably
  re-reads the favicon on. Firefox ignores an in-place href change, and it
  equally ignores a removal — so clearing the badge by deleting our link left
  a stale count painted on the tab until a hard reload. Clearing it instead
  inserts a new link of ours carrying the original base href.

- Holding last place has to be defended: on a client-side navigation React
  re-hoists its metadata icon link into <head>, landing after ours, and the
  base icon silently wins again. A MutationObserver on <head> moves our own
  link back to the end whenever a foreign icon link appears — moving only our
  node, never anyone else's. It no-ops once ours is last again, so a move
  cannot feed itself.

- The badge is a full-width band across the foot of the icon, drawn to the
  metrics measured from Gmail's own 16px favicon: band height 0.625 of the
  icon, digit cap height 0.44, flush to the edges, corners rounded by about a
  pixel. Full width is what keeps a three-glyph label legible — rounded ends
  waste exactly the horizontal space it needs. Neutral white with black digits
  rather than the conventional red: faviconUrl is admin-overridable and
  Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.

- The base SVG may be admin-uploaded, and the branding route deliberately
  serves it under a sandboxing CSP because SVG can carry script. Re-emitting
  it as a same-origin data: URL would un-fence that, so script, foreignObject
  and every on* handler are stripped before serialising.

- Mounted in the root layout, not on the mail route: the badge belongs to the
  tab, so mounting it on the page would clear it on every hop to settings,
  calendar or contacts.
This commit is contained in:
honzup
2026-07-16 18:00:41 +02:00
committed by Linus Rath
parent 578339c400
commit 334fdbfb86
32 changed files with 1598 additions and 1 deletions
+4
View File
@@ -949,6 +949,10 @@
"label": "Zobrazit celkový počet zpráv",
"description": "Zobrazí celkový počet zpráv vedle složek a štítků spolu s počtem nepřečtených. Vypněte pro zobrazení pouze nepřečtených."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro rozhraní (experimentální)",
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Vis samlet antal beskeder",
"description": "Viser det samlede antal beskeder ud for mapper og tags sammen med antallet af ulæste. Slå fra for kun at vise ulæste."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-grænseflade (eksperimentel)",
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Gesamtzahl der Nachrichten anzeigen",
"description": "Zeigt neben Ordnern und Tags die Gesamtzahl der Nachrichten zusätzlich zur Anzahl ungelesener Nachrichten an. Deaktivieren, um nur ungelesene Nachrichten anzuzeigen."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-Oberfläche (experimentell)",
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Show Total Message Count",
"description": "Show the total message count next to folders and tags, alongside the unread count. Disable to show only unread counts."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro Interface (Experimental)",
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostrar el número total de mensajes",
"description": "Muestra el número total de mensajes junto a las carpetas y etiquetas, además del número de mensajes no leídos. Desactívalo para mostrar solo los no leídos."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfaz Pro (experimental)",
"description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.",
+4
View File
@@ -952,6 +952,10 @@
"label": "نمایش تعداد کل پیام‌ها",
"description": "تعداد کل پیام‌ها را در کنار پوشه‌ها و برچسب‌ها، همراه با تعداد خوانده‌نشده‌ها نمایش می‌دهد. برای نمایش فقط تعداد خوانده‌نشده‌ها غیرفعال کنید."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "رابط حرفه‌ای (آزمایشی)",
"description": "چیدمان قدرت-کاربری فقط دسکتاپ",
+4
View File
@@ -949,6 +949,10 @@
"label": "Afficher le nombre total de messages",
"description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interface Pro (expérimental)",
"description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.",
+4
View File
@@ -914,6 +914,10 @@
"label": "הצגת מספר ההודעות הכולל",
"description": "מציג את מספר ההודעות הכולל לצד תיקיות ותגיות, לצד מספר ההודעות שלא נקראו. כבו כדי להציג רק את מספר ההודעות שלא נקראו."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "ממשק Pro (ניסיוני)",
"description": "פריסה של משתמש כוח לשולחן עבודה בלבד עם גלישה מרובת-הודעות וזרימות עבודה חוצות-חשבונות. הממשק הסטנדרטי אינו מושפע; תוכל להחזור בכל עת.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Összes üzenet számának megjelenítése",
"description": "Megjeleníti az üzenetek teljes számát a mappák és címkék mellett, az olvasatlanok számán túl. Kapcsolja ki, ha csak az olvasatlanok számát szeretné látni."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro felület (Kísérleti)",
"description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostra il numero totale di messaggi",
"description": "Mostra il numero totale di messaggi accanto a cartelle ed etichette, insieme al conteggio dei non letti. Disattiva per mostrare solo i non letti."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfaccia Pro (sperimentale)",
"description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.",
+4
View File
@@ -949,6 +949,10 @@
"label": "メッセージの総数を表示",
"description": "フォルダーやタグの横に、未読数に加えてメッセージの総数を表示します。未読数のみを表示するには無効にします。"
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro インターフェイス(実験的)",
"description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
+4
View File
@@ -949,6 +949,10 @@
"label": "전체 메시지 수 표시",
"description": "읽지 않은 수와 함께 폴더 및 태그 옆에 전체 메시지 수를 표시합니다. 읽지 않은 수만 표시하려면 비활성화하세요."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro 인터페이스 (실험적)",
"description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Rādīt kopējo ziņojumu skaitu",
"description": "Rāda kopējo ziņojumu skaitu blakus mapēm un tagiem, kā arī nelasīto ziņojumu skaitu. Atspējojiet, lai rādītu tikai nelasītos."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro saskarne (eksperimentāla)",
"description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Totaal aantal berichten tonen",
"description": "Toont het totale aantal berichten naast mappen en labels, naast het aantal ongelezen berichten. Schakel uit om alleen ongelezen aantallen te tonen."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-interface (experimenteel)",
"description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Pokaż łączną liczbę wiadomości",
"description": "Pokazuje łączną liczbę wiadomości obok folderów i etykiet, obok liczby nieprzeczytanych. Wyłącz, aby pokazywać tylko nieprzeczytane."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfejs Pro (eksperymentalny)",
"description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostrar contagem total de mensagens",
"description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interface Pro (experimental)",
"description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Afișează numărul total de mesaje",
"description": "Afișează numărul total de mesaje lângă foldere și etichete, pe lângă numărul celor necitite. Dezactivează pentru a afișa doar mesajele necitite."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfață Pro (experimentală)",
"description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Показывать общее количество сообщений",
"description": "Показывает общее количество сообщений рядом с папками и метками, наряду с количеством непрочитанных. Отключите, чтобы показывать только непрочитанные."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-интерфейс (экспериментальный)",
"description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Zobraziť celkový počet správ",
"description": "Zobraziť celkový počet správ vedľa priečinkov a štítkov spolu s počtom neprečítaných."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro rozhranie (experimentálne)",
"description": "Rozloženie pre pokročilých používateľov iba pre stolné počítače.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Toplam mesaj sayısını göster",
"description": "Klasörlerin ve etiketlerin yanında, okunmamış sayısının yanı sıra toplam mesaj sayısını gösterir. Yalnızca okunmamışları göstermek için devre dışı bırakın."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro Arayüz (Deneysel)",
"description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Показувати загальну кількість повідомлень",
"description": "Показує загальну кількість повідомлень поруч із теками та мітками, разом із кількістю непрочитаних. Вимкніть, щоб показувати лише непрочитані."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-інтерфейс (експериментальний)",
"description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
+4
View File
@@ -949,6 +949,10 @@
"label": "显示邮件总数",
"description": "在文件夹和标签旁边显示邮件总数,以及未读数量。停用后仅显示未读数量。"
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro 界面(实验性)",
"description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",