Enforcement

Run the rules, don't just read them

All 8 rules as one Biome setup, exceptions included. It is rebuilt from the rules whenever they change, so re-download it rather than maintaining a copy by hand — that way a rule we sharpen reaches your CI. Enforcing one rule at a time? Each rule's page carries its own snippet, plus a ripgrep one-liner for projects with no linter at all.

Nothing to install from us beyond Biome itself, which parses TSX with no parser to configure. These keys are a fragment to merge: the config says nothing about Biome's recommended rules, so dropping it in cannot silently change what else your project lints. You own all of it once it lands — soften a rule to warn, scope it with overrides, or drop an entry you disagree with.

Wire it up

# 1. Biome, if the project does not have it yet
npm i -D @biomejs/biome

# 2. The GritQL plugins — one per rule Biome has no built-in for
mkdir -p ui-lib-rules
curl -o ui-lib-rules/no-appearance-classes-on-layout-elements.grit https://ui-lib.quebi.de/api/rules/plugins/no-appearance-classes-on-layout-elements.grit
curl -o ui-lib-rules/no-hardcoded-design-values.grit https://ui-lib.quebi.de/api/rules/plugins/no-hardcoded-design-values.grit
curl -o ui-lib-rules/bind-fields-through-conform.grit https://ui-lib.quebi.de/api/rules/plugins/bind-fields-through-conform.grit
curl -o ui-lib-rules/render-field-text-through-the-field.grit https://ui-lib.quebi.de/api/rules/plugins/render-field-text-through-the-field.grit
curl -o ui-lib-rules/validate-on-the-server-with-the-same-schema.grit https://ui-lib.quebi.de/api/rules/plugins/validate-on-the-server-with-the-same-schema.grit

# 3. The config — merge these keys into your biome.jsonc
curl -O https://ui-lib.quebi.de/api/rules/biome.jsonc

npx biome lint src

How Biome carries each rule

Two mechanisms, and the difference decides how a rule's documented exceptions are applied.

Built-in rules
Interactive elements correctness/noRestrictedElements
Primitive imports style/noRestrictedImports
File length style/noExcessiveLinesPerFile

Configured in biome.jsonc, so their exceptions are ordinary overrides — Biome's own path scoping.

GritQL plugins

Biome loads plugins globally and overrides does not scope them, so each plugin carries its own exceptions as $filename guards compiled into the pattern. Same records, same carve-outs — a different mechanism because the tool requires one.

biome.jsonc — all 8 rules

/api/rules/biome.jsonc
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules.json — do not edit by hand.
//
// Every rule, message, and exception below comes from a rule record in quebi
// ui-lib, so this file and https://ui-lib.quebi.de/rules cannot drift apart.
//
// Merge these keys into your own biome.jsonc. The `plugins` entries are
// GritQL files served alongside this one — fetch them into ./ui-lib-rules/:
//   curl -o ui-lib-rules/no-appearance-classes-on-layout-elements.grit https://ui-lib.quebi.de/api/rules/plugins/no-appearance-classes-on-layout-elements.grit
//   curl -o ui-lib-rules/no-hardcoded-design-values.grit https://ui-lib.quebi.de/api/rules/plugins/no-hardcoded-design-values.grit
//   curl -o ui-lib-rules/bind-fields-through-conform.grit https://ui-lib.quebi.de/api/rules/plugins/bind-fields-through-conform.grit
//   curl -o ui-lib-rules/render-field-text-through-the-field.grit https://ui-lib.quebi.de/api/rules/plugins/render-field-text-through-the-field.grit
//   curl -o ui-lib-rules/validate-on-the-server-with-the-same-schema.grit https://ui-lib.quebi.de/api/rules/plugins/validate-on-the-server-with-the-same-schema.grit
//
// Biome's overrides do not scope plugins, so each plugin carries its own
// exceptions as $filename guards inside the pattern. The overrides below
// therefore only cover the built-in rules.

