Claude Code hook · CI check · MCP server · MIT
Keeps coding agents on your design system.
It knows your real components, props, variants and tokens, catches the moment an agent invents one and has it fix it, and the same check gates your PRs. Local, zero config, works alongside @shadcn/lint.
In Claude Code, the plugin's hook checks each file right after it is written and hands the errors back, each with its fix. Other agents run check_ui on their own output, and the CI check gates whatever they wrote. Built for design-system and platform teams with a React design-system package; a shadcn/ui app works with no config at all.
Claude Code plugin· hook, server, skill
/plugin marketplace add dgesteves/onsystem/plugin install onsystem@dgesteves
Any MCP client, over stdio
$ npx -y onsystem~/acme-app — agent session
import { Trash2 } from "lucide-react"import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"export function DangerZone({ onDelete }: { onDelete: () => void }) { return ( <Card className="border-[#ef4444] p-[13px]"> <CardHeader> <CardTitle>Delete workspace</CardTitle> <Card.Description>This cannot be undone.</Card.Description> </CardHeader> <CardContent className="flex items-center gap-3"> <Badge tone="warning">Irreversible</Badge> <Button variant="danger" onClick={onDelete}> Delete workspace </Button> <Button variant="ghost" size="icon"> <Trash2 /> </Button> <button className="rounded-[7px] bg-gray-100 px-3 text-sm"> Cancel </button> <p style={{ color: "#737373", marginTop: 6 }}>Owners only.</p> </CardContent> </Card> )}Add a danger-zone card to the workspace settings page
- Write app/settings/danger-zone.tsx +29 lines
- onsystem · check_ui (path: "app/settings/danger-zone.tsx")
8 errors·3 warningseach with a rule id, a location and a fix
- 9:22no-hardcoded-colorerrorborder-[#ef4444] → border-destructive
- 9:39no-hardcoded-spacingwarningp-[13px] → p-3
- 12:10no-unknown-componenterror<Card.Description> → <CardDescription>
- 15:16no-unknown-properrortone= → variant=
- 16:25no-unknown-varianterrorvariant="danger" → variant="destructive"
- 19:10icon-button-accessible-nameerror<Button size="icon"> → aria-label="Delete"
- 22:10prefer-design-system-componenterror<button> → <Button>
- 22:28no-hardcoded-radiuswarningrounded-[7px] → rounded-sm
- 22:42no-hardcoded-colorerrorbg-gray-100 → bg-muted
- 25:29no-hardcoded-colorerrorcolor: "#737373" → text-muted-foreground
- 25:50no-hardcoded-spacingwarningmarginTop: 6 → mt-1.5
- Edit app/settings/danger-zone.tsx resolved all 11 findings
- check_ui No design-system problems
The agent writes the card from what it learned in training.
Real output: the findings and fixes are what check_ui returns for this file against the demo design system, generated when this site was built. Try it on your own code
The problem
Your agent can't see your Storybook. TypeScript only catches part of it.
Ask for a settings card in a shadcn/ui project and you get bg-[#ef4444], p-[13px], a native <button> with hand-rolled classes, variant="danger" on a Button that only knows destructive, and an icon button nobody can name with a screen reader.
On the draft above, tsc reports 3 of the 11 problems: the invalid variant, the unknown prop and the missing member. It has no opinion on hex colors, off-scale spacing, native elements or accessible names. Review catches the rest, when someone has the time.
Does it help?
The same ten components, built twice per model.
Claude Code built ten components a chat product needs for vercel/ai-chatbot, a real shadcn/ui app: once as it ships, once with the plugin. Then onsystem check scored every file it wrote.
Components with no design-system errors
Design-system errors, all ten tasks
- Cost
- −11%
- $1.31 → $1.17
- Avg. time
- 46 → 42 s
- Tool calls
- 5.6 per task
Components with no design-system errors
Design-system errors, all ten tasks
- Cost
- +7%
- $7.74 → $8.31
- Avg. time
- 108 → 141 s
- Tool calls
- 7.3 per task
Every task, every runShowHide
| Task | Haiku 4.5without | Haiku 4.5with plugin | Opus 5without | Opus 5with plugin |
|---|---|---|---|---|
| Usage bannera dismissible warning banner shown when the user is close to their monthly message limit ("You've used 90% of your messages this month"), with an Upgrade button and an icon-only close button. | 1 error1 × icon-button-accessible-name | clean | clean | clean |
| Delete dialoga confirmation dialog for deleting all chats, with a Cancel action and a red, destructive confirm action. | clean | clean | clean | clean |
| Settings carda card with a title, a description, an API key text input, a Save button and a small green 'Connected' status label. | 2 errors2 × no-hardcoded-color | clean | 4 errors4 × no-hardcoded-color | clean |
| Empty historyan empty state for the chat history sidebar with an icon, a short message and a 'New chat' button. | clean | clean | clean | clean |
| Message toolbara row of icon-only buttons under an assistant message: copy, regenerate, thumbs up and thumbs down. | clean | clean | clean | clean |
| Upgrade carda pricing card for the Pro plan with the price, a list of four features with check icons, a 'Most popular' label and an 'Upgrade to Pro' button. | clean | clean | clean | clean |
| Rate limit alertan inline red error alert telling the user they were rate limited, showing when they can retry, with a 'Try again' button. | 9 errors9 × no-hardcoded-color | clean | clean | clean |
| Share dialoga dialog showing the share link in a read-only input, with an icon-only copy button next to it and a secondary 'Make private' action. | 1 error1 × prefer-design-system-component | clean | clean | clean |
| Profile carda small card with the user's avatar initials in a colored circle, their name and email, a 'Free plan' label and a 'Sign out' button. | clean | clean | 1 error1 × no-hardcoded-color | clean |
| Shortcuts dialoga dialog listing keyboard shortcuts (New chat ⌘K, Toggle sidebar ⌘B, Copy last answer ⌘⇧C) in two columns, with styled key labels. | clean | clean | clean | clean |
Without the plugin, the misses were raw colors for things the prompt described (“a red alert”, “a green label”), a native <label> where the project has Label, and an icon button nobody could name with a screen reader. With it, the agent looked components and tokens up before writing (5.6 to 7.3 design-system tool calls per task), and the hook that checks each file never had to step in.
It is one project and forty runs, one per task, model and condition, so read it as a direction rather than a rate. Opus 5 already does well by copying the codebase; the smaller model gained the most, and cost less with the plugin than without. The method, per-run results and every generated file are in the repository.
On real codebases, with zero config
Run as is, with no config, on public apps: the counts the corpus snapshot records, rechecked on every pull request that changes the rules. These counts are not a judgement of the teams: hardcoded values and unlabeled icon buttons slip through review everywhere, and agents copy what they see.
How often it is wrong is measured, not guessed: the corpus runs check on 10 public repositories pinned by commit (21 runs, 3,702 distinct findings) and compares the findings with hand labels. In a random sample of 126, 66 are clearly right, 55 debatable and 5 false positives (4.0%); weighted by run and rule, about 1.0% of all findings are false positives.
vercel/ai-chatbot
c2f8235- Found with zero config
components.json→ 23 components, 52 tokenscheckfindings- 72 in
app/andcomponents/: 41 raw colors, 19 native elements the design system wraps, 11 icon-only buttons without an accessible name, 1 arbitrary radius
midday
5158731,apps/dashboard- Found with zero config
- workspace package
@midday/ui→ 79 components checkfindings- 1,232 in
src/, including 97 icon-only buttons without an accessible name. Adopted with a baseline
shadcn/ui website
0132174- Found with zero config
- custom
uialias → 68 components checkfindings- 115 in
app/andcomponents/, mostly raw colors (96)
How it works
Reads your source. Checks every edit. Gates the pull request.
Static analysis from start to finish: no model calls, no API key, and it runs offline. One check of the demo file takes 1.1 ms, so it can run after every edit.
01
Reads your design system
One TypeScript program over your component files, with your tsconfig, so path aliases and dependency types resolve.
- Props, with types, defaults and JSDoc
cva()andtv()variants, and the classes each applies- Parts:
CardHeadernext toCard, orCard.Header - The native element each component wraps
- Tokens from CSS variables, Tailwind v4
@themeor a v3 config, and DTCG JSON - Docs and examples from Markdown next to the components
No config for a shadcn/ui components.json, an app whose components live in a workspace package (@acme/ui), or the design-system package itself. How it finds them.
02
Checks every edit and every pull request
In Claude Code, the plugin's hook runs these rules right after each file is written and hands the errors back for Claude to fix. In CI, check runs them on every pull request.
- no-hardcoded-colorerror
- no-hardcoded-spacingwarn
- no-hardcoded-radiuswarn
- prefer-design-system-componenterror
- no-unknown-componenterror
- no-unknown-properror
- no-unknown-varianterror
- icon-button-accessible-nameerror
Any agent can run them itself as check_ui, which parses the code on its own, so it works on fragments the agent has not saved.
03
Answers the agent over MCP
list_components
List design-system components
get_component
Get a component contract
search_components
Search components by intent
get_tokens
Get design tokens
check_ui
Check UI code against the design system
Plus ds://tokens and ds://components/{name} as resources, and a build-with-design-system prompt. Every tool is read-only.
What the agent sees
Compact Markdown for the model, with JSON structuredContent for programs. These are the server's responses on the demo design system, captured when this site was built.
> get_component name: "Badge"# BadgeA small status label: counts, states ("Active", "Overdue") or categories.Not interactive by default; pass `asChild` to render a link.import { Badge } from "@/components/ui/badge"Renders <span> · components/ui/badge.tsx:29 · docs: docs/badge.md## Props- variant?: "default" | "secondary" | "destructive" | "success" | "outline" = "default"- asChild?: boolean = false — Render the child element with badge styles instead of a `<span>`.- …plus 280 props from React.ComponentProps<"span"> (onClick, id, role, children, aria-*, data-*, …)## Variantsvariant (default "default") default border-transparent bg-primary text-primary-foreground secondary border-transparent bg-secondary text-secondary-foreground destructive border-transparent bg-destructive text-white success border-transparent bg-success text-success-foreground outline text-foreground## Design tokens usedradius-md (rounded-md), ring (ring-ring), primary (bg-primary), primary-foreground (text-primary-foreground), secondary (bg-secondary), secondary-foreground (text-secondary-foreground), destructive (bg-destructive), success (bg-success), success-foreground (text-success-foreground), foreground (text-foreground)## Docs (docs/badge.md)### Guidelines- Keep the label to one or two words.- Map meaning to variants, not to colors: `success` for healthy or completed states, `destructive` for errors and overdue items, `secondary` for neutral metadata, `outline` for filters and tags.- Badges are not buttons. Wrap them in a link with `asChild` if they navigate.## Examples### Status```tsx<Badge variant="success">Active</Badge><Badge variant="destructive">Overdue</Badge>```### Tag```tsx<Badge variant="outline">Design system</Badge>```> check_ui code:<Button variant="primary" className="bg-blue-600 px-[18px]">Save</Button>snippet.tsx: 2 errors, 1 warning1:17 error [no-unknown-variant] "primary" is not a valid variant for <Button>. Allowed: default, destructive, outline, secondary, ghost, link. Did you mean "default"?1:38 error [no-hardcoded-color] `bg-blue-600` is Tailwind's default palette, not a design-system color. No token has this hue; nearest is ring (ΔE 0.294), a gray. Pick the semantic token that fits. <Button> already sets bg-* through `variant`; prefer a variant over overriding it.1:50 warning [no-hardcoded-spacing] `px-[18px]` is 18px, which is on the spacing scale: use `px-4.5`.Fix every error, then run check_ui again.⏺ Write(app/promo/page.tsx) ⎿ PostToolUse hook: app/promo/page.tsx breaks the project's design system 1:58 error [no-hardcoded-color] Hardcoded color `bg-[#f5f5f5]` → `bg-muted`. 1:77 error [prefer-design-system-component] Native <button> where the design system has <Button>. …⏺ The hook flagged five issues. Looking up Button and the color tokens before fixing.⏺ onsystem - get_component (MCP)(name: "Button")⏺ Write(app/promo/page.tsx) → <Button variant="destructive"> on bg-muted, hook passesAlongside @shadcn/lint
Different layers. Running both is a sensible setup.
@shadcn/lint is a linter: it polices the classes written against a component and the theme, in React, Svelte and Vue, on Tailwind v4. onsystem checks that the components, props and variant values the agent used exist, and in Claude Code has the agent fix them right after the edit.
Both
- Read your components and theme through
components.jsonin a shadcn/ui project - Flag raw colors in classes (
bg-[#ef4444]) and inline styles - Flag arbitrary values (
p-[13px],rounded-[7px])
onsystem adds
- A Claude Code hook that checks each file after it is written and has the agent fix what it invented, and the same check in CI
- Components, props and variant values that do not exist (
<Card.Header>,tone,"danger") - Native elements the design system wraps (
<button>→<Button>) - Icon-only buttons without an accessible name
- Ground truth before the agent writes, over MCP, and check_ui as a tool for any agent
- Tailwind v3 as well as v4, design-system packages, and apps that import one from a workspace package
@shadcn/lint adds
- Restyling a component with classes it should not take
- Unknown and dynamic classes
- Per-component contracts you write
- Svelte and Vue, as well as React
- Runs as ESLint 9.30+ or Oxlint rules
Works with
Your agent, your stack.
React only (.tsx and .jsx), with Node.js 20.19 or later. Styling is checked in Tailwind classes, style objects and color attributes, not in CSS-in-JS or CSS Modules. The full list of limits is in the docs.
Agents and editors
- Claude Code
- Cursor
- VS Code
- Claude Desktop
- Codex CLI
- GitHub Copilot CLI
- Windsurf
- JetBrains IDEs
- Zed
- Gemini CLI
- Grok Build
- Any stdio MCP client
Configs for each are in the setup guide.
Codebases
- shadcn/ui
- Radix
- Base UI
- React Aria Components
- Tailwind v3 and v4
- pnpm, npm, Yarn and Bun workspaces
- Design-system packages
- W3C DTCG tokens
Other layouts take a config file with a few globs.
Adopting it
An existing codebase fails only on new findings.
An established app can start with hundreds of findings: midday's dashboard has 1,232. Record them once, commit the file, and check fails only on what is new.
$ npx -y onsystem check src --update-baselineBaseline: 1,232 findings in 269 files → onsystem.baseline.json$ npx -y onsystem check srcNo new problems in 569 files (1,232 in the baseline).- Entries are keyed by file, rule and the offending text, with a count, not by line: edits elsewhere in a file do not invalidate them.
- When findings get fixed, check says so and prints the command that drops them, which locks the progress in.
- The baseline is for the CLI: check_ui still shows the agent every finding in the file it is editing.
- run: npx onsystem check . --format github --require-design-system--format github turns findings into pull request annotations. --require-design-system fails the job when the globs stop matching, so CI cannot pass by checking nothing. More on CI.
Hold your agent to the design system you already have.
Install it, then run npx -y onsystem inspect in your app to see what it found. If it gets something wrong on your codebase, an issue with the snippet is the most useful thing you can send.
Claude Code plugin· hook, server, skill
/plugin marketplace add dgesteves/onsystem/plugin install onsystem@dgesteves
Any MCP client, over stdio
$ npx -y onsystem