Lint
php artisan ui:check lints your
Blade views against the design system. Your components stay flexible; you decide how they may be used.
When a class breaks the rules, the finding says what to use instead: the component's variants and
sizes, the nearest theme token, the scale step with the same value, or the spelling you meant.
Agents read the same text, so a violation costs one correction round, not a guess.
Note
resources/css/ui.css
(and its imports) for tokens, @utility names, custom variants and class selectors,
and your ui-lock.json for each component's contract.Run it
php artisan ui:check
php artisan ui:check --fail-on=warning # CI gate
php artisan ui:check --format=sarif > brok.sarif # editors and code scanning
php artisan ui:check --json # agents and scripts
php artisan ui:check --explain no-restyle # one rule, its options, its docs
php artisan ui:check --list-rules # every rule with its effective level
php artisan ui:check --fix # apply exact replacements and spelling fixes, then re-check
php artisan ui:check --fix --unsafe-fixes # also apply approximations (nearest theme colour)
Every finding carries a stable id (UI-408), a name
(no-restyle), the file and line, the guidance, and a link to this documentation.
A finding that knows the exact replacement carries a fix; --fix
applies the safe ones (a scale step with the same value, a spelling correction, the logical twin of a physical utility)
and re-checks. A nearest-colour suggestion is an approximation and needs --unsafe-fixes.
UI-408 resources/views/orders/edit.blade.php:12 ........ ERROR no-restyle
"p-4" is not allowed on <button>: <button> owns its spacing. Use a size (sm, md, lg, icon),
or margin on it, gap on the parent element, or padding on the container for space around it.
Configure
Add a lint block to ui.json. Each rule takes a level
(off, warning, error)
or [level, options]. Rules are keyed by name or id; a rule you do not list keeps its default.
Component internals under resources/views/components/ui are outside the default scan,
so components can style themselves; a rule's include adds paths it should also run on
(keep no-raw-colors and no-unknown-classes on inside the component
directory, as shadcn recommends), and exclude takes paths away from any rule. Both accept
project-relative prefixes or globs.
{
"lint": {
"note": "See docs/design-rules.md for approved exceptions.",
"rules": {
"no-restyle": ["error", {
"allow": ["layout"],
"contracts": [
{ "pattern": "^card\\.title$", "allow": ["layout", "typography"], "deny": ["font-*"] },
{ "pattern": "^card\\.content$", "allow": ["layout", "spacing"] }
]
}],
"no-raw-colors": "error",
"no-arbitrary-values": ["warning", { "allow": ["layout"] }],
"no-inline-styles": ["error", { "allow": ["transform"] }],
"no-unknown-classes": ["warning", { "include": ["resources/views/components/ui"] }],
"require-static-classes": "error",
"no-dark-color-override": "off",
"no-physical-direction": ["warning", { "exclude": ["resources/views/legacy/**"] }]
}
}
}
A mistake in the block, such as a misspelled rule, an unknown option or a category typo, is reported as
UI-006 invalid-config on ui.json, and the affected rule pauses
until it is fixed. Nothing is silently ignored.
The policy
The class rules share one option shape. allow exempts matching classes,
deny removes matches from allow, and
contracts set a policy for the components a pattern matches.
| Configuration | Policy |
|---|---|
| neither | No exceptions. |
| allow: [...] | Only matching classes are exempt. |
| deny: [...] | Everything except these matches is exempt. |
| allow + deny | Allow minus deny. |
Entries are categories (layout, color,
typography, spacing, shape,
effects, motion), class groups
(px, bg-color, rounded),
or classes and wildcard patterns (p-4, p-*,
md:p-*). An entry without a colon matches the base class, ignoring variants,
important markers, negative prefixes and opacity modifiers; an entry with a colon matches the whole class.
Margin, sizing, position, transforms and text alignment are layout; padding and gap are spacing.
A contract's pattern is a regex on the component name as written after the prefix:
^button$ matches only <brok:button>;
^card\. matches every card part. A contract replaces the keys it writes and
inherits the rest; when several match, the last one applies. For no-inline-styles
the entries are CSS property names (transform, border-*,
--*).
Your own words
Every rule accepts message. no-restyle also accepts an object keyed by
category with a default. A contract can carry its own message. The linter picks the contract's
category message, then its default, then the rule's message, then the built-in guidance.
{{key|fallback}} fills an empty slot; an unknown key stays literal and a near-miss is warned about.
note is appended to every finding.
"no-restyle": ["error", {
"allow": ["layout"],
"message": {
"spacing": "Use a {{component}} size: {{sizes|none defined}}.",
"default": "Use a {{component}} variant: {{variants|none defined}}."
}
}],
"no-raw-colors": ["error", {
"message": "Use a theme color for \"{{className}}\". See {{file}}."
}]
| Placeholder | Value |
|---|---|
| className | The class, or an attribute such as fill="#f00". |
| component | The component name, empty on plain elements. |
| property | The inline CSS property. |
| suggestions | Nearby tokens, the scale replacement, or a spelling correction. |
| file | The theme CSS, or the recipe file for variants. |
| category, variants, sizes, entries, around | On no-restyle findings. |
| tokens, suggestion, replacement, attribute, value | On token and class findings that provide them. |
Suppress one line
A reasoned comment on the preceding line suppresses one rule by name or id. Malformed, unknown and unused
suppressions fail as UI-005, so an exception never outlives its reason.
{{-- ui-lint-disable-next-line no-raw-colors: Brand moment approved by design, see DES-142. --}}
<span class="bg-pink-500">New</span>
For agents
Findings are written to be acted on, never to be argued with: no message asks the reader to change the policy.
--json returns every finding with its meta
(the component, the class, the allowed values, the suggestions) and a docs link.
--explain <rule> prints the rule's contract, options and effective level.
--path takes a single file, --stdin=<path> lints an unsaved buffer,
and --watch re-runs on every change, so the loop stays under a second.
Put php artisan ui:check --json in your agent instructions and let it run after each edit;
the MCP server exposes the same rule catalog.
Does it pay off?
We ran the shadcn/lint eval method against ui:check: three Blade tasks (a settings page, a pricing
table, an orders table), an agent with no tools that answers with the file, up to three correction rounds, and the shipped linter
as the only judge. The feedback after each round was either a generic "it still breaks the design rules" or the exact
ui:check findings.
| Model, feedback | Reached zero | Rounds (mean) | Cost per task at API list price (mean) |
|---|---|---|---|
| Sonnet 5, generic nudge | 3 / 12 | 1.3 when it did | $0.36 – $0.55 |
Sonnet 5, ui:check findings | 12 / 12 | 0.5 – 1.3 | $0.08 |
| Opus 5, generic nudge | 1 / 6 | 3.0 when it did | $0.56 – $0.75 |
Opus 5, ui:check findings | 5 / 6 | 0.5 – 1.0 | $0.25 – $0.26 |
With the findings in the loop the agent reached zero almost always in one round; with a nudge it fixed what it guessed and left
the rest, round after round. The findings loop cost 55 to 86 % less per task (token cost at API list price, as the CLI reports it). Small sample (36 cells, September 2026); the
harness, the raw cells and the caveats are in packages/evals/lint.
Editors
The Brok Lint extension for VS Code (packages/vscode-brok-lint) runs
ui:check --stdin on the unsaved buffer as you type and shows each finding on its class, with the rule id
as the code, the docs page one click away, and a quick fix wherever the finding carries one; "Apply safe lint fixes in file"
applies them all. Any editor that reads SARIF can use ui:check --format=sarif instead.
JSX and the extension kit
@brokui/eslint-plugin holds Preact and React code to the same six class rules: the engines are
shadcn/lint's, the defaults, the
UI-nnn ids and the docs links are this catalog's, so a finding on a <Button>
in the extension kit reads like one on <brok:button>. Add
recommended() from the package to your flat ESLint config.
Rules
Every rule has a page with examples, options and limits. Six rules police classes with the shared policy; the rest read component contracts, accessibility and Livewire usage.
Tokens and classes
| UI-301 | no-raw-colors | Use theme colours. Reports raw palette colours, undeclared colour tokens, and literal colours in SVG attributes. | warning |
| UI-302 | no-raw-spacing | Spacing must come from the approved scale, not inline lengths or off-scale steps. | warning |
| UI-303 | no-raw-z-index | Use the semantic z-index layers, never numeric z-index values. | warning |
| UI-304 | no-physical-direction | Use logical utilities (ms-, pe-, start-) so layouts mirror under RTL. | warning |
| UI-305 | no-dark-color-override | Dark mode is driven by tokens, not per-utility dark: colour overrides. | warning |
| UI-306 | no-arbitrary-values | Use theme tokens and scale values instead of arbitrary values such as p-[13px]. | warning |
| UI-307 | no-inline-styles | Style through classes. Reports inline style properties, unreadable style bindings, and <style> elements. | warning |
| UI-308 | no-unknown-classes | Catch class names and variants the project's Tailwind cannot generate, with spelling suggestions. | warning |
| UI-309 | require-static-classes | Class values on design-system components must be readable by the linter. | warning |
Component contracts
| UI-401 | no-invalid-variant | A variant or size prop must use one of the values the component defines. | error |
| UI-402 | no-deprecated-component | The component is deprecated; use its replacement. | warning |
| UI-403 | no-deprecated-api | The prop or slot is deprecated; follow the published migration. | warning |
| UI-404 | no-internal-classes | Consumer views must not reference internal package classes. | error |
| UI-405 | no-local-fork | An application component looks like a copy of an installed one; extend it instead. | warning |
| UI-406 | no-unknown-component | The component is not installed or is missing from ui-lock.json. | error |
| UI-407 | no-invalid-component-api | A prop, slot, or child must match the component's published API. | error |
| UI-408 | no-restyle | Classes on a design-system component are limited to what the policy and the component's contract allow; appearance comes from variants. | warning |
Adoption
| UI-101 | no-raw-button | Use <brok:button> instead of a raw <button>. | warning |
| UI-102 | no-raw-controls | Use the design-system control instead of a raw <input>, <select>, or <textarea>. | warning |
| UI-103 | require-field-wrapper | Form controls belong inside <brok:field>, which wires label, description, and error. | warning |
| UI-104 | no-mixed-composition | A view that uses the Brok API must not mix in raw controls. | error |
| UI-105 | no-raw-forms | Use <brok:form> instead of a raw <form>. | warning |
| UI-106 | no-raw-composite | Hand-built markup duplicates a composite Brok already ships (table, tabs, accordion, progress, switch, tooltip, dropdown). Use the matching component. | warning |
Accessibility
| UI-201 | require-accessible-label | Every control needs a detectable accessible label. | error |
| UI-202 | require-icon-label | Icon-only controls need an accessible name. | error |
| UI-203 | no-modal-bypass | Use <brok:dialog> instead of a raw <dialog> or a hand-rolled modal. | warning |
| UI-206 | no-invalid-interactive-element | Clickable markup must be a native button or link. | error |
| UI-207 | require-motion-guard | Animation utilities need a reduced-motion guard. | warning |
| UI-208 | no-broken-references | Static ids must be unique and ARIA references must resolve. | error |
| UI-213 | require-loading-announcement | Loading state must be announced to assistive technology. | warning |
| UI-214 | require-destructive-confirmation | Destructive actions need a confirmation step. | warning |
Livewire
| UI-204 | livewire-loading | Livewire actions need loading state the component exposes. | warning |
| UI-205 | livewire-contract | A component must be used the way its Livewire behavior contract allows. | error |
| UI-209 | require-stable-wire-key | wire:key must be a stable identifier, not a loop index. | error |
| UI-210 | no-eager-live-model | Text inputs with wire:model.live need .debounce, .throttle, or .blur. | warning |
| UI-211 | field-binding-match | A field wrapper and its control must bind the same name. | error |
| UI-212 | no-ignored-livewire-directive | A Livewire directive on an element that cannot honor it is ignored at runtime. | warning |
| UI-215 | require-async-feedback | Custom async actions need disabled and aria-busy bindings. | warning |
| UI-216 | require-server-validation | Forms need server-side validation wiring. | warning |
| UI-217 | require-unsaved-guard | Forms with unsaved state need a navigation guard. | warning |
Boundaries
| UI-901 | core-package-boundary | The runtime package must not contain installable Blade components. | error |
| UI-902 | no-product-domain-import | Shared UI must not import product domain code. | error |