ux
Blog · Technical · 2026-05-28

Regex linter for AI coding — 51 rules that catch design slop in CI.

Recommenders ask the model to do the right thing. Linters punish the model when it doesn't. ux-skill ships both — but only the linter runs in your CI on every push. Here's how 51 regex rules catch the recognizable fingerprints of AI design output before they merge, with no LLM call, no prompt, and no token budget.

Why regex, not an LLM

Every alternative looks like LLM-as-judge: ask Claude or GPT to grade the output, take its word for it, retry on a low score. That approach has three problems that compound the more you use it.

It's nondeterministic. The judge model's verdict on the same input drifts run to run. CI failures become flakes, contributors learn to retry instead of fix, and the signal collapses.

It's expensive. Every PR push spends tokens. Every retry doubles the bill. A 200-PR-per-week repo running an LLM design-grader on the diff is paying for compute to confirm what a regex would catch in 12 milliseconds.

It hides the rule. When the judge fails a PR, the author gets prose. When a regex fails a PR, the author gets a pattern they can match against their own code and learn from. The first creates a culture of appeals; the second creates a culture of fluency.

The 51 rules are documented in the AI design fingerprints taxonomy — every regex paired with the why and the fix. The linter is the executable form of that taxonomy.

What the 51 rules cover

Pulled from data/anti-patterns.json in the repo. Every rule has an id, a severity (low | medium | high | critical), a category, a regex with scoped file extensions, a one-line why, and a one-line fix.

