ui_guardrails 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/doc/A11Y.md +87 -0
  5. data/doc/LOOKBOOK.md +52 -0
  6. data/doc/PRD.md +145 -0
  7. data/doc/PUBLISHING.md +98 -0
  8. data/doc/ROADMAP.md +158 -0
  9. data/doc/UPSTREAM-snap_diff-issue-draft.md +63 -0
  10. data/doc/VISUAL-DIFF.md +135 -0
  11. data/lib/guardrails/a11y_audit.rb +249 -0
  12. data/lib/guardrails/a11y_deep.rb +119 -0
  13. data/lib/guardrails/audit/auto_fixer.rb +155 -0
  14. data/lib/guardrails/audit/markdown_writer.rb +218 -0
  15. data/lib/guardrails/audit.rb +472 -0
  16. data/lib/guardrails/class_itis.rb +196 -0
  17. data/lib/guardrails/configuration.rb +101 -0
  18. data/lib/guardrails/cross_codebase_patterns.rb +242 -0
  19. data/lib/guardrails/erb_parser.rb +91 -0
  20. data/lib/guardrails/hex_normalizer.rb +47 -0
  21. data/lib/guardrails/icons.rb +233 -0
  22. data/lib/guardrails/init/config_writer.rb +101 -0
  23. data/lib/guardrails/init/media_query_scaffolder.rb +60 -0
  24. data/lib/guardrails/init/prompter.rb +60 -0
  25. data/lib/guardrails/init/stack_detector.rb +108 -0
  26. data/lib/guardrails/init.rb +115 -0
  27. data/lib/guardrails/lookbook/component_report.rb +78 -0
  28. data/lib/guardrails/lookbook/panel_registration.rb +93 -0
  29. data/lib/guardrails/lookbook/views/lookbook_panels/_guardrails.html.erb +44 -0
  30. data/lib/guardrails/partial_similarity.rb +231 -0
  31. data/lib/guardrails/railtie.rb +23 -0
  32. data/lib/guardrails/stimulus_audit.rb +118 -0
  33. data/lib/guardrails/token_matcher.rb +40 -0
  34. data/lib/guardrails/tokens/tailwind_config_parser.rb +140 -0
  35. data/lib/guardrails/tokens.rb +256 -0
  36. data/lib/guardrails/version.rb +5 -0
  37. data/lib/guardrails/view_component_audit.rb +150 -0
  38. data/lib/guardrails/visual_diff/snap_diff.rb +81 -0
  39. data/lib/guardrails/visual_diff.rb +117 -0
  40. data/lib/guardrails.rb +14 -0
  41. data/lib/tasks/guardrails.rake +176 -0
  42. data/lib/ui_guardrails.rb +9 -0
  43. metadata +145 -0
