farol
Accessibility testing for Phoenix LiveView, in pure Elixir.
farol is portuguese for lighthouse, and Lighthouse is the audit tool most
of the web knows. This one speaks LiveView natively: it knows what
phx-click on a <div> means, and it explains every finding instead of
just pointing at it. No node, no browser, no axe-core port. Just Elixir,
running inside the test suite you already have.
test "user card is accessible" do
html = render_component(&user_card/1, user: user)
assert_accessible html
end
When it fails, it teaches:
x [img-alt] <img src="avatar.jpg"> has no alt text
wcag 1.1.1 (level a) - error
why: screen readers announce the filename ("i m g underscore avatar
dot jay peg") or silence. 2.2 billion people live with some form of
vision impairment; alt text is how the image reaches them.
fix: add alt="..." describing what the image conveys - or alt=""
(empty) when the image is purely decorative, so screen readers skip
it.
Every finding carries three layers: what was found, why it matters to humans, and how to fix it. The "why" is written for the developer reading a failing test at 5pm, not for a compliance auditor.
installation
def deps do
[
{:farol, "~> 0.2", only: :test} # x-release-please-version
]
end
Then import the assertion in your test case (or in ConnCase/DataCase
template, to have it everywhere):
import Farol.Assertions
using it
assert_accessible/2 takes any rendered HTML string, which is exactly
what render_component/2 and render/1 return in LiveView tests:
test "settings page is accessible" do
{:ok, view, html} = live(conn, "/settings")
assert_accessible html
end
Escape hatches are explicit on purpose, so exceptions stay greppable in code review:
assert_accessible html, except: ["landmark-regions"]
assert_accessible html, only: [:img_alt, :label_association]
Want the raw findings instead of an assertion? Farol.check/2 returns
them as data, and Farol.Report.format/1 renders the same report the
assertion prints.
static analysis: mix farol
The second engine audits HEEx template source — no running app, no
test to write. It walks the same parser LiveView compiles with, so
attr={@expr} and <%= if %> blocks are understood, and every finding
carries the file and line it came from:
$ mix farol
4 accessibility finding(s): 3 error(s), 1 warning(s)
x [img-alt] <img src="@user.avatar"> has no alt text
wcag 1.1.1 (level a) - error - lib/my_app_web/components/card.ex:2
...
It audits lib/**/*.heex by default, takes paths or globs as arguments,
and exits non-zero on error-severity findings or unparseable templates:
mix farol lib/my_app_web/live --except landmark-regions
mix farol --format sarif --output farol.sarif
--format sarif emits SARIF 2.1.0 for GitHub code scanning. The farol
GitHub Action wraps the whole flow: run the audit, upload the SARIF so
findings annotate the pull request, and fail the workflow on errors:
- uses: erlef/setup-beam@v1
with: {elixir-version: "1.18", otp-version: "27"}
- run: mix deps.get
- uses: zeetech/farol@v0.2
The static engine needs phoenix_live_view (that is where the HEEx
parser lives). It is an optional dependency: if your app already renders
LiveViews, there is nothing to add.
the rule catalog
The catalog is the product. Every rule is a plain module implementing the
Farol.Rule behaviour, which makes the catalog the extension point too.
structure
| rule | wcag | severity | what it checks |
|---|---|---|---|
img-alt |
1.1.1 | error | images without alt text (or explicit decorative marker) |
label-association |
1.3.1 | error | form controls without a programmatic label |
landmark-regions |
1.3.1 | warning | documents with no <main> landmark |
heading-order |
1.3.1 | warning | skipped heading levels (h1 straight to h3) |
duplicate-id |
4.1.1 | error | ids repeated in the document |
html-lang |
3.1.1 | error | <html> without a declared language |
document-title |
2.4.2 | warning | documents with a missing or empty <title> |
aria validity
| rule | wcag | severity | what it checks |
|---|---|---|---|
valid-role |
4.1.2 | error | role values outside the ARIA spec |
valid-aria-attr |
4.1.2 | error | misspelled or invented aria attributes |
no-aria-on-hidden |
4.1.2 | warning | aria on elements nothing can announce |
accessible names
| rule | wcag | severity | what it checks |
|---|---|---|---|
button-name |
4.1.2 | error | buttons (including icon buttons) with no name |
link-name |
2.4.4 | error | links with no name |
iframe-title |
4.1.2 | error | iframes without a title |
liveview-aware (the reason farol exists; nobody else checks these)
| rule | wcag | severity | what it checks |
|---|---|---|---|
phx-click-interactive |
2.1.1 | error | phx-click on elements keyboard users cannot reach |
toggle-aria-pairing |
4.1.2 | warning | JS.toggle/JS.show/JS.hide triggers without aria-expanded/aria-controls |
focus-after-patch |
2.4.3 | warning | phx-update containers that can swallow focus, with no hook to restore it |
live-region-usage |
4.1.3 | warning | flash containers outside an aria-live region |
contrast (opt-in)
| rule | wcag | severity | what it checks |
|---|---|---|---|
contrast-token |
1.4.3 | error | inline colors below WCAG AA ratios, resolved through your design tokens |
contrast, driven by your design tokens
contrast-token only runs when you declare a token map, because without
it the rule cannot know what your color names mean. Declare it once and
your design system becomes the test fixture:
config :farol, :tokens, %{
"bg" => "#000A0F",
"fg" => "#F7F7FF",
"accent" => "#9655FF"
}
Inline styles then check against WCAG AA: 4.5:1 for text, 3:1 for large
text (24px, or 18.66px bold). Values can be hex literals, token names, or
var(--token) references. For the zeetech palette above, farol can tell
you that #9655FF on #000A0F sits at about 4.8:1, and show the math.
Deliberate scope line: tokens plus inline styles only. Resolving CSS classes means writing a CSS engine, and that is a later conversation.
writing your own rules
A rule is a plain module with a behaviour, no macros:
defmodule MyApp.Rules.NoTargetBlank do
@behaviour Farol.Rule
alias Farol.{Finding, Node}
def id, do: "no-target-blank"
def wcag, do: "3.2.5"
def level, do: "a"
def severity, do: :warning
def why, do: "new tabs break the back button and disorient screen reader users."
def fix, do: "drop target=\"_blank\", or warn in the link text that it opens a new tab."
def check(nodes) do
nodes
|> Node.find(&(Node.attr(&1, "target") == "_blank"))
|> Enum.map(fn node ->
%Finding{
rule: id(),
wcag: wcag(),
level: level(),
severity: severity(),
message: "#{Node.snippet(node)} opens a new tab without warning",
snippet: Node.snippet(node)
}
end)
end
end
Rules receive the parsed document as Farol.Node trees and return
findings. They never raise and never do IO, which keeps them trivially
testable: pass a string through Farol.check/2 with only: and assert on
the findings.
license
MIT. Built by zeetech, de minoria pra minoria.