guidelines — 15 rules

rules

15 rules for building an app with ui-lib components. Each one names what to import instead of what it forbids, shows a real wrong/right pair, and ships a check you can run in your own project. Agents can read the same rules as JSON from /api/rules.json.

why these exist

04 failure modes

These rules are written for the code an agent writes. Not because agents are careless — because they optimise for the shortest path to something that looks right, and four specific detours are the predictable result.

It rebuilds what already exists

Asked for a card, a button, a badge, an agent writes one. Producing four Tailwind classes is faster and more certain than discovering that the library ships the component — and the result renders correctly, so nothing in review objects. The same reflex reaches past the library for the values inside those components: toLocaleString() is one call, and it looks right on the machine that wrote it. Repeated across a codebase, that is how a design system stops being a system: not through one bad decision, but through fifty reasonable ones in fifty files. And once it has found the component, it uses it for every box — a card per section inside a card — which is the library's own surface, pressed into service as a layout.

It hand-rolls forms it was told to bind

Forms are where an agent's training data pulls hardest against the project's conventions. It writes useState, value, onChange and a literal name because that is the shape it has seen most often, rather than reading the binding off Conform's field metadata or posting to a React Router action. The form submits, so it looks finished — while the error wiring, the default values, and the server-side validation are simply absent.

It takes the shortcut the runtime offers

A global needs no import, no provider and no discovery. alert() is there; Array.prototype.sort is there; both produce something that works on the first click, in the one tab the agent can see. Neither is wrong as JavaScript, which is what makes them hard to catch in review. But alert() draws a dialog the design system does not own, cannot theme and cannot render in a test, and sorting the rows already in memory reorders the answer the server just gave rather than asking it a different question — so the table and the query behind it quietly stop agreeing. The shape that would have been right in both cases — a toast under a provider, an awaited confirmation under another, a re-query through the callback the component already exposes — costs an import and a round trip. The shortcut costs neither, and that is the whole of its appeal.

It appends until the file is unreadable

Adding to the file already open is the cheapest edit available, and an agent never has to scroll the result. Nothing pushes back at 400 lines, or 900, or 2,000 — and past that point no human reviews the file properly and every later agent edit starts from a worse position, because reading it costs more and landing an edit in the right place is less certain.

Why a linter, and not just instructions

Instructions are read once, at best
A CLAUDE.md or a skill competes for attention with the actual task, and loses — it is skipped when context is tight, skimmed when it is long, and interpreted generously when it is vague. None of that is visible to you: the agent does not report which of your conventions it decided not to apply.
A rule is checked every time, on every file
A lint run does not get tired, does not run out of context, and does not weigh your convention against the feature it is trying to ship. It applies the same way on the first file and the four-hundredth.
The error arrives where the mistake is
This is the part that changes agent behaviour. A rule does not say "follow the design system" — it says, on the line in question, "use <Button> from @/components/button". That is a correction an agent can act on immediately and verify, which is why every message here names the replacement rather than the offence.
It survives the people who wrote it
Instructions decay as the codebase changes and nobody notices. These rules are generated from records the build validates, so a rule pointing at a component that no longer exists fails CI instead of quietly misleading whoever reads it next.

element usage

07 rules

Layout is yours. Appearance is the library's.

