Skills / Token Drift

Token Drift

Finds hard-coded colors and spacing that bypass your design tokens, maps each to the right token, and fixes the safe ones.

$ node scripts/scan.mjs examples/acme-dashboard
token-drift · examples/acme-dashboard/  8 files · 380 tokens from 4 sources

  Drift score   13%  11 of 83 design values use tokens

  EXACT       48  equals a token → swap in the token (41 auto-fixable)
  NEAR        10  within threshold of a token
  OFF-SCALE   14  far from every token

  Button.jsx:7   hover:bg-[#1e4fd6]     → hover:bg-brand-600         ΔE 0.41
  Modal.vue:32   oklch(0.55 0.22 263)   → var(--color-brand-500)     ΔE 0.53
  global.css:14  #2563ea                → var(--color-brand-500)     ΔE 0.13

$ node scripts/scan.mjs examples/acme-dashboard --fix
Fixed 41 values in 5 files
Drift score 13% → 63%
Real output on the bundled example. Values that need judgment (named z-index layers, negative offsets, JS-computed styles) are listed, not changed.

Examples

Each example is a real run of the skill on a fixture in examples/. Nothing here is mocked up.

Example 1

A Tailwind v3 dashboard where only 13% of values use the tokens

YouFind the hardcoded colors and magic numbers that should be using our design tokens, and fix the safe ones.

A small app with a Tailwind v3 config, CSS custom properties and legacy SCSS variables, plus drift planted in five flavors: near-miss hex, 15px against a 16px token, Tailwind arbitrary values, JSX inline styles and an oklch() near-match. The scan finds 48 EXACT, 10 NEAR and 14 OFF-SCALE values. --fix rewrites the 41 provably safe ones (Tailwind classes, var(), $scss and JSX strings), lists the 3 swaps that will now follow dark mode, and lifts the score from 13% to 63%. Reviewing the near misses with --fix=near takes it to 74%; what is left is off-scale values and cases that need a human.

The top of report.md: a 13% drift-score gauge, a table of 48 EXACT, 10 NEAR and 14 OFF-SCALE values, and a near-miss color table where each found color sits next to its token in a pair of swatches that look identical, with ΔE between 0.13 and 0.53.
report.md: six near-miss colors, each shown beside the token it should have been. Every pair is under ΔE 1, so nobody could see the difference by eye.
Terminal: the --fix --dry-run diff turns rounded-[8px] px-[16px] into rounded-lg px-4 in Button.jsx and padding 15px 24px into 15px var(--space-6) in global.css, warns that 3 replacements will follow dark mode, then --fix prints 13% → 63% and --fix=near prints 63% → 74%.
--fix --dry-run first, then apply. text-[14px] and the off-scale 15px are left alone on purpose.
Terminal summary of the scan: 13% drift score, EXACT 48, NEAR 10, OFF-SCALE 14, and six near misses with true-color swatches, for example #2563ea → var(--color-brand-500) ΔE 0.13.
The terminal summary prints the same near misses with true-color swatches.
Result
Drift score 13% → 63% with --fix (41 EXACT values) → 74% with --fix=near
Command
node scripts/scan.mjs examples/acme-dashboard --fix --dry-run
Input
acme-dashboard
$ node scripts/scan.mjs examples/acme-dashboard
token-drift · examples/acme-dashboard/  8 files scanned · 380 tokens from 4 sources

  Drift score   13%  ███░░░░░░░░░░░░░░░░░░░░░  11 of 83 design values use tokens

  EXACT       48  equals a token → swap in the token (41 auto-fixable)
  NEAR        10  within threshold of a token → likely drift (9 fixable with --fix=near)
  OFF-SCALE   14  far from every token → new token or deliberate one-off
$ node scripts/scan.mjs examples/acme-dashboard --fix
Drift score 13% → 63%  (7 exact · 10 near · 14 off-scale left)
$ node scripts/scan.mjs examples/acme-dashboard --fix=near
Drift score 63% → 74%  (7 exact · 1 near · 14 off-scale left)

Example 2

shadcn-style components full of p-[13px] and bg-[oklch(…)]

YouClean up the Tailwind arbitrary values like p-[13px] and text-[#0a0a0a] in our components.

A Tailwind v4 notes app with shadcn-style semantic tokens: light values in :root, dark overrides in .dark, mapped through @theme inline, plus one fixed brand color in @theme. Its components hard-code the same values as arbitrary classes, in hex and in oklch. --fix maps 49 of them to canonical classes (bg-[oklch(0.577_0.245_27.325)] → bg-destructive, rounded-[14px] → rounded-xl via calc(var(--radius) + 4px), padding: 12px → calc(var(--spacing) * 3), text-[14px] → text-[length:var(--text-sm)] so the line height is not changed) and moves the score from 18% to 85%. The 15 swaps whose token changes in dark mode are listed for confirmation. p-[13px], text-[13px] and text-[15px] sit midway between two steps and are left for a human.

Terminal: the dry-run diff for button.tsx replaces bg-[oklch(0.577_0.245_27.325)] with bg-destructive, border-[oklch(0.922_0_0)] with border-border and text-[14px] with text-[length:var(--text-sm)], then a yellow warning lists 15 replacements whose token changes per theme, then --fix prints 18% → 85%.
Raw oklch and hex become semantic classes. The yellow list names every swap that will now follow dark mode.
The exact-matches table in report.md with swatches: hover:bg-[#f5f5f5] and bg-[oklch(0.97_0_0)] → bg-accent, text-[#0a0a0a] → text-foreground, text-[#737373] → text-muted-foreground, rounded-[6px] → rounded-sm, while text-[#a1a1a1] is held back because its exact match, --color-ring, is meant for borders.
report.md: the same color written as hex and as oklch maps to one token. text-[#a1a1a1] is held back because its exact match is a ring color.
Result
Drift score 18% → 85%; 15 of the 49 fixes flagged as theme-aware
Command
node scripts/scan.mjs examples/nimbus-notes --fix --dry-run
Input
nimbus-notes
$ node scripts/scan.mjs examples/nimbus-notes
  Drift score   18%  ████░░░░░░░░░░░░░░░░░░░░  13 of 73 design values use tokens
  EXACT       51  equals a token → swap in the token (49 auto-fixable)
  NEAR         1  within threshold of a token → likely drift (1 fixable with --fix=near)
  OFF-SCALE    8  far from every token → new token or deliberate one-off
$ node scripts/scan.mjs examples/nimbus-notes --fix
Drift score 18% → 85%  (2 exact · 1 near · 8 off-scale left)
! 15 replacements use a theme-aware token: the value will now follow dark mode. Confirm that is intended:
    src/components/NoteListItem.tsx:10 bg-[oklch(0.97_0_0)] → bg-accent (follows --accent, which changes per theme)
    src/components/ui/button.tsx:6 bg-[oklch(0.577_0.245_27.325)] → bg-destructive (follows --destructive, which changes per theme)
    src/components/ui/button.tsx:7 border-[oklch(0.922_0_0)] → border-border (follows --border, which changes per theme)

Example 3

A 2019 codebase with no tokens at all

YouWe don't have design tokens yet. Suggest a starter palette and spacing scale from what the code already uses.

A bakery ordering app written with CSS modules and styled-components, with 119 hard-coded design values and no tokens anywhere. tokens.mjs says so plainly, the scan scores it 0% without pretending any category has a score, and --propose clusters the values into candidate tokens: #333333, #323232 and #343434 become one --color-gray-700; #e5e5e5, #e6e6e6 and #e7e7e7 become one --color-gray-200; 15px folds into 16px and 180ms into 200ms. Only clusters used at least twice are proposed (25 of them); the 19 values used once are listed as one-offs instead of becoming tokens. Names follow Tailwind's palette, so the brand orange #e8590c comes out as --color-orange-600, and pure white stays --color-white instead of swallowing #f5f5f5 and #fafafa. After saving the proposal as src/tokens.css, --fix rewrites 87 values and the score goes from 0% to 74%. The names are still only a starting point: rename them to your roles before they ship.

The "Proposed tokens" table in report.md with swatches: --color-orange-600 #e8590c; --color-gray-700 #333333 absorbs #333333×5, #323232 and #343434; --color-white #ffffff stands alone; --color-gray-200 #e5e5e5 absorbs #e6e6e6 and #e7e7e7; --space-16 absorbs 15px; --font-size-14 absorbs 13px and 15px.
report.md: each proposed token lists the near-duplicate values it would replace. White does not absorb the off-whites.
Terminal: tokens.mjs reports no design tokens found; scan.mjs --propose shows a 0% score and the top 10 of 25 proposed tokens, notes that 19 values used once are left out as one-offs, and suggests saving the proposal and re-running; after saving it as src/tokens.css, --fix rewrites 87 values in 7 files and the score goes from 0% to 74%.
From no tokens to a starter set: propose, approve the clusters, then let --fix swap them in.
Result
119 raw values → 25 proposed tokens + 19 one-offs; adopting them takes the score from 0% to 74%
Command
node scripts/scan.mjs examples/crumb-orders --propose
Input
crumb-orders
$ node scripts/scan.mjs examples/crumb-orders --propose
  No design tokens found — every value counts as off-scale. Bootstrap a token set with --propose.
  Drift score    0%  ░░░░░░░░░░░░░░░░░░░░░░░░  0 of 119 design values use tokens
  Proposed tokens (25 clusters used 2+ times) → .knack/token-drift/proposed-tokens.css
    --color-orange-600         #e8590c        7 uses: #e8590c
    --color-gray-700           #333333        7 uses: #333333, #323232, #343434
    --color-white              #ffffff        6 uses: #ffffff
    --color-gray-200           #e5e5e5        5 uses: #e5e5e5, #e6e6e6, #e7e7e7
    --space-16                 16px           9 uses: 16px, 15px
  Left out: 19 values used once, listed in report.md as one-offs (keep them, or --min-uses 1 to propose them too).
# after saving proposed-tokens.css as src/tokens.css:
$ node scripts/scan.mjs examples/crumb-orders --fix
Drift score 0% → 74%  (0 exact · 11 near · 20 off-scale left)

About this skill

Design systems rot quietly. A #2563ea here, a padding: 15px there, p-[13px] in a hurry, and six months later "brand blue" exists in nine shades. Coding agents make it faster: they read your token file and then paste the hex anyway. Most design-token tools generate tokens. Token Drift audits them. It finds every raw color, spacing, font size, radius, shadow, z-index and duration that bypasses your tokens, tells you which token it should have been, and rewrites the safe ones for you.

Token Drift report preview

What it does

  • Finds your tokens automatically. It reads CSS custom properties in root and theme scopes (light, dark and other themes, including CSS variables defined in SCSS mixins that are @included at the root), Tailwind v4 @theme, Tailwind v3 tailwind.config.* with its presets (read statically by default, never executed), W3C DTCG / Style Dictionary JSON, and SCSS / LESS variables. shadcn-style channel variables (--primary: 222 47% 11% used as hsl(var(--primary))) and calc(var(--radius) - 2px) values are resolved to real colors and pixels.
  • Understands monorepos. Each token remembers its workspace package. A file only matches tokens its package can actually see (its own, an ancestor's, a workspace dependency's, or those of the app that renders it), with the Tailwind major version of that package, and a Tailwind class only counts as available if the CSS entry compiling that code includes the @theme defining it.
  • Scans the code in .css .scss .less .js .jsx .ts .tsx .vue .svelte .astro .html: declarations, <style> blocks, styled-components templates, Tailwind arbitrary values (p-[13px], text-[#333]), JSX style={{ padding: 13 }} and inline style="" attributes. It respects .gitignore and skips comments, tests, stories, generated files (compiled Tailwind output), JS palette/theme objects, token definitions, @font-face descriptors, transition delays and lines marked token-drift-ignore or stylelint-disable-line.
  • Matches each value to the nearest token. Colors use CIEDE2000 (plus OKLab distance), sizes use px and percent. Each value is classified:
    • EXACT: equals a token. Swap it in.
    • NEAR: within threshold. Almost certainly drift.
    • OFF-SCALE: far from everything, or midway between two steps. Add a token or accept it as a one-off.
  • Scores it. The drift score is the % of design values that already use tokens, broken down by category and file. Categories the project has no tokens for, colors in plain JS strings (canvas, charts, data) and files that can't use CSS variables (emails, OG images, React Native) are listed but not scored. --fail-under 90 turns it into a CI guard.
  • Fixes conservatively. --fix rewrites only EXACT matches, using the reference each file type needs: var(--x) in CSS, $x in SCSS (or ns.$x when the file @uses it with a namespace), a canonical class like p-4 for Tailwind, 'var(--x)' in JSX. --dry-run shows the unified diff first. It leaves alone anything where the swap could change rendering or meaning: Sass arithmetic, values computed in JS, text-[14px] → text-sm without a pinned line height, tokens from another package or another theme scope, named z-index layers, and a border token pasted into a background. NEAR fixes are opt-in (--fix=near) and meant for batch review.
  • Proposes tokens. --propose clusters off-scale leftovers (say, 13 near-identical grays) into a minimal set of new tokens, or bootstraps a whole token set when you don't have one.

It has no dependencies (plain Node 18+) and makes no network calls. On a 4,400-file monorepo (cal.com) a full scan takes about 1.5 s and 130 MB.

Example prompts

  • "Find hardcoded colors that should use our tokens"
  • "Audit design system drift in this repo"
  • "How consistent is our spacing?"
  • "Replace the hex values in src/components with our CSS variables"
  • "Clean up the Tailwind arbitrary values like p-[13px]"
  • "We don't have tokens yet. Suggest a palette and spacing scale from what the code already uses"
  • "Add a CI check so nobody adds new magic numbers"

Sample output

Run on the bundled fixture (examples/acme-dashboard):

token-drift · acme-dashboard/  8 files scanned · 380 tokens from 4 sources

  Drift score   13%  ███░░░░░░░░░░░░░░░░░░░░░  11 of 83 design values use tokens

  EXACT       48  equals a token → swap in the token (41 auto-fixable)
  NEAR        10  within threshold of a token → likely drift (9 fixable with --fix=near)
  OFF-SCALE   14  far from every token → new token or deliberate one-off

  By category            tokens  exact  near   off  score
    color                      6     14     6     4    20%
    space                      4     13     1     5    17%
    radius                     1      6     0     1    13%
    ...

  Not auto-fixable (EXACT/NEAR that need a human)
        2  <token> is a named layer; use it only if this element belongs to that layer
        2  value computed in JS; review by hand
        1  text-sm also sets line-height; add a leading-* class or rewrite by hand

  Near misses (first 6)
    src/components/Button.jsx:7  hover:bg-[#1e4fd6] → hover:bg-brand-600  ΔE 0.41
    src/components/Card.scss:17  #6c7280 → var(--color-gray-500)  ΔE 0.51
    src/components/Modal.vue:32  oklch(0.55 0.22 263) → var(--color-brand-500)  ΔE 0.53

--fix --dry-run shows exactly what would change:

-    'inline-flex items-center gap-2 rounded-[8px] px-[16px] py-[10px] text-[14px] font-[600] transition duration-[150ms]';
+    'inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-[14px] font-semibold transition duration-150';
-      <span style={{ fontSize: 12, color: '#6b7280', letterSpacing: 0.4 }}>{label}</span>
+      <span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-gray-500)', letterSpacing: 0.4 }}>{label}</span>
-  padding: 15px 24px;
+  padding: 15px var(--space-6);

(text-[14px] stays: text-sm would also set a line height that the original doesn't.) Applying --fix, then --fix=near, takes the fixture from 13% → 63% → 74%. The rest is off-scale values and cases that need a human: a negative margin, named z-index layers, a color chosen by a JS ternary, canvas chart colors. Every rewritten file still parses (TypeScript, Sass, PostCSS, Vue), and every rewritten Tailwind class compiles in Tailwind 3.4.

How it was tested

The scanner was benchmarked on six open-source codebases with real token systems: shadcn-ui (Tailwind v4 + CSS variables), cal.com and documenso (Tailwind v3/v4 monorepos with shadcn-style channel variables and presets), Mastodon (CSS variables defined in SCSS mixins), Excalidraw (SCSS + theme-scoped variables) and Primer React (with @primer/primitives passed via --tokens). On hand-judged random samples of findings, precision went from about 39% to over 95%, and --fix / --fix=near on copies of all six produced zero parse errors, zero Sass compile regressions, zero invalid Tailwind classes, and no unflagged value changes in Tailwind rewrites (each old/new class pair was compiled and compared). Fixes whose token changes per theme are listed for confirmation.

Try it

node skills/token-drift/scripts/tokens.mjs skills/token-drift/examples/acme-dashboard
node skills/token-drift/scripts/scan.mjs   skills/token-drift/examples/acme-dashboard
node skills/token-drift/scripts/scan.mjs   skills/token-drift/examples/acme-dashboard --fix --dry-run
node skills/token-drift/scripts/scan.mjs   skills/token-drift/examples/acme-dashboard --propose

Reports land in ./.knack/token-drift/ (report.md with color swatches, a "What to do next" checklist and grouped tables, report.json, fix.diff, proposed-tokens.css).

The fixture has planted drift in five flavors: near-miss hex colors, 15px vs a 16px token, Tailwind arbitrary values, JSX inline styles and an oklch() near-match. It also includes traps the scanner must ignore: comments, SVG path data, a commented-out <div>, and JSX text with apostrophes.

Examples

Three worked examples in examples/, each a real run of tokens.mjs / scan.mjs on a bundled fixture, with rendered report sections and terminal excerpts in examples/output/ (manifest: examples/examples.json):

  • Tailwind v3 dashboard (acme-dashboard/): Tailwind config, CSS variables and SCSS. 48 EXACT, 10 NEAR, 14 OFF-SCALE. --fix rewrites 41 values (13% → 63%) and lists the 3 that will now follow dark mode. --fix=near takes it to 74%.
  • Tailwind v4 + shadcn-style tokens (nimbus-notes/): @theme inline over light/dark CSS variables, with components full of p-[13px], text-[#0a0a0a] and bg-[oklch(…)]. 49 safe fixes to semantic classes (18% → 85%), 15 of them flagged as theme-aware. Midway values like p-[13px] are left for a human.
  • No tokens yet (crumb-orders/): CSS modules and styled-components with 119 hard-coded values. --propose clusters them into a starter set (#333333/#323232/#343434 → one gray). Values used once are listed as one-offs rather than turned into tokens; adopting the 25 proposed tokens lets --fix take the score from 0% to 74%.

Limitations

  • Static analysis only. Values computed at runtime (padding: ${size * 4}px, cn(isBig ? 'p-[13px]' : …) built from variables) aren't followed, and values chosen by JS expressions are reported but never rewritten.
  • Tokens that live in an npm package (e.g. @primer/primitives) aren't discovered from node_modules; pass the package's CSS or JSON folder with --tokens.
  • SCSS maps (map-get($spacers, 3)), the font shorthand, line-height, letter-spacing and width/height aren't audited.
  • Style objects outside style={{ }} (e.g. const styles = { padding: 13 }, React Native StyleSheet.create) aren't scanned as styles.
  • Package visibility comes from package.json dependencies and stylesheet @imports. Undeclared cross-package CSS (a global stylesheet loaded by a <link> tag) can hide a token from a package that does see it at runtime.
  • Numbers inside calc() expressions that also contain var() are still compared to the scale, though they're often arithmetic constants.
  • Palette classes used instead of semantic tokens (bg-red-500 where the system has bg-destructive) aren't flagged; Token Drift looks at raw values, not at which token was picked.
  • It doesn't render the page, so NEAR fixes are imperceptible by threshold, not verified pixel by pixel.

See references/token-sources.md for supported formats and references/fix-policy.md for exactly what gets auto-fixed and why.