<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohamed</title>
    <description>The latest articles on DEV Community by Mohamed (@mohamed_taha6608).</description>
    <link>https://dev.to/mohamed_taha6608</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4112267%2F90bd7489-6712-4a6b-afb1-f3c7f834842a.jpg</url>
      <title>DEV Community: Mohamed</title>
      <link>https://dev.to/mohamed_taha6608</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohamed_taha6608"/>
    <language>en</language>
    <item>
      <title>A logo at 1.00:1 contrast passed every check we had</title>
      <dc:creator>Mohamed</dc:creator>
      <pubDate>Fri, 18 Sep 2026 23:01:56 +0000</pubDate>
      <link>https://dev.to/mohamed_taha6608/a-logo-at-1001-contrast-passed-every-check-we-had-45ja</link>
      <guid>https://dev.to/mohamed_taha6608/a-logo-at-1001-contrast-passed-every-check-we-had-45ja</guid>
      <description>&lt;p&gt;I run a service that publishes landing pages for other people's brands. Every page&lt;br&gt;
gets the client's own logo in the header bar, and the bar comes in three colours:&lt;br&gt;
white, the brand colour, or a neutral dark.&lt;/p&gt;

&lt;p&gt;Picking the bar looked like the most trivial decision in the system. It produced&lt;br&gt;
a bug that is invisible to every signal you would normally trust. The page renders.&lt;br&gt;
The logo file is served. Every other gate is green. And half the client's name is&lt;br&gt;
not there.&lt;/p&gt;
&lt;h2&gt;
  
  
  The case that broke the naive version
&lt;/h2&gt;

&lt;p&gt;A client wordmark, transparent PNG: the word in white, a second word in the brand&lt;br&gt;
blue, hyphen between them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the &lt;strong&gt;brand-blue bar&lt;/strong&gt;, the blue word measured &lt;strong&gt;1.15:1&lt;/strong&gt;. Gone.&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;white bar&lt;/strong&gt;, the white word measured &lt;strong&gt;1.00:1&lt;/strong&gt;. Perfectly gone.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;That is the first lesson: with two options it is easy to find one that scores&lt;br&gt;
acceptably &lt;em&gt;on average&lt;/em&gt;, and averages are exactly the wrong statistic here.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why average contrast is the wrong measurement
&lt;/h2&gt;

&lt;p&gt;The obvious implementation is: composite the logo over the bar, compute contrast&lt;br&gt;
per pixel, average it, pick the winner.&lt;/p&gt;

&lt;p&gt;It fails because it cannot tell these two situations apart:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A whole &lt;strong&gt;word&lt;/strong&gt; vanished.&lt;/li&gt;
&lt;li&gt;A faint interior &lt;strong&gt;highlight&lt;/strong&gt; faded.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The difference is not in &lt;em&gt;how much&lt;/em&gt; ink is lost, it is in &lt;strong&gt;where&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A word owns its horizontal slice of the mark — nothing else is in those columns.&lt;br&gt;
A lost interior detail shares its columns with ink that still reads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So you stop measuring pixels and start measuring &lt;strong&gt;columns&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VIS_COLS&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;     &lt;span class="c1"&gt;# full-height column slices across the mark's bbox
&lt;/span&gt;&lt;span class="n"&gt;VIS_COL_MIN_INK&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.015&lt;/span&gt;  &lt;span class="c1"&gt;# a column holding &amp;lt;1.5% of total ink is noise, never judged
&lt;/span&gt;&lt;span class="n"&gt;VIS_COL_LOST&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;   &lt;span class="c1"&gt;# a judged column keeping &amp;lt;12% of its ink readable is a hole
&lt;/span&gt;&lt;span class="n"&gt;VIS_MAX_LOST_INK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;   &lt;span class="c1"&gt;# legible iff hole ink is &amp;lt;=15% of the mark...
&lt;/span&gt;&lt;span class="n"&gt;VIS_MIN_VISIBLE&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;   &lt;span class="c1"&gt;# ...and &amp;gt;=25% of all ink is CRISP overall
&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# What the eye gets: the pixel alpha-composited over the bar.
&lt;/span&gt;        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;
        &lt;span class="n"&gt;comp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;comp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;INK_MIN_CONTRAST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;VIS_READABLE_FLOOR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bucket that ink by column and flag the holes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lost_ink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;VIS_COL_MIN_INK&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;total_ink&lt;/span&gt;   &lt;span class="c1"&gt;# ignore noise columns
&lt;/span&gt;               &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;VIS_COL_LOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# this column went dark
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whitespace never enters the calculation — a column with no ink simply isn't there.&lt;br&gt;
The &lt;code&gt;VIS_COL_MIN_INK&lt;/code&gt; floor handles the case that is left: a column holding a few&lt;br&gt;
stray anti-aliased pixels, where two of them fading would otherwise read as a hole.&lt;/p&gt;
&lt;h2&gt;
  
  
  WCAG 3:1 is the wrong floor here
&lt;/h2&gt;

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VIS_READABLE_FLOOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  Opaque logos ask a completely different question
&lt;/h2&gt;

&lt;p&gt;This part is counter-intuitive.&lt;/p&gt;

&lt;p&gt;Everything above applies to &lt;strong&gt;transparent&lt;/strong&gt; logos, where the bar shows through and&lt;br&gt;
the question is &lt;em&gt;can I still read the ink&lt;/em&gt;.&lt;/p&gt;

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

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

&lt;p&gt;Three logo shapes, three different questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Logo&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transparent&lt;/td&gt;
&lt;td&gt;Does the ink survive the bar?&lt;/td&gt;
&lt;td&gt;A word disappears&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opaque&lt;/td&gt;
&lt;td&gt;Does the bar match the plate?&lt;/td&gt;
&lt;td&gt;A pasted rectangle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Text wordmark, safe by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

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

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




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://seosellers.com/free?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto-header-contrast" rel="noopener noreferrer"&gt;SEOSellers&lt;/a&gt;,&lt;br&gt;
where this check is one of eleven gates a page has to clear before it is&lt;br&gt;
allowed to publish. First page and article are free if you want to see the output.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>a11y</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
