$ 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%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.

![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%.](../../assets/skills/token-drift/examples/output/acme-dashboard/terminal-fix.png)

$ 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%.](../../assets/skills/token-drift/examples/output/tailwind-v4-shadcn/terminal-theme-aware.png)
![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.](../../assets/skills/token-drift/examples/output/tailwind-v4-shadcn/report-exact-matches.png)
$ 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.


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

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 v3tailwind.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 ashsl(var(--primary))) andcalc(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
@themedefining 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]), JSXstyle={{ padding: 13 }}and inlinestyle=""attributes. It respects.gitignoreand skips comments, tests, stories, generated files (compiled Tailwind output), JS palette/theme objects, token definitions,@font-facedescriptors, transition delays and lines markedtoken-drift-ignoreorstylelint-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 90turns it into a CI guard. - Fixes conservatively.
--fixrewrites only EXACT matches, using the reference each file type needs:var(--x)in CSS,$xin SCSS (orns.$xwhen the file@uses it with a namespace), a canonical class likep-4for Tailwind,'var(--x)'in JSX.--dry-runshows 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-smwithout 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.
--proposeclusters 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.--fixrewrites 41 values (13% → 63%) and lists the 3 that will now follow dark mode.--fix=neartakes it to 74%. - Tailwind v4 + shadcn-style tokens (
nimbus-notes/):@theme inlineover light/dark CSS variables, with components full ofp-[13px],text-[#0a0a0a]andbg-[oklch(…)]. 49 safe fixes to semantic classes (18% → 85%), 15 of them flagged as theme-aware. Midway values likep-[13px]are left for a human. - No tokens yet (
crumb-orders/): CSS modules and styled-components with 119 hard-coded values.--proposeclusters 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--fixtake 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 fromnode_modules; pass the package's CSS or JSON folder with--tokens. - SCSS maps (
map-get($spacers, 3)), thefontshorthand,line-height,letter-spacingand width/height aren't audited. - Style objects outside
style={{ }}(e.g.const styles = { padding: 13 }, React NativeStyleSheet.create) aren't scanned as styles. - Package visibility comes from
package.jsondependencies 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 containvar()are still compared to the scale, though they're often arithmetic constants. - Palette classes used instead of semantic tokens (
bg-red-500where the system hasbg-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.