0.0
The project is in a healthy, maintained state
Opinionated auditing and enforcement for design-system consistency: component inventory, icon sprites, type scale, and color token management.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.13

Runtime

>= 0.10
>= 7.2
 Project Readme

UI-Guardrails

A Rails toolset that prevents UI drift in AI-assisted applications. Static audits over your views, components, stylesheets, JS controllers, and tokens — surfacing the kinds of inconsistencies that compound silently as an AI assistant ships code faster than design-system discipline can keep up.

Built and maintained by Meticulous.

Current release: 1.4.0 — V0 + V1 + V2 complete, published on RubyGems.org as ui_guardrails. The Ruby module stays Guardrails (so require "guardrails" is unchanged) — only the gem package name on rubygems carries the ui_ prefix, to clear RubyGems' similarity rule against the unrelated guard-rails gem. See doc/ROADMAP.md for status, CHANGELOG.md for the full naming rationale.


The problem

AI-assisted Rails development is fast. Too fast. Without design guardrails in place, codebases accumulate:

  • Inline style= attributes nobody asked for
  • Hex literals scattered across views that should reference a token
  • bg-[#fa3] arbitrary Tailwind values when a theme color already exists
  • Six near-duplicate "card" partials when one parameterized component would do
  • <button> elements with no accessible name
  • Orphan ViewComponent slots and Stimulus controllers
  • The same 7-class utility soup pasted onto 30 buttons

Guardrails catches these patterns without rendering anything — every detector is a static AST walk over your source. Findings stream into a unified report (text or JSON) with the same exit-code contract every other lint tool uses.


Installation

Guardrails works two ways: installed in your project (auto-loaded via a Railtie, runs the audit on every CI pass) or sidecar (cloned alongside your app, audits any tree on demand without modifying its Gemfile).

Installed in-project (recommended)

# Gemfile
group :development, :test do
  gem "ui_guardrails", "~> 1.0"
end
bundle install
bundle exec rake guardrails:init       # writes guardrails.yml, scaffolds MQs
bundle exec rake guardrails:audit      # run the full audit

The gem's Railtie auto-loads rake tasks and (when Lookbook is also in your Gemfile) registers the Guardrails panel inside every preview's inspector.

Sidecar (no Gemfile change)

Useful when you want to audit a Rails app without committing to the gem yet, or in CI for repos you don't own.

git clone https://github.com/meticulous/guardrails ~/code/guardrails
cd ~/code/guardrails && bundle install

# From your Rails app's root:
cd ~/code/my-rails-app
bundle exec --gemfile=~/code/guardrails/Gemfile \
  rake -f ~/code/guardrails/lib/tasks/guardrails.rake \
  guardrails:audit

Every rake task accepts the same env vars in sidecar mode as in-project. The tasks default root to Rails.root when Rails is loaded, else Dir.pwd — so just run from the target app's root.


Quick start

# One-time setup — detects your token stack, writes guardrails.yml,
# scaffolds prefers-color-scheme media queries if your stylesheet
# doesn't already have them. Interactive when on a TTY; CI-safe
# default fallback otherwise.
bundle exec rake guardrails:init

# Full audit — exits 1 on any violation.
bundle exec rake guardrails:audit

# Get a markdown checklist of fixable findings:
SUGGEST=1 bundle exec rake guardrails:audit
# → writes doc/guardrails-suggestions-{TIMESTAMP}.md

# Auto-fix raw_color and tailwind_arbitrary where a token matches:
APPLY=1 bundle exec rake guardrails:audit

Tasks at a glance

Task What it does
guardrails:init Stack detection, writes guardrails.yml, scaffolds prefers-color-scheme / prefers-contrast media queries. Refuses to overwrite an existing config — FORCE=1 overrides.
guardrails:audit Runs every detector — view drift, stimulus, partial similarity, view-components, a11y, cross-codebase patterns, class-itis. Exits 1 on violations.
guardrails:tui The same audit, browsable: a severity roll-up you drill into (category → finding → source), regroup by file, filter, and jump from into your editor or the HTML report. See Browsing findings.
guardrails:icons Generates an SVG sprite from app/assets/images/icons/, flags inline <svg> in views, catches emoji / glyph icons across views + helpers + models + JS + locales, reports unused sprite entries. Exits 1 on any inline SVG, unknown sprite reference, or emoji/glyph icon (1.5.0). Dead icons stay reporting-only. SUGGEST=1 prints sprite-name suggestions; FORMAT=json emits the machine-readable payload.
guardrails:tokens Parses your color and type-scale tokens (CSS vars / SCSS vars / Tailwind v3 config / Tailwind v4 @theme), reports hex literals in stylesheets that should reference a token.
guardrails:a11y:deep Reads axe-core JSON output and folds it into the unified report. Doesn't run axe itself (no Capybara / headless Chrome runtime deps) — point it at axe output your existing tooling produces.
guardrails:visual:deep Consumes screenshot-diff tool output (snap_diff-capybara today; BackstopJS in flight, #15) and reports visual regressions. Same parse-only design — your existing test toolchain runs screenshots; Guardrails reports.

Detectors

View-level drift (guardrails:audit)

Static AST walk over every .html.erb under app/views/ and app/components/ (via Herb, the ERB-aware parser):

Detector Catches
inline_style <div style="…"> — inline styles bypass tokens entirely.
raw_color Hex / rgb literals in color-bearing attributes (fill, stroke, color, bgcolor, background, data-*color*, etc.).
tailwind_arbitrary bg-[#fa3], text-[14px], p-[7px] — arbitrary values that bypass the theme.
helper_recommended <button> / <a> wrapping <%= … %> ERB output. Suggests tag.button / link_to / button_to for clean accessible names. Skips elements that already have aria-label.
image_alt / button_name / link_name / input_label The four most common static a11y misses (see doc/A11Y.md).

Suggest mode and auto-fix

  • SUGGEST=1 rake guardrails:audit writes doc/guardrails-suggestions-{TIMESTAMP}.md — a markdown checklist with one [ ] per fixable finding, the rule that fired, and the proposed replacement (closest token by exact match, else near-match within a configurable channel-distance).
  • APPLY=1 rake guardrails:audit auto-fixes raw_color (→ var(--token)) and tailwind_arbitrary (→ Tailwind theme color shorthand) when a matching token exists. Near-match auto-fix is gated by near_match_policy in guardrails.yml (fix / leave / notify).

Tokens

Guardrails::Tokens parses every token source it can find:

  • CSS custom properties in your configured colors_file
  • SCSS variables in the same
  • Tailwind v3 tailwind.config.js — flat colors and nested scales (gray.50 → gray-50). Found automatically at the repo root or at config/tailwind.config.js (the tailwindcss-rails default); set tokens.tailwind_config for anything else (.cjs, .ts, monorepo paths)
  • Tailwind v4 @theme {} blocks — picked up by the CSS-custom-property scanner

Then scans every other stylesheet for hex literals and reports drift, matching each to the closest defined token. Block + line comments are stripped before matching, preserving line/column positions so reports are accurate.

Stimulus

Guardrails::StimulusAudit cross-references data-controller="…" attributes against app/javascript/**/controllers/*_controller.{js,ts} (works across importmap, Webpacker, Vite, and Avo's app/javascript/js/controllers/ layout):

  • Orphaned — controller referenced in a view but no JS file defines it.
  • Dead — JS file exists but no view references it.
  • Picks up Ruby helper syntax: tag.div(data: { controller: "foo" }).

ViewComponent

Guardrails::ViewComponentAudit:

  • Reports components without a preview file (Lookbook discoverability).
  • Reports renders_one / renders_many slots declared in the component class but never referenced in the template (orphan slots).

Partial similarity

Guardrails::PartialSimilarity runs n-gram Jaccard over the tag-stream of every partial and component template, groups near-duplicates by connected components, and reports clusters above the configured threshold (default 0.7). Pair sample lines surface the most similar match in each cluster.

Cross-codebase patterns (0.3.0)

Guardrails::CrossCodebasePatterns walks every element subtree, fingerprints the tag-only shape (article(header(h2),section(p,p),footer(a))), and reports shapes appearing 3+ times with ≥ 5 elements. Distinct from PartialSimilarity (which compares existing partials) — this finds shapes that should be partials but aren't yet. Dedupes redundant nested patterns so a repeating table doesn't generate three findings (table(…), thead(…), tr(…)) for the same locations.

Class-itis (0.4.0)

Guardrails::ClassItis groups elements by (tag, sorted-uniq-class-list). Reports tuples with ≥ 5 distinct classes appearing on the same tag in ≥ 3 places — the classic AI-assisted-Rails failure mode of 8-utility soup pasted across many buttons when the codebase should have a shared component or @apply rule. ERB-driven class fragments are dropped; only the static portion is fingerprinted.

Visual diff via snap_diff-capybara (0.8.0)

Guardrails::VisualDiff consumes screenshot-diff tool output and folds findings into the unified report. The shipped adapter is snap_diff-capybara — the Rails-native baselines-in-git visual-regression gem:

# Your existing system tests produce diffs under doc/screenshots/
bundle exec rspec spec/system/

# Fold visual-diff findings into the audit:
VISUAL_DIFF=1 bundle exec rake guardrails:audit

Or standalone via rake guardrails:visual:deep. Parse-only — same trade as deep a11y, no Capybara/Chromium runtime deps in the gem. BackstopJS adapter tracked in #15. See doc/VISUAL-DIFF.md.

Deep a11y via axe-core JSON (0.6.0)

Guardrails::A11yDeep consumes axe-core JSON output and folds findings into the unified report:

npx @axe-core/cli http://localhost:3000/ --save axe.json
AXE_JSON=axe.json bundle exec rake guardrails:audit

Or standalone via rake guardrails:a11y:deep. Stays parse-only — your existing test toolchain runs axe (axe-core-rspec, the CLI, Puppeteer, a CDP script — anything that emits axe v4 JSON), Guardrails provides the merge + report. See doc/A11Y.md.

Lookbook auto-panel (0.5.0)

When Lookbook is in the Gemfile, Guardrails auto-registers a :guardrails panel that appears next to every preview's Source / Notes panels. The panel renders Guardrails::Lookbook::ComponentReport#for(component_class_name) — drift in the template, orphan slots, similar templates — inline. Host apps override by dropping their own partial at app/views/lookbook_panels/_guardrails.html.erb. See doc/LOOKBOOK.md.

Emoji and glyph icons (1.5.0)

The guardrails:icons task now catches emoji and Unicode-dingbat glyphs used as UI iconography — the most common way AI-assisted code bypasses the sprite. Two tiers, both on by default:

Tier What matches Example
emoji Extended_Pictographic pictographs + multi-codepoint sequences (VS16, ZWJ, flags, keycaps) 📄 👨‍👩‍👧 🇺🇸 1️⃣
glyph Single codepoints in Arrows, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Misc Symbols & Arrows ✓ → ★ ● ⚠

Grapheme-cluster iteration counts VS16-styled pictographs / ZWJ families / flag pairs / keycaps as one violation with the full cluster in the snippet. \p{Emoji} is deliberately not used (it matches ASCII digits, #, and *). Explicitly excluded from both tiers: letters, digits, punctuation, currency signs, ©®™, ellipsis, dashes, quotes — those are copy, not icons.

Scans, by default: app/views, app/components, app/helpers, app/models, app/presenters, app/javascript, and config/locales — where the emoji-as-icon pattern usually lives (a helper returning "📄" per content type, a Stimulus controller setting textContent = "✓"). Comments are masked per file type: Prism-parsed for .rb, <%# %> for ERB, // and /* */ for JS/SCSS/CSS, # for YAML. Line and column stay accurate.

Config in guardrails.yml (defaults shown; guardrails:init writes this block for you):

guardrails:
  icons:
    emoji:
      enabled: true          # the emoji tier
      glyphs: true           # the dingbat/glyph tier; false = emoji only
      scan_paths:            # optional override of the default file list
      allow_files:           # emoji-as-content: scanned but never flagged
        - app/models/reaction.rb
      allow_chars: []        # specific codepoints to never flag, e.g. ["→"]

Inline escape for a single line — the first inline-marker convention in the gem:

BAD = "📄" # guardrails-ok: emoji intentional in this seed data

# guardrails-ok: emoji shipping consciously — deprecation notice text
LEGACY = "📎 old attachment"

Works on the same line or the preceding line, in Ruby # comments, ERB <%# %> blocks, JS //, SCSS //, and YAML #.

Suggestions with SUGGEST=1: when the consumer's sprite contains a conventional name for the flagged emoji (📄 → check for icon-file or icon-document, ✓ → icon-check / icon-checkmark), the task prints → use <use href="#icon-document"/>. When no equivalent exists, it prints → no sprite equivalent — add one. Only names the consumer's collect_icon_names includes are surfaced. No auto-fix path (APPLY=1) — the right sprite name depends on the consumer's semantics.

No-fix policy: emoji contribute to guardrails:icons exit code (part of Result#violations?). Dead icons stay reporting-only — deleting a file is a human decision.


Output

Every task prints a human-readable text report by default and exits 1 when violations or failing findings exist. For machine consumption:

FORMAT=json bundle exec rake guardrails:audit > findings.json

The JSON payload has a summary: block with finding counts per category plus per-detector arrays — see Guardrails::Report::Run#to_h for the exact shape.

Browsing findings

On a real codebase the text report is long (one of our test apps produces 981 findings). Two ways to navigate it instead of scrolling it:

bundle exec rake guardrails:tui                 # interactive, in the terminal
FORMAT=html bundle exec rake guardrails:audit   # one self-contained HTML file

guardrails:tui opens on the same severity roll-up the text report leads with. ⏎ drills in — category → findings → a single finding with its suggestion, every location, and the source around it. tab regroups the whole thing by file (the files with the most findings first), / filters across category, title, suggestion, and path, o opens the highlighted location in your editor at the right line, w writes and opens the HTML report, r re-runs the audit, ? lists the rest. It takes the same env vars as guardrails:audit except APPLY and SUGGEST — browsing never rewrites files. No new dependencies: it's built on Ruby's bundled io/console.

The editor is $GUARDRAILS_EDITOR, then $VISUAL, then $EDITOR. VS Code-family, Zed, Sublime, TextMate, JetBrains, Helix, and vim/nvim/nano/emacs all open at the finding's line; anything else gets the file path. With no editor set, it falls back to open / xdg-open.

FORMAT=html writes tmp/guardrails/audit.html (override with OUTPUT=path): the roll-up, every finding with its suggestion, locations, and snippet, a live filter, and severity toggles. Everything is inline — no server, no assets, no network — so it works opened from disk or uploaded as a CI artifact. File locations become editor links once you pick your editor from the menu in the header (remembered per browser). Note the file embeds absolute paths from the machine that generated it; that's what makes the editor links work.

Common env vars

Var Effect
SUGGEST=1 Write the markdown checklist alongside the text report.
APPLY=1 Auto-fix raw_color + tailwind_arbitrary where tokens match.
SEVERITY=error / SEVERITY=warning Severity floor. error checks errors only; warning drops suggestions; default is everything. Applies to the report, the exit code, JSON, HTML, and the TUI alike. Detectors that can only produce muted findings aren't run. An unrecognized value aborts rather than being ignored.
FORMAT=json Emit one JSON document to stdout (all other audit output is suppressed).
FORMAT=html / OUTPUT=path Write the self-contained HTML report (default tmp/guardrails/audit.html).
GUARDRAILS_EDITOR=cmd Editor guardrails:tui opens locations in. Falls back to $VISUAL, then $EDITOR.
FORCE=1 Bypass init's refuse-to-overwrite default.
AXE_JSON=path Fold axe-core findings into the unified report.
VISUAL_DIFF=1 Fold visual-diff findings into guardrails:audit. Embedded installs can flip this on permanently via Guardrails.configure { |c| c.visual_diff.enabled = true }.
VISUAL_DIFF_DIR=path / VISUAL_DIFF_THRESHOLD=0.02 Tune the visual-diff snap_diff adapter and mismatch threshold.
SIMILARITY_THRESHOLD=0.85 Override the partial-similarity Jaccard threshold.
PATTERN_MIN_SIZE=8 / PATTERN_MIN_OCCURRENCES=4 Tune the cross-codebase pattern detector.
CLASSITIS_MIN_CLASSES=6 / CLASSITIS_MIN_OCCURRENCES=4 Tune the class-itis detector.

CI

The audit task is a single shell command:

# .github/workflows/ci.yml
- name: Guardrails audit
  run: bundle exec rake guardrails:audit

Adopting Guardrails on an existing codebase usually means a backlog of warnings you can't clear in one PR. Gate on errors now and ratchet down later:

- name: Guardrails audit (errors only)
  run: bundle exec rake guardrails:audit SEVERITY=error

SEVERITY=error exits 1 only when there are error-level findings (raw colors, arbitrary Tailwind values, a11y, visual diffs) and skips the warning- and suggestion-tier detectors entirely, so it's also the fastest way to run the audit. The report ends with a line saying what wasn't checked, so a green run can't be mistaken for a clean codebase.

For richer integration:

- name: Guardrails audit (JSON)
  run: bundle exec rake guardrails:audit FORMAT=json > findings.json
  continue-on-error: true

- name: Upload findings
  uses: actions/upload-artifact@v4
  with:
    name: guardrails-findings
    path: findings.json

Configuration

guardrails.yml at the repo root. guardrails:init writes a sensible default after detecting your stack — what's below is annotated to show every available key:

guardrails:
  scan_paths:
    - app/views
    - app/components
  ignore:
    - app/views/layouts
  tokens:
    # Where your color tokens live. The init task picks this up from
    # stack detection (CSS vars, SCSS, Tailwind v3/v4); edit if it
    # guessed wrong.
    colors_file: app/assets/stylesheets/tokens/_colors.css
    type_scale_file: app/assets/stylesheets/tokens/_type.css
    # Optional. Tailwind v3 config to read theme colors from. Not needed
    # for tailwind.config.js at the root or under config/ — init writes
    # it for the latter, and both are discovered without it.
    tailwind_config: config/tailwind.config.js
    # Per-channel R/G/B distance at which a near-match becomes a
    # "close enough to suggest" finding. 0 = exact only; 4 = default
    # (catches #0066ff ↔ #0067fe); 20+ = aggressive.
    near_match_threshold: 4
    # What to do with near-matches: notify (default; suggest in the
    # report), fix (auto-fix with APPLY=1), or leave (silence).
    near_match_policy: notify

Demo app

examples/demo is a bootable Rails 7.2 app with intentionally seeded findings — every detector trips at least once. Clone, bin/setup, bin/rails server, then visit /, /broken, and /rails/lookbook to see the auto-registered Guardrails panel. bundle exec rake guardrails:audit runs the full audit against the seeded tree. See examples/demo/README.md.


Status & roadmap

  • V0 (foundation) — ✅ shipped
  • V1 (polish + ecosystem) — ✅ shipped
  • V2 (advanced) — ✅ shipped (cross-codebase patterns, class-itis, visual diff via snap_diff-capybara)

Full status table and decision log: doc/ROADMAP.md.


Development

bundle install
bundle exec rspec        # 453 examples

Real-world signal lives in examples/demo (integration spec) and in the dogfood patches against four real codebases (Patchvault, Talos, Forem, Avo — see CHANGELOG.md for what each round caught).

Issues and PRs: https://github.com/meticulous/guardrails


License

MIT