Skip to content

@tabula-css/stylelint-plugin

Tabula CSS governance: the five project-CSS rules (no @apply, no hand-authored rules, root-only custom properties, no @theme inline, no !important) as stylelint rules, plus the PostCSS kernel tabula check:css runs.

Install

bash
npm install --save-dev @tabula-css/stylelint-plugin

Dev dependency. postcss (^8.4.0) is a required peer; stylelint (^16.0.0) is an optional peer — the ./kernel export has no dependency on stylelint at all, which is what lets @tabula-css/cli's check:css reuse it without pulling stylelint into the CLI's graph.

Overview

Every ESLint rule in this profile governs .ts/.tsx; until this package existed, nothing in the system ever opened a project .css file. A hand-authored .card .title { color: red } in an imported stylesheet broke Locality with every other gate green. @tabula-css/stylelint-plugin closes that gap at authoring time — five rules, wired into your editor via stylelint — and shares its rule kernel with tabula check:css, so the two enforcement points read the exact same PostCSS logic and cannot disagree about what a .css file is allowed to contain.

Enable

js
// stylelint.config.js
import tabula from '@tabula-css/stylelint-plugin/config';

export default {
  ...tabula,
  overrides: [
    // The sanctioned entry — the one file holding `@import "../.tabula/source.css";` — is
    // exempt from `no-raw-rules` only, so it can also hold root-level application CSS.
    { files: ['src/app.css'], rules: { 'tabula/no-raw-rules': [true, { sanctionedEntry: true }] } },
  ],
};

@tabula-css/stylelint-plugin/config exports the shared config a project spreads into its own: all five rules on, no severity softening — these are the CSS half of the same closed vocabulary the ESLint plugin enforces in .tsx. Keep the overrides entry list identical to tabula check:css --css-entry's flags; the two gates share rule logic but not a config file, so the sanctioned-entry list is the one thing that can drift between them.

Rules

Every rule below fires over project CSS — any .css file you or an agent wrote — and, except where noted, over the emitted stylesheets in .tabula/ (a violation there is a generator bug, not an authoring mistake).

tabula/no-apply

TAB-E221. Disallows @apply anywhere. It composes a class list into a hand-written selector — exactly the indirection the profile removes.

css
/* ❌ violating */
.card { @apply p-4 rounded-md; }

/* ✅ passing — write the classes on the element instead */

tabula/no-raw-rules

TAB-E222. Disallows hand-authored rules outside the sanctioned entry stylesheet. Project CSS may contain, at the top level, only @import, @source, @utility, @custom-variant, @charset, a bodiless @layer a, b; order declaration, and comments — anything else (a style rule, @theme, @media, a @layer { … } block) is styling reached by selector rather than by class. Reported once per top-level block, project scope only.

css
/* ❌ violating */
.card p { color: red; }

/* ✅ passing */
@import "../.tabula/source.css";

Pass { sanctionedEntry: true } (via the overrides block above) to exempt exactly one file — the one holding the @import "../.tabula/source.css"; line — from this rule and no other.

tabula/no-scoped-custom-property

TAB-E223. The single most important check in the system. Disallows a profile custom property (--tb-* / --d-*) defined anywhere but a root subject (:root, html, or either refined by attribute selectors — :root[data-theme="dark"] counts, .card does not). Project CSS has no legitimate reason to write a profile property at all: dyn() is the sanctioned per-element channel, and it writes an inline style, not a stylesheet.

css
/* ❌ violating */
.panel { --tb-color-accent: red; }

/* ✅ passing */
:root[data-theme="dark"] { --tb-color-accent: #111; }

In the emitted scope this rule only flags a property that is a theme token (defined at :root somewhere in the emitted set) — the profile's own utilities legitimately write per-element composition properties (.ring-accent { --tb-ring-color: … }), which are inherits: false and per-element by design.

tabula/no-theme-inline

TAB-E224. Disallows @theme inline (matched as a whitespace-bounded option, so @theme inline reference and @theme static inline both trigger it). It inlines a token's value at its use site, so the root axis block can no longer re-point it for a different theme — the shadcn-ecosystem trap.

css
/* ❌ violating */
@theme inline { --tb-color-accent: red; }

/* ✅ passing */
@theme { --tb-color-accent: red; }

tabula/no-important-css

TAB-E225. Disallows !important on any declaration in project CSS. It puts a declaration outside the rank model cn()/resolve() rely on. Emitted CSS is generator output, so this rule does not run in the emitted scope.

css
/* ❌ violating */
.card { color: red !important; }

/* ✅ passing */
.card { color: red; }

@tabula-css/stylelint-plugin/kernel

ts
import {
  checkCss,
  CSS_RULE_CODES,
  collectRootDefinedProperties,
  isRootSubject,
  noApply,
  noImportantCss,
  noRawRules,
  noScopedCustomProperty,
  noThemeInline,
  type CheckCssOptions,
  type CssRuleName,
  type CssScope,
  type CssViolation,
} from '@tabula-css/stylelint-plugin/kernel';

The pure PostCSS implementation behind every rule above, with no stylelint and no filesystem in its import graph — this is exactly what @tabula-css/cli's check:css command imports directly, so the CLI and the editor plugin enforce byte-identical logic.

ExportSignature (simplified)What it does
checkCss(root: Root, opts: CheckCssOptions) => CssViolation[]Runs all five rules over a parsed stylesheet and returns violations in source order.
noApply, noRawRules, noScopedCustomProperty, noThemeInline, noImportantCss(root: Root, opts: CheckCssOptions) => CssViolation[]Each rule standalone, for callers that need one check in isolation.
collectRootDefinedProperties(root: Root) => Set<string>Every profile custom property defined at a root subject — the theme-token set no-scoped-custom-property needs to distinguish a token from a composition property in emitted scope.
isRootSubject(selector: string) => booleanWhether a selector string selects the document root and nothing else, in every comma branch.
CSS_RULE_CODESReadonly<Record<CssRuleName, ErrorCode>>The rule-name → TAB-Exxx mapping, frozen, so the CLI and the docs cannot disagree with the plugin.

CheckCssOptions carries scope ('project' | 'emitted', default 'project'), the file label for messages, sanctionedEntry (exempts no-raw-rules only), and — emitted scope only — tokenProperties (the set from collectRootDefinedProperties).

@tabula-css/stylelint-plugin/config

ts
import tabula, { config } from '@tabula-css/stylelint-plugin/config';

The shared stylelint config object: { plugins: ['@tabula-css/stylelint-plugin'], rules: { 'tabula/no-apply': true, 'tabula/no-raw-rules': true, 'tabula/no-scoped-custom-property': true, 'tabula/no-theme-inline': true, 'tabula/no-important-css': true } }. Spread it into your own stylelint.config.js as shown in Enable.

What tabula check:css runs

tabula check:css (and tabula build --check, unless passed --no-css) walks every project .css file plus the emitted stylesheets in .tabula/, and calls this package's checkCss kernel function on each — the project files with scope: 'project', the emitted files as one set with scope: 'emitted' and tokenProperties from collectRootDefinedProperties. See @tabula-css/cli for its flags and exit behavior.

See also

Released under the MIT License.