Skip to main content
  1. Posts/

The ring was 1.00:1 against its background. The key read 35% at 65%.

··1540 words·8 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Deleting ClaudeDeck - This article is part of a series.
Part 4: This Article
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.
Tested with herdr 0.8.0 · macOS
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

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’s lifecycle state, and the ring is how full its context window is. Two independent signals, one key.

That sentence is the bug. It took me three weeks and an adversarial review to hear it.

It started as a text bug
#

The slot titles were hardcoded fill="#fff". White text on every status colour, and the status colours are Catppuccin Mocha accents, half of which are pale.

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:

StatusBackgroundWhite textDark text
working#f9e2af1.27:112.91:1
done#a6e3a11.49:111.03:1
blocked#f38ba82.32:17.08:1
idle#6c70864.88:13.36:1
offline#45475a9.12:11.80:1

WCAG AA for normal text is 4.5:1. Three of five statuses failed, and working, 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.

Note the last two rows. White is genuinely correct on the dark half of the palette. So the fix is not “swap white for dark”, it is “pick per background”:

export function inkFor(backgroundHex: string): string {
  return contrastRatio(backgroundHex, INK_DARK) > contrastRatio(backgroundHex, INK_LIGHT)
    ? INK_DARK
    : INK_LIGHT;
}

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.

I shipped that, titled the commit “make slot text readable”, and wrote in the message that the new test meant a future palette change could not quietly reintroduce the problem.

The review said I had fixed the wrong surface
#

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.

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.

The ring took its colour from thresholdColor(pct), green under 50%, yellow to 79%, red above, drawn straight onto the status-coloured background. Two palettes, both Catppuccin accents, on the same surface.

Three collisions, and only two were the same colour
#

Those are working, blocked and done. The threshold palette is #94e2d5, #f9e2af, #f38ba8.

Two of the collisions are trivial once you line the hexes up:

THRESHOLD_YELLOW === STATUS_COLOURS.working    // "#f9e2af"
THRESHOLD_RED    === STATUS_COLOURS.blocked    // "#f38ba8"

Same string. A yellow ring on a yellow key, a red ring on a red key.

The third one is the one I missed, and it is more interesting. done is #a6e3a1 and THRESHOLD_GREEN is #94e2d5. 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.

#a6e3a1  relative luminance 0.65618
#94e2d5  relative luminance 0.65493

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.

Across all eighteen ring-and-background pairs I get eleven under the 3:1 floor for non-text graphics, and three at exactly 1.00:1, on working, blocked and done. 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.

Invisible would have been the good outcome
#

This is where a contrast bug turns into a correctness bug.

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 stroke="#000" at stroke-opacity="0.35", a dark groove so you can see where the ring would go.

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.

So a working 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.

Not blank. Not a question mark. A confident, readable, wrong number, and inverted in the specific sense that it reported the complement. working and blocked are the two states where you are actively deciding whether to intervene, and both of them lied.

The fix deleted a signal
#

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.

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 inkFor(backgroundHex), the same ink as the text:

const ink = inkFor(render.backgroundHex);

That resolves the collision by construction and inherits the text’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 blocked, 4.75:1 on done, 5.43:1 on working, 8.93:1 on idle and 13.41:1 on offline. All clear of the 3:1 non-text floor, and the test asserts it per status.

thresholdColor 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.

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 “hard to read”. It was “reads as a different number”.

The near-miss underneath
#

One more, because it makes the same point a third time and I only caught it because the tests were parameterised by then.

An intermediate version drew the text at fill-opacity="0.75" for a softer look. The suite was measuring the ink against the background and reporting 7.08:1 on blocked and 4.88:1 on idle, both passing. Composite the actual 75% ink over the actual background and you get 4.40:1 and 3.54:1. Both below AA. The suite was measuring a colour that was never drawn.

There is now a test asserting no partially-transparent text, which is a blunter rule than “composite before measuring” and the right one for a surface this small.

Lessons
#

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • Measure the pixels you actually draw. Opacity, gradients and overlays all mean the colour in your test is not the colour on the screen.
  • 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.

References
#

Deleting ClaudeDeck - This article is part of a series.
Part 4: This Article

Related

I deleted six subsystems by swapping one dependency. The protocol billed me.

Six of the ten posts in my "Building ClaudeDeck" series document code that no longer exists. Over four days in August I rebuilt the plugin on herdr's socket API, and the hook dispatcher, the PTY runner, the statusline auto-patcher, the AppleScript focus path, the Claude project watcher and the shell-PID resolver all went in the bin, taking the `.app` bundle, the codesigning step and every TCC prompt with them. Then the substrate sent its invoice. Deleting ClaudeDeck · Part 1 of 4 1 2 3 4 🧪 Tested with herdr 0.8.0 · macOS ClaudeDeck was complicated for exactly one reason. Claude Code has no control API, so every fact the plugin needed had to be stolen from somewhere it was not offered.

Five Stream Deck keys, N Claude sessions: LRU that keeps the order I see

A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't "does the right session get evicted", it is "do the four survivors stay on the keys they were already on." Building ClaudeDeck · Part 3 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Two bits of context for anyone new to the stack: Stream Deck is Elgato’s USB grid of programmable LCD keys, and a “session” here is a single Claude Code conversation: claude running in one terminal tab, with its own working directory, its own context window, its own history. LRU stands for “least-recently used,” the standard cache-eviction policy: when you need to make room, drop the entry nobody has touched in the longest time.)

TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it.

··1734 words·9 mins
My daemon's preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed: the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC's designated requirement to the binary's cdhash, and `bun build --compile` produces a different cdhash on every rebuild. Building ClaudeDeck · Part 8 of 10 1 2 3 4 5 6 7 8 9 10 I’m building a Stream Deck plugin called ClaudeDeck (Stream Deck is Elgato’s little USB grid of programmable keys with LCD displays under each one). The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability, automating other apps, through System Settings → Privacy & Security → Accessibility, the pane you’ve probably toggled for tools like Rectangle or BetterTouchTool. On first install I added the daemon, toggled it on, and got back to work.