A design token system has one job, which is to be the only place a colour is written down. PubTrivia has one, and it is clean: app/globals.css declares 25 tokens for the light theme and 24 each for the dark and forced-light themes, as 72 HSL triplets, with zero hex literals in the file.
.dark { --background: 0 0% 9%; --foreground: 0 0% 98%; --card: 0 0% 11%; --muted-foreground: 0 0% 60%; --border: 0 0% 18%; ... } Note the zeroes. The dark palette the public site actually renders in is pure neutral: hue 0, saturation 0, nothing but lightness.
| Token | Hex | HSL | Contrast on the background |
|---|---|---|---|
| background | #171717 | hsl(0, 0%, 9%) | 1.00 |
| card | #1c1c1c | hsl(0, 0%, 11%) | 1.05 |
| border | #2e2e2e | hsl(0, 0%, 18%) | 1.32 |
| muted-foreground | #999999 | hsl(0, 0%, 60%) | 6.29 |
| foreground | #fafafa | hsl(0, 0%, 98%) | 17.18 |
Body text at 17.18:1 and secondary text at 6.29:1 is a comfortable pass of WCAG AA, with room to spare. So far so good. The problem is that a browser is not the only thing that has to draw this product.
Surface two: the link preview card
Our Open Graph images are generated at build time with Satori, which turns JSX into a PNG. Satori does not run CSS custom properties, so lib/og/og-image.tsx opens with the palette written out again, by hand, in hex:
const BACKGROUND = "#171717" const FOREGROUND = "#fafafa" const MUTED = "#999999" const BORDER = "#2e2e2e" const BRAND = "#F5A623" Four of those five are the dark tokens above, correctly converted. The fifth is not in the token file, not in the stylesheet, and not anywhere else in the repository. I grepped:
$ grep -rn "F5A623\|245, 166" --include="*.ts" --include="*.tsx" --include="*.css" . | grep -v node_modules ./lib/og/og-image.tsx:32:const BRAND = "#F5A623" ./lib/og/og-image.tsx:126: backgroundImage: `radial-gradient(circle at center, rgba(245, 166, 35, 0.16), rgba(245, 166, 35, 0) 62%)` Two hits, both in the same file: a constant and a 16-per-cent amber glow behind the logo. #F5A623 is hsl(37, 91%, 55%), an unmissable warm orange, and it sits at 8.85:1 against our background, so it is a perfectly usable accent.
It is also the only accent colour this product owns, and the only place it is used is the image we hand to Twitter, Slack, WhatsApp and Google, a 91,693-byte PNG that no visitor to the site ever loads. A brand colour that exists exclusively in link previews is a strange thing to find in a repository. Either the site should use it or the card should not.
Surface three: the one email we send
PubTrivia sends exactly one transactional email of its own, the invite that lets a venue owner add a co-host. It is a table-layout HTML document with inline styles, because every email is, and here is its palette:
| Role | Hex | HSL | Contrast on the card |
|---|---|---|---|
| page background | #09090b | hsl(240, 10%, 4%) | |
| card background | #18181b | hsl(240, 6%, 10%) | |
| card border | #27272a | hsl(240, 4%, 16%) | 1.19 |
| heading | #fafafa | hsl(0, 0%, 98%) | 16.97 |
| body copy | #a1a1aa | hsl(240, 5%, 65%) | 6.91 |
| eyebrow label | #71717a | hsl(240, 4%, 46%) | 3.67 |
| footnote | #52525b | hsl(240, 5%, 34%) | 2.29 |
Every grey in that table has a hue of 240. Every grey in the stylesheet has no hue at all. This is the shadcn zinc scale, which is what the project started from, kept in a file that was written once and never revisited, while the site itself moved to neutral greys. Of the seven values, six appear nowhere else in the codebase. The seventh, #fafafa, is shared with both other surfaces, which is how the drift stayed invisible: the thing you look at first is the only thing that matches.
And two of the four text colours fail WCAG AA.
eyebrow "PUBTRIVIA", 13px, weight 600, uppercase 3.67:1 needs 4.5:1 footnote "This link expires in 7 days", 12px 2.29:1 needs 4.5:1 The footnote is the one that stings, because of what it says. "If you weren't expecting this, you can safely ignore it" and "This link expires in 7 days" is the security-relevant sentence in the entire message, set in the lowest-contrast grey in the document, at 12 pixels, at 2.29:1. The site's own equivalent grey manages 6.29:1 doing the same job.
For what it is worth, the email gets one thing deliberately right that looks like another inconsistency: its font stack is -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif rather than the Geist the site uses. Webfonts in email are a coin flip, so a system stack is the correct answer there, and that difference is a decision rather than a drift.
Why three palettes is not a stupid mistake
It is worth being precise about the cause, because "use design tokens" does not actually solve this.
A CSS custom property is resolved by a browser reading our stylesheet. Satori is not a browser and never loads the stylesheet; it reads inline style objects, so it needs literal values. An email client is a browser of sorts, but Gmail strips <style> blocks and var() support across clients is not something you bet an invite on, so it needs literal values too, inline, on every element.
So the palette must exist three times. That part is forced. What is not forced is that the three copies were allowed to disagree, which happened because nothing imports anything: og-image.tsx has its own constants and invite.ts has string literals buried in a template, and no test compares either of them to the token file.
What I am changing
- One exported hex palette. A small TypeScript module with the resolved hex for each token.
globals.csskeeps its HSL triplets, because shadcn's utilities need the bare triplet form, but the card and the email import from the module instead of spelling colours out. - A test that reads both. Parse the HSL triplets out of
globals.css, convert, and assert they match the module. That is a dozen lines, and it is the only thing that would have caught the zinc scale sitting in the email for months. - Raise the two failing greys. The footnote moves to the body-copy grey, which is 6.91:1 and already in the same document. Nothing in a transactional email needs to be quieter than that.
- Decide about the amber. Either it becomes a real token and appears on the site, or the card drops it. I lean towards keeping it and using it, because a product with no accent colour at all is a product whose buttons are all the same shade of white.
Check it yourself
The card is public, and so is the palette it half agrees with:
# the generated card, as the crawlers get it curl -s -o /tmp/card.png -w "%{http_code} %{content_type} %{size_download}\n" \ https://pub-trivia.app/opengraph-image # the site's own tokens, as shipped CSS=$(curl -s https://pub-trivia.app/ | grep -oE '/_next/static/chunks/[a-z0-9]+\.css' | head -1) curl -s "https://pub-trivia.app$CSS" | grep -o '\--background:[^;}]*' | head -3 Open the card in a tab and pick the glow behind the logo with any colour picker: it is the amber. Then open any page on the site and look for that colour anywhere. It is not there.
For the contrast numbers, this is the whole calculation, and it runs in any console:
const lum = h => { const c = [1,3,5].map(i => parseInt(h.substr(i,2),16)/255) .map(v => v <= 0.03928 ? v/12.92 : ((v+0.055)/1.055)**2.4); return 0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2]; }; const ratio = (a,b) => { const [x,y] = [lum(a),lum(b)].sort((p,q)=>q-p); return (x+0.05)/(y+0.05); }; ratio('#52525b','#18181b') // 2.29 the email's footnote ratio('#999999','#171717') // 6.29 the site's equivalent The email itself is the one surface you cannot inspect from outside, because inviting a co-host needs a plan with more than one account on it, and the free tier has exactly one. Everything else here is a curl away: the pricing page will tell you which plans those are, and the free tools are the fastest way to see the neutral palette doing real work with no account at all.

