Accessibility: built-in guarantees, the audit command, the build gate
GoFastr treats accessibility in three layers: the component library
ships correct semantics by default, gofastr build enforces the static
floor the type system can see, and gofastr audit a11y --url runs the
full axe-core engine against the running app for everything only a real
render can catch.
What the framework already does
framework/ui and core-ui components carry their ARIA contract
internally: labelled landmarks, aria-expanded on disclosures,
aria-selected mirroring on tabs, focus traps on modals,
aria-live announcement regions, keyboard navigation on menus, trees,
carousels, and sortable lists. Composing the design system instead of
hand-rolling markup is the single biggest accessibility win an app can
make (see the UI architecture doc's hard rules).
The typed HTML layer makes the remaining requirements visible in the
config struct: html.Image has an Alt field, html.Button a
Label, html.Nav a Label/LabelledBy pair, html.FieldSet a
Legend. The audit below checks that you actually set them.
The document language (WCAG 3.1.1) defaults to en. Set it for a
non-English app with app.WithLang("…") (or uihost.WithLang("…")),
which stamps <html lang> on every rendered surface: pages, error
screens, and the PWA offline shell.
gofastr audit a11y: the guided static lint
$ gofastr audit a11y Accessibility lint — 2 issue(s) in 1 file(s) app/screens/home.go:42: html.Image: missing required field "Alt" in ImageConfig fix: every image needs Alt. Informative image → describe what it shows ("Team photo at launch"). Decorative image → explicit empty Alt: "" so screen readers skip it. Never omit the field.
It scans every non-test, non-generated .go file for core-ui/html
element configs missing their required accessibility fields, and each
finding explains the rule. The goal is that the fix teaches WCAG
name/role/value basics rather than just flagging a line. Exit code 1 on findings,
so it can gate CI directly.
The lint understands the ARIA escape hatch: an ExtraAttrs literal
carrying aria-label / aria-labelledby / role satisfies the
matching typed field (icon-only buttons are the canonical case). Note
that ui.Button owns aria-label: an ExtraAttrs["aria-label"] is
dropped, and the accessible name comes from Label. html.Button sets
aria-label from AriaLabel, else from Label, so an ExtraAttrs
value survives only when both are empty — the icon-only case. When
several buttons share a visible Label ("Revoke") but need distinct
accessible names, set ButtonConfig.AriaLabel — it overrides the
Label-derived name without changing the visible text. Two
deliberate scope limits: a config built in a variable
(cfg := html.ImageConfig{…}; html.Image(cfg)) and a non-literal
ExtraAttrs value can't be inspected statically, so they pass the lint.
The elements' own runtime validation (html.Button panics without an
accessible name) and the axe runtime scan cover those paths.
Checked elements: Image (Alt), Button (Label), Link/LinkHTML
(Href + text), Nav/Section/Aside (Label or LabelledBy), Group
(Role), Label (For + Text), Input/Select/TextArea (Name),
FieldSet (Legend), Heading (Level), Form (Method), Abbr
(Title), Time (Datetime), Source (Src + Type).
The build gate
gofastr build runs the same lint between go vet and compilation and
fails the build on findings, printing the guided report. gofastr
dev applies the identical gate to every rebuild, so dev and build
enforce the same floor: a finding stops the server from starting, the
watcher keeps running, and fixing + saving retries. The rules are cheap
(pure static analysis, no browser) and every finding has a concrete
fix, so the default is enforcement. --no-a11y (on either command)
skips the gate when you genuinely need a build anyway. Treat it like
//nolint, not like a setting.
gofastr audit a11y --url: the full runtime audit
$ gofastr audit a11y --url http://localhost:8080 Auditing 14 page(s) at http://localhost:8080 under 2 color scheme(s)… Audited 14 of 14 discovered pages. /pricing (dark scheme) [serious] color-contrast: Elements must meet minimum color contrast ratio thresholds guide: https://dequeuniversity.com/rules/axe/4.10/color-contrast at: .pricing-card__footnote
This drives headless Chrome with the vendored axe-core engine (the same
harness the framework's own example gates use: hermetic, no CDN fetch)
and audits both color schemes: contrast, focus order, landmark
structure, ARIA validity. These are the classes of failure only a
rendered page exposes. Pages are discovered from the app's /sitemap.xml
(uihost.WithSitemap), so a sitemap-configured app gets full-site
coverage with zero flags; use --pages /a,/b to scope. Exit code 1 on
violations.
For an authenticated app, pass the same credentials a user enters on the
auth battery's /login page:
gofastr audit a11y --url http://localhost:8080 \ --email admin@example.com --password "$ADMIN_PASSWORD" \ --pages /admin,/admin/entities/products
The auditor fills input[name=email] and input[name=password], then clicks
the login form's submit button so the app's own submit event, session cookie,
and redirect all run normally. With no explicit page list, sitemap discovery
runs after login in that authenticated browser session.
Every run reports Audited N of M discovered pages. Redirected pages are
listed under Could not reach and make the command exit 1 even when axe found
no violations on the reachable pages. A run whose only page is /login also
exits 1 with a coverage warning; it is not evidence that authenticated pages
are clean.
Requires a Chrome/Chromium install (headless). Run it against gofastr
dev's server during development, or against a staging deploy in CI.
The axe test harness: pin the gate as a test
framework/testkit/axetest is the same harness the audit command and
the framework's own example gates use, exported so a host app can pin
the runtime audit into its own suite:
import "github.com/DonaldMurillo/gofastr/framework/testkit/axetest" func TestAxeAllPagesClean(t *testing.T) { browser := axetest.NewBrowser(t) for _, page := range pages { // derive from your screen catalog tab, done := axetest.NewTab(t, browser) if err := chromedp.Run(tab, chromedp.Navigate(base+page), axetest.WaitReady(), ); err != nil { t.Fatalf("load %s: %v", page, err) } violations, err := axetest.ScanSchemes(tab, axetest.Schemes, allowlist) // fail on err or violations; each Violation carries its Scheme done() } }
The pieces, for a gate that needs more than one load-and-scan per page:
| Call | What it does |
|---|---|
NewBrowser(t) / NewTab(t, browser) | One headless Chrome per test, a fresh tab (30 s deadline) per page so the previous page's SSE socket closes. |
WaitReady() | Waits until the page has settled: the load event fired, web fonts loaded, the runtime booted, no client navigation is in flight, and every runtime module the page asked for has registered. It fails after 10 s and names what is still missing, for example runtime module tabs has not registered. |
Prepare(scheme) | Freezes transitions and forces light or dark on the current page. It returns once <html data-color-scheme> and the color-scheme meta name the scheme and the page's colors have stopped changing. It can run again on the same page to switch schemes. |
Scan(tab, scheme, allowlist, opts...) | Runs axe on the page as it is right now, so you can open a modal first and then scan. The axe engine is injected once per page; a second scan reuses it. A window.axe the page defines itself is replaced, never used. A rule that crashes (axe throws, for example on a color it cannot parse) is an error naming the rule, since it checked nothing; a rule in the allowlist is skipped either way. |
ScanSchemes(tab, schemes, allowlist, opts...) | Prepare then Scan for each scheme on one loaded page. An empty scheme list is an error. |
Scan both schemes on one page load with ScanSchemes instead of opening a
tab per scheme. Loading the page again for the second scheme costs a
navigation and an axe injection and finds nothing new.
Derive the page list from the same source your screens register from
(a catalog, app.Routes()) so a new screen is scanned automatically.
A hand-maintained list drifts. examples/site/axe_test.go is the full
reference pattern (per-page allowlists with justifications, mobile
target-size pass).
Every successful Scan also records the scanned path into
.gofastr/axe-coverage.json (the axe-coverage manifest,
framework/axecov) under the canonical coverage root:
GOFASTR_AXE_COVERAGE_DIR when set, else the module root. The result
is a per-project record of which pages the axe suite actually
exercised. The manifest is a local build artifact: gitignored, wiped by
make clean, never shipped. GOFASTR_AXE_COVERAGE=0 disables
recording. Note the recorded path is the browser's FINAL location:
a scan that got redirected records the destination, never the page you
asked for. Apps that opt into uihost.WithStrict() fail dev boot for
any page route the manifest doesn't cover: every screen must have an
axe test (gofastr docs strict-mode).
Recommended loop
- Compose
framework/uicomponents; reach forcore-ui/htmlonly for genuinely bespoke fragments. - Let
gofastr buildkeep the static floor green (it's on by default). - Before shipping UI changes:
gofastr audit a11y --url http://localhost:8082and fix what axe reports in both schemes. - For apps with their own test suites, pin the runtime gate as a test.
See
examples/site/axe_test.gofor the reference pattern (per-page allowlists with justifications, mobile target-size pass).
Common mistakes
Alt: ""versus noAltat all. The empty string is a deliberate "decorative, skip me" signal; omitting the field entirely is the bug the lint flags. Decide which one the image is, and don't silence the finding with filler text like "image".- Silencing the build gate permanently.
--no-a11yis an escape hatch for a blocked build, not a project setting. If a rule seems wrong for a real case, the element probably isn't the right primitive (aSectionthat needs no label may just be aDiv). - Auditing only one color scheme. Contrast regressions hide in the scheme your machine doesn't use. The runtime audit forces dark AND light on every page for exactly this reason. Don't scope it back down.
- Running the runtime audit without a sitemap or page list. Without
uihost.WithSitemapthe scan falls back to/. The coverage line makes that narrow run visible, but it still cannot discover routes that were never advertised. Configure the sitemap or pass--pagesexplicitly. - Auditing protected routes without credentials. A redirect to
/loginis reported as unreachable and fails the run. Pass--emailand--password; both are required together. - Fixing the symptom in CSS. An axe
color-contrastfinding on a component means the token is wrong (--color-text-subtleon--color-surface), not that one selector needs an override. Fix the theme so every component inherits the correction.