@duro-app/eslint-plugin
ESLint rules that enforce the Duro design system conventions mechanically — what CLAUDE.md can only suggest, these rules gate.
Requires ESLint 9 (flat config). The plugin has zero runtime dependencies.
Usage
// eslint.config.js
import duro from '@duro-app/eslint-plugin'
import tseslint from 'typescript-eslint'
export default [
...tseslint.configs.recommended,
duro.configs.recommended,
{
rules: {
// Example: a data grid the design system doesn't cover yet.
'duro/no-raw-html-element': ['error', {allow: ['table', 'thead', 'tbody', 'tr', 'th', 'td']}],
},
},
]
The recommended preset registers the plugin under the duro namespace, so
you never have to register it yourself. Don't also register it manually under
another key — you'd get two live namespaces and confusing config.
Rules
| Rule | Preset | Fix |
|---|---|---|
duro/no-raw-html-element | error | suggestion |
duro/no-tokens-barrel-import | error | autofix |
duro/no-deprecated-table-parts | error | autofix / suggestion |
duro/no-raw-design-values | error | suggestion |
duro/no-raw-layer-values | warn | suggestion |
duro/no-raw-breakpoint-query | error | autofix |
no-raw-html-element
Raw lowercase JSX intrinsics (<div>, <span>, …) must be react-strict-dom
html.* elements. SVG subtrees are exempt (RSD has no SVG primitives).
Elements RSD doesn't export (<table>, <canvas>, …) report as non-portable;
allow them explicitly per-project via allow. The rewrite is a suggestion,
not an autofix, because html.* props are stricter than raw DOM props — a
mechanical --fix would trade lint errors for type errors.
Options: {allow?: string[], reportUnsupported?: boolean, htmlModule?: string}
no-tokens-barrel-import
import {colors} from '@duro-app/tokens' breaks the StyleX babel plugin. The
autofix splits the barrel import into the right deep imports
(@duro-app/tokens/tokens/colors.css, …), preserving aliases and inline
type specifiers. Imports with any unmapped specifier report without a fix.
Options: {packages?: string[]}
no-deprecated-table-parts
Flags <Table.Container> (Root owns the container query now; report-only —
unwrapping is layout-unsafe to automate) and the no-op isActions prop on
<Table.HeaderCell> (autofixed away for literal values; suggestion when the
value is an expression). Matches by identifier name (Table, TableCore by
default) — configure via tableIdentifiers.
no-raw-design-values
Inside css.create() objects, a raw design value is the finding — anything
with a token equivalent, and anything on a tokenised property that is off the
scale:
-
colors — a hex /
rgb()/hsl()literal anywhere. A palette value suggests itscolors.*token; an off-palette one reports without a fix. -
spacing and radius — a number or
'Npx'on the padding/margin/gap families →spacing.*, on the border-radius family →radii.*. On the scale it suggests the token; off the scale (marginTop: 23) it reports — "not a token" is the point. -
breakpoints — a px inside a
'@media …'/'@container …'condition key. On the scale it suggests the computed key[`@media (min-width: ${breakpoints.md})`]and the import. -
font size (number,
pxorrem) →typography.fontSize*or, for the steps typography lacks,typeScale.fontSizeN; font weight 400–700 →typography.fontWeight*;boxShadow→shadows.*on an exact match, otherwise a report;transitionDuration/animationDuration'Nms'→duration.*; timing functions →easing.*. -
sizes and border widths (since 5.0, ADR-0027) — a number,
'Npx'or negative onwidth/heightand their min/max,flexBasis, block/inline size; onborder*Width,outlineWidth,outlineOffset; and the width inside aborder*oroutlineshorthand string. Each property draws from its own candidates: sizes →sizes.*; border widths →borders.hairline / strong / accent; outline width →borders.focusRing; outline offset →borders.focusOffset / focusOffsetSm. Since 5.2 the size candidates follow the property's axis: a…W/…MinW/…MaxWtoken is only offered on a width property, a…H/…MinH/…MaxHtoken only on a height one, and an unsuffixed token on both (flexBasistakes either). One match suggests it (a negative ascalc(-1 * …)); several give one suggestion each. When none matches, the message names the nearest role: every same-axis token at the nearest value within ±10% (width: 232→sizes.gridColSm,sizes.sidebarW,sizes.popoverWSm(240)); when two values are equally near, both are listed, smaller first. Only when nothing is that close does it say to add a token. Acalc()or template literal whose static text holds a px is reported too. -
boxShadowin a template literal (since 5.2) — a px length in its static text (`inset 0 0 0 1px ${colors.border}`) is a raw shadow; use ashadows.*token. -
micro spacing — an off-scale spacing of 1/2/3/5/6px suggests
microSpacing.pxN.
Allowed: 0, relative units (%, vh, fr, em, rem, ch…), keywords
(auto, max-content…), identifiers and member expressions (tokens), a
calc() of tokens. Skipped on purpose: negatives on spacing properties,
'opacity 150ms'-style transitions, and positions (top, left) and
lineHeight. Since 3.0 it ships as error: a raw value is the drift the
design system exists to stop.
Shorthands (since 5.7). A spacing or radius string with several words
(padding: '4px 8px', gap: '8px 16px') is split on whitespace and every
nonzero px word is checked as a single value would be: '4px 8px' gives two
reports, '6px 14px' gives rawMicroSpacing and offScaleSpacing. 0,
auto, %, rem, em, var(...) and calc(...) words pass. A shorthand
report carries no suggestion, because swapping one word would rewrite the
whole string; the message names the token for each word.
Inline styles (since 5.7). A style={{ ... }} object literal on any JSX
element is read like a css.create style, so a raw colour, font size, weight,
measure or px in a shorthand is reported. These reports carry no
suggestion, since plain-React code has no .css.ts token imports; the
message still names the token. style={styles.x}, arrays, identifiers and
spreads are ignored, and so are custom-property keys ('--wb-vw': '12px') and
computed keys. Set inlineStyle: false to turn this off.
Options: {factories?: string[], spacingProperties?: string[], radiiProperties?: string[], exemptFiles?: string[], inlineStyle?: boolean}
(property lists replace the defaults, they don't merge). inlineStyle
defaults to true. exemptFiles takes
globs (**, *, ?, {a,b}), matched against the path relative to the
working directory and the absolute path; a matching file is not checked. Name
only a file whose literals are a technique, not a design value — Duro names
its visually-hidden module.
no-raw-layer-values
Since 5.3. Inside css.create() objects:
zIndex— a number or numeric string other than0and negatives. A layers value suggests its token (zIndex: 1000→layers.overlay,1050→layers.popup); any other value names the layers it sits between, without a fix (zIndex: 16→ betweenlayers.raised(1) andlayers.floating(50)). Since 5.4, a value from2up tolocalMax(default9) is not reported: it orders children inside one component, which is not a layer (1islayers.raisedand keeps its suggestion).backdropFilter/filter— ablur(...)literal.blur(2px)suggestseffects.overlayBlur,blur(6px)(5.4)effects.surfaceBlur; another blur reports without a fix.
It ships as warn for the 5.x line, so no consumer's lint turns red on a
minor, and becomes error in the next major. It is its own rule, apart
from no-raw-design-values, because ESLint sets severity per rule.
Leaving local stacking unreported is this rule's scope, not an exemption from
ADR-0027: a z-index is not one of the measures
design-system.every-measure-is-a-token makes a token (spacing, radius, size,
border width). localMax is capped at 49, below layers.floating (50), so
the skip never reaches a layer; {localMax: 0} reports every positive value.
Options: {factories?: string[], exemptFiles?: string[], localMax?: number}
no-raw-breakpoint-query
A media-query string anywhere in a file — useMediaQuery('(min-width: 768px)'), matchMedia(...), a constant — carries a raw breakpoint that
no-raw-design-values never sees (it only reads css.create). On the scale
the autofix rewrites the literal to `(min-width: ${breakpoints.md})` and
adds the import (a css.defineConsts string, so it interpolates to
'768px'); off the scale it reports. Skips css.create keys, and skips the
fix when a local breakpoints binding would shadow the import.
Token data
The rule data (deep-path map, spacing/radius/breakpoint px values, font
sizes and weights, shadows, durations, easings, color palette) is mirrored
from @duro-app/tokens as literals so the published plugin stays
dependency-free. test/token-drift.test.ts rebuilds every table from the real
tokens package and fails CI when they drift.
Only test/ imports @duro-app/tokens; src/ never does. The root prepare
script relies on that: it builds this plugin and the tokens package in parallel
(pnpm --no-sort). If src/ ever imports @duro-app/tokens, drop --no-sort
there, or a fresh install can build the plugin before the tokens it reads.