@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
npm install --save-dev @tabula-css/stylelint-pluginDev 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
// 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.
/* ❌ 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.
/* ❌ 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.
/* ❌ 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.
/* ❌ 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.
/* ❌ violating */
.card { color: red !important; }
/* ✅ passing */
.card { color: red; }@tabula-css/stylelint-plugin/kernel
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.
| Export | Signature (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) => boolean | Whether a selector string selects the document root and nothing else, in every comma branch. |
CSS_RULE_CODES | Readonly<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
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
@tabula-css/cli— the CI-side gate sharing this package's kernel.- Concepts — why each of the five rules exists.
- CSS governance — the full picture, including what the gate still does not cover.
- Getting started — wiring the sanctioned entry stylesheet.