The entire web app manifest for pub-trivia.app is one function returning an object, and you can read the output without taking my word for anything:
curl -s https://pub-trivia.app/manifest.webmanifest
{"name":"PubTrivia","short_name":"PubTrivia","description":"Live pub quiz nights, scan, join, play!",
"start_url":"/","display":"standalone","background_color":"#171717","theme_color":"#171717",
"icons":[{"src":"/icon.svg","sizes":"any","type":"image/svg+xml","purpose":"maskable"}]}
Seven fields and one icon. No screenshots, no shortcuts, no categories, no id. I want to explain why it is that small, and then show you the two things in it that are actually wrong, because I only went looking after wondering why the icon on my phone looked strange.
Why there is almost nothing in it
A manifest is for people who install your site. Ours has two audiences and only one of them ever would.
A player arrives by scanning a code on their table, plays for one evening, and never comes back to that URL. Installing a quiz night as an app is not a thing anybody wants, and the whole point of a browser based player is that there is nothing to install when you are holding a drink in your other hand.
The host is different. The host opens the same site every week, usually on the same laptop or tablet behind the bar, and for them a standalone window is a genuine improvement: no address bar to fat finger, no tab that somebody closes mid round. So display: standalone is there for exactly one user per venue, and every field a manifest can carry has to justify itself against that single use.
start_url is / rather than /dashboard, which I went back and forth on. Booting an installed app directly into the dashboard is nicer when you are signed in and worse when you are not, because then the app opens on a redirect to a login form. The landing page handles both states, so it wins.
theme_color appears twice, here and as a <meta name="theme-color"> in the document head. Not a mistake: the manifest value applies when the site is installed, and the meta tag applies to browser chrome on an ordinary visit, which is every single player's visit. Two mechanisms, two audiences, one hex value that has to match in both.
Problem one: the icon iOS actually reads
Three icon declarations go out in the head. You can see all of them:
curl -s https://pub-trivia.app/ | grep -oE '<link rel="(icon|apple-touch-icon|manifest)"[^>]*>'
<link rel="manifest" href="/manifest.webmanifest"/>
<link rel="icon" href="/favicon.ico?favicon.1s1wf25m_8_hs.ico" sizes="200x200" type="image/x-icon"/>
<link rel="icon" href="/favicon.svg"/>
<link rel="apple-touch-icon" href="/icon.svg"/>
That last line is the bug. iOS does not read the manifest for a home screen icon. It reads apple-touch-icon, and it expects a PNG, conventionally 180 by 180. Hand it an SVG and it does not scale your vector logo elegantly: it ignores the declaration and falls back to a snapshot of the page, so somebody who adds the site to their home screen gets a tiny blurry screenshot of our landing page where the logo should be.
So the one audience the manifest exists for, the host who wants the site on a device behind the bar, gets the worst version of the icon if that device is an iPad. Which is a decent proportion of the devices sitting behind bars.
The irritating part is the absence of any feedback. Nothing in the build warns about it. The file resolves, the content type is correct, the SVG renders perfectly everywhere else, and the browser reports no error. The failure appears only on a physical device, in a place developers rarely look, which is the home screen. Every tool in the chain is satisfied and the output is wrong.
The fix is a 180 by 180 PNG and one changed href. It is not interesting. What is interesting is how long a file can be wrong when the only feedback loop runs through somebody's phone.
Problem two: maskable with nothing beside it
The single manifest icon declares "purpose": "maskable" and nothing else.
Maskable means: this image has its important content inside a safe zone, so a launcher may crop it to whatever shape that platform uses, a circle, a squircle, a rounded square. It is a promise about padding.
Declaring it as the only purpose means there is no entry saying "here is the icon as it should look uncropped". Surfaces that want a plain icon have only a maskable one to choose from, and a maskable icon used unmasked tends to look oddly small and floaty, because the padding that keeps the logo safe under a circular crop is just empty margin when nothing crops it.
The right shape is two entries pointing at two files, one with purpose: "any" and one with purpose: "maskable", drawn with different amounts of padding, because they are genuinely different images rather than one image used twice. We have one SVG doing both jobs and therefore doing neither of them precisely.
The one I cannot explain
<link rel="icon" href="/favicon.ico?favicon.1s1wf25m_8_hs.ico" sizes="200x200" type="image/x-icon"/>
sizes="200x200", on an .ico. That is generated from the file in app/, and 200 by 200 is not a conventional favicon size, so it is presumably what the actual bitmap inside the file measures. It works. Browsers scale it. It is also 4.4 KB to deliver an image that is displayed at 16 pixels, next to a 1 KB SVG that would be sharper at every size, and the only reason the .ico is there at all is for clients too old to take the SVG.
I am leaving it, and I am noting it, because "works and is slightly wrong" is the natural state of favicon configuration and pretending otherwise would be dishonest.
Check any of this yourself
The manifest and the head are public, so the two curl commands above are the whole audit. If you have an iPhone, the sharper test takes ten seconds: open pub-trivia.app in Safari, share, add to home screen, and look at what you get. That is the screenshot fallback, and it is what the missing PNG costs.
If you want to see the surface the standalone window is actually for, the pricing page lists the plans and the free tier takes no card. The host side is where the installed app would spend its time.
What I took from it
Manifests and icon declarations are the one corner of a web app where every layer of your tooling will report success on a configuration that does not work. There is no type to get wrong, no request that 404s, no console warning. The platform that consumes the file is not the platform you are building on, and it fails quietly by falling back to something plausible.
So the rule I would write for myself: any file whose only consumer is an operating system gets verified on that operating system, once, by hand, on a real device. Everything else in a build has a feedback loop. This does not.
Sources for the iOS behaviour: webhint's apple-touch-icon hint, which checks that each declared touch icon is a PNG of a supported resolution.
Top comments (0)