data/doc/PUBLISHING.md ADDED
@@ -0,0 +1,98 @@
1
+ # Publishing to RubyGems
2
+
3
+ Guardrails publishes to [RubyGems.org](https://rubygems.org/) using **RubyGems Trusted Publishing** — no long-lived API key stored in repo secrets. The `.github/workflows/release.yml` workflow runs on every `v*` tag push: it verifies the gemspec version matches the tag, runs the suite, and pushes the gem via a short-lived OIDC token RubyGems mints in exchange for the GitHub Actions identity.
4
+
5
+ ## One-time setup (before the first release)
6
+
7
+ The gem doesn't exist on RubyGems.org yet, so the trusted publisher is configured as **pending** — it claims the name on the first successful push.
8
+
9
+ 1. **Sign in to rubygems.org** with the account that should own the gem (`meticulous` org owner). Confirm the account has 2FA enabled.
10
+
11
+ 2. **Open the pending trusted publishers page**: <https://rubygems.org/profile/oidc/pending_trusted_publishers> → **Create**.
12
+
13
+ 3. **Fill the form:**
14
+
15
+ | Field | Value |
16
+ |---|---|
17
+ | RubyGem name | `ui_guardrails` |
18
+ | Repository owner | `meticulous` |
19
+ | Repository name | `guardrails` |
20
+ | Workflow filename | `release.yml` |
21
+ | Environment | `release` |
22
+
23
+ 4. **Save.** The pending trusted publisher will sit there until the first tagged push from this repo successfully authenticates against it. After that first push, RubyGems automatically converts it to a regular trusted publisher attached to the now-created gem.
24
+
25
+ ## Cutting a release
26
+
27
+ After the change set is merged to `main`:
28
+
29
+ ```bash
30
+ # Pull main locally
31
+ git checkout main
32
+ git pull --ff-only
33
+
34
+ # Confirm version + CHANGELOG are in the right shape
35
+ grep VERSION lib/guardrails/version.rb
36
+ head -20 CHANGELOG.md
37
+
38
+ # Tag the merge commit and push the tag (replace X.Y.Z with the
39
+ # version you're cutting — the next-release example would be the
40
+ # version in lib/guardrails/version.rb on main right now).
41
+ git tag -a vX.Y.Z -m "vX.Y.Z — <short summary>"
42
+ git push origin vX.Y.Z
43
+ ```
44
+
45
+ The `release.yml` workflow takes over from there. Watch the run at <https://github.com/meticulous/guardrails/actions> — it'll:
46
+
47
+ 1. Verify `Guardrails::VERSION` matches the tag (refuses to publish a mismatched gem)
48
+ 2. Run the test suite
49
+ 3. Build the gem
50
+ 4. Push to RubyGems.org via trusted publishing
51
+
52
+ The whole flow typically runs in under 90 seconds.
53
+
54
+ ## What if the workflow fails?
55
+
56
+ | Failure | Likely cause |
57
+ |---|---|
58
+ | `Guardrails::VERSION (X.Y.Z) does not match tag (vA.B.C). Refusing to publish.` | The tag points at a commit whose `lib/guardrails/version.rb` doesn't match. Re-tag against the right commit or bump the version. |
59
+ | Spec suite fails | Same fix as a regular CI failure — root cause whatever the spec reported. |
60
+ | `rubygems/release-gem` fails with an OIDC error | The pending trusted publisher on rubygems.org wasn't created, the workflow filename doesn't match, or the environment name doesn't match. Double-check the form in step 3 above. |
61
+
62
+ ## Manual fallback (legacy path)
63
+
64
+ If trusted publishing breaks for any reason, the gem can still be published manually with an API key:
65
+
66
+ 1. Create an [API key on rubygems.org](https://rubygems.org/profile/api_keys) with the `push_rubygem` scope. Lock it to the `ui_guardrails` gem.
67
+ 2. Configure local credentials:
68
+ ```bash
69
+ mkdir -p ~/.gem
70
+ touch ~/.gem/credentials
71
+ chmod 600 ~/.gem/credentials
72
+ # then edit and add:
73
+ # ---
74
+ # :rubygems_api_key: rubygems_xxxxxxxxxxxxxxxxxxxxxxxx
75
+ ```
76
+ 3. Build and push:
77
+ ```bash
78
+ gem build *.gemspec
79
+ gem push *.gem
80
+ ```
81
+
82
+ This path is for emergencies only — prefer the workflow.
83
+
84
+ ## Why trusted publishing over an API key secret
85
+
86
+ - **No long-lived secret rotation.** The OIDC token is minted per workflow run and expires in minutes.
87
+ - **Repo-bound by construction.** A leaked API key in a different repo's workflow would be useless against guardrails — trusted publishing is scoped to (repo × workflow × environment).
88
+ - **Audit trail.** RubyGems records which workflow run published each version; you can confirm provenance at <https://rubygems.org/gems/ui_guardrails/versions>.
89
+
90
+ ## Yanking a bad release
91
+
92
+ If a published version has a critical bug:
93
+
94
+ ```bash
95
+ gem yank ui_guardrails -v X.Y.Z # replace with the version to pull
96
+ ```
97
+
98
+ Yanking removes the version from `gem install` resolution but leaves the version slot reserved — you can't republish a yanked version number. Bump and re-release.
data/doc/ROADMAP.md ADDED
@@ -0,0 +1,158 @@
1
+ # Guardrails — Roadmap
2
+
3
+ **Status:** **1.0.0 released on RubyGems.org** — V0 ✅, V1 ✅, V2 ✅ (visual-diff via snap_diff-capybara landed 0.8.0; published as 1.0.0 once the trusted-publisher pipeline merged)
4
+ **Last updated:** 2026-05-11
5
+
6
+ ## Context
7
+
8
+ Guardrails is a Ruby gem that prevents UI drift in Rails apps — particularly drift introduced by AI coding assistants that generate UI faster than design-system consistency can be maintained. The gem is also the conceptual scaffolding for the Rails World talk. The talk is **concept-led, not demo-led**, so the gem is the durable artifact rather than a live-demo dependency.
9
+
10
+ This doc tracks shipped vs. planned scope and parks remaining unknowns. V0 + most of V1 landed in PR #1; 0.2.x patches refined detectors against four real codebases (Talos, Avo, Forem, Patchvault); 0.3.0 added cross-codebase structural pattern detection; 0.4.0 added the class-itis detector.
11
+
12
+ ---
13
+
14
+ ## V0 — Foundation
15
+
16
+ ### Audit (`rails guardrails:audit`)
17
+
18
+ | Item | Status | Notes |
19
+ |---|---|---|
20
+ | Inline `style=` attributes | ✅ shipped | |
21
+ | Hex / rgb color literals | ✅ shipped | Scoped to color-bearing attributes (fill, stroke, color, bgcolor, background, flood/lighting/stop-color, data-*color*) after Copilot review caught href="#section" false positives |
22
+ | Tailwind arbitrary values (`bg-[#fa3]`) | ✅ shipped | Reported once as `tailwind_arbitrary`, not double-flagged as raw_color |
23
+ | Hardcoded `font-size` / `line-height` | ❌ dropped as standalone | Folded into inline_style + tailwind_arbitrary detectors. Type-scale awareness lives in suggest mode token matching |
24
+
25
+ ### Suggest mode (`SUGGEST=1`)
26
+
27
+ | Item | Status | Notes |
28
+ |---|---|---|
29
+ | Markdown checklist artifact | ✅ shipped | `doc/guardrails-suggestions-{TIMESTAMP}.md` |
30
+ | Closest-token matching | ✅ shipped | Exact + near-match (channel-distance ≤ 4) |
31
+ | Per-violation `[ ]` checkbox + rule + replacement | ✅ shipped | |
32
+ | Type-scale matching | ✅ shipped | `text-[1rem]` matches a defined `--text-base: 1rem` token |
33
+
34
+ ### Auto-fix (`APPLY=1`)
35
+
36
+ | Item | Status | Notes |
37
+ |---|---|---|
38
+ | `raw_color` exact-match rewrite to `var(--token)` | ✅ shipped | Right-to-left within line; verifies expected text at column before writing |
39
+ | `raw_color` near-match rewrite | ✅ shipped | Gated on `near_match_policy: fix` in guardrails.yml |
40
+ | `tailwind_arbitrary` auto-fix | ✅ shipped | Replaces `bg-[#hex]` with `bg-tokenname` when a `:tailwind` theme token matches by value. Variant prefixes (`lg:hover:`, `[&>div]:`) preserved |
41
+ | `inline_style` auto-fix | ❌ deferred | Structural rewrite, not value swap |
42
+ | `font-size` auto-fix | ❌ deferred | Same constraint as inline_style |
43
+
44
+ ### Icons (`rails guardrails:icons`)
45
+
46
+ | Item | Status |
47
+ |---|---|
48
+ | SVG sprite generation | ✅ shipped |
49
+ | Inline `<svg>` detection in views | ✅ shipped |
50
+ | Dead-icon report | ✅ shipped |
51
+
52
+ ### Init / analysis (`rails guardrails:init`)
53
+
54
+ | Item | Status | Notes |
55
+ |---|---|---|
56
+ | Stack detection (CSS-vars / SCSS / raw-hex) | ✅ shipped | |
57
+ | `guardrails.yml` writing with sensible defaults | ✅ shipped | |
58
+ | `prefers-color-scheme` + `prefers-contrast` MQ scaffolding | ✅ shipped | Skipped if ConfigWriter refuses to overwrite |
59
+ | Interactive prompts (TTY) | ✅ shipped | near_match_policy / near_match_threshold / scan_paths / ignore paths (matched as exact paths or directory prefixes — not shell globs). CI-safe non-TTY fallback to defaults. Skipped entirely when config exists and FORCE=1 isn't set |
60
+ | Refuse-overwrite by default | ✅ shipped | |
61
+ | `FORCE=1` to overwrite + re-scaffold MQs | ✅ shipped | |
62
+
63
+ ### Tokens (`rails guardrails:tokens`)
64
+
65
+ | Item | Status | Notes |
66
+ |---|---|---|
67
+ | Parse CSS custom properties + SCSS variables from `colors_file` | ✅ shipped | |
68
+ | Parse `type_scale_file` alongside `colors_file` | ✅ shipped | |
69
+ | Tailwind v4 `@theme {}` blocks | ✅ shipped | Goes through the existing CSS custom property scanner |
70
+ | `tailwind.config.js` (v3) regex parsing | ✅ shipped | Best-effort: flat colors, nested scales (gray.50 → gray-50), spread/function values skipped |
71
+ | Stylesheet drift scan with comment stripping | ✅ shipped | |
72
+ | Hex normalization (case + short form + alpha) | ✅ shipped | |
73
+ | Drift matching against Tailwind theme colors | ✅ shipped | |
74
+
75
+ ### Stimulus audit
76
+
77
+ | Item | Status | Notes |
78
+ |---|---|---|
79
+ | Orphan controllers (referenced, no JS file) | ✅ shipped | |
80
+ | Dead controllers (JS file, never referenced) | ✅ shipped | |
81
+ | Ruby helper syntax detection | ✅ shipped | `tag.div(data: { controller: "foo" })` and hash-rocket variants |
82
+
83
+ ### CI integration
84
+
85
+ | Item | Status | Notes |
86
+ |---|---|---|
87
+ | Exit codes (0 clean, 1 violations) | ✅ shipped | |
88
+ | `FORMAT=json` machine-readable output | ✅ shipped | Unified across all sub-audits |
89
+ | `--strict` flag | ❌ dropped | No semantic distinction beyond exit-1-on-violations default. Revisit if/when warning vs error severities emerge |
90
+
91
+ ---
92
+
93
+ ## V1 — Polish + Differentiation
94
+
95
+ | Item | Status | Notes |
96
+ |---|---|---|
97
+ | ViewComponent preview detection | ✅ shipped | |
98
+ | ViewComponent slot validation | ✅ shipped | Known limit: code-only components (`def call`) flag declared slots as orphans |
99
+ | Component-shape similarity | ✅ shipped | n-gram Jaccard, default threshold 0.7. PartialSimilarity scans both `_*.html.erb` partials and `*_component.html.erb` VC templates |
100
+ | Lookbook integration | ✅ shipped (0.5.0) | `Guardrails::Lookbook::ComponentReport` data API + Railtie auto-registers the `:guardrails` panel when Lookbook is loaded; partial ships inside the gem. Host can override the partial via standard view-path precedence. |
101
+ | ERB-partial structural similarity | ✅ shipped | Same PartialSimilarity engine |
102
+ | A11y integration | ✅ shipped (0.6.0) | Static checks shipped (image_alt, button_name, link_name, input_label). button_name and link_name skip when the element body wraps ERB output — those cases get a `helper_recommended` finding instead. **Deep mode shipped via `Guardrails::A11yDeep`** — consumes axe-core JSON output (single or multi-page) and folds findings into the unified report. `AXE_JSON=path/to/axe.json bundle exec rake guardrails:audit` or `rake guardrails:a11y:deep`. Stayed parse-only (no Capybara / headless Chrome runtime deps) per the original size constraint. |
103
+ | `helper_recommended` detector | ✅ shipped | Flags `<button>` / `<a>` wrapping ERB output and suggests `tag.button` / `button_to` / `link_to`. Pairs with the a11y skip for the same case |
104
+ | Targeted auto-fix | ✅ shipped | See V0 auto-fix table; tailwind_arbitrary now also auto-fixes when a `:tailwind` theme token matches |
105
+ | Sample app in `examples/` | ✅ shipped (0.7.0) | Bootable Rails 7.2 app. `cd examples/demo && bundle install && bin/rails server` renders the seeded views and mounts Lookbook with the Guardrails panel auto-registered. Same tree the integration spec exercises. |
106
+
107
+ ---
108
+
109
+ ## V2 — Advanced
110
+
111
+ | Item | Status | Notes |
112
+ |---|---|---|
113
+ | Cross-codebase pattern detection | ✅ shipped (0.3.0) | `Guardrails::CrossCodebasePatterns` — fingerprints element subtree shapes, surfaces shapes appearing 3+ times across `app/views` and `app/components`. Drops redundant nested patterns dominated by an outer shape. Verified against Patchvault (24 patterns), Talos (90), Forem (50), Avo (0, expected — ViewComponent-driven). |
114
+ | Class-itis reduction | ✅ shipped (0.4.0) | `Guardrails::ClassItis` — groups elements by `(tag, sorted-class-list)`, reports tuples with >= 5 classes appearing in >= 3 places. ERB-driven fragments are dropped; static portion only. Verified against Forem (27 clusters incl. an `<h1>` repeating 27 times), Patchvault (1), Avo (1), Talos (0 — classes mostly ERB-fragmented). |
115
+ | Visual diff integration | ✅ shipped (0.8.0) | `Guardrails::VisualDiff` parses screenshot-diff tool output and folds findings into the unified report. Initial adapter: snap_diff-capybara (baselines-in-git, the Rails-native default). BackstopJS adapter tracked in issue #15. Same parse-only constraint as `A11yDeep` — no Capybara / Chromium runtime deps. See `doc/VISUAL-DIFF.md` + `doc/RESEARCH-visual-diff.md`. |
116
+
117
+ ---
118
+
119
+ ## Decisions Captured
120
+
121
+ | Question | Decision |
122
+ |---|---|
123
+ | Tailwind arbitrary values | **In V0.** High signal even outside AI-coded apps. |
124
+ | ViewComponent vs ERB | **Uniform in V0.** Rich support in V1 with Lookbook docs. |
125
+ | Auto-fix | **Suggest in V0** via markdown checklist. **Targeted auto-fix in V1**: raw_color → `var(--token)` with `APPLY=1`. Broad auto-fix not committed. |
126
+ | VS Code extension | **Out of scope.** |
127
+ | Stimulus | **In V0.** |
128
+ | A11y | **Static checks in V0/V1.** axe-core full wrapper deferred — bundling Capybara + headless Chrome was too heavy; documented integration path instead. |
129
+ | Sample app | **Yes, fake-Rails-app structure** (not bootable). |
130
+ | Tailwind v4 | **Supported in V0** via `@theme` directives. |
131
+ | Tailwind v3 (`tailwind.config.js`) | **Supported in V0, colors-only by design.** Best-effort regex parsing of `theme.colors` / `theme.extend.colors`. Non-color tokens (spacing, fontSize, fontFamily, screens) are NOT extracted from v3 configs — projects that want non-color token coverage should migrate to Tailwind v4 `@theme {}` directives, which our existing CSS-custom-property scanner parses cleanly. |
132
+ | Suggestions-md location | `doc/guardrails-suggestions-{TIMESTAMP}.md`. |
133
+ | Near-match handling | Always *suggest* by default. Per-project policy in `guardrails.yml` (`fix` / `leave` / `notify`). |
134
+ | `--strict` flag | **Dropped from V0.** No distinction beyond default exit-1-on-violations until severity levels exist. |
135
+ | Standalone font-size detector | **Folded into inline_style + tailwind_arbitrary.** Type-scale awareness lives in suggest mode. |
136
+ | Near-match threshold | **Max per-channel R/G/B = 4 units (default).** Configurable per-project via `tokens.near_match_threshold` in `guardrails.yml`; ConfigWriter emits a footer comment explaining the scale (0/1/4/10/20+). |
137
+ | Init rerunnability | **Refuses overwrite by default; `FORCE=1` overrides.** Prompts are skipped entirely when overwrite is refused (no questions whose answers won't apply). |
138
+ | MQ scaffold conflicts | **Skip if any matching `@media` block already exists.** Don't augment partial blocks. |
139
+ | ERB-aware a11y | **Resolved via `helper_recommended` detector.** `<button>` / `<a>` wrapping ERB output trip the helper-recommendation rule (suggest `tag.button` / `link_to` etc.); the corresponding a11y rule (button_name / link_name) skips that case so users get one actionable suggestion, not two overlapping flags. |
140
+ | Tailwind utility-name auto-fix | **Shipped.** APPLY=1 rewrites `bg-[#hex]` to `bg-tokenname` against `:tailwind` theme tokens. Variant prefixes (`lg:hover:`, `[&>div]:`) preserved. Suggestion text falls back to `bg-[var(--name)]` when only a `:css_var` token matches (still arbitrary, but parameterized). |
141
+
142
+ ---
143
+
144
+ ## Sample App (`examples/`)
145
+
146
+ A fake-Rails-app directory tree (no Gemfile or `config/`, not bootable as a Rails server) that exercises every audit. Three roles:
147
+
148
+ 1. **Integration test surface** — `spec/integration/demo_app_spec.rb` runs each audit and asserts concrete counts.
149
+ 2. **Showcase / talk material** — clear contrast between `welcome/index.html.erb` (clean) and `welcome/broken.html.erb` (every detector finds at least one violation).
150
+ 3. **Onboarding reference** — `examples/demo/README.md` explains what's seeded.
151
+
152
+ If a bootable Rails server is needed for the talk demo, that's a separate scaffolding step on top of this tree — not currently shipped.
153
+
154
+ ---
155
+
156
+ ## Open Questions
157
+
158
+ None currently. All four V1 follow-up questions have been resolved (see Decisions Captured below). Add new ones here as they emerge during V2 work.
@@ -0,0 +1,63 @@
1
+ # Upstream issue draft — snap_diff-capybara JSON report companion
2
+
3
+ > Persistent draft for the ask Guardrails wants to make of snap_diff-capybara upstream: add a JSON companion to the HTML report so mismatch ratios flow through. Kept in-repo as decision/communication history regardless of whether the upstream issue has been filed (and what its status is). If filed, link the resulting upstream issue here.
4
+ >
5
+ > **Upstream issue status:** _not yet filed (awaiting sign-off)._
6
+ > **Target repo:** <https://github.com/snap-diff/snap_diff-capybara/issues/new>
7
+
8
+ ---
9
+
10
+ **Suggested title:**
11
+
12
+ ```
13
+ Feature: emit a snap_diff_report.json companion to the HTML report
14
+ ```
15
+
16
+ **Suggested labels:** `enhancement`, `report`
17
+
18
+ ---
19
+
20
+ **Body:**
21
+
22
+ ```markdown
23
+ ## Context
24
+
25
+ Hi, thanks for maintaining this gem — we're integrating it into [Guardrails](https://github.com/meticulous/guardrails), a static-audit gem that consolidates Rails UI-drift findings into a single report (static a11y, view-level drift, partial similarity, ViewComponent / Stimulus audits, etc.). The latest release (0.8.0) adds a snap_diff-capybara adapter for our `Guardrails::VisualDiff` audit, walking `doc/screenshots/` for `<name>.diff.png` files and folding them into the unified report. Works well.
26
+
27
+ The one gap: we'd like per-finding **mismatch percentages** in the unified report, but the filesystem layout is binary (a `.diff.png` either exists or doesn't). Our current adapter emits `mismatch_ratio: nil` and treats nil as "unconditionally failing" — fine for snap_diff itself, but it means our `VISUAL_DIFF_THRESHOLD=0.02` knob doesn't do anything for snap_diff users (the threshold needs a numeric ratio to filter against).
28
+
29
+ ## Ask
30
+
31
+ Would you be open to emitting a `snap_diff_report.json` alongside the HTML report? Something like:
32
+
33
+ ```json
34
+ {
35
+ "generated_at": "2026-05-11T08:23:14Z",
36
+ "fail_if_new": true,
37
+ "scenarios": [
38
+ {
39
+ "name": "homepage",
40
+ "viewport": "desktop_1280",
41
+ "passed": false,
42
+ "mismatch_ratio": 0.0237,
43
+ "baseline_path": "doc/screenshots/homepage.png",
44
+ "current_path": "tmp/snap_diff/homepage.actual.png",
45
+ "diff_path": "doc/screenshots/homepage.diff.png",
46
+ "heatmap_path": "doc/screenshots/homepage.heatmap.diff.png"
47
+ }
48
+ ]
49
+ }
50
+ ```
51
+
52
+ BackstopJS's `jsonReport.json` is a reasonable shape reference if you want a prior-art example.
53
+
54
+ ## Why not just walk the filesystem (more)
55
+
56
+ We could read the diff PNGs ourselves to compute mismatch ratios, but that pulls libvips / ChunkyPNG into Guardrails' install footprint — and snap_diff already has the comparison data internally during its run. Keeping image work in the gem that already does image work is cleaner.
57
+
58
+ ## Happy to PR
59
+
60
+ If the proposal seems reasonable, I'm happy to draft the implementation. Wanted to check on the shape and whether you'd want it gated by an opt-in config option before opening one.
61
+
62
+ Thanks again for the gem.
63
+ ```
@@ -0,0 +1,135 @@
1
+ # Visual-diff integration
2
+
3
+ Guardrails consumes screenshot-diff tool output and folds findings into the unified audit report — same pattern as [deep a11y](A11Y.md). Guardrails doesn't bundle a browser or run screenshots itself; your existing visual-regression toolchain produces the artifacts, we provide the merge + report + exit-code contract.
4
+
5
+ The shipped adapter in 0.8.0 is **snap_diff-capybara** (the Rails-native gem; commits baselines to git under `doc/screenshots/`). A BackstopJS adapter is tracked in [issue #15](https://github.com/meticulous/guardrails/issues/15).
6
+
7
+ ## Quick start
8
+
9
+ In a Rails app with `snap_diff-capybara` installed and system tests that call `screenshot "name"`:
10
+
11
+ ```bash
12
+ # Run your system tests as usual — snap_diff writes baselines/diffs
13
+ # under doc/screenshots/ when assertions fail:
14
+ bundle exec rspec spec/system/
15
+
16
+ # Then fold the visual-diff findings into the unified audit:
17
+ VISUAL_DIFF=1 bundle exec rake guardrails:audit
18
+
19
+ # Or run the visual check standalone:
20
+ bundle exec rake guardrails:visual:deep
21
+ ```
22
+
23
+ If the diffs sit somewhere other than `doc/screenshots/`:
24
+
25
+ ```bash
26
+ VISUAL_DIFF_DIR=spec/screenshots bundle exec rake guardrails:visual:deep
27
+ ```
28
+
29
+ To tolerate small mismatches (when the adapter emits a numeric ratio — snap_diff currently emits only a binary pass/fail, see "Adapter limits" below):
30
+
31
+ ```bash
32
+ # VISUAL_DIFF=1 still needs to be set — VISUAL_DIFF_THRESHOLD alone
33
+ # doesn't enable the check, just tunes it.
34
+ VISUAL_DIFF=1 VISUAL_DIFF_THRESHOLD=0.02 bundle exec rake guardrails:audit
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ ### Embedded (Gemfile install) — Rails initializer
40
+
41
+ Drop a `config/initializers/guardrails.rb`:
42
+
43
+ ```ruby
44
+ Guardrails.configure do |c|
45
+ c.visual_diff.enabled = true
46
+ c.visual_diff.adapter = :snap_diff # default
47
+ c.visual_diff.snap_diff_dir = "spec/screenshots" # default: doc/screenshots
48
+ c.visual_diff.threshold = 0.02 # default: 0.0 (strict)
49
+ end
50
+ ```
51
+
52
+ With `enabled = true`, `rake guardrails:audit` always runs the visual-diff check; no need to set `VISUAL_DIFF=1` on each invocation.
53
+
54
+ ### Sidecar (no Gemfile change) — environment variables
55
+
56
+ | Var | Effect |
57
+ |---|---|
58
+ | `VISUAL_DIFF=1` | Enable visual-diff on `rake guardrails:audit` (the standalone `guardrails:visual:deep` task is always enabled). |
59
+ | `VISUAL_DIFF_DIR=path` | Override the snap_diff baseline directory (default `doc/screenshots`). |
60
+ | `VISUAL_DIFF_THRESHOLD=0.02` | Per-finding mismatch ratio above which the audit fails. Currently only meaningful for adapters that emit numeric ratios — snap_diff is binary. |
61
+
62
+ Env always overrides Configuration.
63
+
64
+ ## How the snap_diff adapter works
65
+
66
+ The snap_diff-capybara gem commits baseline screenshots to git under `doc/screenshots/<name>.png` and, on test failure, writes a sibling `<name>.diff.png` (changed pixels in red) plus a `<name>.heatmap.diff.png` (variance density). Tests fail on baseline mismatches in CI, so the diff files are an artifact of "this regression slipped through to the artifact directory."
67
+
68
+ Guardrails walks the configured directory recursively, pairs each `<name>.diff.png` with its baseline `<name>.png`, and emits one finding per pair. `.heatmap.diff.png` files are excluded (visualization companions, not separate findings).
69
+
70
+ Each finding carries:
71
+
72
+ | Field | Source |
73
+ |---|---|
74
+ | `scenario` | Path under the screenshots dir, sans `.diff.png` (`checkout/cart` for `doc/screenshots/checkout/cart.diff.png`). |
75
+ | `baseline_path` | Sibling `<name>.png` path, relative to the repo root. |
76
+ | `diff_path` | The `.diff.png` path. |
77
+ | `mismatch_ratio` / `viewport` / `url` / `selector` / `current_path` | All `nil` for snap_diff — see "Adapter limits". |
78
+
79
+ ## Adapter limits (snap_diff)
80
+
81
+ - **No mismatch percentage.** snap_diff is binary at the filesystem layer — either a `.diff.png` exists (failed) or it doesn't (passed). `Guardrails::VisualDiff` treats `nil` mismatch_ratio as "unconditionally failing", so the audit fails on any diff regardless of the configured threshold. Tracked upstream — when snap_diff-capybara adds a `snap_diff_report.json` companion (or equivalent) we'll wire mismatch percentages through.
82
+ - **No URL / viewport / selector.** Those live in snap_diff's Capybara tests, not the artifact tree. Adapters that have them (BackstopJS, issue #15) populate the optional fields.
83
+
84
+ ## JSON output
85
+
86
+ When `FORMAT=json` is set on `guardrails:audit`, visual-diff findings appear under `visual_diff:`:
87
+
88
+ ```json
89
+ {
90
+ "summary": {
91
+ "...": "...",
92
+ "visual_diff": 2
93
+ },
94
+ "visual_diff": [
95
+ {
96
+ "scenario": "checkout/cart",
97
+ "viewport": null,
98
+ "mismatch_ratio": null,
99
+ "baseline_path": "doc/screenshots/checkout/cart.png",
100
+ "current_path": null,
101
+ "diff_path": "doc/screenshots/checkout/cart.diff.png",
102
+ "url": null,
103
+ "selector": null
104
+ }
105
+ ]
106
+ }
107
+ ```
108
+
109
+ ## CI
110
+
111
+ A complete CI loop, end-to-end:
112
+
113
+ ```yaml
114
+ - name: System tests (snap_diff captures baselines/diffs)
115
+ run: bundle exec rspec spec/system/
116
+ continue-on-error: true # don't fail the job here — let Guardrails report
117
+
118
+ - name: Guardrails audit
119
+ run: VISUAL_DIFF=1 bundle exec rake guardrails:audit FORMAT=json > findings.json
120
+
121
+ - name: Upload findings + diff images
122
+ if: always()
123
+ uses: actions/upload-artifact@v4
124
+ with:
125
+ name: guardrails
126
+ path: |
127
+ findings.json
128
+ doc/screenshots/**/*.diff.png
129
+ ```
130
+
131
+ ## Why parse-only
132
+
133
+ Bundling axe-core for deep a11y would have meant bundling Capybara + headless Chrome. We didn't, and 0.6.0 shipped a parser instead. Visual-diff is the same trade: bundling Playwright/Chromium would inflate the install footprint for users who don't run system tests. Users who do run system tests already have a screenshot tool; Guardrails layers on top.
134
+
135
+ See the full evaluation: [`doc/RESEARCH-visual-diff.md`](RESEARCH-visual-diff.md).