Accessibility
August 1, 2026 · View on GitHub
For AI agents and human contributors touching any file under
src/gui/. Linked fromAGENTS.mdso 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 +
QAccessibleValueChangeEventon 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 —
QAccessibleInterfacesubclasses 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
-
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 (QFramewrappers, decorative spacers, static "Hz" suffix labels next to a spin box) don't need one — the name on the parent control covers the group. -
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.
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'ssetAccessibleDescriptionis 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 viaQAccessible::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. -
Tab focus — every keyboard-operable widget needs to be reachable:
widget->setFocusPolicy(Qt::TabFocus);Leave
Qt::StrongFocusorQt::WheelFocusalone if already set. Decorative-only widgets: leaveQt::NoFocus.Test it. Tab to your widget from a sibling without touching the mouse, then activate it with
SpaceorReturn. If you can't reach it, the focus policy is wrong. If tabbing reaches it butSpace/Returndoesn't activate it, the widget needs akeyPressEventhandler (or it should be aQPushButtoninstead 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
MeterSmoothersettled-frame pattern the meter widgets use to drive their final repaint (seeMeterSmoother.h). - For knob/dial-driven changes, fire the announcement on
QAbstractSlider::sliderReleasedrather thanvalueChanged. - 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:
- Is there a token whose name describes this widget × state? Use it.
- Is there a token for this state on a similar widget (knob vs button, say)? Mirror its namespace and add the new token.
- 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
QAccessibleInterfacesubclass (namedFooAccessible) that returns meaningfultext(QAccessible::Name)andtext(QAccessible::Value)strings, and register it viaQAccessible::installFactory; or - Annotate the file with
// TODO(a11y): QAccessibleInterface neededand 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 withVO+arrowkeys (Ctrl+Option+arrowby default). - Windows — NVDA (free, recommended) or
the built-in Narrator (
Win+Ctrl+Enter). - Linux — Orca (built into GNOME accessibility; run
orcaon 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.