DEV Community

Mohamed
Mohamed

Posted on Edited on

A logo at 1.00:1 contrast passed every check we had

Column-by-column contrast saves vanishing words

I run a service that publishes landing pages for other people's brands. Every page
gets the client's own logo in the header bar, and the bar comes in three colours:
white, the brand colour, or a neutral dark.

Picking the bar looked like the most trivial decision in the system. It produced
a bug that is invisible to every signal you would normally trust. The page renders.
The logo file is served. Every other gate is green. And half the client's name is
not there.

The case that broke the naive version

A client wordmark, transparent PNG: the word in white, a second word in the brand
blue, hyphen between them.

  • On the brand-blue bar, the blue word measured 1.15:1. Gone.
  • On the white bar, the white word measured 1.00:1. Perfectly gone.

Two bars, and each one erased a different half of the same logo. There was no
correct answer in the set, which is how the neutral third bar got built.

That is the first lesson: with two options it is easy to find one that scores
acceptably on average, and averages are exactly the wrong statistic here.

Why average contrast is the wrong measurement

The obvious implementation is: composite the logo over the bar, compute contrast
per pixel, average it, pick the winner.

It fails because it cannot tell these two situations apart:

  1. A whole word vanished.
  2. A faint interior highlight faded.

Both remove a similar quantity of low-contrast ink. Only one is a defect. Average
them into a single number and the word disappearing looks like a mild dip.

The difference is not in how much ink is lost, it is in where:

A word owns its horizontal slice of the mark — nothing else is in those columns.
A lost interior detail shares its columns with ink that still reads.

So you stop measuring pixels and start measuring columns.

The implementation

Composite over the bar (alpha included — this is what the eye actually receives),
then slice the mark's bounding box into full-height columns and ask which ones went
dark:

VIS_COLS         = 16     # full-height column slices across the mark's bbox
VIS_COL_MIN_INK  = 0.015  # a column holding <1.5% of total ink is noise, never judged
VIS_COL_LOST     = 0.12   # a judged column keeping <12% of its ink readable is a hole
VIS_MAX_LOST_INK = 0.15   # legible iff hole ink is <=15% of the mark...
VIS_MIN_VISIBLE  = 0.25   # ...and >=25% of all ink is CRISP overall

for y in range(h):
    for x in range(w):
        r, g, b, a = px[x, y]
        if a < 25:
            continue
        # What the eye gets: the pixel alpha-composited over the bar.
        f = a / 255.0
        comp = (r * f + bar[0] * (1 - f),
                g * f + bar[1] * (1 - f),
                b * f + bar[2] * (1 - f))
        c = contrast(comp, bar)
        ink.append((x, c >= INK_MIN_CONTRAST, c >= VIS_READABLE_FLOOR))
Enter fullscreen mode Exit fullscreen mode

Then bucket that ink by column and flag the holes:

lost_ink = sum(tot for tot, seen in cols.values()
               if tot >= VIS_COL_MIN_INK * total_ink   # ignore noise columns
               and seen / tot < VIS_COL_LOST)          # this column went dark
Enter fullscreen mode Exit fullscreen mode

Whitespace never enters the calculation — a column with no ink simply isn't there.
The VIS_COL_MIN_INK floor handles the case that is left: a column holding a few
stray anti-aliased pixels, where two of them fading would otherwise read as a hole.

WCAG 3:1 is the wrong floor here

My first threshold was WCAG's 3.0:1 for non-text contrast. It rejected logos that
were obviously fine on screen: a solid green icon at ~2.1:1, an orange one at
~2.2:1. Both perfectly readable.

WCAG's 3:1 is calibrated for thin UI strokes. A chunky solid mark carries far
more ink per glyph and survives well below it. So the floor is two-tier:

VIS_READABLE_FLOOR = 1.8
Enter fullscreen mode Exit fullscreen mode

A column only counts as lost when its ink cannot manage even 1.8:1 — truly gone
(white-on-white at 1.00, blue-on-blue at 1.15), not merely muted. The separate
"crisp" share still uses the strict threshold, so a muted mark scores low without
being failed outright.

Every threshold in that block is pinned by a calibration test against real rendered
headers. When you are hand-tuning constants against human perception, the test suite
is the only thing standing between you and re-breaking it in three months.

Opaque logos ask a completely different question

This part is counter-intuitive.

