Skip to content

Instantly share code, notes, and snippets.

@usirin
Created March 15, 2026 03:51
Show Gist options
  • Select an option

  • Save usirin/321d87684abd8f2e1ce5ec01bc3a6d2c to your computer and use it in GitHub Desktop.

Select an option

Save usirin/321d87684abd8f2e1ce5ec01bc3a6d2c to your computer and use it in GitHub Desktop.
[Spec] A11y CLI Proof of Concept — Effect-TS v4, iOS, radio button roles
title date status author tags notion
[Spec] A11y CLI Proof of Concept
2026-03-14
draft
Umut Sirin
spec
a11y
poc
TBD

Goal

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:

  1. a11y tree ios captures the a11y tree from Settings > Appearance
  2. a11y check runs interactiveElementsHaveRoles, finds radio buttons without roles
  3. Fix the mana radio button component
  4. Metro hot reloads
  5. a11y tree ios captures again
  6. a11y verify confirms violations are gone, no regressions

Package Setup

Location

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

Dependencies

  • 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.

Dev dependencies

  • @effect/vitest for testing
  • typescript
  • tsup or similar for building the CLI binary

Snapshot Schema

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)

Output Schema

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,
})

CLI Commands (POC scope)

a11y tree ios

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 --json or hardcode for POC
a11y tree ios --output /tmp/before.json --wait-for-label "Appearance"

a11y check <snapshot>

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 --json

Exit codes:

  • 0: pass (no violations)
  • 1: fail (violations found)

a11y verify

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 --json

Exit codes:

  • 0: improved
  • 1: regressed
  • 2: unchanged

First Property: interactiveElementsHaveRoles

iOS implementation

WDA source tree elements have a type field that maps to UIKit element types. The property checks:

For every element where:

  • type is in interactive set (Button, Switch, TextField, SearchField, Slider) OR
  • element responds to tap (heuristic based on traits/type)

Assert:

  • Element has a meaningful type that 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 UIAccessibilityTraitSelected when 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

Notes generation

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"

Implementation Steps

Phase 1: Package scaffold

  1. Create misc/users/usirin/monorepo/ with pnpm workspace
  2. Create packages/a11y/ with package.json, tsconfig
  3. Set up Effect v4 dependency (link to local effect-smol)
  4. Set up vitest
  5. Verify: pnpm test runs (even with 0 tests)

Phase 2: Snapshot schema + tree command

  1. Implement snapshot schema types in src/schema/
  2. Implement iOS adapter (src/adapters/ios.ts): fetch WDA /source, parse
  3. Implement a11y tree ios command with --output and --wait-for-label
  4. Test: capture a real tree from the iOS simulator, inspect the JSON

Phase 3: Check command + first property

  1. Implement violation types in src/schema/violation.ts
  2. Implement CheckResult in src/schema/result.ts
  3. Implement interactiveElementsHaveRoles for iOS in src/properties/
  4. Implement a11y check command
  5. Test: run against the captured tree, verify it finds the radio button violations

Phase 4: Verify command

  1. Implement VerifyResult in src/schema/result.ts
  2. Implement a11y verify command with before/after diff logic
  3. Test: create a mock "after" tree with fixed roles, verify it reports "improved"

Phase 5: End-to-end demo

  1. Navigate to Settings > Appearance on iOS simulator
  2. a11y tree ios --output /tmp/before.json --wait-for-label "Appearance"
  3. a11y check /tmp/before.json --property interactiveElementsHaveRoles --json
  4. Fix the radio button component in mana
  5. Wait for Metro hot reload
  6. a11y tree ios --output /tmp/after.json --wait-for-label "Appearance"
  7. a11y verify --before /tmp/before.json --after /tmp/after.json --property interactiveElementsHaveRoles --json
  8. Record terminal session for the RFC demo

What's NOT in the POC

  • Web adapter (Playwright)
  • Android adapter (UIAutomator)
  • Any property other than interactiveElementsHaveRoles
  • Pipeline orchestration (Asana -> fix -> PR)
  • CI integration
  • list-properties command (trivial, add later)
  • Pretty output (JSON only for POC)

Open Questions

  1. 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."
  2. 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.
  3. Effect CLI setup. Effect v4 has @effect/platform-node with CLI helpers. Need to check what's available for arg parsing. Alternatively just use a simple arg parser and compose Effect services behind it.

Success Criteria

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment