Current section
Files
Jump to
Current section
Files
README.md
# 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.
```elixir
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
```elixir
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):
```elixir
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:
```elixir
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:
```elixir
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:
```yaml
- 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:
```elixir
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:
```elixir
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](https://zeetech.io), de minoria pra minoria.