<?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: Chris Morris</title>
    <description>The latest articles on DEV Community by Chris Morris (@chris_morris).</description>
    <link>https://dev.to/chris_morris</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%2F4004554%2F7e888014-decb-4ea7-a10b-eeb7edff7ab6.jpg</url>
      <title>DEV Community: Chris Morris</title>
      <link>https://dev.to/chris_morris</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chris_morris"/>
    <language>en</language>
    <item>
      <title>Resolving color contrast over CSS gradients</title>
      <dc:creator>Chris Morris</dc:creator>
      <pubDate>Mon, 20 Jul 2026 17:23:34 +0000</pubDate>
      <link>https://dev.to/chris_morris/resolving-color-contrast-over-css-gradients-3ke9</link>
      <guid>https://dev.to/chris_morris/resolving-color-contrast-over-css-gradients-3ke9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, September 2026.&lt;/strong&gt; The central claim in the original version of this post was wrong, and I have rewritten it. I said the worst-case contrast across a gradient always sits at one of its colour stops. That holds for light text and fails for dark text, because a blend of two colours can be darker than either one. My own scanner measured gradients this way and reported passes it should not have. The corrected method is below, along with the counterexample that found it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Run an automated accessibility check on a modern landing page and the headline often comes back as "needs review" rather than pass or fail. The usual reason is a gradient, a background photo, or a scrim over one. Here is why that happens, and how each of them gets resolved into an actual pass or fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gradients become a blind spot
&lt;/h2&gt;

&lt;p&gt;The contrast check in &lt;a href="https://github.com/dequelabs/axe-core" rel="noopener noreferrer"&gt;axe-core&lt;/a&gt; compares the colour of text against the colour behind it. To do that reliably it reads the computed &lt;code&gt;background-color&lt;/code&gt; of the element and its ancestors, a single solid value it can reason about.&lt;/p&gt;

&lt;p&gt;A CSS gradient is not a solid colour. It is set through &lt;code&gt;background-image: linear-gradient(...)&lt;/code&gt;, so when text sits directly on one, axe has no single background value to measure against. Rather than guess, it does the responsible thing and returns the result as &lt;strong&gt;incomplete&lt;/strong&gt;, the "needs review" state. That is correct, but it has an awkward side effect: the text it punts on is frequently the &lt;em&gt;most prominent&lt;/em&gt; text on the page, the hero headline sitting on a colourful gradient banner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why checking the stops is not enough
&lt;/h2&gt;

&lt;p&gt;A gradient is fully defined by its colour stops, so the obvious shortcut is to measure the text against each stop and take the worst result. That is what I originally built, and what this post originally recommended. For light text it holds. For dark text it can pass a gradient that fails.&lt;/p&gt;

