opsinjs
HandbookCorrectness and costTooling

Stylelint plugin

The proposed CSS rule set catches hardcoded colours, tier violations, and a status token applied to a category surface.

The short version

NOT IMPLEMENTED. This component does not exist in any released version of opsinjs. There is no package to install, no module to import and no props interface to generate code against. Everything on this page is a specification of intended behaviour and may change without notice. Do not write code against it.

This component is not built yet

There is nothing to render because there is nothing to install. What you can read on this page is the specification the implementation will have to satisfy.

PlannedRoadmapWhat “planned” means

stylelint-plugin-opsinjs is specified but not built. CSS needs its own plugin rather than an ESLint rule because most of the violations that matter happen in a stylesheet, not in JSX. They surface in an override file, a theme, a copied component's CSS.

// stylelint.config.mjs is proposed and not yet publishable
export default {
  plugins: ["stylelint-plugin-opsinjs"],
  extends: ["stylelint-plugin-opsinjs/recommended"],
}

How it works

Proposed rules

opsinjs/no-raw-color is an error-level rule. Any colour literal in a declaration whose property affects colour: hex, rgb(), hsl(), oklch() and named colours. The exception is the token definition files themselves, where the literals are the point. Why: the same reason as in JSX, with a wider blast radius. A stylesheet rule applies to every instance.

opsinjs/token-tier is an error-level rule. Flags a component-level custom property assigned a primitive value directly, skipping the semantic tier. Tiers are defined in Token architecture. Why: the three-tier structure is what makes a theme swap possible; a component reading a primitive is not themeable.

opsinjs/no-axis-crossing is an error-level rule. Flags a --opsin-status-* value assigned to a --opsin-category-* property, or the reverse, and flags a status token used in a declaration on a selector that also carries [data-category] in a way that changes the same visual channel. Why: the two colour axes, enforced where the mixing actually happens.

opsinjs/no-contrast-floor-violation is a warn-level rule. Where both sides of a foreground/background pair are opsinjs tokens, checks the pair against the published floor using the same APCA implementation as check-contrast.mts. Why: it moves a CI failure to the editor. It cannot check a pair involving a value it does not know, which is why it warns rather than errors.

opsinjs/no-transition-on-layout is a warn-level rule. Flags transition and animation on width, height, top, left, margin, padding. Why: they do not composite. See Motion in practice.

opsinjs/require-logical-properties is a warn-level rule. Flags margin-left, padding-right, left, right, text-align: left|right in favour of their logical equivalents. Why: right-to-left support is cheap to keep and expensive to retrofit. See Internationalisation.

opsinjs/no-global-component-variable is a warn-level rule. Flags a --opsin-<component>-* property set on :root, html or body. Why: it changes every instance in the application, including the ones the author has never seen. See Styling.

opsinjs/no-opacity-for-emphasis is a warn-level rule. Flags an opacity below a threshold on an element carrying text. Why: reducing opacity to make something look secondary reduces contrast against the background, which is the most common accidental contrast failure and the one that hits stale values hardest. See The contrast floor.

Do this

  • Run it on your overrides and your theme files, which is where the violations concentrate.
  • Keep the token definition files in the documented exception list rather than disabling the rule for them ad hoc.
  • Fix no-opacity-for-emphasis with a token, not with a slightly higher opacity.

Not this

  • Do not exclude your theme file to make no-raw-color pass. The theme is where the literals belong, and the exception should say so explicitly.
  • Do not disable no-axis-crossing. If it fires, either the token is wrong or the design is. Both are worth stopping for.
  • Do not run it on globals.css wholesale in this repository. That file is in .prettierignore for related reasons, and its lyra block is intentionally preserved byte-for-byte from the shadcn CLI so it can be diffed against a fresh init.

Gotchas

  • CSS custom properties are untyped. The plugin can only reason about names it recognises; a project-specific property assigned a status value is invisible.
  • Contrast checking needs both sides. A token against inherit, against an image, or against a translucent material cannot be evaluated statically. That is what the contrast floor and its scrim rules exist for.
  • Tailwind generates CSS the linter never sees. Utility classes are compiled from your markup, so a hardcoded colour in an arbitrary value is an ESLint problem, not a Stylelint one.
  • oklch() literals are legitimate in exactly one place. That place is the generated token layer, and they are illegitimate everywhere else, which makes the exception list load-bearing.

On this page