@olundot/eslint-plugin-tokens
v0.3.0
Published
ESLint plugin for OLUN design tokens — blocks arbitrary `[var(--xxx)]` in JSX className, enforcing exposed Tailwind utilities instead.
Maintainers
Readme
@olundot/eslint-plugin-tokens
ESLint plugin for OLUN design tokens. Blocks arbitrary
[var(--xxx)]in JSXclassNameso tokens stay routable through the@theme inlineutility layer.
Install
pnpm add -D @olundot/eslint-plugin-tokensPeer: eslint >=9 (flat config).
Usage (flat config)
// eslint.config.mjs
import olunTokens from "@olundot/eslint-plugin-tokens";
export default [
{
plugins: { "@olundot/tokens": olunTokens },
rules: {
"@olundot/tokens/no-arbitrary-css-var": "error",
"@olundot/tokens/require-semantic-fallback": "error",
"@olundot/tokens/no-select-empty-option": "error",
"@olundot/tokens/no-forced-open-modal-dropdown": "error",
"@olundot/tokens/no-selected-border-left": "error",
},
},
];Rules
| Rule | Fixable | What it does |
|---|---|---|
| no-arbitrary-css-var | — | Blocks arbitrary [var(--xxx)], forcing the exposed Tailwind utility. |
| require-semantic-fallback | ✅ | Requires a fallback on --semantic-* status tokens. |
| no-select-empty-option | — | Blocks duplicate empty Select options; use clearable. |
| no-forced-open-modal-dropdown | — | Blocks forced-open modal DropdownMenu previews. |
| no-selected-border-left | — | Blocks left-border selected indicators. |
no-arbitrary-css-var
Flags Tailwind arbitrary-value syntax [var(--xxx)] inside JSX className (and lowercase class) attributes.
Why. OLUN tokens are exposed as Tailwind utilities via @olundot/tokens/tailwind/v4 @theme inline mappings. Using bg-[var(--bg-canvas)] bypasses that layer — token renames/retires stop being lintable and the design system's single source of truth fragments. See INTEGRATION.md (Layer γ rationale).
Covers:
- String literals —
<div className="bg-[var(--bg-canvas)]" /> JSXExpressionContainerwrapping a string —<div className={"bg-[var(--bg-canvas)]"} />- Template literals (fully static or with unrelated interpolations) —
<div className={`bg-[var(--bg-canvas)] ${x}`} /> CallExpressionstring/template args —cn("base", "bg-[var(--bg-base)]"),clsx(...), nestedcn(cn(...))- Standalone class helper calls —
cva("base", { variants: { tone: { brand: "text-[var(--accent-fg)]" } } }) - Lowercase
classattribute (Solid / Vue compatibility)
Static-only. When a template literal interpolates inside the token name itself (e.g. `text-[var(--text-${v})]`), the rule intentionally under-reports rather than guess at dynamic token names.
Options:
"@olundot/tokens/no-arbitrary-css-var": ["error", {
allowList: ["--legacy-color"] // token names WITH the leading "--"
}]| Option | Type | Default | Description |
|---|---|---|---|
| allowList | string[] | [] | Token names (include the -- prefix) that are explicitly allowed as arbitrary values. Use for intentional exceptions — document the reason in code. |
Examples:
Valid:
<div className="bg-canvas text-primary" />
<div className={cn("base", "p-4")} />
<div className="bg-[var(--legacy-color)]" /> /* with allowList: ["--legacy-color"] */Invalid:
<div className="bg-[var(--bg-canvas)]" />
<div className="flex bg-[var(--foo)] p-4" />
<div className={cn("base", "bg-[var(--bg-base)]")} />
<div className={`bg-[var(--bg-canvas)] ${x}`} />
<div class="bg-[var(--bg-canvas)]" />require-semantic-fallback
Flags --semantic-* status-token references that are missing their var(--x) fallback, and autofixes them to the two-arg form.
Why. The --semantic-* namespace (--semantic-error, --semantic-warning, --semantic-info, --semantic-success and their -bg variants) is a compatibility alias layer declared in the core @olundot/tokens tokens.css SSOT (section 10.5). A consumer that pulls in components without the matching tokens version — or a ref-HTML context that only defines the un-prefixed runtime status tokens — would resolve var(--semantic-error) to nothing and render an unstyled value. Writing var(--semantic-error, var(--error)) makes the un-prefixed runtime token a graceful fallback. This rule keeps that convention from regressing.
Covers the same surface as no-arbitrary-css-var:
className/ lowercaseclass— string literals,JSXExpressionContainer, template literals, andcn()/clsx()/nested-call string args.style— both the string form (style="color:var(--semantic-error)") and string property values in the object form (style={{ color: "var(--semantic-error)" }}).
Scope. Only the 8 canonical --semantic-* status tokens are checked (kept in sync with tokens.css section 10.5). Any other --semantic-* name is ignored, since there is no known fallback target to fix it to. The two-arg form var(--semantic-error, var(--error)) is never flagged (the comma disambiguates it from the bare form).
No options. The token → fallback map is fixed.
Examples:
Valid:
<div className="text-[var(--semantic-error,var(--error))]" />
<div className="bg-[color:color-mix(in_srgb,var(--semantic-info,var(--info))_8%,var(--bg-surface))]" />
<div className="text-[var(--error)]" /> /* un-prefixed runtime token alone is fine */Invalid (→ autofixed):
<div className="text-[var(--semantic-error)]" />
/* → text-[var(--semantic-error, var(--error))] */
<div style={{ color: "var(--semantic-success-bg)" }} />
/* → "var(--semantic-success-bg, var(--success-bg))" */no-select-empty-option
Flags inline Select options with value: "".
Why. OLUN Select owns the not-selected/reset state through clearable and clearLabel. A synthetic empty option duplicates that state and makes examples drift into “default option” hallucinations.
Scope. Only literal inline arrays are checked. Dynamic options={options} stays out of scope because the rule cannot safely inspect runtime data.
Valid:
<Select clearable clearLabel="선택 안 함" options={[{ value: "a", label: "A" }]} />
<Select options={options} />Invalid:
<Select options={[{ value: "", label: "선택" }, { value: "a", label: "A" }]} />no-forced-open-modal-dropdown
Flags forced-open DropdownMenu previews unless they also set modal={false}.
Why. Docs fixtures sometimes keep a menu open for visual comparison. If a forced-open menu remains modal, it captures unrelated page interaction and can make users click several times after route changes.
Valid:
const [open, setOpen] = useState(true);
<DropdownMenu modal={false} open={open} onOpenChange={setOpen} />Invalid:
const [open, setOpen] = useState(true);
<DropdownMenu open={open} onOpenChange={setOpen} />
<DropdownMenu open />no-selected-border-left
Flags selected/active/current state indicators that use border-l or the old accent pseudo-bar pattern.
Why. OLUN selected states use fill + type weight. A left border reads as a separate navigation rail convention and has repeatedly regressed in examples.
Scope. Structural side borders remain valid. For example, Sheet side borders and scroll-area borders are not flagged unless the same class is tied to selected/active/current state.
Valid:
<SheetContent className="border-l" />
<button className="data-[state=active]:bg-[var(--action-selected)]" />Invalid:
<button className="data-[state=active]:border-l-2" />
<div className={selected ? "border-l border-[var(--border-accent)]" : ""} />
<div className="data-[selected=true]:before:bg-[var(--accent-solid)]" />Roadmap
- v0.2 — Autofix for
no-arbitrary-css-var. Load the@theme inlinemapping table (newtokensSourceFileoption) and replacebg-[var(--bg-canvas)]with the exposed utility (bg-canvas) when one exists; leave unmapped arbitrary values for human review. - Additional rules under discussion (see report
2026-04-20):no-tailwind-color-literal— block raw Tailwind color literals likebg-red-500.no-spacing-literal— enforce spacing tokens overp-4-style literals.dark-mode-class-pair— detectdark:variants missing light/dark token pairs.
Companion
pnpm add @olundot/tokens
pnpm add -D @olundot/tokens-lint stylelint # CSS-side rules
pnpm add -D @olundot/eslint-plugin-tokens eslint # JSX-side rules