Nakodo has 76 public pages, all of them built from the same colour tokens. I have written about the 866 lines of CSS those tokens live in and about the design report that chose them, but I had never actually measured a single contrast ratio. I just assumed the palette was fine, which is the same as not knowing.

So I measured every piece of text on all 76 pages. Here is what came back.

  • 13,403 text runs, of which 15 are invisible on purpose
  • 2,745 distinct combinations of colour, background, size, weight and tag
  • 25 distinct foreground-on-background pairs in the whole site
  • 13,386 of 13,388 visible runs pass WCAG AA
  • 9,257 of them, 69.1 per cent, reach AAA
  • one failure, at 3.48:1, on a 12px badge that appears twice on the home page

Getting a real sRGB value out of the browser

The first problem is that nothing in this app is written in a colour space you can do arithmetic on. The stylesheet declares oklch(), and getComputedStyle in Chrome hands back lab():

"--color-ink-500": { "css": "lab(56.4751% -1.49497 -5.74161)", "hex": "#818991" }, "--muted-foreground": { "css": "lab(42.5395% -1.93667 -7.83916)", "hex": "#5c6671" }, "--foreground": { "css": "lab(13.4665% -2.01512 -11.7954)", "hex": "#162433" }, "--background": { "css": "lab(96.7997% .35131 4.58077)", "hex": "#f9f5ed" } 

The WCAG formula wants sRGB channels. Rather than implement Lab to sRGB and get it subtly wrong, let the browser do it: paint the colour onto a 1x1 canvas and read the pixel back. Canvas accepts any colour syntax the CSS parser does, and getImageData is sRGB bytes by definition.

const cv = document.createElement("canvas"); cv.width = cv.height = 1; const ctx = cv.getContext("2d", { willReadFrequently: true }); const rgba = (c) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = "rgba(0,0,0,0)"; ctx.fillStyle = c; // ignored if the parse fails, hence the reset above ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2], d[3] / 255]; }; 

The fillStyle reset matters: an unparseable colour leaves the previous value in place, so without it one bad string silently colours every later measurement.

The background is not a property, it is a stack

The second problem is that backgroundColor is rgba(0, 0, 0, 0) on nearly every element that holds text. The real background is whichever ancestor last painted one, with every semi-transparent layer and every inherited opacity on the way down applied in order.

