The lint rules
Two conventions hold the system together, and both are easy to state and easy to break:
- a component never writes a colour down — it names one from the vocabulary, and the theme decides what the name means, in dark, in light and in each product’s accent;
- no screen uses a native
<select>— the browser draws that popup itself, in the operating system’s chrome, and no CSS reaches inside it.
dowel-ui ships the rules that enforce them.
Turn it on
Section titled “Turn it on”import dowel from 'dowel-ui/eslint'
export default [ // ...your config ...dowel.configs.recommended,]The plugin comes with the package, so a product that has the theme already has the rule. ESLint is an optional peer: nothing installs it on your behalf.
no-raw-color
Section titled “no-raw-color”// ✗ A colour the theme cannot reach.<div className="bg-[#d9569e]" /><div className="border-zinc-800" /><div style={{ color: 'rgb(217 86 158)' }} />
// ✗ A component deciding which theme it is in.<div className="bg-white dark:bg-neutral-900" />
// ✓ Names from the vocabulary. Both themes, every accent, one class list.<div className="bg-raise border border-line text-text" /><button className="bg-accent text-on-accent hover:bg-accent-2" />The two errors are separate because the two fixes are:
- a raw colour is swapped for the token that means what it was trying to mean;
- a
dark:utility is deleted. There is nothing to replace it with — the token underneath already changes.
Tailwind’s stock palette (zinc-800, red-500) is reported too. The line
drops it from the theme deliberately, so those classes compile to nothing; the
rule points at the file that wrote one instead of leaving you to find a colour
that silently did not arrive.
bg-white and text-black are the same mistake in shorter clothes: white
chrome stays white when the ground turns white.
With an opacity, though, they are allowed:
// ✓ A scrim is made of black. A caption over a photograph is made of white.<div className="fixed inset-0 bg-black/50" /><span className="text-white/70">{caption}</span>Neither is a theme colour that failed to get a name — that is what a
translucent veil and ink on an arbitrary image are actually made of, which is
why the theme keeps --color-white and --color-black on purpose. kilna, the
first product to run this rule, had eleven of them and every one was right.
What it leaves alone
Section titled “What it leaves alone”The rule reads string literals in TypeScript and TSX — where a class list is assembled. It never reads CSS, because the theme is the one place raw colour belongs: that is what a token is.
Two exemptions are worth copying into your own config:
{ // Data that is *about* colour - a brand registry, a chart palette - and // tests that must write a colour to have something to check. files: ['src/brand.ts', '**/*.test.{ts,tsx}'], rules: { 'dowel/no-raw-color': 'off' },}If a component needs a colour that has no name yet, the answer is a new token, not an exemption. A colour worth using is worth a word.
no-native-select
Section titled “no-native-select”// ✗ The browser's own dropdown, in the operating system's chrome.<select value={value} onChange={onChange}> <option value="a">A</option></select>
// ✓ The component. Same job, drawn by the product.<Select items={items} value={value} onValueChange={setValue}> …</Select>A <select> cannot be made to look like the rest of an application. The
browser renders its popup itself, with the operating system’s fonts and
spacing, and no amount of CSS reaches inside. On a screen where every other
control is the product’s own, the one native dropdown reads as a foreign
object — and on Windows it reads as a foreign object from 1998.
<option> and <optgroup> are reported too: they exist only inside a
<select>, so one on its own is either a <select> being assembled somewhere
else or a misunderstanding.
The rule is about the element, never about the name. <Select> from this
set renders <button role="combobox"> and no native element at all, which is
what the convention was always asking for.