Everything above applies to transparent logos, where the bar shows through and
the question is can I still read the ink.

An opaque logo — one with its own baked-in background plate — is legible on
every bar by construction. The ink question is meaningless. The real question is
whether the bar matches the plate, because if it does not, the logo renders as
a visible pasted rectangle sitting on your header.

Judging an opaque logo by ink contrast doesn't merely fail to help. It actively
selects the wrong bar, because maximum ink contrast is often precisely the bar that
clashes hardest with the plate.

Three logo shapes, three different questions:

Logo Question Failure mode
Transparent Does the ink survive the bar? A word disappears
Opaque Does the bar match the plate? A pasted rectangle
None — Text wordmark, safe by construction

The takeaway

The reason I keep coming back to this one: it is a whole category of bug where
every automated signal is green and the output is still wrong. The page renders,
the logo file is served, every gate passes. The only detector is a human
looking at the header — which does not scale past the first few dozen pages, and
is exactly the step an unattended run skips.

The fix is never "check it by eye." It is finding the measurement that actually
encodes what your eye is doing, then pinning it with tests against ground truth you
verified once, by hand, properly.


I build SEOSellers,
where this check is one of eleven gates a page has to clear before it is
allowed to publish. First page and article are free if you want to see the output.

Top comments (7)

Collapse
 
beusebiu profile image
Eusebiu Balan •

Two bars each erasing a different half of the same wordmark is a nasty one, since an average contrast score over the whole logo would have called both of them fine. Judging the ink column by column gets around that.

I would have checked the file, seen it served, and moved on.

Collapse
 
mohamed_taha6608 profile image
Mohamed •

That was the misleading part: the file loaded and the page rendered, but neither check told us whether the name was readable. Checking each column caught what the average missed.

Collapse
 
to21as profile image
Tobias •

Slicing the bbox into columns and asking which ones went dark is what makes a lost word and a faded highlight different events. An average cannot separate them.

I work on the PDF accessibility side, where the same question is already settled and not in your favour: the Matterhorn Protocol splits ISO 14289-1's 136 failure conditions into 89 a machine can settle and 45 that need a human reading the document, and contrast is one of the 45. So every conformance checker passes the file your blue word vanished from, correctly. You moved one of those 45 into the 89 for one case.

Do the thresholds hold for a monogram, where the mark is mostly one glyph?

Collapse
 
mohamed_taha6608 profile image
Mohamed •

I haven't validated the thresholds on a dedicated monogram set, so I wouldn't claim they hold there.

If the whole glyph disappears, the overall visible-ink check should catch it. The harder case is a defining stroke disappearing while other strokes keep the same columns above the threshold. That could pass even though the glyph no longer reads correctly. The wordmark case benefits from words occupying separate horizontal regions; a monogram doesn't necessarily give me that separation.

I'd want monogram cases with missing strokes, checked at their actual header sizes, before trusting the same thresholds. And I'd describe this as catching a specific rendering failure, not moving a conformance check out of human review. The calibration already depends on human judgments.

Collapse
 
compoundlabs profile image
Compound Labs •

The threshold block leaves INK_MIN_CONTRAST undefined, even though it controls the total_ink denominator and which columns can be judged as holes. Is that floor fixed across rendered logo sizes?

Collapse
 
mohamed_taha6608 profile image
Mohamed •

You're right, I should have included that line in the excerpt.

INK_MIN_CONTRAST is fixed at 3.0 in the code. I use it as the strict/crisp floor, basically the WCAG non-text contrast floor. The softer 1.8 floor is only for deciding whether a column has truly disappeared.

So the split is:

  • >= 3.0 counts as crisp visible ink
  • >= 1.8 counts as still readable enough that the column didn't vanish
  • below 1.8 is treated as lost ink

It isn't adjusted per rendered logo size. The size handling happens earlier: the logo is trimmed first, then measured in its rendered/header-sized form. After that, the column rules are what stop tiny anti-aliased leftovers from dominating the result: a column has to carry at least 1.5% of total ink before it's judged, and it only becomes a hole when less than 12% of that column's ink clears the readable floor.

The reason I kept 3.0 fixed is that changing the contrast floor by size made the result harder to reason about. Size already affects how much ink survives in each column; I didn't want the definition of "visible" moving at the same time.

Collapse

Some comments have been hidden by the post's author - find out more