Notifio is a desktop app with a website in front of it, and between the two of them there are exactly four icon files. They are all the same bell: Bootstrap's bell-fill, MIT licensed, scaled 16x onto a 256 unit canvas, with a comment in each one saying which background it is for.

<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none"> <!-- Bootstrap bell-fill, scaled 16x to fill 256x256 canvas. Light variant: white fill, transparent background (for dark UIs). License: MIT (Bootstrap Icons) --> <path fill="#ffffff" d="M128 256a32 32 0 0 0 32-32H96a32 32 0 0 0 32 32z ..." /> </svg> 

Three SVGs, differing only in those two lines: icon-light.svg is white on transparent for dark backgrounds, icon-dark.svg is #0a0a0a on transparent for light backgrounds, and icon.svg is white on an opaque black square. Plus icon.png, 512 by 512, 8,818 bytes, the opaque black one rasterised.

I went looking at this because I wanted to know what electron-builder was doing with it. I came away having found two places where the bell is the wrong bell.

Job one: the macOS .icns nobody writes

There is no icon key anywhere in our electron-builder config. There is only this:

"directories": { "output": "release", "buildResources": "assets" }, 

buildResources is a convention: point it at a directory, drop an icon.png in there, and the app icon is handled. The packaged app has a file I never created:

Notifio.app/Contents/Resources/icon.icns 33,733 bytes 

An .icns is an archive, so you can take it apart:

$ iconutil -c iconset icon.icns -o out.iconset $ for f in out.iconset/*.png; do sips -g pixelWidth "$f"; done icon_16x16.png 16 icon_16x16@2x.png 32 icon_32x32.png 32 icon_32x32@2x.png 64 icon_48x48.png 48 icon_128x128.png 128 icon_128x128@2x.png 512 icon_256x256.png 256 icon_512x512.png 512 

Nine entries out of one source file, which is a genuinely good deal for a line of configuration I did not write. Two things in that list are worth reading twice.

icon_128x128@2x.png and icon_512x512.png are both 512 pixels, and they are byte for byte identical. That is correct: the first is the 256 point slot at 2x and the second is the 512 point slot at 1x, and both of those want 512 actual pixels.

What is missing is icon_512x512@2x, the 1024 pixel entry. It is missing because there is nothing to make it from. My source is 512, so 512 is the ceiling, and on a Retina display the largest version of our icon that exists is half the size the format has a slot for. The fix is to make the source 1024 and rebuild, and the cost of not doing it is a slightly soft icon in Finder's largest preview, which is why it has survived this long.

The other thing is what the bell looks like at that size. It is a full-bleed black square with square corners. Every other icon in the Dock is a rounded rectangle with padding, because macOS does not round your icon for you, it expects you to have drawn one that is already the right shape. Ours is a black tile. That is not a bug in the build, it is a thing I never drew.

Job two and three: the window and the tray

In the main process, the same path is read twice:

function createWindow(): void { const iconPath = path.join(__dirname, '..', 'assets', 'icon.png'); mainWindow = new BrowserWindow({ width: 1000, height: 800, // ... icon: iconPath, show: false, backgroundColor: '#0a0a0a', }); 
function createTray(): void { const iconPath = path.join(__dirname, '..', 'assets', 'icon.png'); let trayIcon: ReturnType<typeof nativeImage.createFromPath>; try { trayIcon = nativeImage.createFromPath(iconPath); if (trayIcon.isEmpty()) throw new Error('Icon is empty'); } catch { trayIcon = nativeImage.createEmpty(); } tray = new Tray(trayIcon); tray.setToolTip('Notifio'); 

The BrowserWindow one is a Windows and Linux feature. On macOS it is ignored, because the window icon there comes from the bundle, which comes from the .icns, which comes from the same file anyway. So that line does nothing on the machine I develop on and everything on the machine most of our users are on, which is a good argument for not deleting lines that appear to do nothing.

The tray is the one that bothers me. A macOS menu bar icon lives in a slot about 22 points tall. We hand it 512 pixels of opaque black square and let Electron scale it down by a factor of 23 at runtime. It also is not marked as a template image, which is the mechanism by which menu bar icons invert themselves so they stay legible when the menu bar is light. Ours cannot invert, because it is not a shape with transparency, it is a picture of a black tile with a bell on it.

I did write the fallback thoughtfully, and I want to point at what it chooses:

catch { trayIcon = nativeImage.createEmpty(); } 

An empty image does not mean no tray. It means a tray item with nothing drawn in it: still there, still clickable, still carrying the context menu with Show Window and Quit on it. Given that our window has no quit button and closing it only hides it, a tray that fails to appear at all would leave people with a running app and no way to stop it. An invisible-but-present menu item is a bad outcome. A missing one is a trap.

Jobs four and five, and the bug

On the website, the same bell shows up in the metadata:

icons: { icon: [ { url: "/icon-light.svg", type: "image/svg+xml" }, { url: "/icon.png", type: "image/png" }, ], apple: "/icon.png", }, 

and once more as the logo in the Organization JSON-LD, which is the version search engines read.

Now go back to the comment at the top of this post. icon-light.svg is the white one, "for dark UIs". The site is #0a0a0a everywhere, so white is right for the site. But a favicon is not drawn on the site. It is drawn in the browser's tab strip, and on every default light-mode browser that is a white bell on a near-white background.

Here is what actually goes out, fetched from production:

$ curl -s https://notifio.app | grep -o '<link rel="icon"[^>]*>' <link rel="icon" href="/icon-light.svg" type="image/svg+xml"/> <link rel="icon" href="/icon.png" type="image/png"/> 

Two icons, and the SVG wins in anything that supports SVG favicons. The PNG behind it would have been fine, because the PNG has the opaque black background, so it stays visible on light and dark. We overrode the one that worked with the one that does not.

The file that fixes it has been in public/ since July, served, cached and linked by nothing:

<!-- Dark variant: #0a0a0a fill, transparent background (for light UIs). --> <path fill="#0a0a0a" d="M128 256a32 32 0 0 0 32-32H96a32 32 0 0 0 32 32z ..." /> 

Past me drew both halves of a prefers-color-scheme pair and then wired up one of them. The Next metadata API takes the media query directly on the descriptor:

icon: [ { url: "/icon-dark.svg", type: "image/svg+xml", media: "(prefers-color-scheme: light)" }, { url: "/icon-light.svg", type: "image/svg+xml", media: "(prefers-color-scheme: dark)" }, { url: "/icon.png", type: "image/png" }, ], 

which renders as <link rel="icon" href="/icon-dark.svg" media="(prefers-color-scheme: light)"/> and so on, with the PNG left last as the fallback for anything that understands neither.

One more detail I only noticed while checking this. The favicon is in the preload set:

<link rel="preload" as="image" href="/icon-light.svg"/> 

So the wrong-colour icon is also the one we have asked the browser to go and get early. It is 550 bytes, so the cost is nothing, but it is a nice illustration of how an asset pipeline will enthusiastically optimise the delivery of a decision nobody checked.

What I would take from this

Four files was the right number. Five consumers reading from them was fine too. What went wrong is that each consumer has a different definition of correct, and they do not look like different definitions when you are looking at the file names:

  • the .icns wants a 1024 pixel square with the padding and corner radius already drawn in
  • the tray wants a small transparent monochrome shape it is allowed to invert
  • the Windows window icon wants the same square the Dock does
  • the favicon wants whatever contrasts with a browser chrome I do not control
  • the JSON-LD logo wants something that reads at any size in a search result

One of those is served correctly by a 512 pixel white bell on black. The file count was never the problem. The problem is that "we have an icon" is five claims wearing one noun.

The app the bell belongs to is at notifio.app, the per-site pages are under alerts, and if you want to see the black tile in a Dock for yourself, the download page has the builds.