Design system adoption looks like a UI cleanup. Ship the tokens, swap a few classes, call it done.
Then you open a PR six months later and still find text-white, #ef4444, and outline-blue-300 sitting next to components that already use semantic variables. The system exists. Half the UI never moved onto it.
This post is a case study on OpenHands: using Codemod Grep and Insights to find leftover theme hardcodes, measure them, and keep the backlog from going stale.
In our buy-vs-build maintenance taxonomy, that work sits under Architecture & Internal Platforms—same execution bucket as an internal SDK migration. Tokens in Figma are not "done." Done means new UI defaults to tokens, exceptions are explicit, and remaining findings trend down.
Theme migrations stall when leftover hardcodes stay invisible
The happy path is mechanical: palette utility → semantic class, hex → var(--token). Failures hide in icon color props, SVG stroke maps, focus outlines, and one-off meter fills. Grepping by eye misses them. An agent without a saved query invents a new search every session.
"We cleaned up chat" is not a fleet signal. You need a count that survives the next contributor.
OpenHands already had concrete tickets: unreadable modal descriptions, budget meter colors, fixed focus outlines. Useful. Not enough by themselves.
Grep showed the debt had moved sideways
I indexed a fork of OpenHands and asked Grep for JSX that hardcodes SVG fill or stroke, intending to migrate those to theme tokens:
Using Codemod Grep, find JSX elements that hardcode an SVG's fill or stroke color, so I can migrate them to theme tokens.
Against snapshot 975441627b2c8944751a1bd83e743ac4ea58121a, direct fill / stroke attributes in TSX were mostly already clean (currentColor, none, transparent, or var(--oh-…)). The debt had moved into icon paint props and stroke maps.

Three pattern families showed up:
Pattern | Where | Example |
Stroke map without tokens |
|
|
Icon | Chat, suggestions, files, loaders |
|
Asset SVG literals |
| Brand hexes in |
Most of the 16 Grep hits were the middle row: a JSX color prop with a literal. That is a jsx_attribute node whose name is a property_identifier (color), and whose value contains a string node ("white", "#fff", …). The rule below matches that shape and skips values that are already tokenized (var(…), currentColor, none, transparent, inherit):
id: jsx-paint-literal
language: tsx
message: Literal fill|stroke|color string; prefer var(--oh-…) or currentColor + text token class.
rule:
kind: jsx_attribute
all:
- has:
kind: property_identifier
regex: "^(fill|stroke|color)$"
- has:
stopBy: end
kind: string
regex: "(?i)(#[0-9a-f]{3,8}\\b|\\b(rgb|rgba|hsl|hsla)\\s*\\(|\\b(white|black|red|blue|green|gray|grey|orange|yellow|purple|pink)\\b)"
not:
regex: "(?i)(var\\s*\\(|currentcolor|\\bnone\\b|\\btransparent\\b|\\binherit\\b)"In plain terms: find JSX attributes named fill, stroke, or color, then look anywhere under that attribute for a string that looks like a hardcoded color. stopBy: end means the string can sit inside an expression (color={disabled ? "…" : "white"}), not only as a bare color="white". A sibling rule in the same Grep YAML catches fill/stroke that are not tokenized at all (including stroke={TONE_STROKE[tone]}), and another catches style={{ fill|stroke: … }} object pair nodes inside a style attribute.
If you only search Tailwind palette utilities, you miss the icon paint path. Token migrations usually split into several small families like this.
A share link dies; a dashboard is how the next person does not re-derive the query
A one-off Grep result is useful once. The next engineer starts from zero unless the queries live somewhere durable.
We saved an Insights dashboard, Theme migration and accessibility debt, over src/components, src/ui, and src/routes on the OpenHands fork. The CSS token-mining package was incomplete for this TypeScript surface, so the tiles share one component scan—a limitation, not a feature.

Issue-linked leftovers sit on the same board: 4× outline-blue-300 (#18517), 2× budget meter bg-red-500 / bg-blue-500 (#18525), 1× text-modal-muted (#18539).
94% adoption can hide a rising series
Over the last 12 weeks, raw palette utilities peaked at 619 (9 Sep 2026) and sit at 87 today. Fixed colors moved from 45 → 55. Legacy exceptions from 18 → 19. Raw utilities can crash while fixed colors and exceptions creep up. Without a timeseries, leadership sees "94% tokens" and you miss the trade.

Table widgets turn that into a reviewable hit list: raw utilities awaiting replacement, and exceptions still on the allowlist so they do not pretend to be finished work.

What this case study actually teaches
Three things I would take into the next token migration:
- Search the paint path, not only the class path. Clean
fill/strokeattributes did not mean clean icons. Iconcolorprops and stroke maps were the real remainder. - Track more than one series. A single adoption % can improve while fixed colors and exceptions rise. Require the conflicting charts.
- Ship PRs against a counted finding. "Theme polish" without a before/after number is hard to review. Removing a row from the raw-utilities table is not.
Limits that still matter here
- Scans do not know intent.
bg-blackon a marketing screenshot may be correct. Exceptions need humans. - One repo is not a multi-package design system. OpenHands is a single product surface. Ownership and rollout across many apps is a different problem (the buy-vs-build post covers that coordination side).
- Incomplete miners force blunt tiles. We used one shared component scan because CSS token mining did not cover this TS UI yet. Sharper packages would refine the numbers; they would not remove the need for durable queries.
If your team ships tokens and Figma kits but cannot answer how many production components still bypass the system, the design system is still optional in practice. Treat remaining hardcodes like a deprecated internal API: a continuous count, not a quarterly cleanup.
Grep made the first pass concrete. Insights made the conflicting series visible. The next PR is easier when "remaining findings" is a number the whole team can open.