const bgOf = (el) => { const stack = []; for (let n = el; n; n = n.parentElement) { const s = getComputedStyle(n); const c = rgba(s.backgroundColor); const op = parseFloat(s.opacity); if (c[3] > 0) stack.push([c[0], c[1], c[2], c[3] * (isNaN(op) ? 1 : op)]); } let base = [255, 255, 255, 1]; // the canvas under the document for (let i = stack.length - 1; i >= 0; i--) base = over(stack[i], base); return base; }; 

Then the text colour is composited over that result, inherited opacity included, and only then is the ratio taken. On this site that detail changes real numbers: the same muted grey measures 5.37:1 on the page background and 5.74:1 inside a card, because the card is a slightly lighter cream.

25 pairs for a whole site

Counting by text runs, these are the ones that matter:

 7.56 #44505e on #f9f5ed 2,965 runs body text 14.47 #162433 on #f9f5ed 2,862 runs headings 10.98 #2b3847 on #f9f5ed 2,393 runs strong body text 5.37 #5c6671 on #f9f5ed 2,271 runs muted, captions, footer 6.34 #005ba6 on #f9f5ed 1,195 runs links 15.46 #162433 on #fefdf9 605 runs headings in cards 5.74 #5c6671 on #fefdf9 524 runs muted in cards 8.66 #f9f5ed on #004783 171 runs text on the dark plate 9.26 #fefdf9 on #004783 153 runs headings on the dark plate 5.69 #a1cdff on #004783 86 runs muted on the dark plate 

Twenty-five pairs, built from about eight token values, is the entire visible range of a site with 76 pages on it. That is the actual product of having no hex codes in the CSS: you cannot introduce a twenty-sixth pair by accident, because there is nowhere to type one.

The muted pair is the interesting row. 5.37:1 clears AA comfortably and misses AAA, and it is used for 2,271 runs of exactly the text a design system is tempted to make quiet: captions, table headers, the footer, "Prices in EUR, including any VAT or sales tax", the line under every heading. Going to AAA would mean darkening one token and losing the distinction between muted and body text, which is the thing it exists for. I am leaving it, and now I know the number instead of assuming it.

The one failure

 3.48 #818991 on #fefdf9 12px w400 SPAN "Email" 2 runs nakodo.app/ 

It is the "Email, not published" badge in the example creator cards:

state === "none" && "border border-dashed border-ink-400 text-ink-500", 

A dashed outline and text-ink-500 for an absent value. It reads as deliberately faint, which was the intention, and 3.48:1 at 12px is below the 4.5:1 that normal text needs.

The fix is not obviously "darken it", because of where else that token is:

text-ink-500 3 call sites: components/ui/input.tsx placeholder:text-ink-500 components/ui/textarea.tsx placeholder:text-ink-500 components/brand/contact-strip.tsx state === "none" --input: var(--color-ink-500) the border of every input and textarea 

So the same value does three jobs, and it is measured against two different bars. As a border it is non-text content and needs 3:1, and 3.48:1 passes. As placeholder text it needs 4.5:1 and fails, on every input in the app, which you can check on the home page: the hero box has a placeholder reading yourbrand.com, and here is what it measures live.

{ "id": "hero-site", "placeholder": "yourbrand.com", "phColor": "#818991", "bg": "#fefdf9", "ratio": 3.48, "borderRatio": 3.48 } 

One token, two thresholds, one pass and one fail. Placeholder text is not exempt from 1.4.3; only disabled controls and genuinely decorative text are. The honest fix is to stop using the border colour as a text colour: keep ink-500 for borders, and give absent values and placeholders ink-600 or the muted token, both of which already exist and both of which clear 4.5:1.

Thirteen colour pairs that do not exist

My first sweep found 38 distinct pairs and three failures, two of them at 1.30:1 and 2.13:1 on a header link reading "Open the app", on two unrelated pages. I nearly wrote the post around them.

They were not real. The header button carries transition: color 0.15s, background-color 0.15s, and getComputedStyle during a transition returns the interpolated value, so I had caught the same button twice at different points in a 150ms fade. Waiting two seconds after load before measuring dropped the pair count from 38 to 25 and the failures from three to one.

That is the part of this I would tell anyone writing a contrast audit. A computed colour is not a declaration, it is whatever is on screen in that frame, and an audit that does not settle the page first measures the animation.

Fifteen text runs came back at exactly 1.00:1, all three groups of them on the home page: "Reasons, not just a score", "1/5", "Next". Those are the tour notes that are always in the DOM at zero opacity, waiting for the tour to start. Ratio 1.00 is the signature of opacity zero, and it is worth special-casing rather than reporting, because the alternative is an audit that flags every tooltip in the world.

The real accessibility problem is not contrast

The size histogram, in text runs:

 5.5px 1 11px 696 12px 815 13px 423 14px 5,534 15px 4,905 17px 82 22px 245 26px 547 

1,511 runs, 11 per cent of the text on the site, are 12px or smaller, and all of them pass contrast. Contrast was the thing I was worried about and size is the thing I should have been worried about. 11px is the footer, the table headers and the "On this page" labels, and those are all genuinely secondary, but 11px is small enough that it is a reading problem for a lot of people at any contrast ratio.

The smallest text on the site is a single 5.5px SVG label reading "No. 001", the plate number on a hand-drawn illustration. It measures 8.66:1, which is both pleasing and completely irrelevant, since nobody is reading it. Decorative text is exempt and this is the only element in the audit I would call decorative.

What I am doing about it

Three things, in order:

  1. Stop using ink-500 as a text colour. Two placeholder classes and one badge state move to ink-600, and the token keeps its border job. One commit, two measurable numbers.
  2. Leave the 5.37:1 muted token alone, and write the number down in the design notes next to it, so the next person to pick that colour for a smaller size knows what they are spending.
  3. Look at the 11px cases properly, as a type-scale question rather than a colour question.

Everything above was measured from the public pages, so you can check any of it: open the home page, pricing, the glossary or a guide, paste the canvas trick into the console, and compare. The two numbers I would most like somebody to argue with are the 5.37 and the 3.48.