&lt;p&gt;The reason is how browsers blend. Between two stops, a gradient written with ordinary colours mixes the red, green and blue values as they are stored, not as amounts of light. Luminance, which the WCAG ratio is built on, curves against those stored values, so a mix of two colours can come out &lt;strong&gt;darker than either of them&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Take black text on &lt;code&gt;linear-gradient(90deg, #ff0000, #00c800)&lt;/code&gt;. Against the red stop it measures 5.25:1, and against the green stop 9.26:1. Both pass. About 37% of the way across, the blend is a muddy brown and the ratio there is &lt;strong&gt;3.49:1&lt;/strong&gt;, which fails. Checking only the stops reports a pass on text that is genuinely too dark to read against part of its own background.&lt;/p&gt;

&lt;p&gt;The same curve explains why light text was never affected. Luminance along one of these segments is convex, so its maximum is always at a stop. The minimum is the one that can hide in the middle.&lt;/p&gt;

&lt;p&gt;So the check has to sample along each stretch between stops, blending in the same colour space the browser uses, and keep the lowest value it finds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WCAG contrast maths
&lt;/h2&gt;

&lt;p&gt;The thresholds come from WCAG &lt;strong&gt;1.4.3 Contrast (Minimum)&lt;/strong&gt;: a ratio of at least &lt;strong&gt;4.5:1&lt;/strong&gt; for normal text, or &lt;strong&gt;3:1&lt;/strong&gt; for large text (24px and up, or 18.66px and up when bold). The ratio itself is built from relative luminance.&lt;/p&gt;

&lt;p&gt;Each channel is linearised, then weighted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 0–255 channel → linear light&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.03928&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;12.92&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;1.055&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// relative luminance&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.2126&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// contrast ratio between two colours&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contrast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&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="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same formula axe uses for solid backgrounds. We are not changing how contrast is judged, only giving it the right colours to judge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The algorithm, step by step
&lt;/h2&gt;

&lt;p&gt;For each element axe left as "needs review" for colour contrast:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the computed &lt;code&gt;color&lt;/code&gt; (the text) and the font size and weight, which set the required ratio (4.5 or 3).&lt;/li&gt;
&lt;li&gt;Walk up the ancestors to find the nearest element that actually paints a background: a &lt;code&gt;gradient(&lt;/code&gt;, a &lt;code&gt;url()&lt;/code&gt; image, or a colour.&lt;/li&gt;
&lt;li&gt;Pull the colour stops out of that value. This part is fussier than it looks. It is tempting to match &lt;code&gt;rgb()&lt;/code&gt; and &lt;code&gt;rgba()&lt;/code&gt; with a regular expression, but current CSS computes colours to &lt;code&gt;color(srgb ...)&lt;/code&gt;, &lt;code&gt;oklch()&lt;/code&gt;, &lt;code&gt;lab()&lt;/code&gt; and &lt;code&gt;color-mix()&lt;/code&gt;, and Tailwind v4 emits those by default. A regex quietly finds nothing on a modern site and the whole check falls back to "needs review". So each colour token is painted to a one-pixel canvas and read back instead, which works for any syntax the browser itself understands.&lt;/li&gt;
&lt;li&gt;Sample each segment between adjacent stops, mixing in the space the browser uses for that gradient, and keep the &lt;strong&gt;minimum&lt;/strong&gt; contrast across every stop and sample.&lt;/li&gt;
&lt;li&gt;If that worst-case ratio is below the requirement, it is a genuine failure. If it clears the bar, the text passes across the whole gradient, so the "needs review" can be dropped entirely.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;stops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stops&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;SAMPLES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// mix in the colour space the browser blends this gradient in&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stops&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;stops&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;SAMPLES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stops&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// real failure, e.g. "lowest-contrast point is 3.49:1, below the required 4.5:1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which space to mix in matters. A gradient can name one (&lt;code&gt;in srgb&lt;/code&gt;, &lt;code&gt;in oklab&lt;/code&gt;), and otherwise the default depends on the syntax of its stops: sRGB when they are all written in legacy notation, Oklab when any of them uses CSS Color 4 notation. A hard edge, where two stops sit at the same position, paints nothing between them, so nothing is sampled there.&lt;/p&gt;

&lt;p&gt;The result is that a headline which used to come back as an unknown now comes back as "fails at 3.49:1", with the exact element, which is something a developer can actually act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background images and translucent layers
&lt;/h2&gt;

&lt;p&gt;Stops alone were never going to cover two very common cases, and both are now resolved as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background images.&lt;/strong&gt; The colour under text on a photo cannot be derived from the stylesheet, so the image is decoded onto a canvas and the pixels underneath the text are sampled directly. The element box, &lt;code&gt;background-size&lt;/code&gt;, &lt;code&gt;background-position&lt;/code&gt; and &lt;code&gt;background-repeat&lt;/code&gt; together say which part of the image is actually behind the text, and the worst contrast across that region decides. A headline that is readable over the dark half of a photo and invisible over the light half is a real failure, and averaging would hide exactly that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Translucent layers.&lt;/strong&gt; A dark scrim over a hero photo is the standard way to make white text work. If a stop or an overlay has an alpha below 1, it gets composited over whatever sits beneath it, the same way the browser paints it, rather than being abandoned as unknowable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still stays "needs review"
&lt;/h2&gt;

&lt;p&gt;Plenty is still left for a human, on purpose. A wrong "needs review" costs somebody a few minutes. A wrong "pass" tells them their site is fine when it is not, which is the whole problem with compliance badges. So anything that cannot be established is left alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images from another domain.&lt;/strong&gt; Reading pixels back from a canvas that has loaded a cross-origin image is blocked by the browser unless that server allows it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gradients blended in a hue-based space&lt;/strong&gt;, such as &lt;code&gt;in hsl&lt;/code&gt; or &lt;code&gt;in oklch&lt;/code&gt;. The path the colour takes around the hue wheel is harder to reproduce exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any stop that will not parse.&lt;/strong&gt; Dropping one silently could discard the very colour the text fails against, which is its own way of inventing a pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gradients painted by a separate overlay element&lt;/strong&gt; that merely sits on top of the text rather than being one of its ancestors, which is a common hero pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pseudo-element backgrounds&lt;/strong&gt;, text partly covered by another element, fixed-attachment images, ancestor &lt;code&gt;opacity&lt;/code&gt;, filters and blend modes, and text shadows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;Gradients are everywhere in modern design, and they tend to sit behind the text that matters most. Leaving all of it as "needs review" pushes the single most visible contrast decision onto a manual pass that often never happens. Resolving it turns a common blind spot into a concrete pass or fail.&lt;/p&gt;

&lt;p&gt;It also turned out to be a good lesson in checking a shortcut before trusting it. The stop-only version was fast, easy to reason about, and wrong in exactly the direction that matters, because a false pass is the thing an accessibility tool must never produce. You can try a gradient or a photo yourself in the &lt;a href="https://accessibilityscanner.app/tools/image-contrast-checker" rel="noopener noreferrer"&gt;contrast checker&lt;/a&gt;, which runs entirely in your browser, or scan a page with the &lt;a href="https://accessibilityscanner.app/" rel="noopener noreferrer"&gt;free scan&lt;/a&gt; or the &lt;a href="https://accessibilityscanner.app/integrations" rel="noopener noreferrer"&gt;CLI and GitHub Action&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does axe-core mark gradient text as "needs review"?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because its contrast check reads a solid &lt;code&gt;background-color&lt;/code&gt;, and a gradient is set via &lt;code&gt;background-image&lt;/code&gt;. With no single background value, axe correctly returns the result as incomplete rather than guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is checking the gradient stops enough?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For light text, yes: the lightest point of a blend is always at a stop. For dark text, no. Browsers blend the stored red, green and blue values, and the result can be darker than both stops, so dark text can pass at every stop and fail in between. Sample along each stretch between stops instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work for background images?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The colour under text on a photo cannot be read from CSS, so the image is decoded onto a canvas and the pixels behind the text are sampled, with the worst contrast across that area deciding the result. Translucent overlays such as a scrim on a hero image are composited the way the browser paints them. An image served from another domain still cannot be sampled unless that server permits it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What contrast ratios are required?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WCAG 1.4.3 requires 4.5:1 for normal text and 3:1 for large text (24px and up, or 18.66px and up if bold).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build a few products. This one is &lt;a href="https://accessibilityscanner.app" rel="noopener noreferrer"&gt;Accessibility Scanner&lt;/a&gt;, a free WCAG scanner with no overlay and no fake compliance badge.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turning a web tool into a zero-dependency MCP server</title>
      <dc:creator>Chris Morris</dc:creator>
      <pubDate>Fri, 03 Jul 2026 21:44:41 +0000</pubDate>
      <link>https://dev.to/chris_morris/turning-a-web-tool-into-a-zero-dependency-mcp-server-21ca</link>
      <guid>https://dev.to/chris_morris/turning-a-web-tool-into-a-zero-dependency-mcp-server-21ca</guid>
      <description>&lt;p&gt;I run &lt;a href="https://domainintel.app" rel="noopener noreferrer"&gt;DomainIntel&lt;/a&gt;, a small web app that analyzes any&lt;br&gt;
domain: WHOIS, DNS, SSL/TLS, HTTP security headers, blocklist reputation, and&lt;br&gt;
subdomain discovery. The analysis engine was already there as a set of Node&lt;br&gt;
modules behind an Express API. The question was: how do I let AI agents use it&lt;br&gt;
directly, without standing up a whole new service?&lt;/p&gt;

&lt;p&gt;The answer was the &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;&lt;br&gt;
(MCP). This post is the practical, gotcha-heavy version of how I shipped it as&lt;br&gt;
&lt;code&gt;npx -y @domainintel/mcp&lt;/code&gt; — a single file with no runtime dependencies.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;MCP servers expose "tools" an agent can call. I wrapped each analyzer as one:&lt;br&gt;
&lt;code&gt;whois_lookup&lt;/code&gt;, &lt;code&gt;dns_records&lt;/code&gt;, &lt;code&gt;ssl_certificate&lt;/code&gt;, &lt;code&gt;security_headers&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;domain_reputation&lt;/code&gt;, &lt;code&gt;subdomain_discovery&lt;/code&gt;, and a &lt;code&gt;full_domain_report&lt;/code&gt; that runs&lt;br&gt;
everything and returns an overall score. Each takes a &lt;code&gt;domain&lt;/code&gt; and returns&lt;br&gt;
structured JSON. The whole server is ~150 lines on top of the existing analyzers&lt;br&gt;
and the MCP SDK's stdio transport.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't the MCP code. It was packaging.&lt;/p&gt;
&lt;h2&gt;
  
  
  Goal: &lt;code&gt;npx&lt;/code&gt; with no install friction
&lt;/h2&gt;

&lt;p&gt;I wanted a user to run &lt;code&gt;npx -y @domainintel/mcp&lt;/code&gt; and have it work — no clone, no&lt;br&gt;
&lt;code&gt;npm install&lt;/code&gt; of a dependency tree. That means bundling the server &lt;em&gt;and&lt;/em&gt; the&lt;br&gt;
analyzer graph it reuses into one self-contained file. I used esbuild. Four&lt;br&gt;
things bit me; all are general to "bundle a Node CLI that reuses CommonJS code&lt;br&gt;
into an ESM binary."&lt;/p&gt;
&lt;h3&gt;
  
  
  1. createRequire so a bundler can follow your imports
&lt;/h3&gt;

&lt;p&gt;My server originally pulled the analyzers in with &lt;code&gt;createRequire&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRequire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;analyzeDns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../lib/analyzers/dns&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;esbuild can't follow a runtime &lt;code&gt;createRequire&lt;/code&gt; call — it only sees static&lt;br&gt;
imports. So nothing got bundled. The fix was to switch to static default&lt;br&gt;
imports, which Node's ESM loader maps to a CommonJS module's &lt;code&gt;module.exports&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dnsPkg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../lib/analyzers/dns.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;analyzeDns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dnsPkg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now esbuild follows the graph, and &lt;code&gt;node server.mjs&lt;/code&gt; still works in dev.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Node built-ins under ESM output: "Dynamic require of 'net' is not supported"
&lt;/h3&gt;

&lt;p&gt;One dependency (&lt;code&gt;whois&lt;/code&gt;) calls &lt;code&gt;require('net')&lt;/code&gt; internally. In esbuild's ESM&lt;br&gt;
output there's no &lt;code&gt;require&lt;/code&gt;, so it throws at runtime. The fix is a banner that&lt;br&gt;
defines one via &lt;code&gt;createRequire&lt;/code&gt;, which esbuild's shim then uses for built-ins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;banner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#!/usr/bin/env node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;import { createRequire as __cr } from 'module';&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;const require = __cr(import.meta.url);&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. stdout belongs to the protocol — don't log to it
&lt;/h3&gt;

&lt;p&gt;MCP over stdio uses stdout for the JSON-RPC stream. My analyzers' shared logger&lt;br&gt;
wrote to a &lt;code&gt;logs/&lt;/code&gt; directory, which is also wrong for a globally-installed CLI&lt;br&gt;
(it would try to mkdir inside the npm install dir). I swapped it at build time&lt;br&gt;
for a stderr-only stub using an esbuild &lt;code&gt;onResolve&lt;/code&gt; plugin, so the real app keeps&lt;br&gt;
file logging and the bundle stays quiet on stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onResolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/utils&lt;/span&gt;&lt;span class="se"&gt;[\\/]&lt;/span&gt;&lt;span class="sr"&gt;errorLogger&lt;/span&gt;&lt;span class="se"&gt;(\.&lt;/span&gt;&lt;span class="sr"&gt;js&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. The small stuff
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two shebangs.&lt;/strong&gt; The entry file had &lt;code&gt;#!/usr/bin/env node&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; the banner
added one, so the bundle's line 2 was an invalid &lt;code&gt;#&lt;/code&gt;. Drop it from the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;"type": "module"&lt;/code&gt; + CommonJS.&lt;/strong&gt; When I vendored the analyzers into a
standalone repo whose &lt;code&gt;package.json&lt;/code&gt; had &lt;code&gt;"type": "module"&lt;/code&gt;, esbuild treated
the CJS &lt;code&gt;.js&lt;/code&gt; files as ESM and choked on &lt;code&gt;module.exports&lt;/code&gt;. Removing
&lt;code&gt;"type": "module"&lt;/code&gt; (the &lt;code&gt;.mjs&lt;/code&gt; entry stays ESM by extension) fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dependency going ESM.&lt;/strong&gt; &lt;code&gt;whois@2.16&lt;/code&gt; switched to ESM, which broke the
non-bundled &lt;code&gt;require('whois')&lt;/code&gt; dev path. Pinning to &lt;code&gt;2.15.0&lt;/code&gt; kept both the dev
path and a reproducible bundle.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mcp/dist/server.mjs&lt;/code&gt; is one ~1.7 MB file, zero runtime dependencies. Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add domainintel &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @domainintel/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your agent can run &lt;em&gt;"give me a full report on stripe.com"&lt;/em&gt; and get&lt;br&gt;
structured results instead of shelling out to &lt;code&gt;dig&lt;/code&gt;/&lt;code&gt;whois&lt;/code&gt; and parsing text.&lt;/p&gt;

&lt;p&gt;If you maintain a tool with a usable core, wrapping it as an MCP server is a&lt;br&gt;
small lift, and bundling it to a single file makes it genuinely one-command to&lt;br&gt;
adopt. Source is on &lt;a href="https://github.com/Bishop81/domainintel-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; —&lt;br&gt;
happy to answer questions about any of the above.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Almost half the WordPress plugin directory has not been updated in two years</title>
      <dc:creator>Chris Morris</dc:creator>
      <pubDate>Sat, 27 Jun 2026 22:57:10 +0000</pubDate>
      <link>https://dev.to/chris_morris/almost-half-the-wordpress-plugin-directory-has-not-been-updated-in-two-years-26i8</link>
      <guid>https://dev.to/chris_morris/almost-half-the-wordpress-plugin-directory-has-not-been-updated-in-two-years-26i8</guid>
      <description>&lt;p&gt;I indexed the WordPress.org plugin directory and measured how well it is maintained. The headline: of the 57,383 plugins in the index, 45.6 percent have not shipped an update in 24 or more months. Data is as of June 2026.&lt;/p&gt;

&lt;p&gt;Here is the full staleness picture, measured against plugins that report an update date:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;65 percent: no update in 6+ months&lt;/li&gt;
&lt;li&gt;56 percent: no update in 12+ months&lt;/li&gt;
&lt;li&gt;45.6 percent: no update in 24+ months&lt;/li&gt;
&lt;li&gt;41.2 percent: no update in 36+ months&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Not updated in two years" is the common line for calling a plugin abandoned, and WordPress.org uses a similar signal when it warns you on a plugin page. So nearly half the directory carries that flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part is who goes stale
&lt;/h2&gt;

&lt;p&gt;Abandonment is not evenly spread. It tracks inversely with install count:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Active installs&lt;/th&gt;
&lt;th&gt;Abandoned (no update in 24+ months)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1K to 10K&lt;/td&gt;
&lt;td&gt;23.9 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10K to 100K&lt;/td&gt;
&lt;td&gt;10.1 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K to 1M&lt;/td&gt;
&lt;td&gt;2 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M+&lt;/td&gt;
&lt;td&gt;0 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The very large plugins are maintained. The risk and the opportunity sit in the middle. 192 plugins with 10,000 or more active installs have not been updated in over two years. Each one is a real user base running code nobody is shipping fixes for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a builder should care
&lt;/h2&gt;

&lt;p&gt;A plugin with 30,000 installs and no maintainer is not a dead end. It is a ready-made market. The users already exist, the search demand already exists, and the incumbent has stopped competing. The work is not inventing a category. It is shipping a maintained, modern version of something people already rely on.&lt;/p&gt;

&lt;p&gt;That is a very different starting point from a cold launch. You are not asking whether anyone wants the thing. You are looking at the install count and reading the recent one-star reviews to find out exactly what to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use this
&lt;/h2&gt;

&lt;p&gt;If you are looking for a side project or a small product to build, abandoned mid-size plugins are a practical place to start. Pick a niche you understand, find a plugin in it with a stuck user base, read its support forum, and confirm the gap before you write any code.&lt;/p&gt;

&lt;p&gt;The full report, with the named lists of the most-installed abandoned plugins, the most-installed low-rated plugins, and the largest plugins with the weakest support, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wpgoldmine.io/research/state-of-wordpress-plugin-opportunities-2026" rel="noopener noreferrer"&gt;The State of WordPress Plugin Opportunities 2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The numbers above are measured from live WordPress.org data and the method is stated on the report.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>data</category>
    </item>
  </channel>
</rss>
