Skip to content

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.

eslint.config.js
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.

// ✗ 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.

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.

// ✗ 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.