A little while back somebody wrote in about AntiFreeze via the Signal support channel:
Hi! Thanks for working on antifreeze! I’m having trouble with using the dark theme due to my vision. Is there a way for me to change the color scheme?
I’ll call him J. That’s not his name, though. This is an app that people trust with information that could get them hurt. The person who reported a bug doesn’t stop deserving that because their story ended up making them look good.
Below you will find the entire conversation, including the areas where I was wrong (but confidently so), because those are the areas that actually taught me something…
I fixed the wrong thing
When J’s message came in, I thought I knew what he meant.
People had complained about the map before. AntiFreeze has a dark navy UI with a dark map sitting in the middle of it, and some of the street names could be hard to make out. So I had assumed that was the problem and told him I’d work on it.
There was already a problem with the map, to be fair. A problem I had supposedly “fixed” months earlier… The map used to look kind of muddy, so at some point I had turned the brightness up and the contrast down. In my head, that made sense.
But… It did not make sense.
Lowering the contrast pulled the blacks toward gray and compressed everything toward the middle. So while the brightness adjustment was trying to make things easier to see, the contrast adjustment was basically undoing it. I had managed to make the map look more washed out without actually making the useful parts much easier to read.
And then I apparently looked at that result and went, yep, good enough.
There was another problem too. The map tiles I use have the street names baked into the same image as the roads, land, everything else. I had been treating the whole thing as one image and applying one filter to it.
That doesn’t work very well when the street labels and the background need almost opposite treatment.
I finally pulled apart one of the tiles and looked at the actual values. The street names were already being delivered as gray, roughly around the middle of the black-to-white range, with no outline behind them. They weren’t secretly white labels that my CSS had somehow darkened. They were just gray.
So every time I brightened the whole map to help the labels, I brightened the ground underneath them too.
The answer ended up being pretty simple: stop treating the labels and the rest of the map as the same image. Roads and land on one layer. Street names on another. Tune them separately.
I wish I could say I reasoned my way to that immediately. I didn’t, though. I actually found it by taking the thing apart and measuring what was actually there.
Anyway… I spent most of a day fixing all of that, felt fairly accomplished, and asked J to try it again.
He wrote back:
The problem I experience is due to the low contrast in the sighting details. I cannot read the time or location.
Oh, whoops…
Not the map…
The list…
The screen where somebody is trying to figure out whether an ICE vehicle was reported twenty minutes ago or yesterday.
Then I actually measured it
There is a published accessibility standard for text contrast, WCAG. For normal body text, the minimum contrast ratio is 4.5:1.
Checking a pair of colors takes maybe ten seconds.
I had simply never done it.
The timestamp J couldn’t read measured 2.88:1.
Some of the faintest labels elsewhere in the app were 1.99:1.
That changed the problem for me pretty quickly.
Until then, I had been thinking of this as something specific to J. He mentioned his vision, so I mentally put the problem in the category of “this user needs an accessibility option.”
Which sounds considerate until you realize what it means in practice: the app is allowed to stay broken, and the person having trouble with it gets a special setting.
The numbers made that harder to rationalize.
The text was below the standard. Not below some special standard for people with low vision. Just below the standard.
J happened to be the person who told me.
And once I realized that, I started wondering how many people had opened AntiFreeze, struggled to read something, and simply closed it instead of emailing me.
I almost built a toggle
My original plan was to add a high-contrast mode. I was just going to put a switch in Settings. Leave the normal theme alone, because I didn’t want the whole app turning into bright white text everywhere, and then people who needed extra contrast could turn it on.
I genuinely thought that was the thoughtful solution. But the more I thought about it, though, the less I liked it.
First, you have to know the option exists. Then you have to know where to find it. Then you have to recognize that the problem is the app and not your eyes, your screen, the lighting, or whatever else.
And a lot of the people using AntiFreeze are not sitting at a desk carefully configuring an app.
Maybe it’s somebody checking a report while sitting in their car.
Maybe it’s somebody’s grandmother opening the app twice a day.
Maybe somebody is outside in sunlight on an old phone with a scratched screen.
Those people should not have to discover the right settings menu before the basic information becomes readable.
I kept thinking about curb cuts. They were built for a specific accessibility need, but everyone with a stroller, suitcase, shopping cart, or bad knee benefits from them too. More importantly, nobody has to request one every time they reach the sidewalk.
I already knew that argument. I probably would have made it myself if we were talking about somebody else’s software.
Then somebody pointed out the accessibility problem in my software and my first instinct was basically, “Maybe I can build the software equivalent of a ramp you have to call ahead and request.”
So I decided not to do the toggle.
It was the default that was the thing that needed fixing.
The problem was everywhere
J reported one screen.
Once I started looking, I found the same pattern all over my app.
There were 332 places across 29 files where I had dimmed text by lowering its opacity.
Some of those were fine. Some absolutely were not.
I didn’t want to just brighten everything because that really would flatten the interface. Not every piece of text should scream at you at the same volume.
So I went through them based on what they actually did.
Text somebody needs to read:timestamps, addresses, descriptions, labels — had to clear 4.5:1.
Icons that perform a function:chevrons, clocks, things like that — had a 3:1 requirement, so they could stay visually quieter.
Decorative stuff: like the large faded icons on empty-state screens that already have readable labels beside them — stayed faded.
The rule became: if it passes, leave it alone. If it fails, move it only as far as it needs to move.
By the end, 263 colors had changed.
One of the things I had worried about was losing all of the visual hierarchy. I like having secondary information look secondary. I didn’t want every timestamp and metadata label to have the same visual weight as the main content.
That turned out to be much less of a problem than I expected.
The timestamp J originally couldn’t read went from 2.88:1 to 5.33:1, and it still looks like secondary text. It is still visibly dimmer than the white text above it.
Apparently “secondary” and “barely readable” did not need to be synonyms. Who knew.
There was one red I had been using for text that couldn’t be rescued at all. Even at full opacity it only reached 4.16:1 against the navy background.
No amount of opacity tweaking was going to fix it. It was simply the wrong color for body text on that background.
I had been using it anyway.
It still works for certain icons, where the threshold is lower, but not for text.
That was another one I probably could have stared at for a year without realizing. The measurement made it obvious immediately.
I also managed to make the navigation worse
This was probably my favorite bug from the whole process.
The bottom navigation originally showed the active tab with a vivid blue and the inactive tabs with a lighter blue at lower opacity.
The inactive tabs measured 2.63:1, so they needed to come up.
I increased their contrast.
And suddenly the inactive tabs looked more active than the active tab.
The color I had chosen for the inactive state was technically a lighter blue. It only looked subdued because I had made it transparent. Once it became opaque enough to pass, all of those supposedly inactive tabs got brighter than the selected one.
So I had successfully fixed the accessibility problem by making the navigation confusing in an entirely different way.
The answer was to stop trying to communicate everything through brightness.
Inactive tabs are neutral slate now. The active tab stays blue. Their brightness is almost identical. 7.07 versus 7.13. And the state difference comes mostly from hue instead.
Which is also, now that I think about it, what the tab bars on the phones I use every day already do.
Gray when inactive. Color when active.
I could have looked down at my own phone and saved myself some trouble.
There is now a comment in that part of the code explaining why it works that way, mostly so future me doesn’t wander in six months from now and “clean it up” back into several shades of blue.
Some things did change
I don’t want to pretend there was zero tradeoff.
There used to be about five different levels of faint text throughout the app. There are fewer now.
Some of those tiny differences in intensity disappeared because anything below the readability floor had to come up. So the hierarchy relies more heavily on font size, weight, and the difference between white, blue, and neutral text.
That is a real visual change.
The map is darker now too. Roads and street names are much easier to distinguish, but if somebody preferred the softer, hazier version, they’ll notice.
There is also one text color sitting at 4.53:1 against a 4.5 minimum. That technically passes, but barely. If I ever lighten that background, it stops passing.
And I still didn’t add a high-contrast toggle.
If I ever do add a stronger contrast mode, I’d rather connect it to prefers-contrast, so somebody who has already told their operating system that they need more contrast doesn’t have to tell AntiFreeze separately.
That makes a lot more sense to me than burying another switch in Settings.
The part that bothers me
I’ve been building web software for years.
I knew accessibility mattered. I had read about contrast requirements. If somebody had asked me whether I cared about accessibility, I would have said yes, and I would have meant it.
And I still shipped a safety app where somebody with low vision couldn’t read when an ICE vehicle had been reported near him.
That’s the part I keep coming back to.
There is a pretty big gap between agreeing with a value and building habits around it.
In this case, the habit I was missing was almost embarrassingly small: open a free contrast checker and spend ten seconds testing the colors I’m shipping.
I knew the tool existed. I had known for years.
I just hadn’t been using it on my own work.
We talk a lot about systems excluding people, and we should. But exclusion doesn’t always arrive as some huge deliberate policy decision.
Sometimes it is a CSS value.
Sometimes it is Tuesday afternoon and you type opacity: 0.45 because it looks nice on your monitor.
Nobody sits there and says, “I have decided people with poor vision are not part of the audience for this.”
But if they can’t use what you built, the end result doesn’t care whether you made the decision consciously.
That feels especially important for projects like AntiFreeze.
A lot of movement infrastructure is software now. Rapid response tools. Know-your-rights sites. Mutual aid systems. Community alert apps.
And a lot of it gets built fast, often by a tiny number of people who also have jobs and families and everything else going on.
When you’re moving quickly, you naturally build around the person you have in your head.
The problem is that the actual community includes disabled people. Elderly people. People using old phones. People standing outside in terrible lighting. People with cracked screens. People who are stressed and trying to get an answer quickly.
If the tool only works well for the person I unconsciously pictured while building it, then I didn’t really build it for the whole community.
So I pushed the contrast changes and asked J to completely close the app and reopen it so I could be sure he picked up the new version.
He wrote back:
Thank you! I can read it, now!
That was it.
Four words and an exclamation point.
Probably the best message I got all week!
If you use AntiFreeze and something is difficult to read, confusing, broken, or just doesn’t work for you, please tell me!
You’re not bothering me. You’re not asking for some special favor.
J made the app better for thousands of other people by messaging a stranger twice.
And, importantly, it was the second message that actually got me to fix the right thing.
Where AntiFreeze is now
A little straight talk about the project itself.
AntiFreeze is at roughly 12,600 users now.
It is free. There are no ads. There are no accounts. I don’t sell user data because I deliberately built the app so there isn’t much user data for me to have in the first place.
That isn’t changing.
What has changed is the amount of time this thing takes.
I have my regular job, and then there’s AntiFreeze: support, conversations with partner organizations, coding, infrastructure, notifications, and occasionally spending an afternoon discovering that hundreds of CSS values I wrote are wrong.
I’m not complaining. I chose to build it.
But it has basically become another job.
And unlike the first one, this one sends me the bill.
Servers cost money. Infrastructure costs money. Sending notifications to thousands of phones costs money. Right now, that comes out of my pocket every month.
There is another part of this that I don’t really know how to make cute or subtle, so I’m not going to try.
I run a tool that lets immigrant communities and their neighbors warn one another about federal immigration enforcement activity.
That can make you unpopular with people who have considerably more resources than you do.
I’ve taken the precautions I can. AntiFreeze is protected through Cloudflare’s Project Galileo. I’ve had review from EFF. I’ve also been in contact with the First Amendment Clinic at Vanderbilt.
What I don’t have is a legal defense fund.
If somebody ever decides they want to make an example out of me, I’d like the deciding factor in whether I can defend the project to be the merits of the case, not whether I personally have enough money sitting in a bank account to hire a lawyer.
So if you can help…
GoFundMe: https://www.gofundme.com/f/antifreeze-keep-the-ice-watchdog-network-running
Crypto: https://www.gofundme.com/f/antifreeze-keep-the-ice-watchdog-network-running
If you can’t, that’s genuinely fine. Forward this to somebody who’d want the app. That helps too.
Thanks for reading!
And thanks, J!

Top comments (1)
Spot-on architectural analysis! Handling edge cases and connection saturation gracefully is where the real engineering complexity lives. Excellent write-up! 🛡️💻