feat(ui): three-state control doctrine and the availability registry (#5262 M3a) - #5658
Conversation
…5262 M3a) Individual controls are never shown or hidden per radio. Every control renders unavailable (the radio lacks it — dimmed, with a stated reason), inactive (supported, not engaged) or active; hiding survives only at applet granularity for a cohesive radio-specific cluster. WHY DIM RATHER THAN HIDE, which is the whole milestone: a hidden control is not announced to a screen reader at all, so there is no way to learn the radio simply does not support it. The previous "hide it" rule is reversed. WHAT LANDS - Two semantic tokens, color.control.inactive and color.control.unavailable, in both shipped themes and the generated seed. - ControlAvailabilityRegistry (src/gui/). A control declares its predicate once; the registry subscribes to capabilitiesChanged ONE time and APPLIES IMMEDIATELY at registration. That second property is the point: a widget built after the connect edge never saw the signal and sat in its constructor's state, which the Calibration page and DemoApplet each patched with a hand-written second push. On a settled session the signal it missed may never fire again. - The two live a11y regressions this milestone carved out of M3b, both against the #4896 commitment: * TX Band Settings and Inhibit-during-TUNE set setEnabled + setToolTip and nothing else, short of their own acceptance on the day they merged. A QAction has no accessibleDescription; Qt announces its status tip, so the reason goes there. * applyCapabilitySurfaceVisibility was a bare setVisible(). #5299 applied it to twelve Radio Setup surfaces four days after #5266 gave the Enforce Private IP button a disabled state, a tooltip and an accessibleDescription — deleting all three. Split into applyCapabilitySurfaceAvailability (dims with a required reason) and applyCapabilityClusterVisibility (the sanctioned cluster hide). Nine controls dim; three cohesive clusters still hide. - tools/check_a11y.py grows a rule for the shape that regressed twice: a control disabled with a tooltip reason and no accessibleDescription/statusTip. It finds 40 more instances tree-wide — sampled and confirmed real, e.g. m_nr2QsppSlider and the AGC mode items. Warnings only and diff-scoped to changed src/gui/ files in CI, so it surfaces a backlog without spamming PRs; this PR's own changed files report zero. - Doctrine in docs/style/theme-style-guide.md, pointers in AGENTS.md and docs/a11y.md, and both superseded sites rewritten (the map doc's "hidden, not dimmed" section and the MainWindow comment). CONTRAST, STATED RATHER THAN OMITTED. The tokens deliberately reuse the greys the app already uses for disabled text, per the maintainer's call for visual consistency. Measured against the app background that is 2.09:1 (dark) and 1.77:1 (light), below WCAG's 3:1 floor, with the two muted states only 1.39:1 apart in the light theme. The style guide says so plainly and draws the consequence: colour does not reliably distinguish the states, which is why the accessible description is mandatory rather than advisory. A doctrine claiming "legible in both themes" without those numbers would have been false in canon. The underlying greys are pre-existing and used tree-wide; raising them is a visual change to every dialog and is tracked under #4896, not here. TWO THINGS THE TESTS CAUGHT IN MY OWN WORK, recorded because the how matters more than the what: * A QAction's status tip is also shown in the STATUS BAR on hover, so setting it for the merely-inactive state would push "Available, not currently active" into the status bar for most of the Settings menu. A widget has a private accessible channel and can afford the fuller wording; an action shares one with the UI. The reason now rides there only when unavailable. * Swapping setEnabled for setVisible — which inverts the entire doctrine and makes unavailable controls vanish — passed all ten assertions. The test now pre-disables a widget and asserts the registry re-enables it, plus that visibility never changes. Both mutations fail now (3 and 2). radio_setup_ip_config_presentation_test asserted the OLD doctrine ("unsupported capability surface is hidden while connected") — it had locked in the #5299 regression. Rewritten to the new rule, and it gains the accessible-reason coverage it never had. Note a QWidget that has never been shown reports isHidden() regardless, so those cases show() first; an absolute assertion would have passed whatever the code did. Verified: full build, 429/429 CTest, engine boundary / test registration / CI gate / capability + command-plane ratchets / theme seed / touchpoint manifest all green, colour ratchet +0. Refs #5262 (M3a), #4896. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Accessibility check findingsThis PR touches
See Findings are warnings only — the build is not blocked. |
K5PTB
left a comment
There was a problem hiding this comment.
Issue fit
This delivers #5262 M3a, and the direction is ratified: the M3a/M3b split and the "dim, never hide" doctrine were maintainer-ruled on 2026-09-12. Both carved-out accessibility regressions are fixed correctly. The Radio Setup surfaces dim with an accessible reason, the three clusters still hide as ruled, and TX Band and Inhibit-during-TUNE gain a status tip. The mechanism is where it falls short. Of the two ThemeManager treatments M3a asks for, only "unavailable" renders; an "inactive" control is drawn exactly like an active one (Blocker 1). And the registry's own test never reaches the unavailable path that is the reason the registry exists, so dropping its accessible description passes (Blocker 2).
Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
docs/style/theme-style-guide.md, docs/a11y.md, AGENTS.md, radio-capabilities-map.md |
Doctrine, pointers, superseded section rewritten | Yes | In scope (#5262 M3 checklist) |
resources/themes/*.json, ThemeSeedGenerated.cpp |
Two color.control.* tokens |
Yes | In scope |
ControlAvailabilityRegistry.{h,cpp}, CMakeLists.txt |
The registry | Yes | In scope. No production caller yet; see nits |
RadioSetupDialog.cpp, RadioSetupIpConfigPresentation.h |
Nine surfaces dim with a reason, three clusters hide | Yes | In scope, matches the ruled split exactly |
MainWindow.cpp |
Status tips on TX Band and Inhibit-during-TUNE, comment | Yes | In scope |
tools/check_a11y.py |
New a11y-disabled-reason-not-announced rule |
Yes | In scope |
tests/*, tests/tests.cmake |
New registry test, presentation test rewritten | Yes | In scope |
aetherd-touchpoints.md |
Manifest counts regenerated | Implicitly | In scope; a mechanical regen |
Everything in the diff is explained by M3a. No socket tests. No CHANGELOG.md entry. The visible behaviour change, hidden surfaces becoming dimmed, has a maintainer ruling behind it, so it is not a smuggled preference.
Blockers
1. The "inactive" treatment is never rendered. (inline on ControlAvailabilityRegistry.cpp:141)
applyOne writes the state's token into QPalette::Disabled. That is right for Unavailable, where the widget is disabled. An Inactive control is enabled, though, so color.control.inactive lands in a colour group the widget never paints with. I drove the registry against a connected RadioModel through setBackendForTest, the same socket-free seam atu_seam_gate_test uses:
PROBE tokens unavailable=#3a4a5a inactive=#8ea8c0
PROBE unavailable state=Unavailable enabled=0 hidden=0 desc='Not supported by this radio' tip='Not supported by this radio' disabledWindowText=#3a4a5a disabledButtonText=#bebebe
PROBE inactive state=Inactive enabled=1 renderedWindowText before=#000000 after=#000000 token=#8ea8c0 desc='Available, not currently active'
PROBE engaged state=Active renderedWindowText=#000000 desc=''
Inactive and Active render the same colour; only the accessible description differs. The doctrine table in the style guide, and the #5262 M3 item ("define the two ThemeManager-token visual treatments (dimmed-unavailable vs greyed-inactive)"), promise a visual distinction the mechanism does not draw. The same probe shows a narrower gap in Unavailable: the token reaches WindowText only, so a disabled QPushButton's text stays at the style default (#bebebe), not #3a4a5a.
What "greyed but enabled" should look like is a design call, and I am not proposing a colour. But the mechanism should either paint the inactive token into the groups an enabled widget uses (Active/Inactive, and ButtonText/Text alongside WindowText), or the doctrine should stop claiming a second treatment.
2. The registry test cannot see the unavailable path, and the seam to reach it already exists. (inline on tests/control_availability_registry_test.cpp:14-19)
The test's header says unavailable-with-an-announced-reason "is pinned here as well as linted". But its RadioModel is never connected (:45), and applyOne is permissive while disconnected, so every registration resolves to Inactive or Active. I ran 8 mutations of ControlAvailabilityRegistry.cpp against it:
| Mutation | Result |
|---|---|
setEnabled swapped for setVisible |
caught (2) |
| no apply at registration | caught (4) |
| not permissive on disconnect | caught (2) |
| accessible description removed | passes |
| tooltip reason removed | passes |
| widget always enabled | passes |
| action always enabled | passes |
| unavailable palette colour removed | passes |
The first surviving mutant is exactly the #5266 → #5299 regression this milestone exists to stop. The body's two mutation checks both hold; the test just stops there. A connected fixture closes it. This compiled and ran here, and the target needs no extra link libraries beyond what it has:
class FakeBackend final : public IRadioBackend {
public:
RadioCapabilities caps; bool connected{true};
RadioCapabilities capabilities() const override { return caps; }
bool isConnected() const override { return connected; }
void connectRadio(const RadioConnectRequest&) override { connected = true; }
void disconnectRadio() override { connected = false; }
void setSliceFrequency(int, double) override {}
void setSliceMode(int, const QString&) override {}
void setSliceFilter(int, int, int) override {}
void setSliceAgc(int, const QString&, int) override {}
void setPanCenter(const QString&, double, PanCenterIntent) override {}
void setKeying(bool) override {}
void setAtu(bool) override {}
void invokeExtension(const QString&, const QString&, quint64, const QVariant&) override {}
};
// RadioModel connected; auto be = std::make_unique<FakeBackend>(); be->caps.family = "hl2";
// connected.setBackendForTest(std::move(be), "hl2");
// register with a predicate returning false, then assert !isEnabled(), !isHidden(),
// accessibleDescription() == reason, and for a QAction statusTip() == reason.Nits (non-blocking)
- The body says "This PR's own changed files report zero." CI's accessibility step reports 5, all in
RadioSetupDialog.cpp, which this PR changes:mcpBtn(2012),txCheck(2111),roCheck(2184),dhcpBtn(2327),staticBtn(2340). The lines predate the PR. But they are the same disabled-with-tooltip shape, in the same dialog this PR converts, so they are worth taking here or naming as M3b. - The new lint misses one of the two regressions it cites.
MENU_ACTION_REis defined and never used (inline oncheck_a11y.py:408). I removed both newsetStatusTiplines fromMainWindow.cppand re-ran the rule: it flagsm_txBandActionbut notm_tuneInhibitMenu->menuAction()->setEnabled/setToolTip, because(\w+)\s*->cannot matchmenuAction()->. - The style guide says the rule "fails" that shape (inline on
theme-style-guide.md:186). It warns:docs/a11y.md:212says so, and the CI step is warnings-only. - The registry has no production registrations yet.
grep ControlAvailabilityRegistry srcfinds only its own files. The lazy-widget pushes it is described as retiring are still in place, such as the pane re-push atMainWindow_Wiring.cpp:3957-3962and the calibration page's own push inRadioSetupDialog.cpp:792-794. That is consistent with M3b owning the migrations, but the body's "the Calibration page and DemoApplet each patched that" reads as if this PR retires them.
What I tried to break
- The ruling. #5262 records the M3a/M3b split, the exact dim-versus-cluster list for the twelve Radio Setup surfaces, and the lint requirement, all maintainer-ruled on 2026-09-12. The PR matches it item for item.
- The presentation helper.
applyCapabilitySurfaceAvailabilitydisables, keeps the surface shown, and sets both reason channels. It clears them on disconnect and for supported surfaces, and the rewritten test pins all of it. Itsshow()first trap is real. Held. - The QAction status-bar reasoning. The unavailable action carries its reason in
statusTip, and inactive or active actions carry none (probe:action state=Unavailable enabled=0 statusTip='Not supported by this radio'). Held. - Disconnect. An unavailable button returns to enabled with no stale reason (
after disconnect ... enabled=1 tip=''). Held. - Built and ran both tests at 692b9b5, plus the probe and the mutation matrix above. The socket preflight is clean.
Not driven: the automation bridge. The dimmed Radio Setup surfaces only change on a radio without these capabilities, and the demo simulator advertises the Flex-shaped set. The registry itself has no production user to drive.
There was a problem hiding this comment.
Issue fit
Partially. #5262's M3a scope is four things: the doctrine in theme-style-guide.md + the AGENTS.md pointer, two ThemeManager treatments "legible in both themes" plus distinct accessibleDescription text in docs/a11y.md, the availability registry mechanism, and the two carved-out a11y regressions. Three land cleanly — I checked all nine surfaces the maintainer named for re-dimming (private IP label+control, MTU label+control, the three VITA receive-buffer surfaces, reboot info field, remote-on info field) and all nine are converted, with firmwareUpdateGroup / firmwareDisclaimer / networkIdentityGroup correctly left on the sanctioned cluster hide. The TX Band / inhibit-during-TUNE statusTip fix and the lint rule are both there.
What does not land: the visual treatments are not legible in both themes, and the two states are not distinguishable from each other in the light theme. I recomputed every ratio in the table against each theme's own color.gray.900 and the PR's numbers are exactly right (dark 7.71 / 2.09, light 2.46 / 1.77, 1.39:1 between the muted states in light). The PR states this openly rather than hiding it, which is the right call — but "legible in both themes" is a stated acceptance item, and the diff substitutes a mandatory accessible description for it. That is a maintainer decision, not something a reviewer should wave through. Separately, the mechanism does not actually render either token (Blocker 1), so as merged the treatments are not just illegible — they are absent.
Scope
| File(s) | What it changes | Claimed? | Verdict |
|---|---|---|---|
AGENTS.md |
Doctrine pointer bullet | yes (M3a item 1) | in scope |
docs/style/theme-style-guide.md |
§4a "Three-state controls" | yes | in scope; two factual nits below |
docs/a11y.md |
Capability-gated-control section | yes (M3a item 1) | in scope |
docs/architecture/radio-capabilities-map.md |
Supersede §"hidden, not dimmed" | yes (M3 last bullet) | in scope |
docs/architecture/aetherd-touchpoints.md |
Three refcount bumps 147→148, 6→7, 43→44 | no, but generated | in scope — tools/gen_touchpoint_manifest.py output, mechanical |
resources/themes/default-{dark,light}.json, src/core/ThemeSeedGenerated.cpp |
Two new tokens + seed | yes | in scope; check_theme_seed.py passes, and audit_colours.py allow-lists the seed file so the hardcoded-colour ratchet is not touched |
src/gui/ControlAvailabilityRegistry.{h,cpp}, CMakeLists.txt |
The registry | yes (M3a item 2) | in scope — but zero production call sites; see nits |
src/gui/RadioSetupIpConfigPresentation.h, RadioSetupDialog.cpp |
9 hide→dim, 3 kept as clusters | yes (M3a a11y item) | in scope, matches the ruled list exactly |
src/gui/MainWindow.cpp |
setStatusTip ×2 + comment |
yes (M3a a11y item) | in scope |
tools/check_a11y.py |
New check 5 | yes ("add a tools/check_a11y.py rule") |
in scope; two nits |
tests/*, tests/tests.cmake |
New test, rewritten presentation test | yes | in scope; see Blocker 2 |
Nothing in the diff is unexplained by #5262. No CHANGELOG.md edit. No new protocol/settings/capability surface — check_capability_records.py still reports 71 booleans at the frozen 71, so #5619's gate is clear. check_test_registration.py, check_ci_test_gate.py and check_engine_boundary.py all pass on the head checkout.
Socket tests: none added or removed. control_availability_registry_test.cpp binds nothing — a bare RadioModel, QLabels and a QAction, QT_QPA_PLATFORM=offscreen. The Socket-free claim in its header holds.
Blockers
1. The two theme tokens never render. (src/gui/ControlAvailabilityRegistry.cpp:141, inline)
Independently confirms K5PTB's first blocker, and it goes further than reported. The colour is written into QPalette::Disabled — a colour group Qt consults only while !isEnabled(). Inactive controls are left enabled by design (applyOne only disables on Unavailable), so color.control.inactive is unreachable on every path. Only QPalette::WindowText is set, so the Unavailable colour misses ButtonText, Text and WindowText-on-QGroupBox-title consumers. And src/gui/ carries 865 applyStyleSheet call sites — a Qt stylesheet that sets color: wins over the palette outright, so on most real controls the palette write is inert regardless of group.
Two further consequences of the same three lines: w->setPalette(pal) runs unconditionally (including for Active, where colour is invalid and nothing changed), which sets WA_SetPalette and stops the widget inheriting later application-palette changes; and the registry subscribes only to capabilitiesChanged, never to a ThemeManager theme change, so after a theme switch a dimmed control keeps the previous theme's grey. The doctrine's whole "two treatments" half is currently a no-op — which matters more than usual here, because the docs already concede colour is the weaker of the two channels.
2. The test does not pin what its header says it pins. (tests/control_availability_registry_test.cpp:45, inline)
Confirms K5PTB's second blocker. RadioModel model; is never connected, and applyOne's first line is !connected || …, so available is unconditionally true and no registration in this file can reach Unavailable. The file's own preamble claims behaviour 2 — "AN UNAVAILABLE CONTROL IS DIMMED WITH AN ANNOUNCED REASON … pinned here as well as linted" — and that is the one thing not pinned. Deleting setAccessibleDescription, setToolTip, the setStatusTip line, the palette block, or replacing w->setEnabled(…) with setEnabled(true) in the widget branch all leave every check green. The QAction case only asserts the supported path (statusTip().isEmpty()), which passes with the status-tip write removed entirely. check(w.accessibleDescription().isEmpty(), "a fresh widget has no description") tests Qt, not this code.
RadioModel::setBackendForTest exists at src/models/RadioModel.h:1731 and reaches the connected branch socket-free, which is the seam AGENTS.md's test-layer boundary asks for here (capabilities/policy → socket-free CTest). Blocking because the mechanism is landing ahead of its consumers, so this test is the only thing standing between M3b's migrations and a silent regression of exactly the shape #5299 already shipped once.
Nits (non-blocking)
tools/check_a11y.py:408—MENU_ACTION_REis dead, and the rule misses one of its two motivating cases. Confirmed:MENU_ACTION_REis never referenced, andDISABLE_RE's leading(\w+)\s*->cannot matchm_tuneInhibitMenu->menuAction()->setEnabled(cmdPlane)because the character before->setEnabledis). Same forTOOLTIP_RE. So with both newsetStatusTiplines reverted, the rule fires onm_txBandActionand stays silent onm_tuneInhibitMenu— the second of the two regressions the check was written for.- The rule is not function-scoped, despite the comment at line 402 saying "in the same function". It builds one receiver→line dict per file with a ±12-line window. Concrete false positive in the current tree:
src/gui/DspParamPopup.cpp:43–55, whereSliceControl::setEnabled()andSliceControl::setToolTip()are two separate member functions 9 lines apart, flagged forlabel,sliderandvalueLabel. Not harmful — the scriptsys.exit(0)s unconditionally — but the comment overstates what the regex does. For calibration: the rule produces 40 findings on the current tree; I spot-checked several and the majority look like genuine instances of the pattern. docs/style/theme-style-guide.md:186says the check "fails" that shape. It does not —check_a11y.pyexits 0 unconditionally anddocs/a11y.mdcorrectly says "warns". Suggestion inline.docs/style/theme-style-guide.md:213's light-theme column is shuffled. Recomputed:color.text.disabledis 2.09 (dark) / 2.04 (light,gray.500), andcolor.text.labelis 2.94 (dark) / 3.09 (light,gray.300). The 1.77 quoted for lighttext.disabledis actuallygray.600— i.e.control.unavailableitself. That also softens the sentence above it: in the light themecontrol.unavailable(gray.600#b0bcc8) is not the grey the app uses for disabled text (gray.500#a0b0c0), so "deliberately reuse the greys the rest of the app already uses for disabled text" holds in dark and not in light.- The registry has no production caller.
grepacrosssrc/finds it only inCMakeLists.txtand its own two files; the only constructor call in the tree is in the test. The docs already tell contributors to "prefer it to writing the pair by hand" andradio-capabilities-map.mdschedules the migration as M3b, so this is disclosed mechanism-ahead-of-consumer rather than concealed — worth a maintainer eye rather than a change. - Three individual-control hides survive in the function this PR rewrites (
RadioSetupDialog.cpp:1022/1025/1039):m_flexControlInfoField,m_multiFlexInfoFieldandm_licenseInfoGroupare still baresetVisible(). None is on the maintainer's ruled M3a list, andm_licenseInfoGroupis plausibly a cluster — but after this PR the file has three call shapes and only two of them state which rule they invoke. Routing the two info fields throughapplyCapabilityClusterVisibility(or a// M3bmarker) would keep the new "a call site states which rule it is invoking" property intact.
What I tried to break
- CodeGuard's six findings — all refuted, none reported. The five
src/gui/MainWindow.cpphits (9101/9213/9332–9334) and thetools/check_a11y.py:288hit are all on lines this PR does not touch; 9101 isnew QLabel("Optimizing FFT plans for NR2…")and 288 is an f-string inside a warning message. No path handling in any of them. - The contrast table. Resolved
color.gray.*per theme and recomputed all six WCAG ratios plus the 1.39:1 inter-state figure. Every number in §"Contrast, stated honestly" is correct. Only the parenthetical at line 213 is wrong. - The generated-file and ratchet gates.
check_theme_seed.pyreportsThemeSeedGenerated.cppmatchesdefault-dark.json;audit_colours.pyallow-lists that file, so the two added hex literals cannot trip the hardcoded-colour ratchet AGENTS.md warns about.check_capability_records.pyis at the frozen 71 — the diff adds no capability boolean, which is where #5619 would otherwise have bitten. - Lifetime and reentrancy in the registry.
pruneDead'swidget.isNull() && action.isNull()is correct for both entry kinds (the unusedQPointeris null from construction, so a widget entry prunes when its widget dies and an action entry does not prune while its action lives).applyOne(m_entries.last(), …)is evaluated afterpush_back, so aQVectorreallocation cannot dangle it.applyAllprunes before iterating, and every deref goes throughQPointer, so a destroyed control between edges is safe. No finding. - The rewritten
radio_setup_ip_config_presentation_test. ItsgatedSurface.show()before the first assertion is a real fix, not ceremony — a never-shownQWidgetreportsisHidden()regardless, so the pre-show()form would have passed against asetVisible-based implementation too. That test does pin the widget-path behaviour (enabled, tooltip,accessibleDescription) that Blocker 2's test does not. - Could not check: CI status —
get_check_runsreturned a 401 on this run, so I have no build or test evidence, and nothing here was compiled or executed. Every finding above is reasoned from the head checkout at/tmp/aetherclaude/pr-5658plus tooling I ran locally (check_a11y.py,check_theme_seed.py,audit_colours.py,check_test_registration.py,check_ci_test_gate.py,check_capability_records.py,check_engine_boundary.py), never reproduced at runtime. In particular I could not verify thatControlAvailabilityRegistry::stateOf(const QWidget*)'se.widget == widgetcomparison (QPointer againstconst QWidget*) compiles — it looks fine, but only a build settles it.
Recommendation
Request changes — on the two blockers, both of which are mechanical. Blocker 1 is the more important: the doctrine's visual half is currently unreachable, so the PR ships one working channel (the accessible description) out of the two it documents, on tokens whose own contrast table says the description is load-bearing because the colours are close. Blocker 2 matters because this mechanism lands with no production caller, so its test is the only guard M3b's migrations will have.
Concrete next step: drive the treatment through ThemeManager::applyStyleSheet (or set all three foreground roles across Active/Inactive/Disabled and subscribe to the theme-change signal), and add a connected-model case to the registry test via RadioModel::setBackendForTest that asserts Unavailable sets setEnabled(false), accessibleDescription == reason and, for the QAction, statusTip() == reason. The two doc nits and the menuAction() regex gap are worth folding into the same push. The doctrine work itself, the nine-surface conversion and the honesty of the contrast section are all good — the shortfall is in the mechanism, not the design.
One item genuinely needs a maintainer, not a fix: #5262 asks for treatments "legible in both themes" and this PR consciously ships tokens at 1.77:1 and 2.46:1 in light, arguing visual consistency with existing disabled text and deferring the greys to #4896. That reasoning is sound and clearly stated; it is still a departure from the written acceptance item, and @ten9876 is right to have surfaced it rather than quietly meeting the letter of it.
🤖 aethersdr-agent · cost: $9.9807 · model: claude-opus-5
|
Cisco CodeGuard — static analysis of this PR (6 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $10.1967 · model: claude-opus-5 |
…phaned comment Two nits from the review of aethersdr#5675. A QAction has no accessibleDescription, so a screen reader on the disabled Tools entry got the label and nothing else -- and the tooltip describes what the entry IS, never what would make it available. Qt announces an actions STATUS TIP, so that is what now carries the reason, distinguishing "no radio is connected" from "this radio does not provide one" and cleared again when the entry is live so the reason cannot outlive its condition. check_a11y.py is clean on the file. Explicitly a nit and not a governance finding: docs/a11y.md carries no rule for this yet -- aethersdr#5658 proposes exactly that doctrine and is still open. In tests.cmake the three new bandscope targets had landed BETWEEN hl2_ep4_gate_tests explanatory comment and the add_executable it describes, leaving the comment 39 lines from its target. Moved below the gate target instead. No target, source list or test name changes -- only where the three blocks sit in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Accessibility check findingsThis PR touches
See Findings are warnings only — the build is not blocked. |
rfoust
left a comment
There was a problem hiding this comment.
Reviewed and repaired at 54a891e, including current upstream/main 06e45cf. The result fits #5262 M3a and the two explicitly pulled-forward accessibility repairs. The registry is introduced here; production consumer migration remains M3b after M4, as now stated in the body.
| Scope | Verdict |
|---|---|
| Registry, ThemeManager, semantic tokens | Three-state mechanism, themed foreground composition and restoration; within scope |
| Radio Setup and MainWindow | Scoped reason-channel repairs and explicit remaining migration markers |
| Tests, checker, docs and generated records | Evidence and documentation for the same behavior; within scope |
No remaining blockers or nits from this review. The ten existing threads are addressed: actual inactive/unavailable rendering, connected regression coverage, menuAction and lexical-scope lint, warnings-only documentation, corrected contrast figures, and remaining-hide annotations. Approved colors remain unchanged; their contrast limitations remain explicitly documented under #4896.
Empirical validation: full ARM64 macOS app build with RADE enabled, no RNNoise x86 sources, all four focused CTests passing (including six Python checker cases), and nine independently compiled mutations rejected. These remove each reason channel, force widget/action enabled state, remove foreground treatment/immediate application, ignore capability payloads, or leak active state across disconnect. Tests cover both themes, checked ID selectors, tokenized declaration-only styles, token and theme edits, latest base-style replacement, and pixel restoration. Theme seed, touchpoint, registration, frozen CI gate, boundary/capability/command-plane and color ratchets pass.
Native Cocoa bridge proof used fresh temporary settings and an authenticated owned instance of this build. It connected only DEMO-0001 with txAllowed=false; state showed connected=true, sliceCount=1, panCount=1 and transmitting=false. Radio Setup opened and both windows were captured and inspected. The owned process was stopped afterward. This does not claim screen-reader speech, physical-radio behavior, or graceful shutdown validation.
Approval is for this exact head; required CI must finish successfully before squash merge.
Superseded by repairs at 54a891e and approval review 5193844259. Both blockers and both nits are fixed; connected rendered-state regression tests and nine compiled mutations validate the repairs. All related threads are resolved. Required CI remains a merge gate.
…phaned comment Two nits from the review of aethersdr#5675. A QAction has no accessibleDescription, so a screen reader on the disabled Tools entry got the label and nothing else -- and the tooltip describes what the entry IS, never what would make it available. Qt announces an actions STATUS TIP, so that is what now carries the reason, distinguishing "no radio is connected" from "this radio does not provide one" and cleared again when the entry is live so the reason cannot outlive its condition. check_a11y.py is clean on the file. Explicitly a nit and not a governance finding: docs/a11y.md carries no rule for this yet -- aethersdr#5658 proposes exactly that doctrine and is still open. In tests.cmake the three new bandscope targets had landed BETWEEN hl2_ep4_gate_tests explanatory comment and the add_executable it describes, leaving the comment 39 lines from its target. Moved below the gate target instead. No target, source list or test name changes -- only where the three blocks sit in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Implements #5262 M3a: a stable control surface with unavailable, inactive and active states, plus the two accessibility repairs explicitly pulled forward from M3b. Refs #4896.
Unsupported individual controls remain visible, disabled, and carry a reason. Supported but disengaged widgets use
color.control.inactive; unavailable widgets usecolor.control.unavailable; active widgets resume their existing styling. Disconnect returns to inactive, including when an owner retains an engagement flag.The availability registry applies immediately at registration and consumes the capability signal's supplied snapshot. Standard Qt widget foregrounds compose with the existing ThemeManager stylesheet, including ID and checked-state selectors. Theme changes, token edits, and subsequent base-style updates preserve the treatment; returning to active restores the latest base style. Custom-painted widgets must consume the state token themselves. QAction registrations manage enabled state, tooltip and unavailable status-tip reasons; per-action color rendering belongs to the owning menu/toolbar presentation.
This milestone introduces the registry mechanism; it does not migrate the existing Calibration, DemoApplet, or lazy-pan second pushes. Those production consumers remain M3b work after M4. The scoped Radio Setup repairs continue through their presentation helper: nine surfaces dim with reasons and three ruled clusters remain hidden. TX Band Settings and Inhibit during TUNE gain accessible status tips.
menuAction()chainsThe checker remains warnings-only. It no longer mixes same-named receivers across functions/branches or counts braces inside comments and strings as code. Existing unrelated warnings in the changed MainWindow and RadioSetupDialog files remain part of #4896/M3b; this PR does not claim a zero-warning tree.
The approved token values are unchanged. Inactive contrast is 7.71:1 dark / 2.46:1 light; unavailable is 2.09:1 / 1.77:1. The two light-theme states are 1.39:1 apart. These are not a claim of WCAG compliance; contrast improvement remains tracked in #4896. The docs also correct the pre-existing text-token comparisons and distinguish tooltip help from the primary accessible description.
Validation
control_availability_registry_test,radio_setup_ip_config_presentation_test,theme_manager_test, and six Python checker regression cases.DEMO-0001, and TX disabled: connected with one slice and one panadapter; Radio Setup opened and both windows were captured. The owned instance was stopped after the checks; this is not evidence of graceful shutdown.No physical radio or TX validation is involved. Screen-reader speech itself is not verified by widget-state assertions.