Category Count What it catches
A11y12Missing alt text, color-only state, low-contrast pairs, click targets under 44px, no focus styles, decorative SVG without aria-hidden.
Content8"John Doe" placeholders, "Lorem ipsum," "Beautiful experiences," "Revolutionizing X," generic stock imagery URLs, emoji headlines.
Typography6Inter as display, 90px font-size at 1.0 line-height, all-caps body, sans-only stack, hairline 100-weight, no font-feature-settings.
Color6Purple-to-blue gradient (#7C3AED → #3B82F6 family), 600/400 default pairs, neon glow on white, three indigo shades.
Layout6Three equal cards in a row, "Hero + 3-up + CTA" boilerplate, full-bleed gradient hero, centered-everything axis.
Quality6Inline styles on production components, !important overrides, hardcoded pixel values where tokens exist, dead imports.
Motion4Fade-in-up on every element, no prefers-reduced-motion branch, animation longer than 500ms on hover, autoplay carousels.
Visual2Drop shadows on flat designs, blur backdrops without a fallback.
Performance1Animating top/left/width/height instead of transform.
Total51Up from 35 at the v2.0.0 launch; 16 added in the v2.1 alpha cycle.

An actual rule, end to end

Here's the canonical fingerprint — Inter used as a display font — verbatim from the manifest, severity high:

// data/anti-patterns.json — entries[0]
{
  "id": "inter-as-display",
  "name": "Inter used as display font",
  "severity": "high",
  "category": "Typography",
  "detection": {
    "type": "regex",
    "pattern": "font-family:\s*['\"]?Inter['\"]?[^;}]*[;}][^{]*(?:font-size:\s*([4-9]\d|\d{3,})px|\btext-(5xl|6xl|7xl|8xl|9xl)\b)",
    "flags": "im",
    "scope": ["css", "scss", "tsx", "jsx", "vue", "html"]
  },
  "why": "Inter is a body font tuned for screen legibility at small sizes;
          deployed as display it reads as the default startup-landing fingerprint.",
  "fix": "Pair Inter (body) with a distinctive display face:
          Geist, Satoshi, Cabinet Grotesk, General Sans, Outfit, or a brand-specific variable sans."
}

The regex looks for font-family: Inter followed within the same block by font-size at 40px+ or a Tailwind text-5xl (or larger) class. The scope array limits the scan to the file extensions where the pattern matters. The why and fix render in the linter's terminal output so contributors learn why the rule exists, not just that it failed.

Running it locally

One command. Reads every file in the current directory matching the rule scopes, prints a JSON report, exits non-zero if any rule at or above the threshold fires:

# Default: gate on high + critical, scan ./
$ uxskill lint
[OK] Scanned 142 files in 412ms · 0 findings at threshold high

# Lower the gate to medium
$ uxskill lint --threshold medium

# Scan one directory
$ uxskill lint apps/web/src --threshold high

# JSON output for piping
$ uxskill lint --json | jq '.findings | length'

A typical real-world failure looks like:

$ uxskill lint apps/web/src
[FAIL] 4 findings at threshold high · exit 1

  high  inter-as-display          src/components/Hero.tsx:18
                                  Inter used as display font
                                  fix: pair Inter (body) with Geist / Satoshi / Cabinet Grotesk

  high  purple-to-blue-gradient   src/styles/hero.css:42
                                  Default purple-to-blue AI gradient
                                  fix: single restrained accent (Emerald, Electric Blue, Deep Rose)

  high  three-equal-card-grid     src/pages/index.tsx:96
                                  Three equal cards in a row
                                  fix: asymmetric layouts (bento, 2-and-1, 4 with one spanning)

  medium fade-in-up-everywhere    src/components/Section.tsx:24
                                  Fade-in-up applied to every direct child
                                  fix: differentiate motion by role; only animate first impression

No LLM call. Average pass on a 200-file Next.js repo: 380ms cold, 90ms warm.

GitHub Actions workflow

Drop this into .github/workflows/ux-lint.yml and every PR push blocks on the linter. The Python install is the most expensive step at ~6 seconds; the lint itself is sub-second on most repos.

# .github/workflows/ux-lint.yml
name: ux-lint

on:
  pull_request:
    paths:
      - '**/*.{tsx,jsx,vue,css,scss,html,astro,blade}'
  push:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install uxskill
      - run: uxskill lint --threshold high
      - if: failure()
        run: uxskill lint --json > lint-report.json
      - if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: ux-lint-report
          path: lint-report.json

The JSON artifact gets attached on failure so reviewers can see every finding with line numbers without re-running the lint locally.

Pre-commit hook (catch it before the push)

For teams that run pre-commit, this gates the local commit before the diff ever leaves the laptop. The linter only scans staged files, so the average run is under 200ms.

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: uxskill-lint
        name: ux-skill anti-pattern lint
        entry: uxskill lint --threshold high --staged
        language: python
        additional_dependencies: [uxskill]
        files: \.(tsx|jsx|vue|css|scss|html|astro|blade)$
        pass_filenames: true

Before and after on a real page

Here's a stripped hero block from a Next.js starter, lint-failing on three rules, and the rewritten version that exits 0. Same content, same conversion intent, different fingerprint.

before · 3 findings
// inter-as-display, purple-to-blue,
// three-equal-card-grid
<section className="bg-gradient-to-br
  from-purplefrom-purple-500#x2010;500 via-violet-500
  to-blue-500 py-32">
  <h1 className="font-['Inter']
    text-7xl leading-none">
    Build something amazing.
  </h1>
  <div className="grid grid-cols-3 gap-6">
    <Card icon="Zap" title="Fast" />
    <Card icon="Shield" title="Safe" />
    <Card icon="Heart" title="Loved" />
  </div>
</section>
after · 0 findings
// display face, restrained accent,
// asymmetric layout
<section className="bg-stone-50 py-28">
  <h1 className="font-['Fraunces']
    text-6xl leading-[1.04]
    tracking-tight">
    The freight-routing layer
    your TMS never shipped.
  </h1>
  <div className="grid grid-cols-12 gap-6 mt-16">
    <Card className="col-span-7"
      title="Load consolidation" />
    <Card className="col-span-5"
      title="Carrier ranking" />
  </div>
</section>

Three rules fire on the "before": Inter at text-7xl, the canonical AI gradient, and the three-equal-card pattern. The "after" pairs Fraunces (display) with Inter (body, not shown), uses a flat surface, and adopts a 7-and-5 asymmetric grid. Same intent, no fingerprint.

Linters are the only deterministic surface in an AI-assisted codebase. Every other check is probabilistic.

How it pairs with the recommender

The linter alone is reactive — it catches slop in code that already exists. The full ux-skill loop is preventative and reactive:

  1. /ux-frame — 10-field discovery brief (the forcing function).
  2. /ux-recommend — the Python recommender runs five parallel searches and returns a system.
  3. /ux-design — generate the surface against that system.
  4. /ux-lint — gate the output with the 51 regex rules.
  5. /ux-fix — auto-patch the findings the regex can resolve (Inter → Fraunces, indigo gradient → single accent, three-card → bento).

Steps 1–3 reduce the rate at which the model produces slop. Steps 4–5 catch what slipped through. The recommender raises the floor; the linter sets the ceiling.

Honest scope

Regex catches structural fingerprints, not taste.

The linter will never catch "this section is the wrong rhythm" or "the copy reads cold." Those need a human or a model. What regex does catch is every fingerprint that has a literal token, pattern, or shape — which is most of the AI defaults, because the model reaches for the same artifact every time.

We deliberately don't run an LLM judge in CI. If your bar is "looks like a designer made it," that's a code review, not a lint. ux-skill ships /ux-critique for the taste pass — but that runs in your editor on demand, not in CI.

Cross-IDE distribution

Same Python package, every IDE that runs Python or shells out to it: Claude Code, Cursor, Windsurf, Copilot, Gemini Code Assist, Aider, Continue, Cline, Roo Code, Codex, plus seven more. Windsurf install walkthrough · Cursor install walkthrough.

If you want the linter without ux-skill's recommender and brand library, the uxskill lint subcommand is self-contained — install uxskill, point at the directory, get the report. No model, no API key, no telemetry.

Related reading

Install the linter

One package. Three install paths. 51 rules.

The recommender, the linter, the 22 commands, and the 73 brand specs all ship together. The linter alone is sub-second; the rest is on demand. MIT licensed, no telemetry, no account.

$ /plugin marketplace add Laith0003/ux-skill
$ /plugin install ux@ux-skill
— or —
$ pip install uxskill
— or —
$ npx uxskill@alpha init