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.
theme
density
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
high
20px
low
36px
medium ·
28px
touch
44px
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 8pxis--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.
// 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.
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.