{
  "plugins": [
    "./ui-lib-rules/no-appearance-classes-on-layout-elements.grit", // Layout elements are yours — until their classes describe appearance
    "./ui-lib-rules/no-hardcoded-design-values.grit", // Design values come from quebi tokens, never from literals
    "./ui-lib-rules/bind-fields-through-conform.grit", // Bind fields through Conform, never by hand
    "./ui-lib-rules/render-field-text-through-the-field.grit", // Label, description and error come from the field — not from markup beside it
    "./ui-lib-rules/validate-on-the-server-with-the-same-schema.grit" // Validate on the server with the same schema
  ],
  "linter": {
    "rules": {
      "correctness": {
        "noRestrictedElements": {
          "level": "error",
          "options": {
            "elements": {
              "button": "Use <Button> from @/components/button (performs an action in place), or <LinkButton> from @/components/link-button (navigates — a control that changes the URL must be an anchor), or <Toggle> from @/components/toggle (has an on/off pressed state).",
              "a": "Use <Link> from @/components/link (an inline text link (it renders a plain anchor for http(s)/mailto/tel hrefs, so external links keep working outside a router)), or <LinkButton> from @/components/link-button (a link that should look like a button).",
              "input": "Use <TextField> from @/components/text-field (a labelled field with description and error text), or <Input> from @/components/input (the bare control, inside a Field you compose yourself), or <SearchField> from @/components/search-field (type=search), or <NumberField> from @/components/number-field (type=number — adds steppers and locale-aware parsing), or <Checkbox> from @/components/checkbox (type=checkbox), or <RadioGroup> from @/components/radio (type=radio), or <Switch> from @/components/switch (a boolean rendered as a switch).",
              "select": "Use <Select> from @/components/select (a single choice from a known list), or <ComboBox> from @/components/combo-box (the list is long enough to need typeahead), or <MultipleSelect> from @/components/multiple-select (multiple).",
              "textarea": "Use <Textarea> from @/components/textarea.",
              "form": "Use <Form> from react-router (submitting to a route action), or <ConformField> from @/components/conform-field (binding fields to a Conform form).",
              "label": "Use <Label> from @/components/field (composing a field by hand).",
              "dialog": "Use <Modal> from @/components/modal (a centered overlay), or <Drawer> from @/components/drawer (an edge-anchored panel), or <Sheet> from @/components/sheet (a side sheet), or <Dialog> from @/components/dialog (the dialog content itself (title, body, footer)).",
              "table": "Use <Table> from @/components/table (a static table (with TableHeader, TableBody, TableColumn, TableRow, TableCell)), or <AsyncTable> from @/components/async-table (sorting and filtering are server-driven)."
            }
          }
        }
      },
      "style": {
        "noRestrictedImports": {
          "level": "error",
          "options": {
            "paths": {
              "react-aria-components": {
                "importNames": [
                  "Autocomplete",
                  "Breadcrumb",
                  "Breadcrumbs",
                  "Button",
                  "Calendar",
                  "CalendarCell",
                  "CalendarGrid",
                  "CalendarGridBody",
                  "CalendarGridHeader",
                  "CalendarHeaderCell",
                  "CalendarStateContext",
                  "Cell",
                  "Checkbox",
                  "CheckboxGroup",
                  "Collection",
                  "CollectionRendererContext",
                  "ColorArea",
                  "ColorField",
                  "ColorPicker",
                  "ColorPickerStateContext",
                  "ColorSlider",
                  "ColorSwatch",
                  "ColorSwatchPicker",
                  "ColorSwatchPickerItem",
                  "ColorThumb",
                  "ColorWheel",
                  "ColorWheelTrack",
                  "Column",
                  "ColumnResizer",
                  "ComboBox",
                  "ComboBoxContext",
                  "DateField",
                  "DateInput",
                  "DatePicker",
                  "DateRangePicker",
                  "DateSegment",
                  "DefaultCollectionRenderer",
                  "Dialog",
                  "DialogTrigger",
                  "Disclosure",
                  "DisclosureGroup",
                  "DisclosurePanel",
                  "DisclosureStateContext",
                  "DropZone",
                  "FieldError",
                  "FileTrigger",
                  "GridList",
                  "GridListHeader",
                  "GridListItem",
                  "GridListSection",
                  "Group",
                  "Header",
                  "Heading",
                  "Input",
                  "Keyboard",
                  "Label",
                  "Link",
                  "ListBox",
                  "ListBoxItem",
                  "ListBoxSection",
                  "Menu",
                  "MenuItem",
                  "MenuSection",
                  "MenuTrigger",
                  "Meter",
                  "Modal",
                  "ModalContext",
                  "ModalOverlay",
                  "NumberField",
                  "OverlayArrow",
                  "OverlayTriggerStateContext",
                  "Popover",
                  "Pressable",
                  "ProgressBar",
                  "Radio",
                  "RadioGroup",
                  "RangeCalendar",
                  "RangeCalendarStateContext",
                  "ResizableTableContainer",
                  "Row",
                  "SearchField",
                  "Select",
                  "SelectValue",
                  "Separator",
                  "Slider",
                  "SliderOutput",
                  "SliderStateContext",
                  "SliderThumb",
                  "SliderTrack",
                  "SubmenuTrigger",
                  "Switch",
                  "Tab",
                  "TabList",
                  "TabPanel",
                  "TabPanels",
                  "Table",
                  "TableBody",
                  "TableHeader",
                  "Tabs",
                  "Tag",
                  "TagGroup",
                  "TagList",
                  "Text",
                  "TextArea",
                  "TextField",
                  "TimeField",
                  "ToggleButton",
                  "ToggleButtonGroup",
                  "Toolbar",
                  "Tooltip",
                  "TooltipTrigger",
                  "Tree",
                  "TreeItem",
                  "TreeItemContent"
                ],
                "message": "react-aria-components is ui-lib's dependency, not yours: import the quebi component instead (Button -> @/components/button, TextField -> @/components/text-field, and so on). Helpers the library does not wrap, like parseColor, and type-only imports stay allowed. See https://ui-lib.quebi.de/rules/import-components-not-primitives"
              }
            }
          }
        },
        "noExcessiveLinesPerFile": {
          "level": "warn",
          "options": {
            "maxLines": 500
          }
        }
      }
    }
  },
  "overrides": [
    {
      "includes": [
        // no-raw-interactive-elements — Only <input>, and only there.
        // no-appearance-classes-on-layout-elements — Components are made of appearance-styled divs — that is what a component is.
        // no-hardcoded-design-values — The components resolve palette scales deliberately — a danger state on red-500, a Badge intent on emerald-500 — and they are not yours to re-token.
        // import-components-not-primitives — This is the layer that imports the primitives — it is what makes the components components.
        // keep-files-readable — You did not write it and cannot split it without forking it.
        // bind-fields-through-conform — The conform-* variants are where getInputProps is called and the metadata is spread onto a control.
        // render-field-text-through-the-field — The conform-* variants and the field primitives are where this markup is supposed to live — they are the layer that renders the label, the description, and the error, and wires the ids between them.
        "components/ui/**",
        // no-raw-interactive-elements — Only <input>, and only there.
        // no-appearance-classes-on-layout-elements — Components are made of appearance-styled divs — that is what a component is.
        // no-hardcoded-design-values — The components resolve palette scales deliberately — a danger state on red-500, a Badge intent on emerald-500 — and they are not yours to re-token.
        // import-components-not-primitives — This is the layer that imports the primitives — it is what makes the components components.
        // keep-files-readable — You did not write it and cannot split it without forking it.
        // bind-fields-through-conform — The conform-* variants are where getInputProps is called and the metadata is spread onto a control.
        // render-field-text-through-the-field — The conform-* variants and the field primitives are where this markup is supposed to live — they are the layer that renders the label, the description, and the error, and wires the ids between them.
        "src/components/**"
      ],
      "linter": {
        "rules": {
          "correctness": {
            "noRestrictedElements": {
              "level": "error",
              "options": {
                "elements": {
                  "button": "Use <Button> from @/components/button (performs an action in place), or <LinkButton> from @/components/link-button (navigates — a control that changes the URL must be an anchor), or <Toggle> from @/components/toggle (has an on/off pressed state).",
                  "a": "Use <Link> from @/components/link (an inline text link (it renders a plain anchor for http(s)/mailto/tel hrefs, so external links keep working outside a router)), or <LinkButton> from @/components/link-button (a link that should look like a button).",
                  "select": "Use <Select> from @/components/select (a single choice from a known list), or <ComboBox> from @/components/combo-box (the list is long enough to need typeahead), or <MultipleSelect> from @/components/multiple-select (multiple).",
                  "textarea": "Use <Textarea> from @/components/textarea.",
                  "form": "Use <Form> from react-router (submitting to a route action), or <ConformField> from @/components/conform-field (binding fields to a Conform form).",
                  "label": "Use <Label> from @/components/field (composing a field by hand).",
                  "dialog": "Use <Modal> from @/components/modal (a centered overlay), or <Drawer> from @/components/drawer (an edge-anchored panel), or <Sheet> from @/components/sheet (a side sheet), or <Dialog> from @/components/dialog (the dialog content itself (title, body, footer)).",
                  "table": "Use <Table> from @/components/table (a static table (with TableHeader, TableBody, TableColumn, TableRow, TableCell)), or <AsyncTable> from @/components/async-table (sorting and filtering are server-driven)."
                }
              }
            }
          },
          "style": {
            "noRestrictedImports": "off",
            "noExcessiveLinesPerFile": "off"
          }
        }
      }
    }
  ]
}
8 rules
Back to the rules
Every rule names what to import instead of what it forbids, shows a real wrong/right pair, and lists the exceptions this config applies.