Make AI coding agents (Claude Code, Codex, Cursor, Pi, ...) produce UI that is consistent across screens and conforms to real design standards — not "looks AI-generated".
Rules written in DESIGN.md are suggestions. Agents forget them or drift back to training-data defaults:
- Screen A uses a spinner, screen B a skeleton
- Screen C has a breadcrumb header, screen D does not
<textarea>must not be resizable — agent still emits resizable ones- Spacing, alignment, and component composition drift from Material / standard design systems
More prompting does not fix this. Rules must move from text the model reads to constraints a machine checks.
| Layer | Mechanism | Status |
|---|---|---|
| 1. Context | Skill / rule files loaded at the right moment | planned (v1) |
| 2. Constrain | Opinionated component primitives — wrong usage is impossible | planned (v1) |
| 3. Detect | Custom lint rules run via agent hooks after every edit; agent self-corrects | planned (v1) |
| 4. Verify | Screenshot + visual/pixel audit (Playwright + reviewer agent) | phase 2 |
npm i -D @gwinnguyen/fcs@next @gwinnguyen/eslint-plugin-fcs@next @gwinnguyen/fcs-preset-baseline@next
npx fcs init --claude --codex # contract, registry, ESLint, agent hooks
npx fcs add page list-page async-boundary textarea-field
npx fcs checkHooks installed by fcs init:
| Agent | File | PreToolUse | PostToolUse | Stop |
|---|---|---|---|---|
| Claude Code | .claude/settings.json |
block edits to locked files | lint the edited file, exit 2 feeds errors back | full check on changed files, blocks once |
| Codex | .codex/hooks.json |
same, on apply_patch |
same | same |
Codex runs repo hooks only after you trust them: open Codex in the repo and approve them in /hooks.
A thin plugin wraps the CLI as skills: fcs-setup, fcs-import, fcs-new-page, fcs-check. The same plugin folder (plugins/fcs) serves both agents.
# Claude Code
/plugin marketplace add dangnhit/frontend-consistency
/plugin install fcs@frontend-consistency
# Codex
codex plugin marketplace add dangnhit/frontend-consistency
codex plugin add fcs@frontend-consistencyThe plugin carries no logic of its own. Rules, components, lint and hooks come from the npm packages, so the project stays enforced for teammates who do not install the plugin.
Benchmarks (bench/results/SUMMARY.md) showed a clear prose DESIGN.md already steers agents well. Agents drift when the document contradicts itself: the same task ended up with a skeleton in one run and a spinner in the next. fcs import finds those contradictions and turns the team's answer into the one place agents and lint read from.
fcs import DESIGN.md
# CONFLICT loading.page.pattern (in effect: skeleton)
# spinner DESIGN.md:42 "Page and section loading states show a single centered spinner."
# skeleton DESIGN.md:66 "While a table is loading, show skeleton rows ..."
# → pick one: --decide loading.page.pattern=spinner|skeleton
fcs import DESIGN.md --decide loading.page.pattern=spinner --write
# writes .design/contract.yaml overrides + .design/decisions/0001-import-design.md,
# lists the DESIGN.md lines that still say "skeleton", rebuilds agent filesDetection is deterministic. Each rule declares detect: patterns, so preset and project rules extend the importer without code changes. Prose that maps to no rule stays prose.
-
D1 — Scope v1 to layers 1–3; layer 4 deferred (cost, flakiness).
-
D2 — Open-source, multi-user project.
-
D3 — v1 targets React + Next.js + shadcn/ui (Tailwind, TypeScript). Architecture split: stack-agnostic core (design contract + agent-rule generators) and per-stack adapters (
adapter-react: primitives + ESLint plugin). Other stacks via future adapters. -
D4 — Ship an opinionated
baselinepreset (Material 3 + widely accepted practice). Projectsextends: baselineand override per rule ID witherror/warn/off(ESLint-style). Each rule declares: stable ID, why, do/don't code examples, severity, enforcement mechanism (component and/or lint rule). -
D5 — A structured contract is the single source of truth; prose files are build outputs.
.design/ contract.yaml # extends: baseline, tokens, params, scopes rules/*.yaml # active rules only decisions/*.md # ADRs: history, supersedes, approvals — never fed to agentsRule schema extends D4 with
scope(route/surface/portal),params(referenced values, e.g. 300ms, 44px),exceptions, andsupersedes/source. CLI:fcs build(generate DESIGN.md / AGENTS.md / CLAUDE.md / .cursor/rules — active, in-scope rules only),fcs check(detect contradicting rules),fcs import(maps an existing prose DESIGN.md onto contract params, surfacing conflicts for the user to resolve — see below). -
D6 — Layer-2 components ship via a shadcn registry (
npx shadcn add @fcs/<name>) intocomponents/fcs/*, built on the project's own shadcn primitives. Protected by.design/lock.json(per-file hash):fcs checkfails on modification unless done throughfcs eject <component>, which requires a decision record. Agent hooks block Edit/Write oncomponents/fcs/**. v1 component set:Component Enforces AppShell+PageHeaderbreadcrumb / title / actions in fixed slots; no ad-hoc h1 AsyncBoundaryloading / empty / error / filtered-empty; skeleton vs spinner chosen by contract Field+TextareaFieldrequired label, error, hint; resize per contract ConfirmDialogreplaces window.confirm; safe action focused; fixed button orderSubmitButtonpending state keeps width, aria-busyDataTableown scroll region, URL-state pagination FilterBarsingle-row filter chrome, URL-synced state, clear-all, filtered-empty hand-off to AsyncBoundaryToast(notify()API)one placement, variant set, durations from contract params; bans ad-hoc toast calls StatusBadgestatus-to-color map from one source IconButtonaria-label+ tooltip required at type levelEmptyState,Section,Stackspacing accepts tokens only -
D7 — Layer 3 =
@gwinnguyen/eslint-plugin-fcs(ESLint 9+ flat config preset; Biome deferred). Every message names the rule ID, the component to use, and the contract source, e.g.fcs/no-raw-textarea: Use <TextareaField> from @/components/fcs. Rule textarea.resize = none (.design/rules/forms.yaml).fcs/no-disableforbids disablingfcs/*rules inline.fcs initdetects agents present and installs per-agent feedback hooks:Agent Hook Claude Code PostToolUseon Edit/Write: lint touched file, exit 2 to feed errors backCursor afterFileEdithook + generated rulesCodex .codex/hooks.json: same pre/post/stop hooks, matched onapply_patch(fcs init --codex)Pi AGENTS.mdinstruction to runfcs checkbefore finishingAll pre-commit + CI gate as final backstop MCP server (
fc_check,fc_rules_for(file)) deferred to phase 2. -
D8 — Design quality via page-level structure, not per-component prompting:
- Page templates in the registry:
ListPage,DetailPage,FormPage,SettingsPage,DashboardPage,AuthPage(fixed header, content max-width, gutters, action placement, spacing rhythm).fcs/page-templaterequires everyapp/**/page.tsxto render a template at its root;fcs new page <name> --template <t>scaffolds. Marketing/landing routes opt out viascope. SaaS/admin templates first. - Strict tokens: spacing scale only, fixed control heights, no arbitrary Tailwind values, no off-contract shadows/gradients.
- Anti-slop rules in
baseline(why + do/don't): one primary action per region, no card-in-card, no decorative heading icons, no emoji in UI, one h1 per page — lint where countable, prompt otherwise.
- Page templates in the registry:
-
D9 —
baselinekeeps shadcn's visual language and standardizes the measurable skeleton drawn from Material 3, Apple HIG, WCAG 2.2 and Refactoring UI: 4/8pt grid, paired type scale, control heights 32/36/40 by density, target size ≥ 24px (44px on touch), contrast ≥ 4.5:1, layout grid/breakpoints/gutters, action ordering, consistent focus ring, motion 150–250ms with standard easing. Visual flavor (radius, shadow, color) lives in tokens; an M3 flavor can later ship as a token preset, not a rule rewrite. -
D10 — Naming: repo
frontend-consistency; npm packages under the maintainer scope:@gwinnguyen/fcs(CLI),@gwinnguyen/fcs-core,@gwinnguyen/eslint-plugin-fcs,@gwinnguyen/fcs-preset-baseline; entry pointnpx @gwinnguyen/fcs init; CLI binaryfcs(fcis a bash/zsh builtin and taken on npm). Lint rule prefixfcs/, shadcn registry namespace@fcs, installed components undercomponents/fcs/. -
D11 — Effectiveness is measured, not asserted.
bench/holds a clean Next + shadcn fixture app with a contract and ~20 UI tasks, each targeting a known drift (list page with filters, notes form, delete with confirm, ...). Each task runs 3× across{no tool, DESIGN.md only, full fcs}×{Claude Code, Codex}(Cursor later — no headless mode). Deterministic scoring:fcs/*violations, arbitrary values, pages without a template, and cross-run structural divergence. Results table published in README; run manually/scheduled, not per-PR (token cost). Real-world dogfood: an existing production Next + shadcn app viafcs import. -
D12 — Build order: tracer-bullet slices, each crossing every layer. Slice 1
forms.textarea.resize; slice 2loading.page(AsyncBoundary, conditional rule, exercisesscope/params); slice 3PageHeader+ListPage(page-level rule). Then expand to the fullbaseline. Tooling: pnpm workspaces + Turborepo, TypeScript, tsup, vitest, Changesets, MIT. Layout:packages/{cli,core,eslint-plugin,preset-baseline},registry/,bench/.
Full spec: docs/spec.md.
MIT