DEV Community

Hs Yi
Hs Yi

Posted on

I shipped a VS Code theme that only chose 67 of its colors

Last week I wrote about the build gate that refuses to ship any color failing
a WCAG contrast target. Every color in my theme is measured, and the build
exits non-zero if one misses.

Then I counted the colors.

VS Code exposes somewhere north of 400 workbench color keys. My theme set
67. The other 350-odd weren't absent — VS Code filled them in from its
built-in defaults. My "fully measured, contrast-gated" theme was quietly
showing colors it had never chosen, let alone measured.

That's the thing about a gate: it only guards what you hand it.

Unset is not neutral

This is the part I'd want another theme author to take away. When you leave a
workbench key unset, you don't get "nothing" — you get VS Code's fallback,
computed from your theme's declared type (dark or light). It's a
reasonable guess. It is not your palette.

So a theme with 67 keys is a theme with 67 opinions and 350 borrowed ones.
Everywhere those borrowed colors surface — the peek view, the merge editor,
notebook cells, the testing gutter — your careful palette gets diluted by
something that was never checked against anything.

I went from 67 to 417 keys. Bracket-pair colorization (six levels, plus
the unexpected-bracket color), inlay hints, ghost text from inline
suggestions, sticky scroll, peek view, the suggest and hover widgets, the
minimap and overview ruler, notebooks, testing icons, the merge editor, debug
icons, symbol icons in autocomplete, the command center, the settings and
welcome pages.

None of that is exotic. It's what a normal day in the editor looks like.

The other gap: your regexes stop at the language server

Classic TextMate theming matches scopes with regular expressions. That works
until it doesn't, and where it stops is exactly where modern editing begins.

A regex cannot tell you:

  • whether Foo here is a type or a value — same spelling, same scope
  • whether count is a parameter or a local
  • whether a binding is readonly, which arguably matters more than its type
  • whether the symbol you're looking at is deprecated

The language server knows all four. VS Code will hand you its answer through
semantic tokens — but only if your theme asks. Mine didn't. Which means the
moment a language server attached, the colors people actually saw drifted
away from the ones I'd measured.

Turning it on is two things: a flag, and a mapping.

{
  "semanticHighlighting": true,
  "semanticTokenColors": {
    "variable": "#d8dae5",
    "variable.readonly": "#e08a4e",       // reads as a constant, because it is one
    "parameter": { "foreground": "#d8dae5", "fontStyle": "italic" },
    "property.readonly": "#e08a4e",
    "class.defaultLibrary": "#4fb5ad",    // stdlib differs from your own types
    "*.deprecated": { "strikethrough": true }
  }
}
Enter fullscreen mode Exit fullscreen mode

37 rules in my case. The two I'd argue hardest for:

variable.readonly colored as a constant. A regex sees a variable and
colors it like one. The language server knows it can never be reassigned.
That's not a cosmetic distinction — it's the difference between "watch this"
and "ignore this" while reading unfamiliar code.

*.deprecated as a strikethrough, not a dimmer color. This was a contrast
decision disguised as a style one. The obvious way to mark deprecated code is
to fade it. But fading is exactly what my gate exists to prevent: it takes a
color I measured at 4.6:1 and quietly drops it below threshold. A
strikethrough carries the same signal at zero contrast cost. When the
accessible option and the pretty option disagree, the gate breaks the tie.

Growing the surface means growing the gate

Here's the trap. I'd just added 350 color keys — buttons with text on them,
badges, the error status bar, syntax colors rendered inside the autocomplete
popup. Every one of those is a foreground on a background. My gate didn't
know about any of them.

If I'd stopped there, I'd have shipped more unmeasured color than before,
under a README that still said "every pair is checked." Last time an
adversarial audit caught that kind of gap for me. This time it was the obvious
next question: what did I just add that the gate can't see?

The gate now checks 177 distinct pairs, and while I was adding them I found
the old number was flattering itself. Contrast is symmetric — contrast(a, b)
and contrast(b, a) are the same measurement — and the palette reuses colors
across roles: the error color is the red accent, the cursor is the yellow.
Counting checks instead of distinct pairs had been inflating the figure. The
gate now deduplicates, keeping the strictest threshold when one pair is
claimed by two roles. (Keep the looser one and you've quietly lowered your own
bar.) These are the checks that cover genuinely new ground:

# Text sitting ON a colored surface — buttons, badges, error/warning status bar.
# The foreground here is the background color. Easy to forget it's a pair at all.
for name in ("blue", "cyan", "orange"):
    pairs.append((f"button text on {name}", bg["base"], v.accent[name], 4.5))
for role in ("error", "warning", "info", "success"):
    pairs.append((f"status bar text on {role}", bg["base"], v.ui[role], 4.5))

# Syntax colors rendered inside the suggest/hover widget, which uses a
# different background than the editor. Same color, different surface.
for role in ("type", "function", "string", "keyword", "variable",
             "constant", "property", "class"):
    pairs.append((f"{role} in suggest widget", v.syn(role), bg["overlay"], 3.0))
Enter fullscreen mode Exit fullscreen mode

Rule of thumb I'd offer: every new color key is a claim that some text will
be readable somewhere.
If you can't name the pair, you haven't finished
adding the key.

When the gate overruled the design

The best moment in this pass was the one where the measurement changed my mind
instead of confirming it.

Inlay hints — those little inferred type annotations VS Code injects inline —
conventionally get a subtle tinted background so they read as "not really your
code." I did that. Then I measured it:

inlay hint text on tinted background:  3.12 – 3.48 : 1   ✗
inlay hint text on editor background:  4.57 – 4.70 : 1   ✓
Enter fullscreen mode Exit fullscreen mode

The decoration was costing more than a full contrast tier. Two ways out:
lower the threshold for hints because they're "supplementary," or drop the
background.

I dropped the background. The tint was there to say this text is different,
but italic-adjacent placement and the hint color already say that, and neither
costs anything measurable. Lowering the threshold would have been the same
move as picking a color by eye — deciding by what I wanted to be true.

That's what a gate is for. Not to certify decisions you already made, but to
occasionally make one for you.

What changed, concretely

before after
workbench keys 67 417
semantic token rules 0 37
distinct contrast pairs 177 177
build failures 0 0

The pair count didn't move because deduplicating cancelled out the additions —
the old gate was counting the same pair up to three times. That row is the
correction; the last row is the only one that was ever allowed to be non-zero.

What I'd tell other theme authors

  1. Count your keys. If it's under a hundred, most of your theme is VS Code's defaults wearing your name.
  2. Ship semanticHighlighting: true. Without it your careful colors get overruled the moment a language server attaches — which is always.
  3. variable.readonly is the highest-value rule in the file. Regexes can't reach it, and it changes how code reads.
  4. Encode "de-emphasized" without spending contrast. Strikethrough, italics and placement are free; dimming is not.
  5. When you add color surface, add gate coverage in the same commit. Otherwise your README is describing a build you no longer run.

Links

Previous post: the contrast gate itself, and the palette it enforces —
My theme build fails if any color misses WCAG contrast

If a color reads wrong on your setup, open an issue. One palette edit and a
rebuild fixes every port at once.

Top comments (0)