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.1", only: :test}
]
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.
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.
how it works inside
Two engines, one rule catalog. The runtime engine (this release) parses
rendered HTML into a normalized Farol.Node tree via lazy_html (the
lexbor engine). The static engine (planned) will walk the HEEx tokenizer
output at compile time. Both feed the same node struct into the same rule
modules, so the catalog never forks.
Deliberate non-goals: not a component library (farol grades components, it does not ship them), not a browser driver, not a screen reader simulator. Structural checks only, which is exactly what fits in a test suite.
roadmap
- 0.1 (this): runtime engine, 18 rules, ExUnit assertions, terminal report, zero-config default.
- 0.2: HEEx static analysis,
mix farol, SARIF output for GitHub code scanning, source locations. - 0.3:
farol_liveviewpackage with patch-cycle focus tracking and per-route audit mode. - 1.0: rule api freeze, WCAG 2.2 AA coverage target.
license
MIT. Built by zeetech, de minoria pra minoria.