Guidelines
Rules
8 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
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 three 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. 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.
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 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
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. Three tiers: 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.
- Tier 1errorlintNever 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.Read the rule
- Tier 2warnlintLayout 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.Read the rule
- Tier 3errorlintDesign 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.Read the rule
- Tier 4errorlintImport 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.Read the rule
- Tier 5warnlintKeep 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.Read the rule
Forms
Validation is yours. Wiring is the library's.
How a form is bound, where its errors come from, and what has to be true on the server. 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.
- Tier 1errorlintBind fields through Conform, never by handTake the binding off the field metadata: the conform-* variant where one exists, getInputProps (or useInputControl) where it doesn't, and getFormProps on the form element. Per-field useState and a hand-passed name are the failure mode.Read the rule
- Tier 2errorlintLabel, 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.Read the rule
- Tier 3errorlintValidate 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.Read the rule
Enforcement
Run these in your own project
All 8 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