DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three of the five text colours in our alert emails fail WCAG AA, and the one that tells you which search it came from is 2.29:1

Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. The alert is the product. The app finds the listing, posts to the server, and the server sends an email, which for most users is the thing they actually look at.

That email is built by one module, server/src/lib/emails/alert.ts, which I have written about before: four alert emails, eight functions, and a five-line HTML escaper. That post was about the structure. This one is about the colours, because I measured them this morning and the result is worse than I expected in a specific and embarrassing way.

The whole palette is seven hex strings

$ grep -ro "#[0-9a-f]\{6\}" src/lib/emails/alert.ts | sort -u
#0a0a0a  #171717  #262626  #404040  #525252  #a3a3a3  #fafafa
Enter fullscreen mode Exit fullscreen mode

Two of those are backgrounds: #0a0a0a is the page, #171717 is the card the content sits on. #262626 draws the rules between sections. The other four are text, and every one of them is a literal inside a template literal, with its font size right next to it:

$ grep -o "font-size:[0-9]*px[^\"]*color:#[0-9a-f]\{6\}" src/lib/emails/alert.ts | sort | uniq -c
   1 font-size:11px;...;text-transform:uppercase;color:#525252
   4 font-size:11px;...;text-transform:uppercase;color:#525252
   4 font-size:12px;color:#404040
   6 font-size:12px;color:#525252
   4 font-size:22px;font-weight:700;color:#fafafa
Enter fullscreen mode Exit fullscreen mode

Nineteen text nodes with a size and a colour declared in the same breath, which is the easiest possible thing to check. So I checked it. Relative luminance, WCAG 2.1 formula, every text colour against the card it sits on:

Ratio AA Colour Size What it is
17.18:1 pass #fafafa on #171717 22px bold the headline, "7 New Listings Found"
17.18:1 pass #fafafa on #171717 14px 600 the listing title, which is the link
7.11:1 pass #a3a3a3 on #171717 14px body paragraphs
2.29:1 fail #525252 on #171717 11px bold caps the word "Notifio" at the top
2.29:1 fail #525252 on #171717 11px bold caps the search name each group sits under
2.29:1 fail #525252 on #171717 12px the listing URL under each title
2.29:1 fail #525252 on #171717 12px the one instruction in the email
1.73:1 fail #404040 on #171717 12px "Notifio, notifio.app" in the footer

AA wants 4.5:1 for body text and 3:1 for large text, where large means 18.66px bold or 24px regular. Exactly one thing in these emails is large text: the 22px bold headline, which is #fafafa and would pass either way. Everything else is held to 4.5:1, and three of the five text colours do not get a third of the way there.

The non-text pairs are worse and matter less. #262626 rules on #171717 are 1.18:1, and the card against the page is 1.10:1. Those are decorative, nothing depends on seeing them, and I am not going to pretend otherwise.

The two that actually cost the user something

The headline is fine. The link is fine. Nobody was ever going to fail to find the thing they clicked.

The first one that matters is the group header. A Notifio user can watch up to fifteen searches, and when more than one of them turns something up in the same cycle, they all arrive in one email, grouped by search:

<span style="font-size:11px;font-weight:700;letter-spacing:0.08em;
             text-transform:uppercase;color:#525252;">
  ${escHtml(alert.siteName)}
</span>
Enter fullscreen mode Exit fullscreen mode

That siteName is the only thing in the message that says which of your searches a listing came from. It is 11 pixels, uppercased, letter-spaced, and 2.29:1. The listing titles underneath it are 17.18:1 and 600 weight. So the email is at its most legible exactly where every row looks the same, and at its least legible on the one line that distinguishes them.

The second is the footer instruction:

<p style="margin:0 0 8px 0;font-size:12px;color:#525252;">Getting this in
Promotions? Move it to Primary so you never miss a listing.</p>
Enter fullscreen mode Exit fullscreen mode

That is the only sentence in the entire email that asks the reader to do something, and the something is "make sure you can keep receiving these". Gmail does route Notifio alerts to Promotions sometimes. The help page takes that seriously enough to spend two numbered walkthroughs on it, one for moving the thread to Primary and one for turning on notifications for all tabs, at full contrast on a page nobody reads until something has already gone wrong.

The email, which arrives at the moment the advice is useful, whispers it at 2.29:1.

Where the palette came from, and why that is not an excuse

These greys are the website's greys. #525252 is all over the help page and the app's own UI as a Tailwind arbitrary value, and the emails were built to match. On the site it sits on #0a0a0a instead of #171717, where it measures 2.53:1, so it fails there too. I have written about the site's contrast failures and why measuring them needed a canvas, and the honest summary of that post is that the site's values are computed through enough layers that you have to render them to know.

The emails are the opposite. There is no cascade, no utility class, no theme indirection, no runtime. An HTML email cannot have any of that, which is why these files are 4KB template literals full of inline styles. The colour and its background are two hex strings in the same string literal, eleven lines apart.

So the one surface where contrast is trivially checkable is the one surface with no check on it:

$ ls __tests__/**/*.test.ts
__tests__/pricing/display-currency.test.ts
__tests__/seo/indexnow.test.ts
__tests__/seo/metadata-length.test.ts
__tests__/stripe/checkout-session.test.ts
__tests__/stripe/managed-payments.test.ts

$ grep -ril "contrast\|luminance\|wcag" __tests__/ | wc -l
0
Enter fullscreen mode Exit fullscreen mode

Five test files. One of them walks every page on the site to enforce a 60 character title budget, and three separate tests enforce the house ban on one punctuation mark. A punctuation rule has three tests. A legibility rule has none.

The part I cannot test from the repo

One caveat I want to be straight about, because it is the reason this is harder than it looks.

An email cannot rely on prefers-color-scheme. Gmail, Outlook and Apple Mail each apply their own dark-mode handling, which ranges from leaving inline styles alone to inverting backgrounds while keeping text colours, which can turn a deliberately dark palette into dark grey on near-white. Our emails ship one hardcoded dark palette and hope. Measuring the ratios in the source tells you what we specified, not what every client renders, and no amount of unit testing reaches the latter.

What that argues for is a bigger margin, not a smaller one. A 2.29:1 pair has nowhere to go if a client nudges it. A 4.5:1 pair survives being nudged.

The fix is two hex strings and a test

On #171717, #808080 is the first flat grey that reaches 4.5:1, at 4.54. Taking both failing colours up to it is still clearly secondary against a #fafafa title at 17:1, so nothing about the hierarchy changes. The group header could also just stop being 11px uppercase letter-spaced grey, which is three separate legibility taxes on the one line that carries the information.

Then the test, which is about fifteen lines: pull every color:#xxxxxx out of each template with its enclosing background, compute the ratio, and fail under 4.5. The emails are strings, so it needs no browser, no canvas and no rendering. That is the whole reason this one is annoying. It was always the cheap one to check.

You can read the greys yourself. The email you get from a real alert is the same template, and the help page is where you can see the same sentence written in a colour you can actually read.

Top comments (0)