When a raw HTML element is allowed in an app that uses quebi ui-lib, and which component to import when it is not. Seven tiers, outward from the element: interactive and semantic elements are always the library's; layout elements are yours as long as they only lay things out; design values are always tokens; the primitives underneath belong to the library layer; a file past 500 lines is doing more than one job; the numbers and dates rendered inside all of it are formatted through the library, with a locale that is written down; and the library's own surfaces are not stacked inside each other — a card inside a card is a section.

  1. 01Never render interactive or semantic HTML elements directlybutton, input, select, textarea, a, form, label, dialog and table are the library's. Import the component instead — this holds whether or not you style the element.tier 1 · error · lint
  2. 02Layout elements are yours — until their classes describe appearancediv, span, section, ul, li and friends are fine and necessary. Keep their classes to layout and spacing; the moment you add bg-*, border-*, rounded-*, shadow-* or text sizing/colour you are rebuilding a component that already exists.tier 2 · warn · lint
  3. 03Design values come from quebi tokens, never from literalsNo arbitrary values (bg-[#f00], text-[13px]), no raw Tailwind palette scales (text-gray-500), no hex in style props. Use the quebi token that means the thing — and if a value is genuinely domain-mandated, record it as an exception with its justification.tier 3 · error · lint
  4. 04Import the library's components, not the primitives underneath themreact-aria-components is ui-lib's dependency, not yours. In app code import <Button> from @/components/button, never from react-aria-components — the primitive is unstyled and knows nothing about the quebi variants.tier 4 · error · lint
  5. 05Keep a route or component file under 500 linesA file past 500 lines is doing more than one job. Split it — extract the sub-components, move the helpers, lift the data. This is a warning, not an error: the number is a prompt to look, not a law.tier 5 · warn · lint
  6. 06Format numbers and dates through the library, with an explicit localetoLocaleString() and a bare new Intl.NumberFormat() resolve against whatever locale the runtime happens to have, and a .toString() rendered as JSX text prints the wire format in all of them. Render FormattedNumber / FormattedDate, or call formatNumber / formatCurrency with a locale you passed in.tier 6 · error · lint
  7. 07A card inside a card is a sectionNever write a Card inside another Card. The inner one is separating part of the outer one from the rest, and a Heading does that — with a Separator if a line is needed. Items that share the same fields are rows in a Table or a list, not cards.tier 7 · warn · lint

forms

06 rules

Validation is yours. Wiring is the library's.

How a form is bound, where its errors come from, what has to be true on the server, and the three details that make the difference between a form that submits and a form that works. The schema is your app's — it encodes rules nobody else can know. Everything between that schema and the DOM (name, id, defaultValue, aria-describedby, the error text itself) belongs to the components, and hand-wiring it is how a form quietly loses its accessibility while still looking correct.

  1. 08Bind fields through Conform, never by handTake the binding off the field metadata: the conform-* variant, which every submittable control now has, getInputProps (or useControl) for anything else, and getFormProps on the form element. Per-field useState and a hand-passed name are the failure mode.tier 1 · error · lint
  2. 09Label, description and error come from the field — not from markup beside itUse the field's own slots: the label/description props on a conform-* variant, Label and Description inside a react-aria field, and for errors either FieldError (inside a field context) or an element carrying id={field.errorId}. A red paragraph next to a control is not attached to it.tier 2 · error · lint
  3. 10Validate on the server with the same schemaClient validation is UX. Export one schema, parse it again in the route action, return submission.reply(), and feed that back through useForm({ lastResult }) — a form whose only validation is onValidate is an unvalidated form.tier 3 · error · lint
  4. 11Never spread getInputProps onto a Checkbox or SwitchgetInputProps emits the DOM names, defaultChecked and required; react-aria's Checkbox and Switch read defaultSelected (and isRequired, on Checkbox) and drop the others without a word. Use ConformCheckbox or ConformSwitch.tier 4 · error · lint
  5. 12Gate lastResult on an idle navigationPass lastResult only while the navigation is idle — `navigation.state === "idle" ? lastResult : null`. Conform re-reports the result on every render, so an ungated one is applied before React Router has finished revalidating the loader data behind it.tier 5 · error · lint
  6. 13An intent button submits — never type="button"getButtonProps supplies name, value, form and formNoValidate, but never type. A Conform intent button has to be type="submit": with type="button" no submit event fires and the insert, remove or reorder is dropped in silence.tier 6 · error · lint

platform defaults

02 rules

The platform will do it for you. That is not the same as your app doing it.

Two rules about the shortcut the runtime hands you for free. alert() and confirm() draw a dialog with no import, no provider and no state — and no theme, no focus management and nothing to render in a test, because that dialog belongs to the browser rather than to the app. Sorting the array you are already holding reorders the table with no round trip — and no agreement with the server that produced the rows, which is the one thing a server-driven table is for. Both warn rather than fail: the fix is a change of shape, not a change of import, and that is a judgement the linter cannot make for you.

  1. 14Never alert(), confirm() or prompt()These three draw the browser's own dialog: unthemed, blocking, and outside React's tree. Show feedback with a Toast under a ToastProvider, and ask a question with `await confirm(...)` from useConfirm() under a ConfirmProvider.error · lint
  2. 15Sorting a server-driven table is a query, not an array methodServerTable is controlled: it reports the whole new query through onQueryChange and draws its header from the query it was given. Re-query and pass the rows back. If the whole dataset is already in the browser, that is a DataTable, not a ServerTable.warn · lint

enforcement

run these in your own project

All 15 rules as one Biome config, with a GritQL plugin for each rule Biome has no built-in for and the documented exceptions already applied.

set it up →