Accessibility

August 1, 2026 · View on GitHub

For AI agents and human contributors touching any file under src/gui/. Linked from 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 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, 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, scaffolding in #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 principles (POUR: Perceivable, Operable, Understandable, Robust) 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.
  • RobustQAccessibleInterface 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:

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

    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:

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

QAccessibleValueChangeEvent ev(this, newValue);
QAccessible::updateAccessibility(&ev);

For text-only updates where there is no numeric value, use:

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 pattern the meter widgets use to drive their final repaint (see 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:

  • 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 (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 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, which takes two hex codes. For a one-step screenshot-to-ratio workflow, Colour Contrast Analyser (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:

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

// 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).
  • WindowsNVDA (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 runs on every PR via .github/workflows/static-checks.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.