---
title: "ESLint plugin"
description: "The proposed rule set for JSX and TypeScript covers raw colours, mixed axes, banned words, and the component contracts a reviewer should not have to remember."
url: "https://opsinjs.pensievelabs.org/handbook/tooling/eslint-plugin"
source: "https://opsinjs.pensievelabs.org/handbook/tooling/eslint-plugin.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["eslint-plugin-opsinjs", "lint rules jsx", "no-raw-color", "no-mixed-axes"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="handbook" />

## The short version [#the-short-version]

<NotBuiltYet />

`eslint-plugin-opsinjs` is specified but not built. It would ship a
`recommended` config containing the rules below, all of which are decidable from
the source.

```js
// eslint.config.mjs is proposed and not yet publishable
import opsinjs from "eslint-plugin-opsinjs"

export default [opsinjs.configs.recommended]
```

## How it works [#how-it-works]

### Proposed rules [#proposed-rules]

**`opsinjs/no-raw-color`** runs at error level.
Flags a colour literal in a `className`, a `style` prop or a styled call: hex,
`rgb()`, `hsl()`, a named CSS colour, and a Tailwind arbitrary value containing
any of them. Suggests the nearest token where one is unambiguous.
&#x2A;Why:* a raw colour opts out of the theme, both modes, the contrast floor and
the two axes in one keystroke.

**`opsinjs/no-mixed-axes`** runs at error level.
Flags a component receiving both a `status` and a `category` where the
component's documented contract says only one may change the surface, and flags
a status token used in a category role or the reverse.
&#x2A;Why:* [the two colour axes](../../health/two-colour-axes.mdx). This is the rule
the plugin exists for.

**`opsinjs/no-status-without-source`** runs at warn level.
Flags a literal `status` value written directly into JSX. A status should be
derived from a value and a range, not typed by a developer choosing a colour.
&#x2A;Why:* a hardcoded `status="urgent"` is a clinical claim with nothing behind it.

**`opsinjs/no-banned-word`** runs at error level.
Flags "normal", "abnormal", "good"/"bad" applied to a reading, and the other
terms listed in [Reference ranges](../../health/reference-ranges.mdx) and
[Writing status and alerts](../../content/writing-status-and-alerts.mdx), when
they appear in JSX text or in a prop that renders as user-visible copy.
&#x2A;Why:* these words make claims about a person that the product cannot support.

**`opsinjs/require-unit`** runs at warn level.
Flags a numeric health value rendered without a unit, such as a `Value` with no
`unit` prop or a bare number interpolated next to a metric label.
&#x2A;Why:* a number without a unit is an incomplete fact; see
[Numbers, units and precision](../../health/numbers-units-precision.mdx).

**`opsinjs/no-motion-for-urgency`** runs at warn level.
Flags an animation or transition class applied to an element that also carries a
status, where the animation is one of the attention-seeking families (pulse,
bounce, shake, ping).
&#x2A;Why:* [Motion in health UI](../../health/motion-in-health-ui.mdx). Urgency
carried by motion is invisible to a large group of readers and harmful to
another.

**`opsinjs/valid-composition`** runs at error level.
Flags wrong nesting of a compound component's parts, checked against the
generated composition trees.
&#x2A;Why:* wrong nesting is the most common generation failure, by humans and by
agents alike.

**`opsinjs/no-inline-threshold`** runs at warn level.
Flags a numeric comparison against a health value in JSX, such as a ternary on
`value > 140` deciding a colour.
&#x2A;Why:* thresholds have a named owner and belong in one place, not scattered
through render functions.

## Do this [#do-this]

* **Enable `recommended` and treat the errors as errors.** The four rules at
  error level are the ones with no legitimate exception.
* **Pair the plugin with `check-theme`.** Lint catches the literal in the
  source; the theme check catches the token that was defined wrongly.
* **Disable narrowly.** `// eslint-disable-next-line opsinjs/no-raw-color --
  brand logo, not themeable` is a reviewable statement. A file-level disable is
  not.

## Not this [#not-this]

* **Do not use `no-status-without-source` as a reason to compute status in the
  view.** Move the computation to where the thresholds live, not to a `useMemo`.
* **Do not add rules that require type information without saying so.**
  `valid-composition` needs the generated composition data; a project that has
  not run `generate` will see it silently pass.
* **Do not lint generated output.** Exclude `.source/`, `lib/generated/`,
  `registry/__index__.ts` and `public/r/`.

## Gotchas [#gotchas]

* **A colour in a variable is invisible.** `const c = "#e11d48"` used in a
  `className` cannot be caught without type-aware analysis; the runtime warning
  is the backstop.
* **Arbitrary values need parsing, not matching.** `bg-[var(--opsin-surface)]`
  is correct and `bg-[#fff]` is not, and they differ only inside the brackets.
* **Banned-word detection produces false positives on documentation.** A page
  explaining why "normal" is banned contains the word; the rule must exclude MDX
  and test fixtures.
* **`no-mixed-axes` cannot see a spread.** `{...props}` carrying both defeats
  it, which is one more reason to name props explicitly.

## Related [#related]

* [Tooling](./index.mdx) explains why lint rules and not documentation.
* [Stylelint plugin](./stylelint-plugin.mdx) has the same invariants in CSS.
* [Error codes](../error-codes.mdx) covers the runtime layer, with aligned
  names.
* [Rules for agents](../../agents/rules-for-agents.mdx) has the same constraints
  expressed for a generator rather than a linter.
