<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Contrast on Nick Liu - Software Engineer</title><link>https://nick-liu.com/tags/contrast/</link><description>Recent content in Contrast on Nick Liu - Software Engineer</description><generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>nickboy@users.noreply.github.com (Nick Liu)</managingEditor><webMaster>nickboy@users.noreply.github.com (Nick Liu)</webMaster><copyright>2026 Nick Liu</copyright><lastBuildDate>Sun, 30 Aug 2026 00:52:14 -0700</lastBuildDate><atom:link href="https://nick-liu.com/tags/contrast/index.xml" rel="self" type="application/rss+xml"/><item><title>The ring was 1.00:1 against its background. The key read 35% at 65%.</title><link>https://nick-liu.com/posts/herddeck-ring-read-backwards/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0000</pubDate><author>nickboy@users.noreply.github.com (Nick Liu)</author><guid>https://nick-liu.com/posts/herddeck-ring-read-backwards/</guid><description>&lt;div class="lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl"&gt;&#10; &#10;A key on my Stream Deck showed an agent with a context window 65% full. The ring around it displayed a little over a third of a circle, which anyone would read as 35%. Not blank, not obviously broken, no missing-data placeholder. Confidently wrong, by exactly the amount that inverts the decision you are making when you look at it.&#10;&#10;&lt;/div&gt;&#10;&#10;&lt;nav class="series-progress" aria-label="Series progress"&gt;&#10; &lt;span class="series-progress-label"&gt;Deleting ClaudeDeck · Part 4 of 4&lt;/span&gt;&#10; &lt;ol class="series-progress-dots"&gt;&lt;li class="series-progress-dot is-done"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-substrate-bet/" title="I deleted six subsystems by swapping one dependency. The protocol billed me."&gt;1&lt;/a&gt;&#10; &lt;/li&gt;&lt;li class="series-progress-dot is-done"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-backoff-wrong-question/" title="My backoff logged errors=1 492 times. It was answering the wrong question."&gt;2&lt;/a&gt;&#10; &lt;/li&gt;&lt;li class="series-progress-dot is-done"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-frozen-context-ring/" title="Every context ring read 10%. Two bugs, and fixing one hid the other."&gt;3&lt;/a&gt;&#10; &lt;/li&gt;&lt;li class="series-progress-dot is-current"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-ring-read-backwards/" title="The ring was 1.00:1 against its background. The key read 35% at 65%." aria-current="step"&gt;4&lt;/a&gt;&#10; &lt;/li&gt;&lt;/ol&gt;&#10; &lt;/nav&gt;&#10;&lt;div class="tested-with" role="note"&gt;&#10; &lt;span class="tested-with-icon" aria-hidden="true"&gt;🧪&lt;/span&gt;&#10; &lt;span&gt;Tested with &lt;strong&gt;herdr&lt;/strong&gt; &lt;code&gt;0.8.0&lt;/code&gt; · macOS&lt;/span&gt;&#10;&lt;/div&gt;&#10;&#10;&lt;figure&gt;&lt;picture&gt;&#10; &lt;source&#10; type="image/webp"&#10; srcset="https://nick-liu.com/img/herddeck-page-1_hu_603b2fa275025503.webp 800w, https://nick-liu.com/img/herddeck-page-1_hu_36eb5909d6118535.webp 1280w"&#10; sizes="(min-width: 768px) 50vw, 65vw"&gt;&#10; &lt;img&#10; class="my-0 rounded-md"&#10; loading="lazy"&#10; decoding="async"&#10; fetchpriority="auto"&#10; alt="HerdDeck page 1 on a Stream Deck MK.2. The top row is five agent slots, each tinted by lifecycle state with a context-fullness ring drawn around it; the first slot is highlighted because that agent is working"&#10; width="940"&#10; height="530"&#10; src="https://nick-liu.com/img/herddeck-page-1_hu_f75716e8a2a14490.png"&#10; srcset="https://nick-liu.com/img/herddeck-page-1_hu_f75716e8a2a14490.png 800w, https://nick-liu.com/img/herddeck-page-1.png 1280w"&#10; sizes="(min-width: 768px) 50vw, 65vw"&#10; data-zoom-src="https://nick-liu.com/img/herddeck-page-1.png"&gt;&#10; &lt;/picture&gt;&lt;/figure&gt;&#10;&lt;p&gt;That is the hardware, live against five agents on a remote machine. The top row is one key per agent. The tint is that session&amp;rsquo;s lifecycle state, and the ring is how full its context window is. Two independent signals, one key.&lt;/p&gt;</description><content:encoded>&lt;div class="lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl"&gt;&#10; &#10;A key on my Stream Deck showed an agent with a context window 65% full. The ring around it displayed a little over a third of a circle, which anyone would read as 35%. Not blank, not obviously broken, no missing-data placeholder. Confidently wrong, by exactly the amount that inverts the decision you are making when you look at it.&#10;&#10;&lt;/div&gt;&#10;&#10;&lt;nav class="series-progress" aria-label="Series progress"&gt;&#10; &lt;span class="series-progress-label"&gt;Deleting ClaudeDeck · Part 4 of 4&lt;/span&gt;&#10; &lt;ol class="series-progress-dots"&gt;&lt;li class="series-progress-dot is-done"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-substrate-bet/" title="I deleted six subsystems by swapping one dependency. The protocol billed me."&gt;1&lt;/a&gt;&#10; &lt;/li&gt;&lt;li class="series-progress-dot is-done"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-backoff-wrong-question/" title="My backoff logged errors=1 492 times. It was answering the wrong question."&gt;2&lt;/a&gt;&#10; &lt;/li&gt;&lt;li class="series-progress-dot is-done"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-frozen-context-ring/" title="Every context ring read 10%. Two bugs, and fixing one hid the other."&gt;3&lt;/a&gt;&#10; &lt;/li&gt;&lt;li class="series-progress-dot is-current"&gt;&#10; &lt;a href="https://nick-liu.com/posts/herddeck-ring-read-backwards/" title="The ring was 1.00:1 against its background. The key read 35% at 65%." aria-current="step"&gt;4&lt;/a&gt;&#10; &lt;/li&gt;&lt;/ol&gt;&#10; &lt;/nav&gt;&#10;&lt;div class="tested-with" role="note"&gt;&#10; &lt;span class="tested-with-icon" aria-hidden="true"&gt;🧪&lt;/span&gt;&#10; &lt;span&gt;Tested with &lt;strong&gt;herdr&lt;/strong&gt; &lt;code&gt;0.8.0&lt;/code&gt; · macOS&lt;/span&gt;&#10;&lt;/div&gt;&#10;&#10;&lt;figure&gt;&lt;picture&gt;&#10; &lt;source&#10; type="image/webp"&#10; srcset="https://nick-liu.com/img/herddeck-page-1_hu_603b2fa275025503.webp 800w, https://nick-liu.com/img/herddeck-page-1_hu_36eb5909d6118535.webp 1280w"&#10; sizes="(min-width: 768px) 50vw, 65vw"&gt;&#10; &lt;img&#10; class="my-0 rounded-md"&#10; loading="lazy"&#10; decoding="async"&#10; fetchpriority="auto"&#10; alt="HerdDeck page 1 on a Stream Deck MK.2. The top row is five agent slots, each tinted by lifecycle state with a context-fullness ring drawn around it; the first slot is highlighted because that agent is working"&#10; width="940"&#10; height="530"&#10; src="https://nick-liu.com/img/herddeck-page-1_hu_f75716e8a2a14490.png"&#10; srcset="https://nick-liu.com/img/herddeck-page-1_hu_f75716e8a2a14490.png 800w, https://nick-liu.com/img/herddeck-page-1.png 1280w"&#10; sizes="(min-width: 768px) 50vw, 65vw"&#10; data-zoom-src="https://nick-liu.com/img/herddeck-page-1.png"&gt;&#10; &lt;/picture&gt;&lt;/figure&gt;&#10;&lt;p&gt;That is the hardware, live against five agents on a remote machine. The top row is one key per agent. The tint is that session&amp;rsquo;s lifecycle state, and the ring is how full its context window is. Two independent signals, one key.&lt;/p&gt;&#10;&lt;p&gt;That sentence is the bug. It took me three weeks and an adversarial review to hear it.&lt;/p&gt;&#10;&#10;&lt;h2 class="relative group"&gt;It started as a text bug&#10; &lt;div id="it-started-as-a-text-bug" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#it-started-as-a-text-bug" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;p&gt;The slot titles were hardcoded &lt;code&gt;fill=&amp;quot;#fff&amp;quot;&lt;/code&gt;. White text on every status colour, and the status colours are Catppuccin Mocha accents, half of which are pale.&lt;/p&gt;&#10;&lt;p&gt;I measured every combination. These numbers I have recomputed from the palette hexes for this post rather than quoting the ones in my notes, which turned out not to survive checking:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Status&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Background&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;White text&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Dark text&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;working&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;#f9e2af&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;1.27:1&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;12.91:1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;done&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;#a6e3a1&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;1.49:1&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;11.03:1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;blocked&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;#f38ba8&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;2.32:1&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;7.08:1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;idle&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;#6c7086&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;4.88:1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;3.36:1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;offline&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;#45475a&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;9.12:1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1.80:1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;div class="chart"&gt;&#10; &lt;canvas data-chart="&#10;type: &amp;#39;bar&amp;#39;,&#10;data: {&#10; labels: [&amp;#39;working&amp;#39;, &amp;#39;done&amp;#39;, &amp;#39;blocked&amp;#39;, &amp;#39;idle&amp;#39;, &amp;#39;offline&amp;#39;],&#10; datasets: [&#10; { label: &amp;#39;white text&amp;#39;, data: [1.27, 1.49, 2.32, 4.88, 9.12],&#10; backgroundColor: &amp;#39;#f43f5e&amp;#39;, borderRadius: 4 },&#10; { label: &amp;#39;dark text (#1e1e2e)&amp;#39;, data: [12.91, 11.03, 7.08, 3.36, 1.80],&#10; backgroundColor: &amp;#39;#10b981&amp;#39;, borderRadius: 4 }&#10; ]&#10;},&#10;options: {&#10; responsive: true,&#10; indexAxis: &amp;#39;y&amp;#39;,&#10; categoryPercentage: 0.7,&#10; barPercentage: 0.8,&#10; plugins: {&#10; legend: { labels: { color: &amp;#39;#a1a1aa&amp;#39; } },&#10; title: { display: true, text: &amp;#39;Contrast ratio against each status background (WCAG AA for normal text is 4.5:1)&amp;#39;, color: &amp;#39;#a1a1aa&amp;#39; }&#10; },&#10; scales: {&#10; x: { title: { display: true, text: &amp;#39;Contrast ratio (higher is better)&amp;#39;, color: &amp;#39;#a1a1aa&amp;#39; },&#10; ticks: { color: &amp;#39;#a1a1aa&amp;#39; }, grid: { color: &amp;#39;rgba(255,255,255,0.08)&amp;#39; } },&#10; y: { ticks: { color: &amp;#39;#a1a1aa&amp;#39; }, grid: { color: &amp;#39;rgba(255,255,255,0.08)&amp;#39; } }&#10; }&#10;}&#10;"&gt;&lt;/canvas&gt;&#10;&lt;/div&gt;&#10;&#10;&lt;p&gt;WCAG AA for normal text is 4.5:1. Three of five statuses failed, and &lt;code&gt;working&lt;/code&gt;, at 1.27:1, is effectively invisible. That is the state you most need to read at a glance, because it is the one that tells you an agent is busy rather than waiting on you.&lt;/p&gt;&#10;&lt;p&gt;Note the last two rows. White is genuinely correct on the dark half of the palette. So the fix is not &amp;ldquo;swap white for dark&amp;rdquo;, it is &amp;ldquo;pick per background&amp;rdquo;:&lt;/p&gt;&#10;&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;inkFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backgroundHex&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backgroundHex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;INK_DARK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backgroundHex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;INK_LIGHT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;INK_DARK&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; : &lt;span class="kt"&gt;INK_LIGHT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&#10;&lt;p&gt;The status colours themselves are unchanged, so the colour coding still means what it meant. A parameterised test asserts every status clears AA, driven off the palette object itself, so adding a status generates its own case.&lt;/p&gt;&#10;&lt;p&gt;I shipped that, titled the commit &amp;ldquo;make slot text readable&amp;rdquo;, and wrote in the message that the new test meant a future palette change could not quietly reintroduce the problem.&lt;/p&gt;&#10;&#10;&lt;h2 class="relative group"&gt;The review said I had fixed the wrong surface&#10; &lt;div id="the-review-said-i-had-fixed-the-wrong-surface" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-review-said-i-had-fixed-the-wrong-surface" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;p&gt;I asked for an adversarial review of that work. The first finding was that the commit fixed the text on a key whose dominant graphic is the ring, and the ring was worse.&lt;/p&gt;&#10;&lt;p&gt;Worse, and untested. The commit message claimed a guarantee about future palette changes. That guarantee was real for the text and did not exist for the ring, because the test never covered the ring. I had written a confident sentence about a protection I had not built.&lt;/p&gt;&#10;&lt;p&gt;The ring took its colour from &lt;code&gt;thresholdColor(pct)&lt;/code&gt;, green under 50%, yellow to 79%, red above, drawn straight onto the status-coloured background. Two palettes, both Catppuccin accents, on the same surface.&lt;/p&gt;&#10;&#10;&lt;h2 class="relative group"&gt;Three collisions, and only two were the same colour&#10; &lt;div id="three-collisions-and-only-two-were-the-same-colour" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#three-collisions-and-only-two-were-the-same-colour" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;div class="flex justify-between"&gt;&#10; &lt;span&#10; class="w-full py-6 mr-3 rounded-md"&#10; style="background-color: #f9e2af"&gt;&lt;/span&gt;&#10; &lt;span&#10; class="w-full py-6 mr-3 rounded-md"&#10; style="background-color: #f38ba8"&gt;&lt;/span&gt;&#10; &lt;span&#10; class="w-full py-6 mr-3 rounded-md"&#10; style="background-color: #a6e3a1"&gt;&lt;/span&gt;&#10;&lt;/div&gt;&#10;&#10;&lt;p&gt;Those are &lt;code&gt;working&lt;/code&gt;, &lt;code&gt;blocked&lt;/code&gt; and &lt;code&gt;done&lt;/code&gt;. The threshold palette is &lt;code&gt;#94e2d5&lt;/code&gt;, &lt;code&gt;#f9e2af&lt;/code&gt;, &lt;code&gt;#f38ba8&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Two of the collisions are trivial once you line the hexes up:&lt;/p&gt;&#10;&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;THRESHOLD_YELLOW === STATUS_COLOURS.working // &amp;#34;#f9e2af&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;THRESHOLD_RED === STATUS_COLOURS.blocked // &amp;#34;#f38ba8&amp;#34;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&#10;&lt;p&gt;Same string. A yellow ring on a yellow key, a red ring on a red key.&lt;/p&gt;&#10;&lt;p&gt;The third one is the one I missed, and it is more interesting. &lt;code&gt;done&lt;/code&gt; is &lt;code&gt;#a6e3a1&lt;/code&gt; and &lt;code&gt;THRESHOLD_GREEN&lt;/code&gt; is &lt;code&gt;#94e2d5&lt;/code&gt;. Those are not the same colour. One is a green and the other is a teal, and side by side you can tell them apart immediately.&lt;/p&gt;&#10;&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#a6e3a1 relative luminance 0.65618&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#94e2d5 relative luminance 0.65493&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&#10;&lt;p&gt;Contrast ratio is a ratio of relative luminances. Hue does not enter into it. Two colours that differ obviously to the eye can be arithmetically indistinguishable to the contrast formula, and these differ by roughly one part in five hundred, which rounds to 1.00:1.&lt;/p&gt;&#10;&lt;p&gt;Across all eighteen ring-and-background pairs I get &lt;strong&gt;eleven under the 3:1 floor for non-text graphics, and three at exactly 1.00:1&lt;/strong&gt;, on &lt;code&gt;working&lt;/code&gt;, &lt;code&gt;blocked&lt;/code&gt; and &lt;code&gt;done&lt;/code&gt;. My original notes said nine under 3:1, which is why I recomputed everything for this post. If you are going to publish a table criticising a palette, run the numbers again first.&lt;/p&gt;&#10;&#10;&lt;h2 class="relative group"&gt;Invisible would have been the good outcome&#10; &lt;div id="invisible-would-have-been-the-good-outcome" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#invisible-would-have-been-the-good-outcome" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;p&gt;This is where a contrast bug turns into a correctness bug.&lt;/p&gt;&#10;&lt;p&gt;A ring is drawn as two arcs. The filled arc is the percentage. The unfilled remainder is a track, and the track is not the background, it is &lt;code&gt;stroke=&amp;quot;#000&amp;quot;&lt;/code&gt; at &lt;code&gt;stroke-opacity=&amp;quot;0.35&amp;quot;&lt;/code&gt;, a dark groove so you can see where the ring would go.&lt;/p&gt;&#10;&lt;p&gt;Set the filled arc to the same colour as the background and it disappears into the background. The track does not. It is still a dark arc.&lt;/p&gt;&#10;&lt;p&gt;So a &lt;code&gt;working&lt;/code&gt; agent at 65% context lost its filled 65% and kept its empty 35%, and what you saw was a single dark arc covering a bit more than a third of the circle.&lt;/p&gt;&#10;&lt;p&gt;Not blank. Not a question mark. A confident, readable, wrong number, and inverted in the specific sense that it reported the complement. &lt;code&gt;working&lt;/code&gt; and &lt;code&gt;blocked&lt;/code&gt; are the two states where you are actively deciding whether to intervene, and both of them lied.&lt;/p&gt;&#10;&#10;&lt;h2 class="relative group"&gt;The fix deleted a signal&#10; &lt;div id="the-fix-deleted-a-signal" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-fix-deleted-a-signal" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;p&gt;The instinct is to add something: a border, an outline, a different threshold palette tuned for contrast against each background. All of that is more code defending a design that was wrong.&lt;/p&gt;&#10;&lt;p&gt;Arc length already encodes the percentage. Hue was re-encoding the same number, coarser, in the one channel the background had already claimed. So the ring now draws in &lt;code&gt;inkFor(backgroundHex)&lt;/code&gt;, the same ink as the text:&lt;/p&gt;&#10;&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inkFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backgroundHex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&#10;&lt;p&gt;That resolves the collision by construction and inherits the text&amp;rsquo;s contrast guarantee for free, which means a future palette edit cannot bring it back. Post-fix, the filled arc against its own track measures 3.23:1 on &lt;code&gt;blocked&lt;/code&gt;, 4.75:1 on &lt;code&gt;done&lt;/code&gt;, 5.43:1 on &lt;code&gt;working&lt;/code&gt;, 8.93:1 on &lt;code&gt;idle&lt;/code&gt; and 13.41:1 on &lt;code&gt;offline&lt;/code&gt;. All clear of the 3:1 non-text floor, and the test asserts it per status.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;thresholdColor&lt;/code&gt; still exists and still earns its place on the Plan Usage key, whose background is a neutral constant. The function was never the problem. Drawing it onto a surface that already owned that channel was.&lt;/p&gt;&#10;&lt;p&gt;The general lesson has nothing to do with colour. Two independent signals were competing for one channel on one surface, and the symptom of that competition was not &amp;ldquo;hard to read&amp;rdquo;. It was &amp;ldquo;reads as a different number&amp;rdquo;.&lt;/p&gt;&#10;&#10;&lt;h2 class="relative group"&gt;The near-miss underneath&#10; &lt;div id="the-near-miss-underneath" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-near-miss-underneath" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;p&gt;One more, because it makes the same point a third time and I only caught it because the tests were parameterised by then.&lt;/p&gt;&#10;&lt;p&gt;An intermediate version drew the text at &lt;code&gt;fill-opacity=&amp;quot;0.75&amp;quot;&lt;/code&gt; for a softer look. The suite was measuring the ink against the background and reporting 7.08:1 on &lt;code&gt;blocked&lt;/code&gt; and 4.88:1 on &lt;code&gt;idle&lt;/code&gt;, both passing. Composite the actual 75% ink over the actual background and you get &lt;strong&gt;4.40:1 and 3.54:1&lt;/strong&gt;. Both below AA. The suite was measuring a colour that was never drawn.&lt;/p&gt;&#10;&lt;p&gt;There is now a test asserting no partially-transparent text, which is a blunter rule than &amp;ldquo;composite before measuring&amp;rdquo; and the right one for a surface this small.&lt;/p&gt;&#10;&#10;&lt;h2 class="relative group"&gt;Lessons&#10; &lt;div id="lessons" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lessons" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;One surface, one channel, one signal. If two independent values both want colour on the same graphic, one of them has to move to position, length, or a different element.&lt;/li&gt;&#10;&lt;li&gt;A contrast collision on a partially-filled indicator does not produce a blank. It produces the complement, because the unfilled track survives. Confidently wrong beats obviously broken in every way except the one that matters.&lt;/li&gt;&#10;&lt;li&gt;Contrast ratio is a luminance ratio and knows nothing about hue. Two colours you can tell apart instantly can be 1.00:1 to the arithmetic and to anyone glancing at a 72-pixel key.&lt;/li&gt;&#10;&lt;li&gt;Fix the dominant graphic first. I shipped a fix for the text on a key whose main feature is a ring, and the commit message was more confident than the diff.&lt;/li&gt;&#10;&lt;li&gt;If a commit message claims a guarantee, point at the test that provides it. Mine claimed a future palette change could not reintroduce the bug, and the test that would have made that true did not exist.&lt;/li&gt;&#10;&lt;li&gt;Prefer deleting a redundant encoding to defending it. Arc length already carried the number; the colour was a second, coarser copy sitting in a channel that was already taken.&lt;/li&gt;&#10;&lt;li&gt;Measure the pixels you actually draw. Opacity, gradients and overlays all mean the colour in your test is not the colour on the screen.&lt;/li&gt;&#10;&lt;li&gt;A documented design system does not give you contrast for free. Catppuccin Mocha is carefully built, and nothing in it stops you putting its lightest yellow behind white text.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 class="relative group"&gt;References&#10; &lt;div id="references" class="anchor"&gt;&lt;/div&gt;&#10; &#10; &lt;span&#10; class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;&#10; &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#references" aria-label="Anchor"&gt;#&lt;/a&gt;&#10; &lt;/span&gt;&#10; &#10;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;WCAG 2.2 contrast minimum for text, 4.5:1: &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html" target="_blank" rel="noreferrer"&gt;https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;WCAG 2.2 non-text contrast, 3:1 for graphical objects and UI components: &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html" target="_blank" rel="noreferrer"&gt;https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;Catppuccin Mocha palette: &lt;a href="https://github.com/catppuccin/catppuccin" target="_blank" rel="noreferrer"&gt;https://github.com/catppuccin/catppuccin&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;The rebuild these keys belong to: &lt;a href="https://nick-liu.com/posts/herddeck-substrate-bet/" &gt;I deleted six subsystems by swapping one dependency.&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;The other thing that review caught: &lt;a href="https://nick-liu.com/posts/herddeck-backoff-wrong-question/" &gt;My backoff logged errors=1 492 times.&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;My earlier post on this palette: &lt;a href="https://nick-liu.com/posts/catppuccin-mocha-theming/" &gt;Catppuccin Mocha theming&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;HerdDeck slot rendering, &lt;code&gt;inkFor&lt;/code&gt; and the ring: &lt;a href="https://github.com/nickboy/herddeck/blob/main/packages/plugin/src/actions/agentSlot.ts" target="_blank" rel="noreferrer"&gt;&lt;code&gt;packages/plugin/src/actions/agentSlot.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;The parameterised contrast tests: &lt;a href="https://github.com/nickboy/herddeck/blob/main/packages/plugin/src/actions/agentSlot.test.ts" target="_blank" rel="noreferrer"&gt;&lt;code&gt;packages/plugin/src/actions/agentSlot.test.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</content:encoded></item></channel></rss>