# Linting

> Catch Roadie mistakes in your app at lint time with @oztix/roadie-lint-plugin in oxlint, or in ESLint until your app can move.

`@oztix/roadie-lint-plugin` checks the Roadie conventions a rule can be sure of: Phosphor icon imports, sizes, and weights, colour classes, arbitrary radius and z-index values, Tailwind classes not in their canonical form, a `Card` without `Card.Content`, and tests that sleep, assert class strings, or check in jsdom what only a browser can. Calls that need judgement, such as which emphasis a surface takes, stay with review. Lint with oxlint. ESLint runs the same rules, with the same rule IDs, for an app that can't move to oxlint yet.

## Install

```bash
pnpm add -D @oztix/roadie-lint-plugin oxlint
```

It needs oxlint 1.88 or later. Both oxlint and ESLint are optional peer dependencies, so an app installs only the one it lints with. [If your app can't move to oxlint yet](#if-your-app-cant-move-to-oxlint-yet) covers ESLint.

Before 0.2.0, the package was `@oztix/roadie-eslint-plugin`. That name is deprecated: its last release, 0.1.2, re-exports this package and warns once when it loads, outside production. To move, change the name in `package.json` and in your config. The rules and their IDs are the same.

## Turn on the rules

In `.oxlintrc.json`, extend the recommended preset by its path. Oxlint's `extends` takes a file path, not a package name.

```json
{
  "extends": [
    "./node_modules/@oztix/roadie-lint-plugin/oxlint/recommended.json"
  ]
}
```

In `oxlint.config.ts`, import the preset and extend it.

```ts
import { defineConfig } from 'oxlint'

import recommended from '@oztix/roadie-lint-plugin/oxlint/recommended.json' with {
  type: 'json'
}

export default defineConfig({ extends: [recommended] })
```

The preset turns every rule in the [Rules](#rules) table on as an error and loads the plugin under the name `roadie`. To choose rules yourself, load it in `jsPlugins` with that name and name each rule.

```json
{
  "jsPlugins": [
    { "name": "roadie", "specifier": "@oztix/roadie-lint-plugin" }
  ],
  "rules": {
    "roadie/no-dark-variant": "error",
    "roadie/phosphor-icon-weight": "warn"
  }
}
```

Without `name`, oxlint names the plugin after its package, so every rule ID would start with `@oztix/roadie-lint-plugin/` instead of `roadie/`.

## Rules

In a config, each rule takes the `roadie/` prefix, such as `roadie/no-dark-variant`.

| Rule                      | Fails                                                                    | Fix                                                           |
| ------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------- |
| `phosphor-icon-suffix`    | A Phosphor import by its bare name, such as `Heart`                      | Import `HeartIcon`                                            |
| `phosphor-icon-size-prop` | The `size` prop on a Phosphor icon                                       | A `size-*` class                                              |
| `phosphor-icon-weight`    | A Phosphor icon with no weight, or one other than bold, fill, or duotone | `weight='bold'`                                               |
| `no-dark-variant`         | A `dark:` variant                                                        | A semantic colour utility                                     |
| `no-hex-colour-class`     | A hex colour, such as `bg-[#fff]`                                        | A semantic colour utility                                     |
| `no-arbitrary-z-index`    | An arbitrary z-index, such as `z-[5]`                                    | A named z tier, or `z-1` inside a component                   |
| `no-arbitrary-radius`     | An arbitrary radius, such as `rounded-[10px]`                            | A named radius tier, such as `rounded-xl`                     |
| `canonical-class`         | A Tailwind class not in its canonical form, such as `p-[16px]`           | Its canonical form, such as `p-4`, which `--fix` writes       |
| `card-content`            | A `Card` with no `Card.Content` among its parts                          | Put the body, or the title and description, in `Card.Content` |

[Iconography](https://ticketsolutionsptyltd.github.io/roadie/foundations/iconography.md) says which weight goes where, [Colors](https://ticketsolutionsptyltd.github.io/roadie/foundations/colors.md) why `.dark` needs no variant, [Elevation](https://ticketsolutionsptyltd.github.io/roadie/foundations/elevation.md) lists the z tiers, and [Shape](https://ticketsolutionsptyltd.github.io/roadie/foundations/shape.md) the radius tiers.

The class rules read `className`, any prop ending in `ClassName`, and the arguments of `cn`, `clsx`, `cva`, `cx`, `tv`, and `twMerge`. They also read a `const` those name, such as `variants` in `cn(variants[size])`, when it holds a string, array, or object. They follow one constant, not a constant built from another, and don't read CSS, so `@apply` goes unchecked. `roadie/no-arbitrary-radius` also fails a raw length, such as `[--card-radius:10px]`, in a variable that a `rounded-(--card-radius)` class reads.

Two icon rules skip a case on purpose. `roadie/phosphor-icon-weight` skips an icon passed to `Navigator.Item`, including one in a fallback such as `icon={icons[href] ?? <HouseIcon />}`, because Navigator sets its own weight. An icon built elsewhere, such as in a map of icons, still fails, so disable the rule around it and say why. `roadie/phosphor-icon-size-prop` allows `size='100%'`, which fills an SVG around the icon, such as a QR code's centre mark.

`roadie/canonical-class` asks Tailwind for each class's canonical form under Roadie's theme, as Tailwind's editor extension suggests, so `rounded-[8px]` becomes `rounded-lg` and `data-[pressed]:flex` becomes `data-pressed:flex`. It keeps `z-1` rather than `-z-hide`, which reads as a different tier. The answer comes from your app's own `tailwindcss`, so it can change when you upgrade: from 4.2, `start-0` becomes `inset-s-0`. It finds Roadie's stylesheet from the linted file, so it skips a file outside an app with `@oztix/roadie-core` installed. It skips every file in an app with no `tailwindcss`, or one before 4.1.15. Loading Tailwind adds about a second to the first file a lint run checks.

`roadie/card-content` judges a `Card` imported by name from `@oztix/roadie-components`, not through a namespace import, and only when it can see every part. A card whose children include your own component, an expression such as a `map`, or a fragment passes, since any of those might render `Card.Content`. Card warns in development when it renders without one, which catches the rest.

## Testing

The testing preset catches tests that can't be trusted. jsdom has no layout engine and doesn't evaluate CSS, so a jsdom test of layout or CSS passes whatever the page looks like. So does a test that checks the class string a variants function returns. A fixed sleep makes a test slow, and flaky when the wait runs short.

The preset turns its rules on for `*.test.*` files. Tests that run in a real browser skip the jsdom rules: `*.browser.test.*` files, such as tests in Vitest's browser mode, `*.spec.*` files, which Playwright runs, and any file in an `e2e` folder. Those keep `no-fixed-sleep`, and a `*.test.*` file among them keeps `no-cva-output-assertion` too. If your jsdom tests are named `*.spec.*`, turn the jsdom rules and `no-cva-output-assertion` on for those files in a config after the preset.

In `.oxlintrc.json`, extend it after the recommended preset.

```json
{
  "extends": [
    "./node_modules/@oztix/roadie-lint-plugin/oxlint/recommended.json",
    "./node_modules/@oztix/roadie-lint-plugin/oxlint/testing.json"
  ]
}
```

In `oxlint.config.ts`, import `@oztix/roadie-lint-plugin/oxlint/testing.json` and add it to `extends` after `recommended`.

In ESLint, spread `configs.testing` into your config array, as [If your app can't move to oxlint yet](#if-your-app-cant-move-to-oxlint-yet) shows.

| Rule                      | Fails                                                                                                                       | Fix                                                                |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `no-css-class-in-jsdom`   | A `calc()`, container query, or breakpoint class in `toHaveClass`, such as `md:grid`                                        | Measure it in a browser test                                       |
| `no-css-source-in-jsdom`  | Reading CSS source, with a `?raw` import or `readFileSync`                                                                  | Set up the condition in a browser test and read the computed style |
| `no-cva-output-assertion` | An assertion on the class string a variants function returns, such as `buttonVariants()`                                    | Render it and assert what a user sees                              |
| `no-fixed-sleep`          | A fixed wait, such as `setTimeout` inside a promise, `page.waitForTimeout`, or `timers/promises`                            | Wait on a condition with `expect.poll` or `waitFor`                |
| `no-layout-fake-in-jsdom` | A faked layout read, such as `getBoundingClientRect` or `offsetWidth`, or a `matchMedia` fake that answers a viewport query | A browser test at a real width                                     |

A `matchMedia` fake that answers only a preference, such as `prefers-reduced-motion`, passes, because it fakes a setting, not layout. A test that checks a generated file against the CSS it comes from is a fair reason to read CSS source. Turn `roadie/no-css-source-in-jsdom` off for that file in a config after the preset, and say why in a comment.

## Icons without an import

The Phosphor rules find icons by their import. Code that gets icons another way, such as a live code editor with icons in scope, names them in `settings.roadie.phosphorIcons`.

```json
{
  "settings": {
    "roadie": { "phosphorIcons": ["HeartIcon", "StarIcon"] }
  }
}
```

`settings` goes at the top level of the config. Oxlint fails to load a config with `settings` inside `overrides`.

## If your app can't move to oxlint yet

Lint with ESLint 9 or 10 on flat config (`eslint.config.js`) until it can. The rules and their IDs are the same, so moving later changes only the config. Install the plugin without oxlint.

```bash
pnpm add -D @oztix/roadie-lint-plugin
```

Add the recommended config to your config array.

```js
import roadie from '@oztix/roadie-lint-plugin'

export default [
  // Your other configs
  roadie.configs.recommended
]
```

It turns every rule in the [Rules](#rules) table on as an error for JavaScript and TypeScript files, and turns on JSX parsing. TypeScript files still need the parser your config gives them, such as typescript-eslint's.

To choose rules yourself, register the plugin as `roadie` and name each rule.

```js
import roadie from '@oztix/roadie-lint-plugin'

export default [
  {
    files: ['src/**/*.{ts,tsx}'],
    plugins: { roadie },
    rules: {
      'roadie/no-dark-variant': 'error',
      'roadie/phosphor-icon-weight': 'warn'
    }
  }
]
```

Spread the [testing](#testing) preset into the array after the recommended config, and put the `settings` from [Icons without an import](#icons-without-an-import) in a config of its own.

```js
import roadie from '@oztix/roadie-lint-plugin'

export default [
  roadie.configs.recommended,
  ...roadie.configs.testing,
  {
    settings: { roadie: { phosphorIcons: ['HeartIcon', 'StarIcon'] } }
  }
]
```

An app on a legacy `.eslintrc` can't load the plugin in ESLint. Move it to oxlint, or to flat config.

Docs updated 24 Oct 2026.
