| title | date | status | author | tags | notion | |||
|---|---|---|---|---|---|---|---|---|
[Spec] A11y CLI Proof of Concept |
2026-03-14 |
draft |
Umut Sirin |
|
TBD |
Build a working proof of concept of the @usirin/a11y CLI that demonstrates the full property-based verification loop on a real ticket from Discord's accessibility audit.
Target ticket: [A11y] DSC: Settings - Radio button elements are missing programmatic roles
- Asana:
1213198589838820 - WCAG: 4.1.2 Name, Role, Value
- Platform: iOS + Android
- Issue: All radio buttons in Settings pages are identified as "activate" instead of having a radio button role
- Owned by: Design Systems team (our team)
Demo flow:
a11y tree ioscaptures the a11y tree from Settings > Appearancea11y checkrunsinteractiveElementsHaveRoles, finds radio buttons without roles- Fix the mana radio button component
- Metro hot reloads
a11y tree ioscaptures againa11y verifyconfirms violations are gone, no regressions
misc/users/usirin/monorepo/
package.json # pnpm workspace root
pnpm-workspace.yaml # packages: ["packages/*"]
tsconfig.json
packages/
a11y/
package.json # @usirin/a11y
tsconfig.json
src/
bin.ts # CLI entry point
commands/
tree.ts # a11y tree <platform>
check.ts # a11y check <snapshot>
verify.ts # a11y verify --before X --after Y
list-properties.ts # a11y list-properties
adapters/
ios.ts # WDA /source fetch + parse
properties/
index.ts # property registry
interactive-elements-have-roles.ts # first property
schema/
snapshot.ts # tagged union snapshot type
violation.ts # violation types
result.ts # CheckResult, VerifyResult with status + notes
effect(v4 from local source at~/code/github.com/usirin/effect-smol)@effect/platform-node(for HTTP client, filesystem, CLI)- No other runtime deps. Keep it minimal.
@effect/vitestfor testingtypescripttsupor similar for building the CLI binary
Tagged union. Platform is the discriminator.
import { Schema } from "effect"
const IOSDeviceInfo = Schema.Struct({
name: Schema.String,
os: Schema.String,
model: Schema.String,
})
const IOSSnapshot = Schema.Struct({
version: Schema.Literal(1),
platform: Schema.Literal("ios"),
capturedAt: Schema.String,
screen: Schema.String,
deviceInfo: IOSDeviceInfo,
tree: Schema.Unknown, // raw WDA source tree, typed later
})
const WebSnapshot = Schema.Struct({
version: Schema.Literal(1),
platform: Schema.Literal("web"),
capturedAt: Schema.String,
url: Schema.String,
tree: Schema.Unknown,
})
const AndroidSnapshot = Schema.Struct({
version: Schema.Literal(1),
platform: Schema.Literal("android"),
capturedAt: Schema.String,
screen: Schema.String,
deviceInfo: Schema.Unknown,
tree: Schema.Unknown,
})
const A11ySnapshot = Schema.Union(IOSSnapshot, WebSnapshot, AndroidSnapshot)Every command returns status + notes.
const CheckResult = Schema.Struct({
status: Schema.Literal("pass", "fail"),
property: Schema.String,
platform: Schema.String,
elementsScanned: Schema.Number,
notes: Schema.Array(Schema.String),
violations: Schema.Array(Violation),
})
const VerifyResult = Schema.Struct({
status: Schema.Literal("improved", "regressed", "unchanged"),
property: Schema.String,
notes: Schema.Array(Schema.String),
before: Schema.Struct({ count: Schema.Number }),
after: Schema.Struct({ count: Schema.Number }),
removed: Schema.Array(Violation),
added: Schema.Array(Violation),
})
const TreeResult = Schema.Struct({
status: Schema.Literal("ok", "error"),
notes: Schema.Array(Schema.String),
snapshot: A11ySnapshot,
})What it does:
- HTTP GET
http://localhost:8100/source?format=json(WDA running on simulator) - Parse the response into the iOS source tree format
- Wrap in snapshot envelope (version, platform, capturedAt, screen, deviceInfo)
- Write to
--output <file>
Stabilization:
--wait-for-label <text>: poll the tree until an element with this label appears--timeout <ms>: how long to wait (default 10000)- If timeout reached, exit 1 with structured error. Never write a partial/wrong snapshot.
Device info:
- Get from
xcrun simctl list devices booted --jsonor hardcode for POC
a11y tree ios --output /tmp/before.json --wait-for-label "Appearance"What it does:
- Read snapshot file
- Infer platform from
snapshot.platform - Run specified property (or all) against the tree
- Return CheckResult with status, notes, violations
a11y check /tmp/before.json --property interactiveElementsHaveRoles --jsonExit codes:
- 0: pass (no violations)
- 1: fail (violations found)
What it does:
- Read before and after snapshots
- Assert platforms match
- Run property against both
- Compute diff: which violations were removed, which are new
- Return VerifyResult with status and notes
a11y verify --before /tmp/before.json --after /tmp/after.json --property interactiveElementsHaveRoles --jsonExit codes:
- 0: improved
- 1: regressed
- 2: unchanged
WDA source tree elements have a type field that maps to UIKit element types. The property checks:
For every element where:
typeis in interactive set (Button, Switch, TextField, SearchField, Slider) OR- element responds to tap (heuristic based on traits/type)
Assert:
- Element has a meaningful
typethat maps to a known a11y role - Elements that are "activate"-only (the bug) should be flagged
What makes a radio button detectable:
- On iOS, radio buttons should have trait
UIAccessibilityTraitSelectedwhen selected - The ticket says they're announced as "activate" which means they're generic tappable elements without proper role
- Look for elements that appear in groups with mutually exclusive selection but lack radio button semantics
The property generates notes like:
- "5 interactive elements missing programmatic roles"
- "Elements: On, Off, Dark, Light, English (all in Settings > Appearance)"
- "These appear to be radio buttons without radio button role"
- Create
misc/users/usirin/monorepo/with pnpm workspace - Create
packages/a11y/with package.json, tsconfig - Set up Effect v4 dependency (link to local effect-smol)
- Set up vitest
- Verify:
pnpm testruns (even with 0 tests)
- Implement snapshot schema types in
src/schema/ - Implement iOS adapter (
src/adapters/ios.ts): fetch WDA /source, parse - Implement
a11y tree ioscommand with --output and --wait-for-label - Test: capture a real tree from the iOS simulator, inspect the JSON
- Implement violation types in
src/schema/violation.ts - Implement CheckResult in
src/schema/result.ts - Implement
interactiveElementsHaveRolesfor iOS insrc/properties/ - Implement
a11y checkcommand - Test: run against the captured tree, verify it finds the radio button violations
- Implement VerifyResult in
src/schema/result.ts - Implement
a11y verifycommand with before/after diff logic - Test: create a mock "after" tree with fixed roles, verify it reports "improved"
- Navigate to Settings > Appearance on iOS simulator
a11y tree ios --output /tmp/before.json --wait-for-label "Appearance"a11y check /tmp/before.json --property interactiveElementsHaveRoles --json- Fix the radio button component in mana
- Wait for Metro hot reload
a11y tree ios --output /tmp/after.json --wait-for-label "Appearance"a11y verify --before /tmp/before.json --after /tmp/after.json --property interactiveElementsHaveRoles --json- Record terminal session for the RFC demo
- Web adapter (Playwright)
- Android adapter (UIAutomator)
- Any property other than
interactiveElementsHaveRoles - Pipeline orchestration (Asana -> fix -> PR)
- CI integration
list-propertiescommand (trivial, add later)- Pretty output (JSON only for POC)
- WDA source tree format. Need to capture a real tree from the simulator and inspect the shape before finalizing the iOS adapter types. The POC's first step is literally "capture a tree and look at it."
- How to detect radio buttons specifically. The ticket says they're announced as "activate." Need to see what the WDA tree actually shows for these elements to write the right check.
- Effect CLI setup. Effect v4 has
@effect/platform-nodewith CLI helpers. Need to check what's available for arg parsing. Alternatively just use a simple arg parser and compose Effect services behind it.
The POC is done when you can run these 4 commands and get meaningful output:
a11y tree ios --output /tmp/before.json --wait-for-label "Appearance"
a11y check /tmp/before.json --property interactiveElementsHaveRoles --json
# shows violations for radio buttons
# (fix applied, hot reloaded)
a11y tree ios --output /tmp/after.json --wait-for-label "Appearance"
a11y verify --before /tmp/before.json --after /tmp/after.json --property interactiveElementsHaveRoles --json
# shows status: "improved", notes explain what was fixed