# Accessibility — `src/gui/` Patterns

> **For AI agents and human contributors touching any file under `src/gui/`.**
> Linked from [`AGENTS.md`](../AGENTS.md) so every AI tool that reads this
> repo's canonical agent guide picks these patterns up automatically.

These are *patterns*, not gates. CI lints to `::warning` annotations only —
it never blocks a build. Adopt what fits the widget you're touching;
silence the lint when it doesn't fit (see [Suppressing the lint](#suppressing-the-lint)
below). The goal is to make AetherSDR usable for screen-reader users without
making it bureaucratic for sighted contributors. Patterns here are invisible
to sighted users — no visual changes, no layout changes, no new UI elements.

## Background

Phase 1 ([#899](https://github.com/aethersdr/AetherSDR/pull/899), merged
2026-04-07) brought every interactive control up to the point where
VoiceOver can find it and read a name — `setAccessibleName` /
`setAccessibleDescription` on instantiated widgets.

Phase 2 (tracked in
[#3288](https://github.com/aethersdr/AetherSDR/issues/3288),
scaffolding in
[#3289](https://github.com/aethersdr/AetherSDR/pull/3289)) closes the
single highest-impact remaining gap: `QAccessible::updateAccessibility`
was called **zero times** in the entire codebase. That means even widgets
with accessible names were static labels to VoiceOver — no values,
states, or live changes were ever announced. A screen-reader user could
navigate to the S-meter and hear "S-Meter," but they would never hear
"S9" or "S9+20." They could find the VFO frequency display and hear
"VFO A," but tuning the dial was completely silent.

Phase 2 fixes that, and the patterns below are how we keep it fixed.

## What we're following

These patterns implement the four [WCAG 2.1](https://www.w3.org/WAI/WCAG21/quickref/)
principles (POUR: **P**erceivable, **O**perable, **U**nderstandable, **R**obust)
on a Qt desktop application:

- **Perceivable** — accessible names + `QAccessibleValueChangeEvent` on value
  changes + sufficient colour contrast.
- **Operable** — every interactive widget reachable by keyboard, with a
  non-mouse activation path. (AetherSDR still has surfaces that are
  mouse-only today — CHAIN drag-drop, frequency drag, drag-to-pan, custom
  panadapter handlers — which we're working toward; new code shouldn't
  add to the list.)
- **Understandable** — accessible descriptions for non-obvious controls;
  consistent naming across the app.
- **Robust** — `QAccessibleInterface` subclasses for custom-painted content
  so VoiceOver / NVDA / Orca stay informed even when Qt can't introspect
  the rendered output.

Each section below is the concrete "how" for one or more of those.

## Naming interactive and informational widgets

1. **Accessible name** — interactive controls (buttons, sliders, spin boxes,
   custom focusable widgets) and informational displays (meters, readouts,
   status badges) read better with:
   ```cpp
   widget->setAccessibleName(tr("Human readable name"));
   ```
   Use a concise noun phrase (e.g. `"Frequency display"`, `"RF Gain"`).
   Layout-only containers (`QFrame` wrappers, decorative spacers, static
   "Hz" suffix labels next to a spin box) don't need one — the name on
   the parent control covers the group.

2. **Accessible description** — *optional*. Add only when the name alone
   doesn't convey purpose. A redundant description ("RF Gain. The RF gain
   control.") becomes noise on a screen reader.
   ```cpp
   widget->setAccessibleDescription(tr("0 to 100 dB attenuation before AGC"));
   ```

   For *input* widgets (`QSpinBox`, `QLineEdit`, `QComboBox`), the accessible
   name should answer **"what is this?"** and the description should answer
   **"what do I type?"** — units and ranges for a spin box, format for a
   line edit, what the choices mean for a combo box. Qt's
   `setAccessibleDescription` is announced as the widget's primary
   description by every major AT (VoiceOver after a brief pause, NVDA in
   verbose announcement modes, Orca's second-pass). Tooltips do reach AT
   via `QAccessible::Help` — but as *help text*, fired only on explicit
   request — so they're a poorer home for input semantics than the
   description role, which is read automatically as part of the widget's
   identity.

3. **Tab focus** — every keyboard-operable widget needs to be reachable:
   ```cpp
   widget->setFocusPolicy(Qt::TabFocus);
   ```
   Leave `Qt::StrongFocus` or `Qt::WheelFocus` alone if already set.
   Decorative-only widgets: leave `Qt::NoFocus`.

   **Test it.** Tab to your widget from a sibling without touching the
   mouse, then activate it with `Space` or `Return`. If you can't reach
   it, the focus policy is wrong. If tabbing reaches it but `Space` /
   `Return` doesn't activate it, the widget needs a `keyPressEvent`
   handler (or it should be a `QPushButton` instead of a custom
   QLabel — see the [interactive QLabel anti-pattern](#interactive-qlabel-anti-pattern)
   below).

## Live value updates

Methods that change a displayed value — `setLevel`, `updateFreqLabel`,
`updateReadout`, custom `updateValue` — should fire a
`QAccessibleValueChangeEvent` **after** the state change so VoiceOver /
NVDA / Orca announce the new value:
```cpp
QAccessibleValueChangeEvent ev(this, newValue);
QAccessible::updateAccessibility(&ev);
```
For text-only updates where there is no numeric value, use:
```cpp
QAccessibleEvent ev(this, QAccessible::NameChanged);
QAccessible::updateAccessibility(&ev);
```

**Throttle high-rate updaters.** A continuous tuning sweep can drive a
frequency-label `setText` at 30+ Hz; firing an announcement on every
interim frame turns a screen reader into a stutter machine and bothers
sighted users on the same machine because the SR overlay paints over the
UI. Announce *settled* values — match the cadence to what a sighted user
would read as "the value stopped changing." Patterns that work:

- Use the same `MeterSmoother` settled-frame gate already in use for
  lean-mode paint throttling (see [`MeterSmoother.h`](../src/gui/MeterSmoother.h)).
- For knob/dial-driven changes, fire the announcement on
  `QAbstractSlider::sliderReleased` rather than `valueChanged`.
- For free-form updates, debounce to ~10 Hz with a `QTimer::singleShot`.

A noisy `updateAccessibility` is worse than no `updateAccessibility` —
prefer slightly stale to spammy. The linter's per-method suppression
comment (below) is the right answer if a value-change method genuinely
needs to fire silently (e.g., it updates internal cache only).

## Colour contrast

When you author a `setStyleSheet` block for a widget (especially in
AetherSDR's dark-theme RF-instrument palette), aim for
[WCAG 2.1 contrast ratios](https://webaim.org/articles/contrast/):

- **4.5 : 1** for normal text against its background.
- **3 : 1** for large text (≥ 18 pt, or ≥ 14 pt bold) and for non-text
  components — interactive component borders, focus indicators, control
  states that need to be distinguishable.

**Prefer `ThemeManager` tokens whose resolved values hit the WCAG
ratio.** The token system is the long-term home for color decisions —
tokens are theme-switchable, high-contrast-mode-aware, and give one
place to fix a contrast bug across every widget that uses them. If a
semantically appropriate token exists (e.g. `color.background.tx` for
TX state, `color.accent.warning` for warning state), use it.

**When no appropriate token exists, concrete hex is a valid intermediate
state — file a follow-up issue to add the token.** PR
[#3441](https://github.com/aethersdr/AetherSDR/pull/3441) (Reboot Radio
button, merged 2026-06-06 as `25b97f2e`) is the canonical example. The
disabled-button rule had been using `{{color.background.1}}` /
`{{color.meter.bar.fill}}` / `{{color.background.2}}` — generic chrome
tokens that all dim to similar blue-greys, making the button look
*absent* rather than greyed-out. Those tokens weren't semantically
"disabled button," so the fix landed concrete hex values that hit the
contrast ratio, with [#3446](https://github.com/aethersdr/AetherSDR/issues/3446)
filed to add proper `color.button.background.disabled` /
`.foreground.disabled` / `.border.disabled` tokens (mirroring the
existing `color.knob.*.disabled` precedent). The hex values are
*evidence for what the tokens should resolve to*, not a rejection of
the theme abstraction.

Rule of thumb when authoring a state stylesheet (`:disabled`, `:hover`,
`:checked`, etc.) for a new widget:

1. Is there a token whose name describes this widget × state? Use it.
2. Is there a token for this state on a *similar* widget (knob vs button,
   say)? Mirror its namespace and add the new token.
3. Neither exists? Concrete hex that meets the ratio, file an issue to
   add the token. Reference the hex in the issue body so the migrator
   has the verified value.

Quick contrast check: use a screen-eyedropper to pull foreground and
background hex codes (macOS Digital Color Meter, Windows PowerToys Color
Picker, Linux gpick/gcolor3) and drop them into the
[WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/),
which takes two hex codes. For a one-step screenshot-to-ratio workflow,
[Colour Contrast Analyser](https://www.tpgi.com/color-contrast-checker/)
(TPGi, free, all three OSes) has an integrated eyedropper.

For high-contrast accessibility modes (macOS "Increase Contrast",
Windows "Contrast Themes"), enable the OS setting and walk the screen —
if anything that was barely readable becomes unreadable, the contrast
was below WCAG already.

## Custom-painted widgets (`paintEvent` override)

If a class overrides `paintEvent` and draws **data-bearing** content
(spectrum, waterfall, meter, scope, gauge), Qt cannot introspect the
rendered output. Options:

- Provide a `QAccessibleInterface` subclass (named `FooAccessible`) that
  returns meaningful `text(QAccessible::Name)` and `text(QAccessible::Value)`
  strings, and register it via `QAccessible::installFactory`; **or**
- Annotate the file with `// TODO(a11y): QAccessibleInterface needed` and
  open a follow-up issue (link it from the comment).

The linter only flags `paintEvent` overrides on widgets that **also expose
value-change setters**. A purely decorative `paintEvent` (custom badge
backdrop, gradient frame, hover overlay) has no data to announce — those
widgets don't trip the lint, and if one slips through, mark it with
`// a11y-check: skip-file`.

Do **not** use `setAttribute(Qt::WA_AcceptTouchEvents, false)` to hide a
widget from the accessibility tree — that affects touch input, not AT
exposure. The correct way to exclude a purely decorative widget from the
a11y tree is to return `QAccessible::NoRole` from the
`QAccessibleInterface::role()` override, or leave `Qt::NoFocus` set and
omit `setAccessibleName` so AT tools skip it naturally.

## Interactive `QLabel` anti-pattern

Any `QLabel` that has a `mousePressEvent` override or appears inside an
`eventFilter` handling click events is acting as a button without accessible
semantics. Replace it with `QPushButton` (styled flat if needed), or at
minimum add a keyboard activation path (`setFocusPolicy(Qt::TabFocus)` plus
a `keyPressEvent` handler for `Qt::Key_Return`/`Qt::Key_Space`) and return
`QAccessible::Button` from a `QAccessibleInterface::role()` override.

## Suppressing the lint

The CI lint is a nudge, not a gate. If a finding doesn't fit your widget,
silence it.

**Whole-file opt-out** — for genuinely decorative widgets (custom button
chrome, gradient backdrops, badge widgets that don't carry data), add
anywhere in the file:
```cpp
// a11y-check: skip-file
```
The linter short-circuits the file and emits zero findings against it.

**Per-method opt-out** — for a single value-change method that legitimately
shouldn't announce (internal-only cache update, computed value that the
public-facing setter already announces), add `// a11y-check: skip` on the
method definition line itself, or anywhere inside the method body. The
checker only reads those two locations — a comment on the line *above* the
method is invisible to it.
```cpp
// Either form works:
void FooWidget::setInternalCache(float v) {  // a11y-check: skip
    m_cache = v;
}

void FooWidget::setInternalCache(float v) {
    // a11y-check: skip — announcing setter is setLevel()
    m_cache = v;
}
```

Suppression comments are first-class: there is no review pressure to remove
them, and AetherClaude / Claude Code / Copilot will leave them alone unless
explicitly asked to revisit them. The point of the lint is to surface
genuine misses, not to coerce ceremony onto widgets that don't need it.

## Manual verification — actually run a screen reader

The CI lint is static analysis. It can tell you that `updateFreqLabel()`
forgot to fire a `QAccessibleValueChangeEvent`; it **cannot** tell you
whether VoiceOver actually reads your widget the way you intended, whether
the announced order matches the tab order, or whether the description
makes sense out loud. After landing a non-trivial accessibility change,
walk the affected surface with the platform screen reader:

- **macOS** — VoiceOver. Toggle with `Cmd+F5`; navigate with `VO+arrow`
  keys (`Ctrl+Option+arrow` by default).
- **Windows** — [NVDA](https://www.nvaccess.org/) (free, recommended) or
  the built-in Narrator (`Win+Ctrl+Enter`).
- **Linux** — Orca (built into GNOME accessibility; run `orca` on the
  command line, or toggle from the GNOME Universal Access panel).

Walk the widget the way a screen-reader user would: tab in, listen to the
name, change the value, listen for the announcement, tab out. If the flow
makes sense without looking at the screen, the patterns above are doing
their job. If you hear "panel," "label," "button" with no context, or
nothing at all when a value visibly changes, something is missing. The
lint catches what it can detect statically — this is the real test.

## CI enforcement

[`tools/check_a11y.py`](../tools/check_a11y.py) runs on every PR via
[`.github/workflows/a11y-check.yml`](../.github/workflows/a11y-check.yml)
and emits inline GitHub annotations for the patterns above. It exits 0
(warning-only) and never blocks a build — findings are informational so
sighted contributors are not gated on accessibility compliance.
