8px is correct at exactly one density.

xray maps an element’s computed styles back to your design tokens, then flags every value that only works in the theme you happen to be looking at.

Press to inspect this page with xray.

Inspection follows the pointer, so the live demo wants a mouse. The rest of the page reads fine here.

button.hand-button6 locked2 drift
src/App.tsx:36:13
modedarklight
densityhighlowmediummobiletouch
height28pxlocked
28px is --salt-size-base at medium only. This element is locked to one density — it renders wrong at high, low, mobile, touch.
--salt-size-base --salt-spacing-350
high20px
low36px
medium ·28px
mobile44px
touch44px
background-colorrgb(255, 255, 255)locked
rgb(255, 255, 255) is --salt-container-primary-background at light only. That token changes with mode — hardcoding it breaks dark.
--salt-container-primary-background --salt-editable-primary-background --salt-editable-primary-background-hover
darkrgb(36, 37, 38)
light ·rgb(255, 255, 255)
padding (right, left)8pxlocked
8px is --salt-spacing-100 at medium only. This element is locked to one density — it renders wrong at high, low, touch.
--salt-spacing-100 --salt-spacing-fixed-800 --salt-size-unit
high4px
low12px
medium ·8px
mobile8px
touch16px
border-colorrgb(124, 124, 124)drift
rgb(124, 124, 124) matches no colour token; nearest is --salt-color-gray-100 at ΔE 0.021.
border-width1pxdrift
1px is exactly --salt-size-divider-strokeWidth. Same value today, but it will not follow the token.
--salt-size-divider-strokeWidth --salt-size-separator-strokeWidth --salt-size-border
captured from the playground

Everything on this page reports the same way. Click Inspect this page, or press the shortcut, and hover anything.

One of these buttons is lying about being finished.

Both look right at regular density. The one on the left is built from this page’s tokens. The one on the right hardcodes the numbers those tokens happen to hold. Change the density and watch which one moves.

height: var(--control-height)

follows the density axis

Δ 0px

height: 36px

correct at regular, and nowhere else

Nobody argues with a bug. Everybody argues with a style guide. So xray leads with this renders wrong at four of five densities rather than this does not follow the design system.

Click Inspect this page above, or press the shortcut, then hover the Forked button — it reports all five. The overlay owns your clicks while it is up, the way any inspect mode does, so flip these axes from its own chips instead. Esc hands the page back.

Four verdicts, ranked by whether they can actually hurt you.

The example under each one is a real box on this page, not a screenshot of one. Open the overlay above and hover it to get the same verdict the panel is reporting on, live, from the token layer these very cards are printed against.

locked

Locked to one variant

The literal equals a token that varies across a theme, mode or density axis. Correct in the variant you are viewing. Wrong in the others, silently, until someone switches theme.

height: 36px

high20px
low36px
medium ·28px
touch44px

height: 28px is --salt-size-base at medium only

off-scale

Off-scale by a hair

Perceptually within ΔE 0.02 of a token, but not equal to it. Close enough that nobody sees it, far enough to escape every theme.

color: #7ed4fd

drift

Quiet drift

Equals a token that never varies. Nothing breaks today. It simply will not follow the token when the token moves.

border-width: 1px

ok

Tokenised

Resolves through a token that is genuinely in scope. Reported too, so a clean element reads as clean rather than as silence.

padding: var(--spacing-2)

Findings only appear for properties the author actually set, because browser defaults are not your problem. Per-side longhands collapse: four identical corners are one border-radius row, and padding: 8px 16px is two rows rather than four.

Every other token linter reads source text.

Which is why they can tell you padding: 8px is not a var(), and cannot tell you that 8px is --salt-spacing-100, that the token resolves to four different values across four densities, and that this element is therefore correct at exactly one of them. That needs the cascade resolved. Resolving the cascade needs a browser.

01

Tokens resolve by asking the browser

The computed value of a custom property is the substituted token stream: calc(0.5 * 8px), not 4px. So instead of writing a calc evaluator, xray puts a probe inside the element you are inspecting, declares letter-spacing: var(--token) on it, and reads the computed length back. Anything the engine can resolve, xray can read, including nested var() chains, calc(), rem, em and oklch().

02

Axes are discovered, not configured

A token declared once is a constant. A token declared under several selectors with different values varies, and the conditions those selectors disagree on are the axis. That finds Salt’s five densities from classes, its light and dark from [data-mode] attributes, and a shadcn :root and .dark pair where one side has no condition at all. No adapters, no token manifest.

03

The cascade is modelled, not guessed

getComputedStyle cannot tell you whether the author wrote 8px or var(--spacing-100), because it has already erased the difference. So xray collects every readable rule, matches them against the element itself, and sorts by importance, origin, @layer, specificity and document order to recover what was actually authored.

04

It refuses to guess

Between 1px and 48px there are enough tokens that every literal collides with one. A match whose name has nothing to do with the property is reported as untokenised rather than as a bug, because padding: 13px equalling a lineHeight token is arithmetic, not intent. A tool that cries wolf gets muted.

A few lines, then a keystroke.

Vite
// vite.config.ts
import xray from '@stevenmckinnon/xray';

export default defineConfig({
  plugins: [xray(), react()],
});
Next.js
// next.config.ts
import { withXray } from '@stevenmckinnon/xray/next';

export default withXray({ /* your config */ });

// app/layout.tsx — once, inside <body>
import { Xray } from '@stevenmckinnon/xray/next/client';
<Xray />

Neither integration exists in a production build. The Vite plugin is apply: ‘serve’; withXray returns your config untouched outside development. The dev server prints the binding on start, so you never have to guess it.

The honest list.

Vite and Next.js only
No standalone webpack or Rspack integration yet, though the Next loader is an ordinary webpack loader. This page loads the client bundle directly, which is the other way in.
Cross-origin rules are invisible
The panel warns when that applies rather than pretending it saw everything. Token values are unaffected, because the browser resolves those.
Container queries are not evaluated
A rule inside one may be considered when it does not apply. Findings that depend on one are flagged.
Lengths and colours only
Shadows, gradients, transitions and font stacks are not analysed.