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.