Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/a11y-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Accessibility Check

on:
pull_request:
branches: ["**"]

permissions:
pull-requests: write # needed to label and comment; read-only on fork PRs (steps gracefully skip)

jobs:
a11y-check:
name: Qt Accessibility Static Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Get changed src/gui/ files
run: |
git diff --name-only origin/${{ github.base_ref }}...HEAD \
| grep -E '^src/gui/.*\.(cpp|h)$' \
> /tmp/changed_gui_files.txt || true
echo "Changed src/gui files:"
cat /tmp/changed_gui_files.txt || echo "(none)"

- name: Run accessibility check
id: a11y
run: |
FILES=$(cat /tmp/changed_gui_files.txt | tr '\n' ' ')
if [ -z "$FILES" ]; then
echo "No src/gui/ files changed -- skipping."
echo "findings=0" >> $GITHUB_OUTPUT
exit 0
fi
python tools/check_a11y.py $FILES | tee /tmp/a11y_output.txt
COUNT=$(grep -c "^::warning" /tmp/a11y_output.txt || echo "0")
echo "findings=$COUNT" >> $GITHUB_OUTPUT

# Label the PR so AetherClaude picks it up for automated remediation.
# continue-on-error: true because fork PRs have a read-only GITHUB_TOKEN
# and the label step would otherwise fail -- findings are still annotated
# inline on the diff regardless.
- name: Label PR for AetherClaude auto-remediation
if: steps.a11y.outputs.findings != '0'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr edit ${{ github.event.pull_request.number }} \
--add-label "aetherclaude-eligible" \
--repo ${{ github.repository }}

- name: Comment with accessibility summary
if: steps.a11y.outputs.findings != '0'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SUMMARY=$(grep -E "(Files scanned|Files with findings|Total findings)" \
/tmp/a11y_output.txt | sed 's/^/> /' || echo "")
cat > /tmp/a11y_comment.md << COMMENTEOF
## Accessibility check findings

This PR touches \`src/gui/\` files with **${{ steps.a11y.outputs.findings }}** accessibility issue(s).
Full details appear as inline annotations on the diff above.

${SUMMARY}

This PR has been labeled \`aetherclaude-eligible\` — AetherClaude will generate
remediation patches (mechanical fixes: missing \`setAccessibleName\` calls,
\`QAccessibleValueChangeEvent\` in value-change methods, focus policy on
interactive widgets). Structural fixes (keyboard navigation, \`QAccessibleInterface\`
subclasses) are flagged for human review in #3288.

*Findings are warnings only — the build is not blocked.*
COMMENTEOF
gh pr comment ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--body-file /tmp/a11y_comment.md
78 changes: 78 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,81 @@ Filter all status and VITA-49 packets by `client_handle` — three layers:

Early status messages arrive WITHOUT `client_handle`. Create SliceModels for
all initially, remove other clients' when handle arrives.

---

## Accessibility Enforcement — `src/gui/` Rules

Whenever you touch any file under `src/gui/`, apply the following checks to
every widget you create or modify. These fixes are **invisible to sighted
users** — no visual changes, no layout changes, no new UI elements.

### Required on every QWidget subclass constructor

1. **Accessible name** — every interactive or informational widget must have:
```cpp
widget->setAccessibleName(tr("Human readable name"));
```
Use a concise noun phrase (e.g. `"Frequency display"`, `"RF Gain"`).

2. **Accessible description** — add context for screen reader users:
```cpp
widget->setAccessibleDescription(tr("What this control does or displays"));
```

3. **Tab focus** — every interactive widget (buttons, sliders, spin boxes,
custom controls that respond to mouse/keyboard) must have:
```cpp
widget->setFocusPolicy(Qt::TabFocus);
```
If the widget already uses `Qt::StrongFocus` or `Qt::WheelFocus`, leave it.
Decorative-only widgets: leave `Qt::NoFocus`.

### Live value updates

Any method that changes a displayed value — `setLevel(`, `setValue(`,
`updateFreqLabel(`, `setText(` on a label that shows live data — must fire a
`QAccessibleValueChangeEvent` **after** the state change:
```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);
```

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

If a class overrides `paintEvent` and draws its own content (spectrum,
waterfall, meter, scope), Qt cannot introspect the rendered output. You must
either:
- 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 PR with `// TODO(a11y): QAccessibleInterface needed` and open
a follow-up issue tagged `GUI`.

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.

### CI enforcement

`tools/check_a11y.py` runs on every PR via `.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.
Loading
Loading