A webmanifest cannot read a CSS variable, so our brand colour is spelled twice: once as OKLCH and once as hex
Nakodo's stylesheet is 865 lines long and contains no hex colours at all: 98 of those lines declare an oklch() value. Every colour is an OKLCH ramp step, and everything else is a semantic alias pointing at one: --colo
Nakodo's stylesheet is 865 lines long and contains no hex colours at all: 98 of those lines declare an oklch() value. Every colour is an OKLCH ramp step, and everything else is a semantic alias pointing at one:
--color-paper-0: oklch(0.993 0.005 85);
--color-paper-50: oklch(0.972 0.012 85);
--color-paper-100: oklch(0.952 0.013 85);
...
--background: var(--color-paper-50);
There is a grep ban on raw palette classes and on hex values outside that file, written into the brand document. Then there is this, which is 20 lines of TypeScript and contains the same colour twice, written out by hand:
// Served at /manifest.webmanifest. Icons are in public/.
export default function manifest(): MetadataRoute.Manifest {
return {
name: BRAND.name,
short_name: BRAND.name,
description: BRAND.description,
start_url: "/campaigns",
display: "standalone",
background_color: "#f9f5ed",
theme_color: "#f9f5ed",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" },
],
};
}
A webmanifest is JSON read by the operating system. There is no cascade in it, no var(), no computed value, and nothing in it is ever resolved by a browser engine against a stylesheet. The same is true of the theme-color meta tag, our Open Graph image renderer and our email layout, so the full count of places that know this colour as a hex string is five:
$ grep -rn "f9f5ed" src/
src/app/layout.tsx:48:export const viewport: Viewport = { ..., themeColor: "#f9f5ed" };
src/app/manifest.ts:12: background_color: "#f9f5ed",
src/app/manifest.ts:13: theme_color: "#f9f5ed",
src/lib/seo/og.tsx:13:const PAPER = "#f9f5ed";
src/lib/email/layout.ts:15: page: "#f9f5ed", // paper-50
Every one of those is a boundary where CSS stops: the OS, the OS again, the browser chrome, a PNG renderer, and a mail client. The comment on the last line is the only thing in the repository that says which ramp step the string is supposed to be.
The two spellings are the same colour, and nothing enforces it
#f9f5ed is not approximately --color-paper-50. It is exactly it. You can check that without trusting me, by converting the OKLCH value by hand:
import math
L, C, H = 0.972, 0.012, 85
a, b = C * math.cos(math.radians(H)), C * math.sin(math.radians(H))
l_, m_, s_ = L + 0.3963377774*a + 0.2158037573*b, L - 0.1055613458*a - 0.0638541728*b, L - 0.0894841775*a - 1.2914855480*b
l, m, s = l_**3, m_**3, s_**3
r = 4.0767416621*l - 3.3077115913*m + 0.2309699292*s
g = -1.2684380046*l + 2.6097574011*m - 0.3413193965*s
bl = -0.0041960863*l - 0.7034186147*m + 1.7076147010*s
f = lambda x: 12.92*x if x <= 0.0031308 else 1.055*x**(1/2.4) - 0.055
print("#%02X%02X%02X" % tuple(round(f(max(0, min(1, v))) * 255) for v in (r, g, bl)))
# #F9F5ED
So the real state of this codebase is: one colour, two notations, five copies of the second notation, and no test. Nudge the ramp step a thousandth of a lightness and the app's background moves while the Android splash screen, the browser's address bar tint, the social card and every email stay where they were. That drift would be invisible in the pull request and faintly visible in real life, as a seam where the browser chrome meets the page.
The fix is not to delete the hex, because nothing on those five boundaries can consume OKLCH. It is to have one of them: a single exported constant that the manifest, the viewport, the card renderer and the email layout all import, with one test asserting it matches the ramp step's conversion. That is maybe thirty lines including the conversion, and it is now on the list. Writing the post is what put it there, which is most of why I write them.
Two of the five already behave: name, short_name and description in the manifest come from the BRAND constant rather than being typed, so the string in an install prompt is the same string as the meta description on the home page.
Next links the manifest without being told to
Our root layout's metadata export has no manifest key in it. It sets a title template, a description, Open Graph defaults, Twitter card type and the icons, and that is all. The manifest link tag still ships on every page:
$ curl -s https://nakodo.app/ | grep -o '<link rel="manifest"[^>]*>'
<link rel="manifest" href="/manifest.webmanifest"/>
That comes from the file convention: app/manifest.ts exists, so the route exists and the tag is injected. Which is convenient and worth knowing precisely, because it means deleting a manifest key from your metadata does not unlink the manifest, and the thing to delete is the file.
It also means the manifest is linked from every public page, including the marketing pages a stranger lands on, which leads to the next part.
The installed app opens a sign-in form
start_url: "/campaigns",
/campaigns is the signed-in app. It is not public, and our proxy says so:
$ curl -sI https://nakodo.app/campaigns | grep -i '^http\|^location'
HTTP/2 307
location: /login?next=%2Fcampaigns
So: a visitor reads the pricing page, the browser offers to install Nakodo because the page links a manifest with icons over https, they install it, they open the icon, and they get a sign-in form.
I have thought about this one for a while and I am keeping it. For the case the installed app exists for, a customer who uses Nakodo weekly, start_url: "/campaigns" is exactly right: an installed window that opens on the marketing home page would be absurd, and a standalone window shares the browser profile's cookies, so a signed-in customer goes straight to their campaigns. For the stranger case, the redirect carries ?next=%2Fcampaigns, so signing in lands them where the icon said it would. The first run is one screen longer than it should be and it is not a dead end.
What I would not do is point start_url at /. That trades a correct destination for every real user against a tidier first launch for someone who installed an app they have no account for.
The one honest criticism left is that the install prompt is offered at all on a page where the product cannot be used. Chrome decides that, not us, from the manifest and the icons, and the manifest is link-tagged site wide by the convention above. Scoping the tag to the signed-in area would mean dropping the file convention and setting metadata.manifest per route group, which is a larger change than the problem.
Standalone, with no service worker, on purpose
display: "standalone",
There is no service worker in this app. No sw.js, no caching layer, no offline page:
$ curl -s -o /dev/null -w '%{http_code}\n' https://nakodo.app/sw.js
307
That is the sign-in redirect answering for a file that does not exist, which is its own small joke.
standalone without a service worker is a legitimate combination and it is the one I want. It gives an installed window with no tab strip and no address bar, which is the whole benefit anybody gets from installing a dashboard. What it does not give is offline, and offline is meaningless here: every screen in Nakodo is a server render of live state, and the work it does happens on a cron while nobody is looking. An app shell cached on a phone would show a brand their campaign exactly as stale as the last time they opened it, which is worse than telling them the truth about the network.
If a service worker ever shows up in this repository it will be for web push, not for caching.
The icons, and the one the manifest does not own
Three entries, 192 and 512 PNGs plus the SVG at sizes: "any". The PNGs exist because Android's launcher and the install prompt want raster at known sizes, and 192 and 512 are the two the specification and Chrome's install criteria actually look for.
The apple touch icon is not in the manifest at all. It is in the page metadata:
icons: {
icon: [
{ url: "/favicon.ico", sizes: "16x16 32x32 48x48" },
{ url: "/icon.svg", type: "image/svg+xml" },
],
apple: "/apple-touch-icon.png",
},
iOS does not take home screen icons from a webmanifest's icons array, and it does not accept an SVG for apple-touch-icon either. So that one is a 180 by 180 PNG, sitting in public/, and if it were missing or an SVG, iOS would quietly use a screenshot of the page instead. That is the kind of bug nobody files, because the person affected assumes it is normal.
The .ico has explicit sizes for a smaller reason: without them, Chrome prefers the .ico over the SVG favicon, and the SVG is the one that follows the colour scheme.
What the file is worth
Twenty lines, of which four are decisions: where an installed window opens, what the splash screen looks like before the first paint, what display mode the window has, and which icons the OS gets. The colour duplication in it is not a mistake in the manifest, it is the shape of every boundary a design system has to cross, and the only real sin is that we cross it five times with five string literals instead of one.
The manifest is public if you want to read the whole of it, and the pricing page is where a browser will probably offer to install it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.