Skip to content

feat(gui): unified 52 px title bar on Qt 6.12 window chrome — native captions on macOS/Windows, frameless on Linux (RFC #4764) - #6198

Merged
ten9876 merged 25 commits into
aethersdr:mainfrom
jensenpat:aether/qt612-window-chrome
Oct 6, 2026
Merged

ten9876 merged 25 commits into
aethersdr:mainfrom
jensenpat:aether/qt612-window-chrome

Conversation

@jensenpat

@jensenpat jensenpat commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Implements the approved RFC #4764, Frameless window retrofit: one 52 px unified title bar with radio tabs, rebuilt on Qt 6.12's window-hint APIs. Supersedes #4906 and #5489.

What the bar is

One 52 px bar owns the application menu, the brand mark, the radio tabs, the audio cluster, the applet dock/float controls and the window controls.

  • Application menu
    • On Windows and Linux (no native menu bar), the menus live behind a ☰ button that leads the bar, ahead of the brand.
    • The QMenuBar stays as a hidden child. MainWindow still builds into it, and the automation bridge still resolves actions through it.
    • The button opens the menu with popup() (no nested event loop).
    • The menu and its submenus have rounded corners (8 px, like the radio tabs) and slightly see-through panels (color.titlebar.menu.background, 0.92 alpha).
    • Item text starts at the same x in every menu, whether or not the menu has checkable items.
    • Leaf shortcuts and each menu's Alt+letter mnemonic keep working.
    • macOS keeps its system menu bar.
  • Radio tabs
    • One tab per known radio: LAN, SmartLink or routed. Hide/restore is persisted.
    • The strip takes the bar's free width and scrolls only once that runs out. A vertical wheel scrolls it, and dragging a tab sideways pans it; a drag is not a click.
    • Line one is the name. Line two is the state in words (WCAG 1.4.1); the model shows only when a nickname hides it.
  • Link indicator, folded into the radio's own tab dot
    • The standalone heartbeat lamp is gone. The dot pulses per heartbeat.
    • On an unexpected loss it goes red (blinking, or solid with blink off) and line two and the accessible name read "link lost".
    • The alarm stays on the radio that dropped. Its tab is kept visible even when the radio was removed from the strip, reached by address, or has aged out of discovery.
    • A deliberate disconnect, a new session, or removing that tab clears it. A user disconnect never raises it.
  • "+" discovery popover
    • Search, per-radio Actions (Disconnect / Rename… / Radio setup… / Remove from or Add to tabs), Connect manually… and Rescan radios.
    • It does not rebuild under an open Actions menu.
  • Rename dialog
    • A non-modal PersistentDialog (themed, remembered geometry, follows frameless mode).
    • The demo's "not on the air" label cannot be renamed.

Window chrome per platform (src/gui/WindowChrome.h)

Platform Mode Window controls
macOS (cocoa) ExpandedClientAreaHint + NoTitleBarBackgroundHint, empty unified NSToolbar for 52 px light placement Native traffic lights: tiling menu, Stage Manager and native fullscreen
Windows ExpandedClientAreaHint + NoTitleBarBackgroundHint (Qt owns the frame, shadow, rounded corners and resize borders; replaces our nativeEvent/DWM frame) Shared painted caption chips. Qt's own title and caption buttons are suppressed: they render unreliably and in the OS light/dark colour. WS_MINIMIZEBOX/WS_MAXIMIZEBOX are restored on the HWND
Linux (xcb/Wayland) FramelessWindowHint + FramelessResizer (6 px band, no top-edge resize under the bar, #4886) Shared painted caption chips. Title drag uses startSystemMove(), except on xcb, where the #4827 manual move is kept

View → Frameless Window remains the escape hatch everywhere. docs/architecture/window-chrome.md documents the design.

The main window is opaque (WA_TranslucentBackground off). The bar paints a pre-composited opaque fill and a 1 px hairline (WA_OpaquePaintEvent). Its containers are made transparent by object name, so MainWindow's window-wide QWidget rule cannot slab them.

Also in this change

  • Bridge
    • get titlebar and the titlebar verb (selectRadio / showDiscovery / minimize / maximize / close) expose geometry, tabs, link state, chrome mode and the ☰ menu.
    • The new applet verb (dock left|right, float on|off, show, hide, state) covers the dock/float controls.
    • Actions validate synchronously and run on the next main-loop turn ("deferred":true, with the pre-action snapshot).
    • selectRadio refuses a hidden tab.
    • applet state is allowed in observe-only mode.
  • Applet dock/float
    • One coherent dock/float/visible state.
    • PanadapterStack::refreshAfterLayoutShift() re-realizes native pan windows after a dock flip on macOS (Metal) only. Elsewhere it is a repaint sweep.
  • Tokens: 22 new tokens, 20 color.titlebar.* and 2 color.brand.*, documented in docs/theming/canonical-tokens.md. The theme seed is regenerated.
  • Tests
    • unified_title_bar_test (new, socket-free) covers:
      • fill and cascade, tab insets, the status line
      • wheel overflow, the link alarm and its carrier across a drop
      • the minimal-mode separator, the keyboard-only focus ring
      • popover search/actions and its deferred rebuild
      • the ☰ menu, Alt mnemonics, shortcuts and minimal mode
    • minimal_mode_exit_order_test now anchors on the exit branch's restoreGeometry().
    • automation_boundary_core_test pins the deferral and observe-only contract.

Maintainer rulings (@ten9876)

Screenshots (macOS 27.0.1, Qt 6.12.0, isolated profile, built-in demo radio, nothing on the air)

bar after
window
light
tab
popover

🤖 Generated with Claude Code

jensenpat and others added 12 commits October 4, 2026 20:44
…hersdr#4764)

Replaces the 32 px in-app strip with a single 52 px bar that is the window's
only title bar on all three platforms. The window stays frameless everywhere
(Windows via WM_NCCALCSIZE, as before), so exactly one thing draws chrome.

What the bar owns, left to right: window controls, brand mark, radio tabs,
spacer, audio cluster, applet-dock selectors.

New widgets
  * BrandMark          logo + wordmark, "SDR" filled with the brand gradient
  * RadioTabBar        per-radio tabs, status dots, discovered-radios popover
  * WindowCaptionButtons  three control styles: macOS traffic lights (12 px on
                       a 20 px pitch, glyphs on group hover, grey when the
                       window is inactive), Windows 46 px caption buttons,
                       Linux bordered chips
  * mac/WindowChrome   objc++ trim for the frameless NSWindow — puts
                       NSWindowStyleMaskResizable back so AppKit keeps owning
                       edge resize, plus continuous rounded corners and shadow

Heartbeat folded into the active tab's dot
  The standalone lamp is retired; one dot now answers both "which radio is
  this" and "is its link alive". The state machine is unchanged, including the
  two rules that are easy to lose in a rewrite: three missed sweeps is the
  alarm threshold, and the alarm holds SOLID red when the operator has blinking
  disabled rather than going quiet. Only its output moved.

Platform chrome
  * Windows: WM_NCHITTEST answers HTMAXBUTTON over the maximize control so Snap
    Layouts opens on hover, and DwmSetWindowAttribute asks for DWMWCP_ROUND
    (Win10 falls back to square). Hover and click there are driven from the
    WM_NC* messages, since answering HTMAXBUTTON takes the mouse away from Qt.
  * Resize band widened to 8 px, still via QWindow::startSystemResize() so the
    compositor keeps ownership and OS tiling/snapping behave.

No more emoji as controls
  The speaker/headphone controls were literal 🔊 / 🎧 glyphs, which render in
  the platform's own colour and weight and cannot follow the theme. Replaced
  with painter-drawn thin-line marks at the house 1.8 px stroke.

Theming
  15 new tokens under color.titlebar.* and color.brand.* in both bundled
  themes, seed regenerated, canonical-tokens.md updated. The traffic-light and
  chip fills are deliberately NOT tokenised — red/amber/green is the affordance
  being borrowed from the platform, and a themed "red" close control would stop
  reading as one.

Automation
  New `get titlebar` model and `titlebar` verb (selectRadio / showDiscovery /
  minimize / maximize / close). Both the bar and each tab report a screenRect,
  and the model exposes a live linkPulse level so the heartbeat is assertable
  instead of eyeballed. docs/automation-bridge.md regenerated.

Tests
  tests/unified_title_bar_test.cpp, headless. Pins the bar height, that nothing
  reserves a strip above it (offsetInWindow == 0), that a status-only change
  reuses the tab widget rather than rebuilding it, and that state is never
  encoded by dot colour alone — every tab spells it in words on the rendered
  status line and in its accessible name. 242/242 pass.

Not yet verified on hardware: Windows (Snap Layouts, DWM corners, and the aethersdr#4557
top-edge-drift path this touches) and Linux (chips, fractional-scaling drag
jitter). Per RFC review those are PR gates and need someone else's machines.

Refs aethersdr#4764, aethersdr#3445

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the textual rebase merged cleanly but got semantically wrong,
plus one regression of my own that only became visible once the bar landed.

setHeadphoneMuted() was setting an emoji again (aethersdr#4722)
  Upstream added the reconcile setter while this branch was replacing the
  🔊/🎧 emoji with painter-drawn marks, so git kept its setText() verbatim and
  the button would have carried an emoji AND an icon. Routed through
  applyAudioIcon(). The setter's contract is unchanged and still load-bearing:
  the QSignalBlocker suppresses `toggled`, so the repaint has to be explicit or
  the checked state and the mark disagree.

titlebar_headphone_mute_test asserted on those emoji bytes
  It would have failed outright. Both contracts it exists for are still exactly
  right — the explicit repaint, and that a reconcile must not re-emit and turn
  the radio's status echo into a fresh command (Principle II) — so the test is
  retargeted from the label text to the rendered icon rather than weakened.
  Comparing the drawn mark is also stronger: it survives the next change of
  mark, where a byte comparison against a glyph does not.

Minimal mode had lost the radio-link indicator
  This branch folded the heartbeat lamp into the active tab's dot, and
  setMinimalMode() hides the radio strip — so minimal mode would have had no
  link indicator at all, where the old standalone lamp stayed visible. Added
  RadioTabBar::setCompactMode(): active tab only, no "+", indicator intact.
  Minimal mode is precisely when an operator has nothing else on screen that
  would show them a dropped link.

Also: TitleBar.h's class comment still described the macOS approach this branch
abandoned (keeping the real NSWindow and floating the native traffic lights
over the bar). Rewritten to describe what actually ships. Bridge verb table
regenerated — 65 verbs with main's additions alongside `titlebar`.

Test suite on the new base: 282 tests, all green. Five hl2_* tests fail under
`ctest -j8` and pass serially; they share a single-client hpsdrsim and carry no
RESOURCE_LOCK, and this branch touches no HL2 file. Pre-existing, not from here.

Refs aethersdr#4764

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 52 px bar's three applet controls each toggled a field of their own —
dock side, visibility, floating — with no single owner, so the fields could
disagree. A dock click while the panel was floating docked it back and then
fell through and hid it: one click doing two things, leaving an invisible
panel that took two more clicks to recover. That is the "takes two or three
pop-outs" report.

Route all three controls, Ctrl+Shift+S and the bridge through one entry
point, applyAppletPanelState(floating, dockedLeft, visible), which takes the
COMPLETE desired state instead of mutating one field. Two combinations are
now unrepresentable by construction: floating-and-hidden (an empty float
window has no affordance to bring the contents back), and side-set-without-
visibility. The dock-click rule is stated once — clicking the wall the panel
already occupies is the hide gesture, and that only reads as "hide" when the
panel is genuinely docked there and on screen.

Two further defects surfaced only under test, both of which would have
survived a reading:

  * QSplitter will not re-slot a HIDDEN child. Setting the wall while the
    panel was hidden persisted the new side but left the widget in its old
    slot, so it reappeared on the wrong wall. Show before reordering.

  * setAppletPanelDockedLeft() was not idempotent — pre-existing, and the
    one with teeth. insertWidget() detaches the panel first, which shifts
    m_panStack down one index, so re-inserting at the now-stale panIdx drops
    the panel on the far side and silently flips the dock. Docking left twice
    moved it right. The title-bar path never hit it because clicking the same
    wall means hide; the new verb hit it on the first call. Re-asserting the
    current side is legitimate (it happens on un-float and on restore), so
    the guard lives in the setter rather than in its callers.

The dock and pop-out controls carried only tooltips: no objectName, no
accessibleName. They were absent from dumpTree and invisible to screen
readers, and had to be driven by raw pixel coordinates. Named
appletDockLeftBtn / appletDockRightBtn / appletPopOutBtn, with accessible
names and descriptions.

New `applet` verb (dock left|right, float on|off, show, hide, state), 66
verbs total. The snapshot returns geometry and splitterIndex/panIndex beside
the flags, because splitterIndex < panIndex is what actually proves the left
dock — asserting on `side` alone lets a zero-width panel pass as a success.

The mute toggles kept an accent focus ring painted around them after a
click. Fixed with focusPolicy(Qt::TabFocus) rather than by deleting the
:focus rule, so the ring still appears for keyboard navigation, where it is
the only indication of where focus sits.

Verified against the built-in simulator over the bridge: 158 checks, 0
failures, driven through the real widgets via clickAt on the labels rather
than through the verb, so it exercises the operator's own path. Covers the
original repro, eight consecutive pop-out clicks alternating every time, the
full 3-control x 4-state transition matrix with invariants asserted after
every transition, one-click recovery onto the REQUESTED wall from every
state, verb/echo parity, and 30 rapid-fire clicks with no settle. Asserting
the wall rather than just visibility is what caught the hidden-reorder bug.

The state machine is radio-independent and was reproduced with the radio
fully disconnected — it is not specific to the simulator.

Not covered: the applet logic lives inside MainWindow, which has no test
harness, so none of these three bugs is guarded in CI. The driver script is
not checked in.

Developed in parallel with the preceding reconcile commit, which fixed the
same setHeadphoneMuted() emoji leftover and retargeted
titlebar_headphone_mute_test to the rendered icon independently; that work is
kept as it stands there and dropped from here.

Refs aethersdr#4764

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving the applet panel to the LEFT wall left a 260 px strip on the right
that showed the desktop through the window. Measured on real screen
captures: 6/6 left docks on a fresh launch, 0/6 right docks, with the strip
falling at exactly the slot the panel had occupied when docked right.

The panel does not resize when it changes walls, it TRANSLATES, so the
panadapter has to slide the same 260 px the other way. Geometry was already
correct — the widget reported the full 1450 px while its native surface was
still the pre-flip 1190. So the pan repainted happily, every frame, into a
drawable 260 px too narrow, and the strip beyond it was painted by nobody.
MainWindow is WA_TranslucentBackground and its paintEvent is the only source
of pixels for regions the tree leaves bare, and Qt excludes native children
from that backdrop, so "painted by nobody" composites to the desktop rather
than to the app background.

The asymmetry is why the report was always "panel on the left": when the
panel moves RIGHT the strip needing new cover is taken by the applet panel,
an ordinary widget that paints correctly. Only the left flip depends on the
native surface keeping up.

Two changes, because either alone leaves a hole:

  * PanadapterStack::refreshAfterLayoutShift() re-realizes each spectrum's
    native window after a same-window move. resetGpuResources() + update()
    was tried first and did NOT work — re-rendering into the same undersized
    surface changes nothing, which is also why the continuously redrawing
    waterfall never healed it. Destroying the native window is the part that
    re-establishes the geometry, so this reuses the full reparent path at the
    cost of a Metal re-bind on an explicit, infrequent operator action.

  * AppletPanel paints its own background. It had none — no
    autoFillBackground, no WA_StyledBackground, no paintEvent — so its area
    depended entirely on a backdrop that does not reach behind native
    children. The FLOATING panel has always been opaque; this gives the
    docked panel the same guarantee.

Also: the pop-out opened full-screen. restoreGeometry() faithfully restores a
MAXIMIZED state if one was ever captured, and the float window re-saves its
geometry on every Move/Resize — so one accidental maximize (a title-bar
double-click will do it) poisons the setting and every later pop-out opens
across the display. A 260 px applet rail maximized full-screen is never the
intent, so the state is dropped on restore and clean geometry written back,
healing an already-poisoned config on first use.

Verified over the bridge against the built-in simulator: 0/6 left docks
broken (was 6/6), 0/8 right docks, plus 14 rapid clicks with no settle,
float/dock interleaving, and hide/show interleaving — all clean. Every
measurement gates on confirming AetherSDR is frontmost first, after an
earlier run measured an overlapping window and reported false failures.

Refs aethersdr#4764
…rier, Windows minimal mode

Five blockers from @ten9876 and @aethersdr-agent, plus the two nits. The radio-tab
commit is split out to its own branch as asked; what remains here is the chrome.

**A re-clicked tab stayed unchecked forever.** RadioTab is checkable and belongs
to no exclusive group, so QAbstractButton toggles it off on press;
setActiveRadio() then early-returned on an unchanged id, so applyActiveState()
never re-checked it — and nothing else did either, since setRadios() also
early-returns on an identical list. Clicking the tab of the radio you are on is
the most natural thing on the strip to click, and it stopped the strip showing
which radio you were on. The click now re-asserts the checked state.

**Activation was optimistic.** The click moved m_activeId even though MainWindow
deliberately opens the picker instead of switching, so the strip and the bridge's
activeId both claimed a radio the client had never connected to, with the link
visuals following the wrong tab. The active tab now follows the session only —
setActiveRadio() from refreshRadioTabs(). Same fix on the popover row, which had
the same optimistic claim.

**The link indicator had no carrier while disconnected.** Only the tab matching
m_activeId took the override and alarm, so with nothing connected the state
rendered nowhere — and "searching" is reported precisely when nothing is
connected, which is the state it exists for. Minimal mode compounded it: the
strip showed only the active tab, so with no active radio it rendered empty, the
exact loss the strip is kept in minimal mode to prevent. The carrier now falls
back to the first tab, and compact mode shows whichever tab is carrying. An
active id that matches no tab yet (mid-rebuild) is deliberately left uncarried
rather than painted onto an unrelated radio.

**Windows: the maximize control stopped exiting Minimal Mode.** WM_NCHITTEST
answered HTMAXBUTTON unconditionally, so Windows routed the click non-client,
Qt never saw it, and the branch maximized a strip that is not meant to be
maximized — a regression against main, where the control was a QLabel through
TitleBar::eventFilter. Both the hit-test answer and the click are now gated on
minimal mode; declining HTMAXBUTTON there also stops Snap Layouts offering to
tile the strip. Also added the missing TrackMouseEvent(TME_LEAVE|TME_NONCLIENT),
without which WM_NCMOUSELEAVE never arrives and the maximize button stays lit
after the cursor moves into the client area.

**refreshAfterLayoutShift() fired on the no-op path.** applyAppletPanelState()
routes show, hide, un-float, pop-out, Ctrl+Shift+S and the bridge verb through
setAppletPanelDockedLeft(), so a per-panadapter native re-realize ran on
transitions where nothing moved — and on the hide path it ran before the
visibility change that causes the geometry shift. Now scoped to an actual wall
change. The visibility flip does need the same treatment (the panadapter gains or
loses the panel's 260 px), so that call moved into setAppletPanelVisible() where
the width actually changes, guarded on a real change.

Nits: the pulse decay was 1400 ms against a ~1 s beat interval, so the ticker was
re-armed mid-decay and ran at 12.5 Hz for the whole session — the opposite of the
"an idle bar costs nothing" claim next to it. Now 850 ms, with the comment made
honest about why. The macOS CMake comment described keeping the real NSWindow
chrome, which is the approach the branch abandoned; reworded per the suggestion.

All four behaviours are now guarded in unified_title_bar_test, which is where
the review found the gap — blockers 1, 2 and 7 were caught by a reviewer's
scratch harness and by running the app, not by CI. Verified as real guards:
reverting the fixes fails all four assertions.

Verified: 282 tests pass; 158 bridge checks for the dock/float state machine;
and the see-through strip stays fixed after the scoping change — 0/10 across six
dock flips and four hide/show cycles, the path the refresh moved to.

NOT verified, no hardware here: the Windows minimal-mode fix, the TrackMouseEvent
change, and the two PR-gate items the RFC review moved from RFC to PR (aethersdr#4557
frameless top-edge drift, Wayland fractional-scaling drag jitter).

Refs aethersdr#4764
Use Qt expanded client areas and native captions where supported, retaining a shared Linux fallback. Improve radio-switcher search, action gating, naming and tab visibility. Align the macOS brand with measured native traffic-light bounds using a native unified toolbar.

Validated with the macOS Qt 6.12 preview build, four focused offscreen tests, native Cocoa geometry assertions and isolated automation-bridge checks. Windows/Linux CI and native tiling certification remain deferred; this is a testing branch, not a release-toolchain migration.
…rinciple XI.

The bar never drew its own fill or border: `TitleBar {}` cannot select a
namespaced class, so it rendered as the window colour. It now paints a
pre-composited opaque fill and a 1 px hairline (WA_OpaquePaintEvent), so a
tab repaint stops at the bar instead of repainting the window under it.

Painting the fill exposed MainWindow's window-wide `QWidget { background }`
rule slabbing the drag gutter, audio cluster and empty tab viewport in the
window colour; those are now transparent by object name (not by type — the
discovery popover and menus are descendants and keep their panels).

- Tabs 36 px, 8 px clear of the bar edges: outside FramelessResizer's band on
  Linux and Qt's top resize border on Windows.
- Line two no longer repeats the name: [model ·] status [· detail]; the model
  shows only when a nickname hides it. a11y/tooltip keep the name.
- Heartbeat glow repaints only the dot's footprint, not the whole tab.
- A vertical mouse wheel scrolls an overflowing tab strip.
- A user disconnect stops the miss timer and clears the link alarm; an
  unexpected loss still raises it (verified with `sim disconnect`).
- Minimal mode no longer leaves the dock separator dangling (m_dockSep was
  never assigned).
- "+" rings for keyboard focus only; popover empty state says nothing has
  been found yet instead of "No matching radios".
- refreshAfterReparent back to macOS-only, as on main; the Metal fault was
  never reproduced on D3D/GL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jensenpat
jensenpat marked this pull request as ready for review October 5, 2026 04:22
@jensenpat
jensenpat requested review from a team as code owners October 5, 2026 04:22
…ciple VIII.

ThemeManager.h's include count moved 162 -> 165 with the rebased title-bar
work; tools/gen_touchpoint_manifest.py --check failed Static checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Hi @jensenpat — CI on 1f2f7147 went red, and it's a one-line fix in a generated file. Nothing wrong with the title bar work.

What failed

Static checks, at the step aetherd touchpoint manifest (job):

::error::aetherd-touchpoints.md is stale — run `python tools/gen_touchpoint_manifest.py`

That step is python tools/gen_touchpoint_manifest.py --check (static-checks.yml:197). Everything else in that job passed, including the colour ratchet and the accessibility analysis.

Why

I fetched the branch and reproduced it. The regenerated manifest differs from the committed one by exactly one row:

-| `core/ThemeManager.h` | 162 | ui-support — Qt token-based theming singleton …
+| `core/ThemeManager.h` | 165 | ui-support — Qt token-based theming singleton …

The column is "how many files include this touchpoint header", and this branch adds three new GUI files that include core/ThemeManager.h:

  • src/gui/BrandMark.cpp
  • src/gui/RadioTabBar.cpp
  • src/gui/WindowCaptionButtons.cpp

162 + 3 = 165. Confirmed with git diff origin/main...1f2f7147 -- src/ | grep '#include.*ThemeManager': three additions, no removals. The branch never touches docs/architecture/aetherd-touchpoints.md, so it's simply never been regenerated since those files landed.

The header total stays at 239 — the five new src/gui/ headers aren't touchpoints themselves, they're consumers, so no new rows. This is just the one count.

The fix

python tools/gen_touchpoint_manifest.py
git add docs/architecture/aetherd-touchpoints.md

One line changes. Worth re-running after any rebase too — #6195 and #6140 also regenerate this manifest (different rows, so they should merge clean, but the regen is free insurance).

Scope of this report

  • Not CI infrastructure — this is a genuine stale generated file, and the checker is right.
  • The other three jobs (build, check-macos, check-windows) were still in progress when I looked, so I can't speak to them. Sanitizer option configures passed. If check-macos does go red, the most likely cause is the one you already flagged under Test gates: mixer_control_availability_test linking TitleBar.cpp and needing src/gui/mac/NativeWindowTitle.mm in the if(APPLE) block of tests/tests.cmake. I haven't seen that fail — just noting where I'd look first.
  • No Copilot or reviewer comments on the PR yet, so nothing there relates to this failure.

Thanks for the careful writeup on this one — the honest Test-gates checklist and the explicit RFC deviation table make this much easier to review. 73


🤖 aethersdr-agent · cost: $5.3245 · model: claude-opus-5

jensenpat and others added 2 commits October 4, 2026 21:42
… Principle VIII.

ConnectionPanel::renameRadio() constructed a PersistentDialog, so every
target that compiles ConnectionPanel.cpp now needed the frameless-dialog
stack. startup_autoconnect_lockout_test does not link it and failed to link
on Linux and macOS CI.

ConnectionPanel keeps the decision (canRenameRadio) and the write
(setRadioNickname), and asks its owner for the dialog through
radioRenameRequested. MainWindow builds the same PersistentDialog — same
object names (radioNicknameDialog / radioNicknameEditor /
saveRadioNickname), geometry key, styling and behaviour — so the bridge
targets and the operator's view are unchanged, and ConnectionPanel is back to
main's dependency footprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ty_test on Apple. Principle VIII.

aethersdr#6044's test compiles TitleBar.cpp, which on this branch calls the macOS
native-title helpers (mac::updateNativeTitleVisibility / nativeCaptionBounds).
Add it to the existing if(APPLE) foreach that already supplies
NativeWindowTitle.mm and AppKit to the other TitleBar-linking tests; without
it check-macos fails to link the target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Issue fit

Implements the approved RFC #4764 and delivers most of it: the single 52 px bar, the brand mark, radio tabs with a worded status line, the + popover, the heartbeat folded into the active tab's dot (with the solid-red-when-blink-off rule preserved — I checked RadioTabBar.cpp:616-640), the de-emojified audio cluster, the tokens, and the get titlebar / titlebar bridge surface. The right-click blink toggle the RFC's risk 4 promised did move onto the tabs (TitleBar.cpp:259), and the View-menu checkbox still routes through the same setBlinkEnabled().

Partially, on one named RFC deliverable: feature 7 and the use case "Snap Layouts on Windows 11 — hover the maximize button, get the layout flyout" were part of what @ten9876 approved, and this branch regresses them (W1) rather than shipping them, because it hands caption drawing to Qt. The RFC's own problem statement (§2) is Snap Layouts' absence. The deviation is honestly disclosed and the macOS half of the trade is clearly better than approved (decision 3's lost tiling menu comes back) — but "approved RFC feature not delivered on the platform the RFC named it for" is a maintainer call, not a reviewer one. Same for the ~31 px Qt caption buttons vs the RFC's 46 px (W2).

The PR is self-declared Draft with four Windows gates, one Linux gate and one macOS gate unchecked. Nothing below is a reason to re-open that; it is already open.

2. Scope

RFC implementation scope was "14 files, ~1100 insertions". This is 35 files, 4734 insertions.

File / group What it changes Claimed? Verdict
TitleBar.*, BrandMark.*, RadioTabBar.*, WindowCaptionButtons.*, WindowChrome.h, mac/NativeWindowTitle.* The bar itself Yes (RFC + body) In scope
MainWindow.{h,cpp}, MainWindow_Session.cpp, MainWindow_Menus.cpp Bar wiring, tab feed, chrome config Yes In scope
resources/themes/*, ThemeSeedGenerated.cpp, canonical-tokens.md, logo-96.png, resources.qrc 21 tokens + asset (RFC said 15) Yes In scope; token growth undisclosed, see nits
AutomationServer.{h,cpp} — titlebar verb + get titlebar RFC feature 9 Yes In scope (new public surface — see B1)
AutomationServer.{h,cpp} — applet verb New public bridge verb: dock/float/show/hide/state No — body never mentions it; only commit ee5ac3b does Needs maintainer decision, + body update
AppletPanel.cpp, PanadapterStack.{h,cpp} Docked-panel background + refreshAfterLayoutShift() for the dock flip No Explained by 58b4e30/ee5ac3b, not by the body — name it in the body
ConnectionPanel.{h,cpp} (+ MainWindow_Session.cpp dialog) selectRadio/selectManualConnection/rename plumbing; QInputDialog → PersistentDialog Partly (the popover needs the selectors) In scope-adjacent; the nickname-dialog rewrite is a side effect, disclose it
docs/qt612-window-chrome.md (new, 186 lines) Branch diary No Blocker B2
docs/automation-bridge.md, docs/architecture/aetherd-touchpoints.md Verb/model docs, manifest count 162→165 Yes In scope
tests/tests.cmake, tests/unified_title_bar_test.cpp, tests/titlebar_headphone_mute_test.cpp New test + link sets Yes In scope

No CHANGELOG.md entry — correct.

Socket-test record: this PR adds no socket-owning test and removes none. tests/unified_title_bar_test.cpp is socket-free (no QTcpServer/QLocalServer/QUdpSocket/listen/connectToHost); it is registered in tests.cmake:7186 and joins AETHER_SETTINGS_CONSUMERS. It is not in any ctest -R filter in ci.yml, so the body's 18/18 is a local result and CI does not gate it.

3. Blockers

B1 — src/core/AutomationServer.cpp:9244-9263: doTitleBar runs a button click and a window close synchronously inside the socket read callback, against this file's own written rule

Credit: security-audit (CodeGuard), confirmed against the code.

    if (!m_titleBarActionHandler(action.trimmed(), target.trimmed(), &why)) {

That handler is MainWindow::automationTitleBarAction, and its three sinks are exactly the ones this file defers everywhere else:

  • selectRadio → tab->click() (MainWindow.cpp:4562) — a QAbstractButton click. AutomationServer.cpp:4499-4503 on the invoke click path: "a click can open a popup menu or modal dialog with a nested event loop; run synchronously inside the QLocalSocket read callback it corrupts socket/window state (SIGSEGV in QWindow::geometry()). Defer to a clean main-loop turn… (#3646)". Clicking a tab reaches showConnectionDialog(), which raises a top-level window.
  • showDiscovery → RadioTabBar::showDiscoveryPopover() — constructs a popover with a QMenu per row and shows it. doShowMenu defers for this reason.
  • close → close() (MainWindow.cpp:4588) or emit caption->closeRequested(), direct-connected to window()->close() (TitleBar.cpp:601). doClose at 9722 wraps the identical call in QTimer::singleShot(0, qApp, …) and replies "deferred": true, with the rationale in its header: "a confirm-dialog closeEvent can't re-enter the socket callback".

The aggravating detail: the new function was inserted immediately beneath doWindow's #3918 header block, which is still at 9238-9243 and still says

// State changes don't spin a nested event loop, so they're safe to run
// synchronously here (unlike click/menu, which defer).

so doWindow's documentation is now orphaned above a body that does both the click and the close that sentence excludes, and reads as this function's own justification.

Second half, same hunk at 9264-9266: after the handler has run close(), doTitleBar calls m_titleBarSnapshotHandler() to build the echo — reading a MainWindow that has already run its whole closeEvent teardown (MainWindow.cpp:3921+: settings save, socket disconnects, thread wait()). Not a use-after-free today — MainWindow sets no WA_DeleteOnClose — but it is one the day that changes.

Fix: defer, as the siblings do, and take the snapshot before the action rather than after.

    QJsonObject reply{{QStringLiteral("ok"), true},
                      {QStringLiteral("action"), action.trimmed()}};
    if (!target.trimmed().isEmpty()) {
        reply.insert(QStringLiteral("target"), target.trimmed());
    }
    // Snapshot BEFORE the action: `close` tears the window down, and the
    // post-action read would inspect a MainWindow that has already run
    // closeEvent. Re-read `get titlebar` to confirm the result.
    if (m_titleBarSnapshotHandler) {
        reply.insert(QStringLiteral("titlebar"), m_titleBarSnapshotHandler());
    }
    reply.insert(QStringLiteral("deferred"), true);
    return reply;

…with the handler call itself moved into deferInvokeAction(...) / QTimer::singleShot(0, qApp, …). The validation half has to stay synchronous so an unknown action or a missing tab still errors — splitting "resolve and validate now, activate on the next turn" is the shape invoke click already uses.

doAppletPanel at 9289 has the same structure: applyAppletPanelState() creates and tears down the top-level applet float window (MainWindow.cpp:4501-4520) from the read callback. Lower confidence that it can spin a loop, but it's the same invariant, so I'd apply the same treatment.

The docs change needs a matching correction: docs/automation-bridge.md currently promises "The reply echoes the post-action get titlebar snapshot, so a caller never needs a follow-up read."

B2 — docs/qt612-window-chrome.md: a personal branch diary, and it is false against current main

The first paragraph:

This is a development migration, not a release-toolchain change. CMake requires Qt 6.12; the local macOS SDK is the official 6.12.0 preview package dated 2026-09-01. Windows/Linux CI and release packaging remain on their upstream toolchains and cannot build this branch unchanged. Do not publish it as a release-ready, cross-platform-verified migration.

That was true when it was written and is not true now. main already carries the Qt 6.12 floor — CMakeLists.txt:315, find_package(Qt6 6.12 REQUIRED …), shipped with v26.10.1 (8df6db2f, "Qt 6.12") — and this PR's CMakeLists.txt diff does not touch it. At head 93d39e02, build (Linux), check-windows, check-macos, Static checks and Sanitizer option configures are all green. A merged doc telling the next reader that CI cannot build this tree is worse than no doc.

The rest of the file is branch-local process notes, not architecture: a fork branch (jensenpat/AetherSDR/tree/aether/applet-picker), a local laptop bundle name (AetherSDR qt612-applet-picker-0de5a2b6-dirty 1748.app), SFTP/quarantine/ad-hoc-signing steps, "preserved locally on aether/applet-picker-integration", a worktree-local build/qt-sdk recipe, and two dated "Local evidence" sections including "Computer Use clicked the + button". That belongs in the PR body (where most of it already is), not in docs/.

What's genuinely worth keeping is the ~40 lines that describe the shipped design: the WindowChrome.h policy table, the per-platform notes, and the radio-switcher behaviour table. Those read as architecture and would sit well as docs/architecture/window-chrome.md. My ask is: keep that, drop the diary and the stale toolchain framing.

4. Nits (non-blocking)

  • src/gui/AppletPanel.cpp:284-296 — the new comment's premise is contradicted by this same PR: "MainWindow is WA_TranslucentBackground, so its paintEvent is the only thing filling any region the widget tree leaves bare". WindowChrome::configure() sets window->setAttribute(Qt::WA_TranslucentBackground, false) and the body says the window is now opaque. The second half of the comment (Qt excludes native QRhi children from the backdrop) is the part that still holds; the first clause will send the next reader looking for an attribute that isn't set.
  • tests/tests.cmake:6444 — connection_panel_size_test gained src/gui/PersistentDialog.cpp, but 115e822e moved the dialog out of ConnectionPanel. grep PersistentDialog src/gui/ConnectionPanel.cpp now matches only a comment (ConnectionPanel.cpp:1610). Looks like a leftover from the pre-115e822e shape.
  • docs/automation-bridge.md get titlebar — RadioTabBar::state() also returns linkPulse, linkAlarm, linkOverrideColor and discovered[].canRename, none of which the doc mentions. linkPulse/linkAlarm are the fields that make the body's "the heartbeat is asserted rather than eyeballed" claim true (and the RFC's use case names linkPulse explicitly), so they're the ones most worth documenting.
  • resources/themes/*.json, canonical-tokens.md — the body admits color.titlebar.background.mac is now unused. Dropping it in this PR is cheaper than a follow-up that has to touch both themes, the seed and the token count again. Also worth a body line that the RFC's "15 new tokens" became 21.
  • src/gui/TitleBar.cpp barState() — WindowChrome::usesNativeCaption(window()) and window()->windowFlags() are unguarded two lines above window() && window()->windowFlags()…. QWidget::window() won't return null here, so this is consistency rather than a crash, but the mixed guarding suggests one of the two readings is wrong.
  • src/gui/MainWindow_Session.cpp (radioTabActivated lambda) — m_connPanel->selectRadio(radioId) returns bool and the result is dropped. ConnectionPanel::selectRadio only searches m_radios/m_wanRadios, so clicking a tab for a SmartLink radio while logged out, or for the routed/"Manual" synthetic tab, opens the picker on whatever page it was on with no feedback.
  • src/gui/MainWindow_Session.cpp (radioActionRequested remove/restore) — settings.setValue("RadioSwitcher", …) result is unchecked; docs/agents/settings.md asks writers to check. The JSON document also carries no schema version, where Principle V asks for "one versioned JSON document" (the "one object per feature, not another loose flat key" half is satisfied, and app-global is the right scope for a hidden-tab list).
  • src/gui/MainWindow_Session.cpp statusFor for SmartLink — wan.status.compare("Available", Qt::CaseInsensitive) != 0 means an empty or unrecognised status renders as "in use", i.e. "another station has it". Defaulting the unknown case to Available would be the safer read.
  • src/gui/RadioTabBar.cpp showDiscoveryPopover() — "Discovered radios" and "Connect manually…" are bare QStringLiteral while everything around them is tr().
  • src/gui/PanadapterStack.cpp — refreshAfterLayoutShift()'s native re-realize is macOS-only by design, so if the dock-flip see-through strip reproduces on Windows/Linux the repaint sweep alone won't fix it there. Reasonable given no repro, but it's worth saying out loud that 58b4e30 is a macOS fix.

5. What I tried to break (and didn't)

  • Bridge auth and authorization ordering on the new verbs. The constant-time token gate (AutomationServer.cpp:3935) and the observe-only gate (3963) both run in handleLine before verb dispatch, and titlebar/applet are absent from isReadOnlyRequest's safe set (2723-2786), so they fail closed in observe-only mode. get titlebar rides the get entry and is a pure read. No bypass.
  • TX-guard bypass. Neither verb reaches a keying path, and titlebar close is not a new app-terminating capability — the pre-existing close <target> verb already closes top-levels. No Principle VI issue introduced.
  • Injection / unknown-token fall-through. Both handlers are exact-match allow-lists with return fail(...) tails (MainWindow.cpp:4501-4522, 4552-4598); target is used only as an equality key against tab->entry().id and echoed JSON-encoded. doTitleBar not lower-casing action while doAppletPanel does is an inconsistency that fails closed.
  • Handler lifetime. All four are null-checked (9250, 9278, 9285) and their [this] captures are MainWindow, which owns m_automation as a unique_ptr member — so it outlives the server. Only the post-close() snapshot (B1) is exposed.
  • Deleted guards. Every - line citing an issue (#4827, #4886, #4328, #2584) is code motion, not removal: those comments and their guards still exist in head at FramelessResizer.h, WindowGeometryRestore.h, MainWindow.cpp:2593 and MainWindow_Workspace.cpp:412. The other - lines with hex in them are hardcoded colours going away, which matches the body's −50 colour-reference claim.
  • The light-theme caption-glyph contrast I suspected. color.titlebar.caption.glyph resolves to {color.gray.100} = #506070 on a #f5f5f8 bar — that's the same ink as text.secondary, so it passes. Not a finding.
  • Applet state machine. The two "deliberately not representable" combinations are actually enforced in code, not just in the doc: applyAppletPanelState forces visible = true when floating (MainWindow.cpp:9719-9721), and hide passes floating=false (4520). The QSplitter-won't-re-slot-a-hidden-child comment at 9742-9750 describes a real Qt behaviour and the show-then-move order handles it.
  • Rename-path equivalence. The removed QInputDialog branch and the new PersistentDialog route end at the same hl2::Hl2Discovery::setNickname + relabel + eager commit; showRadioContextMenu's early return after renameRadio() is correct because setRadioNickname() does the relabel and emits radioNicknameChanged(). The one behaviour change is the match key (serial only, vs family && serial in the surviving clear-nickname path) — harmless unless two families share a serial.
  • addDemoRadio — demo.serial is assigned before radioDisplayName(demo, …), so the simulator's nickname override resolves rather than silently falling back.
  • Test-link completeness. Only four tests.cmake targets compile src/gui/TitleBar.cpp and all four gained the three new sources; the if(APPLE) foreach covers the same four. No target is left short of NativeWindowTitle.mm.

Everything above is reasoned from reading the code at /tmp/aetherclaude/pr-6198 — I ran no build, no test and no GUI. The whole class of findings the open gates cover is outside what I can reach: macOS native hit-testing under the empty NSToolbar (M1), Snap Layouts hover (W1), Qt's caption-strip synthesized press (W3), Windows top-edge drift (#4557), and Wayland fractional-scale drag/resize. I also cannot judge the screenshots' "before" against anything I can render.

6. Recommendation

Needs maintainer decision, with B1 and B2 to fix first.

B1 is a concrete correctness fix inside the PR's own new code and should land before the Windows/Linux gates get spent on a build that can crash the bridge on titlebar selectRadio. B2 is a one-file decision. Beyond those, the three things only @ten9876 can rule on are: the undelivered Snap Layouts feature from the approved RFC, the opaque-window default that retires main's backdrop-alpha experiment, and the applet verb as new permanent public bridge surface arriving unannounced.

The bar itself is good work, and the test is the honest kind — it mirrors MainWindow's real stylesheet cascade so the bug it was written for (TitleBar {} never matching a namespaced class) actually reproduces in it, and it checks a rendered pixel rather than asserting the implementation back at itself. Thank you for the RFC correction note and for listing the gates as unchecked rather than claiming them.


🤖 aethersdr-agent · cost: $18.7288 · model: claude-opus-5

…k; docs to architecture. Principle VIII.

Review of aethersdr#6198 (B1): `titlebar` clicked a tab (which can raise the Connect
window), built the discovery popup, or ran MainWindow::close() synchronously
inside the QLocalSocket read callback — the aethersdr#3646 hazard `invoke click` and
`close` already defer around — then read a post-close snapshot. `applet`
created and tore down the float window the same way.

Both verbs are now two-phase: the MainWindow handler validates synchronously
(unknown action / missing tab still answer ok:false) and returns the
activation, which AutomationServer runs via deferInvokeAction on the next
main-loop turn. The reply carries "deferred":true and the snapshot taken
BEFORE the action. Verified live: showDiscovery/dock land a turn later, bad
input errors immediately, `titlebar close` exits cleanly in 0.5 s. The aethersdr#3918
doWindow header that had been orphaned above doTitleBar is back on doWindow.

B2: docs/qt612-window-chrome.md was a branch diary with a stale "CI cannot
build this" preamble. Its design content moves to
docs/architecture/window-chrome.md, updated to this head (painted opaque bar,
stylesheet-cascade trap, 8 px tab insets, Snap Layouts status); the local
build/evidence notes are dropped.

Nits: drop the unused color.titlebar.background.mac token (themes, seed,
canonical-tokens 72 -> 71); document linkPulse / linkAlarm /
linkOverrideColor / discovered[].canRename; correct AppletPanel's
"MainWindow is translucent" premise; tr() the popover strings; an
unrecognised SmartLink status reads as available, not in use; a tab whose
radio is not in the picker says so; version the RadioSwitcher document;
consistent window() reads in barState().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ten9876 ten9876 self-assigned this Oct 5, 2026

@ten9876 ten9876 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation substantially covers approved RFC #4764 (brand, two-line radio tabs, discovery panel, audio controls and bridge inspection), but partially satisfies it: the approved Windows Snap Layouts feature is explicitly absent, and the RFC's required Windows/Linux platform checks are still unchecked. Reviewed head cbc4872897efab44c7748214c65864991c5cc85f against merge base 12df694349c0f57081c24563783e267447fc3b20.

Scope

File/group Change Claimed? Verdict
TitleBar.*, BrandMark.*, RadioTabBar.*, WindowCaptionButtons.*, WindowChrome.h, mac/NativeWindowTitle.* Unified bar, tabs, controls, platform chrome Yes RFC implementation; platform acceptance remains outstanding
MainWindow.*, MainWindow_Session.cpp, ConnectionPanel.* Discovery/session integration, hide/restore, rename dialog, opaque main window Yes Disclosed; opaque-window default needs Jeremy's ruling
MainWindow.cpp status-bar styling hunks Change station border, separators, inactive indicators, automation chip, FFT icon and CPU/TX colours below the main content No specific claim or RFC requirement Unbundle unrelated visual changes (B1)
AppletPanel.cpp, PanadapterStack.*, MainWindow_Menus.cpp, applet-state portions of MainWindow.* Dock/float state correction and Metal refresh Yes Disclosed adjacent bug fixes; no new Windows/Linux native re-realization
AutomationServer.*, docs/automation-bridge.md titlebar and applet public verbs, deferred actions Yes titlebar approved in RFC; applet needs Jeremy's decision
resources/themes/*, ThemeSeedGenerated.cpp, canonical-token docs 20 tokens and generated seed Yes In scope
Logo, resource registration, CMake, tests/tests.cmake, titlebar tests Assets, build integration and coverage Yes In scope
Architecture index/window-chrome docs and touchpoint manifest Documentation and generated include count Yes In scope; ThemeManager count rises 162→165 through the three declared new widgets

No commit predates the branch's initial August 6 feature commit. The body usefully discloses dock/float work and three maintainer decisions, but its list does not explain the separate status-bar visual changes.

Blockers

  1. Unbundle the status-bar restyle. src/gui/MainWindow.cpp:5593 changes the bottom status bar's border and separator/inactive colours; :5628 changes the automation chip (including adding a border); :5656 changes the status FFT glyph; :5929 changes CPU warning colours, and :6133 changes the inactive TX label. The station border also changes at :364. None implements the 52 px title bar, and none is identified as a separate defect or accepted requirement in #4764. Using tokens is a valid cleanup, but it also changes existing rendered values outside the approved surface. CONTRIBUTING.md says “One issue per PR. Keep changes focused and reviewable.” GOVERNANCE.md lists “Any change to visual design — colors, fonts, spacing, theme, icons” under RFC-required changes. Keep the title-bar token work here; move these unrelated status-bar hunks into their own proposal/PR. This is a scope finding from the diff, not a reproduced rendering defect.

  2. Complete the expressly required platform acceptance, or obtain an explicit revised maintainer ruling. src/gui/WindowChrome.h:25 replaces the prior Windows custom-frame path with expanded-client-area hints, while src/gui/TitleBar.cpp:684 hides the shared caption controls when native captions are used. The PR body acknowledges that this implementation does not deliver the RFC's Windows 11 maximize-hover Snap Layouts and leaves /winbuild, #4557 top-edge drift, Windows pointer delivery and fractional-scaling Wayland checks unchecked. The maintainer's RFC #4764 decision 2 expressly says “it becomes a PR gate rather than an RFC gate”, with /winbuild, deliberate #4557 regression coverage and fractional-scaling Wayland checks named. Headless QWidget tests cannot settle those native interaction questions. Supply that evidence and deliver the accepted Snap Layouts behaviour, or have Jeremy explicitly revise the acceptance criteria; do not present the RFC as fully implemented before then. This is missing required acceptance evidence and an acknowledged feature gap, not a locally reproduced Windows defect.

Nits and maintainer decisions (non-blocking on their own)

  • Rename dialog misses runtime chrome propagation, reasoned from code: src/gui/MainWindow_Session.cpp:537 constructs a PersistentDialog but does not register it in m_persistentDialogs. ConnectionPanel::setFramelessMode() updates only itself; MainWindow's propagation loop updates tracked dialogs. Add trackPersistentDialog(dialog); before showing it, so an already-open rename dialog follows View → Frameless Window. I could not drive this path because the full app build hit environment quota.
  • src/gui/TitleBar.cpp:110 still claims the design system forbids emoji outright, although the RFC explicitly withdrew that unsupported appeal. State only the rendering/theme reason, and shorten the long historical comments in RadioTabBar.cpp:750 and PanadapterStack.cpp:355 per AGENTS.md's comment guidance.
  • Jeremy / @ten9876: decide the disclosed opaque-window default at src/gui/WindowChrome.h:37, the new permanent applet verb at src/core/AutomationServer.cpp:3697, and the changed native-caption approach. The RFC approved a design; these disclosed deviations do not receive approval merely because the code compiles.

Verification and attacks

  • Test-boundary preflight passed: added/modified test sources introduce no socket-owning peer. Built only unified_title_bar_test with pinned Qt 6.12, Release, in /tmp/pr6198-review; it passes offscreen.
  • Mutation-checked active-tab truth: removing applyActiveState() before radioActivated produces FAIL: re-clicking the active tab leaves it checked and FAIL: clicking an inactive tab does not check it. Restored the exact source and rebuilt/re-ran the passing head target. This validates genuine behavioural coverage of both active and inactive re-clicks.
  • Read the hidden/compact tab carrier, overflow wheel mapping, blink-disabled solid alarm, deferred QPointer tab activation, malformed bridge actions and applet side/idempotence paths. The focused test also exercises theme fill/cascade, tab insets, search/actions, overflow and alarm clear. No additional defect was established by these attacks.
  • Ran the four seam gates on exact merge base and head: 102 engine findings unchanged in signature, zero blocking growth; capability booleans 70→70; raw above-seam commands 369→369; manifests current. Head theme seed and bridge-doc generation checks pass. No checker/baseline weakening in the diff. Automated code-review skill unavailable.
  • No app/bridge session: the full build stopped before compilation with AutoGen ... Writing depfile ... failed / ninja: ... Disk quota exceeded. A focused test build succeeded after freeing only this review's artifacts. No existing instance was driven, no hardware was connected, and no runtime persistence/native-pointer/dock-rendering claim is independently verified here. macOS claims remain author evidence; Windows/Linux native gates remain open.
  • CI is stale relative to current main: earliest run creation on this head was 2026-10-05T06:43:19Z, current main tip 334276112 is dated 2026-10-05T07:51:14Z. With strict: false, green checks do not prove the current merge result.

Recommendation: Request changes. Unbundle the unrelated status-bar styling and complete/revise the explicit native-platform gates. The focused titlebar test and mutation check are useful positive evidence, but they cannot replace native interaction checks or Jeremy's outstanding design/public-surface decisions.

@ten9876 ten9876 removed their assignment Oct 5, 2026

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head cbc48728. I built it on Linux against Qt 6.12.0 and drove it visibly on a Hyprland Wayland desktop at 1.667× fractional scale, connected only to the built-in demo radio (DEMO-0001, isolated settings, TX disabled). That is the Linux gate the body leaves open. The prior agent review was against 93d39e02; I re-checked its two blockers on this head (both fixed, see below) rather than relying on it.

1. Issue fit

This implements approved RFC #4764, and the Qt 6.12 deviation is disclosed honestly. The bar, the tabs with a worded status line, the popover, the get titlebar / titlebar surface and the heartbeat folded into the tab dot are all present and working on Linux. The remaining RFC-level calls are the ones the body already names as maintainer decisions: W1 Snap Layouts, the opaque window, and the applet verb. One correction to the body: it says "Draft.", but isDraft is false, so the PR is open for merge as it stands.

2. Scope

File / group Claimed? Verdict
TitleBar.*, RadioTabBar.*, BrandMark.*, WindowCaptionButtons.*, WindowChrome.h, mac/NativeWindowTitle.* Yes In scope
MainWindow*.cpp/.h: chrome wiring, tab feed, rename dialog, Windows nativeEvent/#4328 re-anchor removal Yes ("our nativeEvent/DWM code (removed)") In scope
MainWindow.cpp:1144: FramelessResizer::install(this, 8), which drops topMoveReserve and widens 6 → 8 px on every edge Only the top band is mentioned Undisclosed for the bottom and side edges. See nit 1
TitleBar.cpp: removal of the xcb manual-move fallback (#4827) No Blocker 2
AutomationServer.*: titlebar verb, get titlebar Yes (RFC feature 9) In scope
AutomationServer.*: applet verb Yes ("please rule") Needs maintainer decision. New public bridge surface
AppletPanel.cpp, PanadapterStack.* (dock-flip strip) Yes In scope. The re-realize is macOS-only, as the body says
ConnectionPanel.*: rename moved to PersistentDialog, selectors Yes In scope. Side effect noted in nit 6
Themes, seed, canonical-tokens.md (20 tokens vs the RFC's 15), logo, qrc Yes In scope
docs/architecture/window-chrome.md, automation-bridge.md, README index, touchpoint manifest (ThemeManager includers 162 → 165) Yes In scope
tests/tests.cmake, unified_title_bar_test.cpp, titlebar_headphone_mute_test.cpp Yes In scope. Socket-free (step-0 preflight: no socket or peer in any added test source)

There is no CHANGELOG.md entry, which is correct. Engine boundary: check_engine_boundary.py reports 102 findings on both the merge base and the head, and the per-file diff shows only line shifts. gen_touchpoint_manifest.py --check is clean on both.

The prior review's blockers, re-verified on this head:

  • B1 (fixed): doTitleBar/doAppletPanel now validate synchronously and run the action through deferInvokeAction (AutomationServer.cpp:9245-9314), taking the snapshot first. Driven live: the replies carry "deferred":true with the pre-action state; applet state read 1 s later shows the flip (side:right → left, splitterIndex 3 → 2); unknown actions and missing tab ids fail synchronously ("no radio tab with id 'nope'").
  • B2 (fixed): docs/qt612-window-chrome.md is gone and its design content is in docs/architecture/window-chrome.md.

3. Blockers

1. src/gui/RadioTabBar.cpp:664-678: after an unexpected link loss, the red "link lost" alarm is painted on whichever radio's tab happens to come first, not on the radio that dropped

    if (m_activeId.isEmpty()) {
        for (RadioTab* tab : m_tabs) {
            if (tab->entry().visibleInTabs) {
                return tab;
            }
        }
    }

When the session drops, refreshRadioTabs() clears the active id (MainWindow_Session.cpp:210, activeSerial is empty when disconnected). The carrier then falls back to the first visible tab, and onHeartbeatLost() raises the alarm on that tab 4.5 s later. LAN radios are listed first, so on any network with a second radio, the radio you were never connected to is the one that goes red.

Reproduced on the demo, with a FLEX-8600 also on the LAN. Bridge, sim disconnect, then get titlebar once per second:

  0.2s alarm False active '' [('2125-1213', 'available', 'Radio FLEX-8600, available')]
  3.4s alarm False active '' [('2125-1213', 'available', 'Radio FLEX-8600, available')]
  4.4s alarm True  active '' [('2125-1213', 'available', 'Radio FLEX-8600, available')]
  5.5s alarm True  active 'DEMO-0001' [('DEMO-0001', 'connected', ...)]

A grab RadioTabBar at +4.7 s shows the red alarm ring on "FLEX-8600 · available · KK7GWY", while the Simulator tab, which actually dropped, shows a plain grey dot. The body's own pitch for this design is "One dot answers both 'which radio' and 'is its link alive'", and here it answers "which radio" wrongly. An operator with two radios is told that the idle one lost its link.

Fix: keep the carrier on the radio whose link this is until a different session starts. For example:

void RadioTabBar::setActiveRadio(const QString& id)
{
    if (m_activeId == id) {
        return;
    }
    if (!id.isEmpty()) {
        m_carrierId = id;   // the link the heartbeat describes, kept through a drop
    }
    m_activeId = id;
    applyActiveState();
}

RadioTab* RadioTabBar::linkCarrierTab() const
{
    const QString& id = m_activeId.isEmpty() ? m_carrierId : m_activeId;
    for (RadioTab* tab : m_tabs) {
        if (tab->entry().id == id) {
            return tab;
        }
    }
    return nullptr;
}

This needs a QString m_carrierId; next to m_activeId in RadioTabBar.h:199. Whether a user disconnect should also clear it is your call; clearLinkAlarm() already handles that path. Please also add a unified_title_bar_test case with two tabs, the second one active, then setActiveRadio({}) plus the alarm, asserting that the second tab is the carrier. The current suite passes with this bug present.

2. src/gui/TitleBar.cpp:810-824: the #4827 xcb manual-move fallback was removed, so dragging the title bar is dead again under QT_QPA_PLATFORM=xcb on Mutter/XWayland

startWindowMove() is now only windowHandle()->startSystemMove(). On main (12df69434:src/gui/TitleBar.cpp:580-640), xcb skipped the system move and used a grabbed manual w->move(), because, per FramelessMoveHelper.h:11-14, which is still in the tree:

On xcb, startSystemMove()/startSystemResize() report success but the WM grab is silently dropped under Mutter/XWayland (QTBUG-69716, #4827)

On xcb startSystemMove() returns true, so the new code accepts the press and nothing moves the window. That is the exact symptom #4827 closed ("Click-and-hold on the menu bar to move the window: the cursor changes to the drag hand, but the window never follows the pointer"). git log -S systemMoveResizeUnreliable traces the removal to 0e0cf124, and the body doesn't mention it. FramelessResizer kept its own xcb fallback, so resize still works; only moving the window regressed. xcb is still reachable as the wayland;xcb fallback (main.cpp:299), on plain X11 sessions, and for anyone who sets QT_QPA_PLATFORM=xcb, which #4725 calls the common setup.

Fix: restore main's branch, so that if (FramelessMoveHelper::systemMoveResizeUnreliable(w)) takes the manual move with grabMouse(), continueWindowMove, and finish on release. It only applies on the Linux frameless path now, because macOS and Windows use native captions.

Reasoned from code, not reproduced. I can't generate a real compositor pointer drag here, and Hyprland is not Mutter. The bridge's synthetic pointer verbs don't go through a WM grab either, so they can't prove or disprove this. A one-minute check on GNOME with QT_QPA_PLATFORM=xcb settles it.

4. Nits (non-blocking)

  1. MainWindow.cpp:1144: the 6 → 8 px band widens the existing #4886 exposure. Measured live in a 1400×800 window: the status-bar controls (automationChip, addPanadapterButton, "Cancel transmit", …) span y = 757–798, so 6 px of each now sits inside the bottom resize band, up from about 4 px. Clicks there resize the window instead of activating the control. The pan-menu column at x = 2 loses the same 2 px on the left edge. #6018, which insets the status bar for the 6 px band, is still open and will need to account for 8. Please state the margin change in the body.
  2. Stale comments left by the topMoveReserve / #4328 removal: TitleBar.h:34-38 (still "MainWindow can pass it as FramelessResizer's topMoveReserve … must stay in lockstep"), FramelessResizer.h:20-21 and :58-61 ("MainWindow reserves TitleBar::kHeight"), MainWindow.h:877-883 (a #4328 comment orphaned above toggleMinimalModeFromAction() after its declaration was deleted), MainWindow.cpp:9648 ("under WA_TranslucentBackground, shows the desktop"; the window is opaque now), and PanadapterStack.h:79-80 ("elsewhere this is just the repaint sweep", although refreshAfterReparent()'s non-mac branch calls resetGpuResources() on every pan per flip).
  3. src/gui/WindowGeometryRestore.{h,cpp} is now dead. Nothing in src/ calls clampFrameToWorkArea or parseSavedWindowGeometry, yet the file is still in CMakeLists.txt:1173, still included by MainWindow.cpp:15, and still pinned by window_geometry_restore_test. Delete it with its test, or say it's being kept for the #4557 gate.
  4. MainWindow_Session.cpp:502-505: SmartLink tabs outlive a logout. m_smartLinkRadios is a copy that nothing clears. ConnectionPanel clears its own list (ConnectionPanel.cpp:1280, :2210), but the strip keeps showing those radios as "available", which contradicts the comment at :225 ("the strip and the picker can never disagree"). The tab-click fallback text handles the click, but not the false status.
  5. The link alarm is colour-only to a screen reader. While linkAlarm was true (the 5.5–6.6 s rows above), the carrier tab's accessible name read "Radio Simulator (not on the air), connected". The PR advertises the status line as WCAG 1.4.1 words, but the one state the dot exists to show has no words. Given #4896, I'd add "link lost" to the status text and accessible name while m_alarm is set.
  6. The demo's "not on the air" label is now user-overridable. addDemoRadio() now runs the label through radioDisplayName(), and get titlebar reports "canRename": true for DEMO-0001. main hardcoded the nickname because "the nickname carries the unmistakable 'not on the air' label". The model ("AetherSDR Demo") still appears on line two once renamed, so this is low-risk, but it is a deliberate safety label becoming editable. Either exclude the sim family in canRenameRadio() or say it's intended.
  7. applet state is refused in observe-only mode. isReadOnlyRequest() (AutomationServer.cpp:2720) has no entry for it, although the doc and the handler both call it read-only.
  8. Nothing pins the deferral contract. No test covers titlebar/applet returning deferred plus the pre-action snapshot. A socket-free AutomationServer handler test would keep B1 from silently regressing.

5. CI and evidence

  • All five checks are green, but the runs were created 06:43Z and main moved at 07:51Z (#6173, #6176: sanitizer lane and WDSP; no file overlap with this PR). The green describes a merge base that is no longer main's tip.
  • Built and ran locally (Linux, Qt 6.12.0, offscreen): unified_title_bar_test, titlebar_headphone_mute_test, hl2_pc_audio_lock_test, mixer_control_availability_test, frameless_resizer_test and window_geometry_restore_test all pass. connection_panel_size_test fails one check ("horizontal overflow is reachable"), and it fails identically on the merge base, so that is my environment and not this PR.
  • Mutation: removing m_dockSep->setVisible(!on) (TitleBar.cpp:1545) makes unified_title_bar_test fail with minimal mode leaves no dangling separator (got 1, want 0). Restoring the line (sha256 matches git show HEAD:) makes it pass again.

6. What I tried to break that held

  • No false alarm after a user disconnect (body claim): disconnect, then 8 s of get titlebar showed linkAlarm false throughout. It holds.
  • Unexpected loss raises and clears the alarm: it raised at 4.4 s and cleared about 2 s after the auto-reconnect. The timing matches the body; only the tab is wrong (blocker 1).
  • Tabs clear the 8 px top band: live screenRect y = 8, h = 36 in a 52 px bar. No interactive bar child starts in the top 8 px except drag-handle labels and the caption container, whose 36 px chips sit at 8–44.
  • Dock flip on Linux: applet dock left/right on the demo. A grab MainWindow after each flip shows no unpainted strip.
  • refreshAfterReparent "back to macOS-only, as on main": the function body is byte-identical to the merge base.
  • The showDiscovery popover opens on the deferred turn (popoverVisible true one second later).
  • Opaque window: no orphaned backdrop-alpha setting or menu remains. Only the stale comment in nit 2.

Linux gate status (RFC decision 2): the bar, caption chips, tab insets, dock flip, popover and link indicator all render and work on Wayland at 1.667×. Not proven: drag jitter and real-pointer resize feel, because I have no way to inject a real pointer here, so that part of the gate stays with a human. One environment note so nobody chases it: on Hyprland the floating window's Qt state reported maximized while the compositor kept it at 1400×800, which suppresses FramelessResizer edges. That is a compositor interaction I did not compare against main, and I'm not counting it against this PR.

7. Recommendation

Request changes for blockers 1 and 2. Both have small, local fixes. The maintainer decisions the body lists (the applet verb, the opaque window, W1 Snap Layouts, 20 tokens vs 15) are still open and independent of these. The Windows and macOS M1 gates are untouched by this review.

…cb move, scope. Principle XI.

Link carrier (blocker): after an unexpected drop the active id goes empty
and the link carrier fell back to the first visible tab, so on a two-radio
LAN the idle radio went red. RadioTabBar remembers the last session's radio
(m_carrierId) and keeps the link there; a fallback carrier (no session yet,
or the dropped radio's tab hidden) never shows the alarm. While alarmed the
tab says "link lost" on line two and in its accessible name (WCAG 1.4.1).
New unified_title_bar_test case fails on the old carrier logic.

xcb window move (blocker, aethersdr#4827): restore main's manual-move fallback —
startSystemMove() reports success on xcb but Mutter/XWayland drops the WM
grab. Only the Linux frameless path reaches it now.

Scope: revert the status-bar restyle hunks (station border, separators,
indicators, automation chip, FFT glyph, CPU/TX colours) to main; they are
not part of the title-bar RFC. Restore main's FramelessResizer install
(6 px, topMoveReserve = TitleBar::kHeight, aethersdr#4886) instead of a silent
8 px band with top-edge resize. PanadapterStack::refreshAfterLayoutShift()
re-realizes only on macOS — elsewhere it no longer resets GPU resources on
every dock flip.

Nits: rename dialog registered with trackPersistentDialog so it follows
View → Frameless Window; the demo's "not on the air" name is not renamable
again (main's behaviour); SmartLink tabs drop on sign-out; `applet state`
allowed in observe-only mode; deferral + observe-only contract pinned in
automation_boundary_core_test; emoji comment no longer cites a rule;
long comments shortened; stale aethersdr#4328 / translucency comments removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 5, 2026
`/pr-review` and `/pr-land` both tell the agent to build the PR head.
The obvious way to do that builds the default `all` target, which is the
whole tree. This PR changes both skills to configure a lean build and
build only the targets the review actually runs.

## Why: what a full build costs

Measured while reviewing #6195 and #6198. Each head was built on Linux
(GCC, Qt 6.12.0, `RelWithDebInfo`, static core: the default
configuration):

| | Full tree (default `all`) |
|---|---|
| Build directory | **84 GB per PR** (two PRs plus one merge base: ~172
GB) |
| Of which, top-level executables | 83.7 GB |
| Test executables | ~628; 232 of them over 100 MB each |
| `AetherSDR` | 832 MB, of which ~680 MB is `.debug_*` sections and ~137
MB code and data |
| Largest tests | `radio_setup_*_test` ~397 MB each,
`weather_radar_loading_test` 408 MB |

The review itself ran about twenty of those tests.

Two causes:

1. **The static core is copied into every test.** Each executable that
links `aethercore` absorbs a full static copy. The `AETHER_SHARED_CORE`
comment in `CMakeLists.txt` already records this ("~200 MB in a Debug
ASan build … shared, they average ~13 MB"); the sanitizer lane switched
to a shared core for exactly this reason.
2. **Full debug info per binary.** `-g` under RelWithDebInfo makes debug
info about 5× the size of the code. That ratio repeats across every
binary.

## What changes

**`/pr-review` step 7**, new "Build only what you run" paragraph:

```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \
  -DAETHER_SHARED_CORE=ON \
  "-DCMAKE_CXX_FLAGS_RELWITHDEBINFO=-O2 -g1 -DNDEBUG" \
  "-DCMAKE_C_FLAGS_RELWITHDEBINFO=-O2 -g1 -DNDEBUG"
cmake --build build --parallel --target AetherSDR <test_a> <test_b>
ctest --test-dir build -R '^(test_a|test_b)$' --output-on-failure
```

- **Choosing tests:** the ones the PR adds or modifies, the ones its
body names, and the ones whose `tests/tests.cmake` block compiles a file
the PR touches.
- **Full-tree builds** are kept for claims about the tree itself (CMake
or link-contract changes), and the report must say when one was done.
- **The merge-base comparison** ("before blaming the PR") builds the
same targets instead of a clean full tree.
- **Caveats, stated in the skill:**
- Linux and macOS only. `AETHER_SHARED_CORE` is a configure error on
Windows, and `-g1` is a GCC/Clang flag.
- The app loads the core as a shared library, which isn't the shipped
shape. Fine for driving behaviour; a finding that could depend on link
mode should say so.
- A test that calls WDSP directly can't rely on the shared core
exporting it (WDSP is a private, hidden-visibility dependency). It links
`aether_wdsp` explicitly or goes through `WdspChannel.h`; no static
reconfigure is needed.
- `-R` matches the `add_test(NAME …)` name, which can differ from the
target name, so both recipes pass `--no-tests=error`. A filter that
matches nothing fails instead of passing silently.
- One binary can also be registered under several names
(`deepfist_cw_model_test` five times; #6208 adds
`rtl_capture_worker_slow_device_test` on `rtl_capture_worker_test`). A
filter built from the target name runs only one, and `--no-tests=error`
can't notice, so the names come from `grep -n 'COMMAND <target>'
tests/tests.cmake`.
- Test selection follows an `OBJECT` library or `…_SOURCES` list to its
consumers. After #6217 the logging sources appear once in `tests.cmake`,
and files compiled into `aethercore` reach every test that links it.
- An unfiltered `ctest` reports unbuilt tests as "Not Run". That means
the `-R` filter is missing, not that a test failed.

**`/pr-land` step 5:** the old `cmake --build build --parallel` (full
tree) becomes the same lean configuration and named targets. The
selection also includes the tests covering the findings being fixed.

## Evidence for the lean recipe

Same machine and toolchain, on `main` at this branch's base. Built
`AetherSDR` plus `radio_setup_label_theme_token_test`,
`sim_backend_test` and `frameless_resizer_test`:

| | Full tree (before) | Lean build (this PR) |
|---|---|---|
| Build directory | 84 GB | **1.3 GB** |
| `AetherSDR` | 832 MB | 144 MB (+ 134 MB `libaethercore.so`, shared by
all targets) |
| `radio_setup_label_theme_token_test` | 397 MB | **18 MB** |
| Cold build, all ccache misses | — | 219 s |

- `ldd build/AetherSDR` resolves `libaethercore.so` from the build tree,
so the shared core really is the one in use.
- `ctest -R
'^(radio_setup_label_theme_token_test|sim_backend_test|frameless_resizer_test)$'`:
100% of 3 passed.

For reference, a second worktree at the same commit, building the same
targets, took **41 s with a 99.4% ccache hit rate** (1094/1101). That
needs a local ccache setting (`base_dir` set and `hash_dir=false`, so
absolute worktree paths don't split the cache key). It is a per-machine
choice, so it is not in the skill text.

## Also: a test that tested nothing under `-DNDEBUG`

`tests/digital_voice_mode_registry_test.c` does all of its checking
inside plain `assert()`, with the calls under test inside them.
RelWithDebInfo and Release define `NDEBUG`, so there it compiled to an
empty `main` and passed whatever the registry did. That covers CMake's
default RelWithDebInfo flags, CI's build and TSan lanes, and this PR's
recipe; only the Debug (ASan) lane ever ran it.

`#undef NDEBUG` before `<assert.h>` fixes it, since `<assert.h>`
re-reads `NDEBUG` on each inclusion. Built at `-O2 -g1 -DNDEBUG`, with
the first assertion inverted on purpose:

| | Unmodified test | First assertion inverted |
|---|---|---|
| Before | 100% passed | **100% passed** (the check never ran) |
| After | 100% passed | **0% passed, 1 failed** |

It is the only test that includes `<assert.h>` without handling
`NDEBUG`. The automated code-review pass found it while checking this
PR's recipe, and it is folded in here by maintainer decision.

## Not in this PR

- **Per-test recompiles:** #6217 compiles the settings store and logging
sources once; the widget sources (`DragValuePopup.cpp` ×40 and others)
remain open in #6214.
- **No build-logic change.** `CMakeLists.txt` and `BUILD-OPTIONS.md`
change only `AETHER_SHARED_CORE`'s help string, comment and table row:
it now names local review builds as well as the sanitizer lane.
`tests/tests.cmake`, CI and release builds are untouched, and shipping
builds keep the static core.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r. Principle XI.

An inline QMenuBar does not sit on the 52 px bar's centre line, so on
platforms without a native menu bar its menus now live behind a hamburger
button ahead of the brand mark (Firefox-style, but on the left). The
QMenuBar stays as a hidden child so MainWindow keeps building into it and
the automation bridge still resolves actions through it; menus added after
the hand-off are mirrored. macOS's native menu bar is untouched.

The button opens the menu with popup(), not a button-owned menu's exec(),
so no nested event loop re-enters discovery, socket or timer callbacks
while a menu is open. Menu shortcuts keep firing because the visible
button carries the menu's action, and each menu's Alt+<letter> mnemonic
is re-registered to pop that menu under the button, since the hidden
bar's own mnemonics never match. The glyph carries 1x/2x/3x pixmaps so it
stays crisp across screens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 7, 2026
…ar's vocabulary. Principle VIII.

Closes #6249. RFC #6226 (style guide), #6198 (title bar).

Structure
- Header modelled on a title-bar radio tab: the title in ink over a status
  line led by a status dot, then a hairline. The status line moves up from
  the bottom of the panel, reserves two lines so a wrapped refusal never
  shifts the slots, and reads like the title bar's subline ("Previewing ·
  slot 3 · 0.4s of 4.2s", "Play failed (slot 3): …").
- Slot rows are 8 px canon.nested cards with a canon.line hairline; hover
  brightens the edge, and the selected row takes the title bar's
  active-tab pair (titlebar.tab.active.*). The row rule is scoped by
  property, so it no longer leaks into its QLabels (QLabel is a QFrame),
  which drew every name and length as a box inside the box.
- F-keys are 22 px key caps and the transport buttons are 24 px, both on
  the canon secondary-control recipe (control fill, lineHi edge, cyan text,
  aqua hover and focus, 4 px corners).
- Controls that cannot act are disabled: STOP while idle, PLAY and PREV on
  an empty slot, and the other start controls while one runs.
- The slot menu is the title bar's rounded menu; the title bar's helper and
  rules move to RoundedMenu.{h,cpp} so both stay identical. Disabled items
  get canon.muted in the DVK menu, which routinely disables Clear/Export.
- The rename field gets a control fill and a cyan edge.

States (provisional, to be adjusted after review)
- On air (PLAY, the playing slot's F-key, its progress bar and the status
  dot) uses the MOX family: background.tx, tx.mox.border, tx.mox.text.
- Recording and preview use the canon cyan active treatment.
- Refusals use accent.danger and always say "failed".
- No red elsewhere: the canon keeps red for transmit indicators.

One tracked stylesheet on the panel resolves every colour from
color.canon.*, color.titlebar.*, the MOX tokens and accent.danger; widgets
carry state as properties. All 11 colour literals are gone (audit ratchet:
-5 unique colours, -35 references, -12 setStyleSheet calls).

Tests: dvk_panel_theme_test (33 cases) drives a real DvkPanel from fw
4.2.20 status lines. It checks that every token resolves in Default Dark
and Default Light, that there are no colour literals, and that each state
(selected, empty, preview, on air, recording, refusal) lands on the right
widget without moving the slots. Set DVK_PANEL_GRAB_DIR to save a PNG of
each state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jensenpat pushed a commit that referenced this pull request Oct 7, 2026
…ar's vocabulary. Principle VIII.

Closes #6249. RFC #6226 (style guide), #6198 (title bar).

Structure
- Header modelled on a title-bar radio tab: the title in ink over a status
  line led by a status dot, then a hairline. The status line moves up from
  the bottom of the panel, reserves two lines so a wrapped refusal never
  shifts the slots, and reads like the title bar's subline ("Previewing ·
  slot 3 · 0.4s of 4.2s", "Play failed (slot 3): …").
- Slot rows are 8 px canon.nested cards with a canon.line hairline; hover
  brightens the edge, and the selected row takes the title bar's
  active-tab pair (titlebar.tab.active.*). The row rule is scoped by
  property, so it no longer leaks into its QLabels (QLabel is a QFrame),
  which drew every name and length as a box inside the box.
- F-keys are 22 px key caps and the transport buttons are 24 px, both on
  the canon secondary-control recipe (control fill, lineHi edge, cyan text,
  aqua hover and focus, 4 px corners).
- Controls that cannot act are disabled: STOP while idle, PLAY and PREV on
  an empty slot, and the other start controls while one runs.
- The slot menu is the title bar's rounded menu; the title bar's helper and
  rules move to RoundedMenu.{h,cpp} so both stay identical. Disabled items
  get canon.muted in the DVK menu, which routinely disables Clear/Export.
- The rename field gets a control fill and a cyan edge.

States (provisional, to be adjusted after review)
- On air (PLAY, the playing slot's F-key, its progress bar and the status
  dot) uses the MOX family: background.tx, tx.mox.border, tx.mox.text.
- Recording and preview use the canon cyan active treatment.
- Refusals use accent.danger and always say "failed".
- No red elsewhere: the canon keeps red for transmit indicators.

One tracked stylesheet on the panel resolves every colour from
color.canon.*, color.titlebar.*, the MOX tokens and accent.danger; widgets
carry state as properties. All 11 colour literals are gone (audit ratchet:
-5 unique colours, -35 references, -12 setStyleSheet calls).

Tests: dvk_panel_theme_test (33 cases) drives a real DvkPanel from fw
4.2.20 status lines. It checks that every token resolves in Default Dark
and Default Light, that there are no colour literals, and that each state
(selected, empty, preview, on air, recording, refusal) lands on the right
widget without moving the slots. Set DVK_PANEL_GRAB_DIR to save a PNG of
each state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jensenpat pushed a commit that referenced this pull request Oct 7, 2026
…ar's vocabulary. Principle VIII.

Closes #6249. RFC #6226 (style guide), #6198 (title bar).

Structure
- Header modelled on a title-bar radio tab: the title in ink over a status
  line led by a status dot, then a hairline. The status line moves up from
  the bottom of the panel, reserves two lines so a wrapped refusal never
  shifts the slots, and reads like the title bar's subline ("Previewing ·
  slot 3 · 0.4s of 4.2s", "Play failed (slot 3): …").
- Slot rows are 8 px canon.nested cards with a canon.line hairline; hover
  brightens the edge, and the selected row takes the title bar's
  active-tab pair (titlebar.tab.active.*). The row rule is scoped by
  property, so it no longer leaks into its QLabels (QLabel is a QFrame),
  which drew every name and length as a box inside the box.
- F-keys are 22 px key caps and the transport buttons are 24 px, both on
  the canon secondary-control recipe (control fill, lineHi edge, cyan text,
  aqua hover and focus, 4 px corners).
- Controls that cannot act are disabled: STOP while idle, PLAY and PREV on
  an empty slot, and the other start controls while one runs.
- The slot menu is the title bar's rounded menu; the title bar's helper and
  rules move to RoundedMenu.{h,cpp} so both stay identical. Disabled items
  get canon.muted in the DVK menu, which routinely disables Clear/Export.
- The rename field gets a control fill and a cyan edge.

States (provisional, to be adjusted after review)
- On air (PLAY, the playing slot's F-key, its progress bar and the status
  dot) uses the MOX family: background.tx, tx.mox.border, tx.mox.text.
- Recording and preview use the canon cyan active treatment.
- Refusals use accent.danger and always say "failed".
- No red elsewhere: the canon keeps red for transmit indicators.

One tracked stylesheet on the panel resolves every colour from
color.canon.*, color.titlebar.*, the MOX tokens and accent.danger; widgets
carry state as properties. All 11 colour literals are gone (audit ratchet:
-5 unique colours, -35 references, -12 setStyleSheet calls).

Tests: dvk_panel_theme_test (33 cases) drives a real DvkPanel from fw
4.2.20 status lines. It checks that every token resolves in Default Dark
and Default Light, that there are no colour literals, and that each state
(selected, empty, preview, on air, recording, refusal) lands on the right
widget without moving the slots. Set DVK_PANEL_GRAB_DIR to save a PNG of
each state.

Squashed-from: #6251

Co-authored-by: Jeremy [KK7GWY] <105957118+ten9876@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 7, 2026
…behaviour. Principle VIII. (#6250)

## Summary

Found while refreshing the GitHub wiki for v26.5.1 → v26.10.1: in-repo
text that no longer matches the app. Each item was checked against
current source before changing it.

**In-app help (`resources/help/`)**
- `aethersdr-help.md`: Title Bar section rewritten for the 52 px bar
(#6198) — radio tabs with the heartbeat on the status dot, ☰ menu, `+`
popover; no heartbeat lamp, lightbulb or Minimal Mode button. Tools →
AetherTX… replaces Aetherial Audio; DIGI applet, overlay DSP button and
status-bar applet ☰ removed. Default applet order now matches
`AppletPanel::kDefaultOrder` (23 IDs, `PHNE`).
- `understanding-noise-cancellation.md`: BNR runs in-process on a local
NVIDIA GPU (no Docker/container, not cloud), with a ~1 GB one-time
download matching the Download button (`docs/nvidia-bnr.md` aligned
too); NNR added as the seventh client method; RN2 Noise Floor slider;
controls live in Settings → AetherRX… → AetherNR; unavailable methods
are dimmed with a reason.
- `configuring-aethersdr-controls.md`: a single click on an off-screen
indicator activates and recentres (#4795).

**Docs**
- README: APRS has a station *table*, not a map; the lightbulb line
points to Help → Submit your Idea… 💡; TMate 2, Ulanzi Dial and AetherPad
added to Tested Controller Devices.
- CONTRIBUTING: Help-menu idea route, per-launch logs under
`<config>/AetherSDR/logs/` (attach the newest; `aethersdr.log` links to
it on Linux/macOS), Help → Support & Diagnostics… / File an Issue….
- BUILDING: QRhi failure shows the "Spectrum renderer unavailable" card
(#4787).
- `psk-reporter-city-lights.md`: Warmth default is 0 throughout
(`kDefaultWarmth`).
- `rtty-decoder-implementation.md`: the pane opens for RTTY only.
- `DEVELOPER-GUIDE.md`: theme tokens instead of hard-coded hex, per
AGENTS.md.
- `docs/agents/gui.md`: naming examples use the CAT applet
(`CatControlApplet`) and the current Support & Diagnostics log category
labels.

**UI strings**
- Automation-bridge Access Token tooltip now agrees with
`docs/automation-bridge.md` (export `AETHER_MCP_TOKEN` only in the
launching shell, never in an MCP config). All four bridge tooltips on
Radio Setup → Network link the hosted doc rather than a source-tree
path.
- Ulanzi mapper fallback no longer says "Linux build only" (backends
exist on all three platforms).
- File an Issue and Submit your Idea AI prompts fetch `AGENTS.md`
instead of `CLAUDE.md`.

**Packaging**
- `60-hid-encoders.rules`: TMate 2 (`1721:0614`, from
`kSupportedDevices` in `HidDeviceParser.cpp`), and a note that AetherPad
(`2341:0266`) is deliberately absent because it is the Arduino Giga R1's
stock identity.

## Testing
- AetherSDR + 7 related test targets built (lean build, Qt 6.12);
`radio_setup_label_theme_token_test`,
`radio_setup_recording_mode_dim_test`,
`radio_setup_max_power_field_test`, `radio_setup_region_field_test`,
`hl2_pc_audio_lock_test`, `titlebar_headphone_mute_test`,
`automation_menu_lookup_test` pass.
- All Static-checks scripts pass, incl. `gen_bridge_docs.py --check`;
colour ratchet +0; a11y warnings unchanged from base.
- No test or translation pins any changed string.
- Not run: the full suite or a live app launch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
aethersdr-agent Bot added a commit that referenced this pull request Oct 8, 2026
…ple VIII.

The unified title bar (#6198) rendered each radio tab's state in the same
all-lower-case spelling the automation bridge publishes: `connected`,
`available`, `in use`, and `link lost` under the link alarm. On the tab, in
its tooltip and in its accessible name that text is prose an operator reads,
and prose starts with a capital.

Capitalizing `radioTabStatusText()` itself was not an option:
docs/automation-bridge.md pins `radios.tabs[].status` to the lower-case
token, so every driver matching on it would have broken. The two spellings
are now separate functions — `radioTabStatusText()` stays the untranslated
machine token, and a new `radioTabStatusLabel()` returns the translated
sentence-case label. Only the first letter is capitalized, so the two-word
state is `In use`, not `In Use`. `RadioTab::statusWord()` and the discovery
popover's rows (text, tooltip, accessible name) take the label; `state()`'s
`status` fields keep the token. The switcher's search text carries both, so
filtering still matches a typed query against a translated label.

Evidence (Principle VIII), with no cmake on this machine:
- tests/unified_title_bar_test.cpp asserts the new display casing on the
  status line, the accessible name and description, and on both ends of the
  link alarm, plus — the point of the split — that `state()` still reports
  `status:"connected"` / `"in use"` while `statusLine` reads `Connected`.
  A negative assertion pins `In use` against `In Use`. Six assertions in
  that file were on the old lower-case spelling and are updated; a repo-wide
  sweep for the prose forms ("link lost", "· connected", `statusLine`,
  `statusWord`) found no other consumer in tests, tools, resources or the
  in-app help — only two prose mentions in docs/architecture/window-chrome.md
  and one comment in src/gui/MainWindow.h, both corrected here.
- Both touched translation units pass `clang++ -fsyntax-only -std=c++20`
  against Qt 6.11.2 headers. The test binary itself was NOT built or run
  locally; CI is the verification of record.
- tools/gen_bridge_docs.py --check, tools/gen_touchpoint_manifest.py
  --check and tools/check_test_registration.py all pass;
  tools/check_a11y.py reports no finding in RadioTabBar.cpp.

Blast radius: not run — `radioTabStatusLabel()` is a new free function in
the RadioTabBar.h/.cpp pair with three in-file call sites, and no symbol
moved between translation units, so no test target gains a link dependency.

CodeGuard: no credential, crypto, certificate, parsing or storage surface is
touched; the C/C++ memory-safety rule finds nothing to apply — the change is
QString-only with no raw buffers, and no banned str*/mem* function is
introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ten9876 pushed a commit to chibondking/AetherSDR that referenced this pull request Oct 9, 2026
…t/dark setting (aethersdr#6266). Principle VIII.

Since aethersdr#6198 handed the Windows frame to Qt's expanded client area, the
left, right and bottom edges are drawn by DWM, coloured from the OS
light/dark preference. A dark AetherSDR theme on a light-mode Windows 10
install showed a light strip on those three edges.

- ThemeManager sets Qt's colour scheme from color.background.app on every
  theme switch and on live edits of that token. Qt's Windows plugin turns
  that into DWMWA_USE_IMMERSIVE_DARK_MODE (Windows 10 1809+).
- On Windows 11, MainWindow also sets DWMWA_BORDER_COLOR to
  color.background.app on show and on every theme change, so the border
  matches the app exactly. dwmapi is linked on Windows only.

No nativeEvent or hit-test changes; aethersdr#6224 decides that seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 9, 2026
…nded chrome (#6266). Principle VIII. (#6269)

## Summary

Fixes #6266.

On Windows 10 with Frameless Window on, a light 1–2 px strip ran along
the left, right and bottom edges of the main window. It started with
#6198.

**Cause:** under `Qt::ExpandedClientAreaHint`, Qt 6.12's
`QWindowsGeometryHint::handleCalculateSize` takes over the top caption,
but keeps a resize-border inset on the left, right and bottom as
non-client. Windows 11 draws that area invisibly. Windows 10 paints it
as a flat light strip, whatever the OS colour scheme or accent settings
are.

**Changes:**
- **`MainWindow::nativeEvent` (Windows 10 only):** answers
`WM_NCCALCSIZE` with the whole window as client area when
`WindowChrome::claimsWholeWindowAsClient()` holds —
`ExpandedClientAreaHint` set and the OS older than Windows 11 (build
22000). While maximized (not full screen), it insets the sides by
`SM_CXSIZEFRAME + SM_CXPADDEDBORDER` and the top and bottom by
`SM_CYSIZEFRAME + SM_CXPADDEDBORDER`, at the window's DPI, so nothing
sits past the screen edge. No `WM_NCHITTEST` code is added: Qt's
`handleNonClientHitTest` already returns
`HTLEFT`/`HTRIGHT`/`HTBOTTOM`/corners from the client edges, which now
are the window edges.
- **`MainWindow::applyWindowsFrameColor` (Windows 11 only):** sets
`DWMWA_BORDER_COLOR` to `color.background.app` on show and on every
`themeChanged`, with Frameless Window on **or off**, so the DWM border
follows the theme. `dwmapi` is linked on Windows only.
- **Window geometry:** the native client-rect save/restore from #6303
(#6310) runs after this handler has taken effect.
`applyWindowsCaptionStyles()`'s `SWP_FRAMECHANGED` in `showEvent` routes
`WM_NCCALCSIZE` through it before `setFramelessWindow()` and the startup
/ minimal-mode re-apply measure the frame, so on Windows 10 the measured
frame is zero and a saved client rect is restored as the window rect
exactly. A comment at that call names the dependency.
- `docs/architecture/window-chrome.md` describes both, and that edge
resize on this path rests on Qt 6.12's hit-test alone.

macOS and Linux are untouched. On Windows 10, Frameless Window off is
untouched. Snap Layouts (#6224) is not addressed.

## Constitution principle honored

Principle VIII: the window edge is continuous with the app's chrome on
all four sides.

## Test plan

- [x] Windows 10 22H2 (19045), FLEX-6600, dark theme, Frameless Window
on: the strip is gone on the left, right and bottom (bridge luminance
readback in the PR thread).
- [x] Windows 10: resize from all four edges and all four corners;
maximize and restore; Win+Up and Aero Snap (checked by hand).
- [x] Windows 10: Frameless Window off is unchanged (checked by hand).
- [x] `unified_title_bar_test` (any host): the engage predicate is on
for Windows 10 under the expanded chrome, off for Windows 11 build 22000
and off for Frameless off; a zero frame restores a client rect as the
window rect. Both predicate checks fail when the predicate is mutated.
- [ ] Windows 10, after the rebase onto #6310: window position and size
across Frameless off → on → off, three relaunch cycles, a maximized
relaunch then un-maximize, and View → Minimal Mode twice.
- [ ] Windows 11: border follows `color.background.app` and updates when
the theme changes.
- [ ] CI (`check-windows` is the compile proof for the Windows-only
lines).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: CJ Johnson <chibondking@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
ten9876 pushed a commit that referenced this pull request Oct 9, 2026
…ow on (#6272). Principle VIII. (#6328)

Fixes #6272.

## What

On Windows with **Frameless Window on**, the main window's ☰ menu and
the panadapter's right-click menu flashed open and closed immediately,
and double-click to tune did nothing. All three work with Frameless off.
This regressed with the unified title bar (#6198) and is still on `main`
after #6269.

## Root cause

With Frameless on, the main window uses Qt's expanded client area with
`CustomizeWindowHint` and **no** title or caption-button hints
(`WindowChrome::chromeFlags`). For exactly those flags, Qt 6.12 answers
`WM_NCHITTEST` in the `else if (CustomizeWindowHint)` branch of
`QWindowsWindow::handleNonClientHitTest`
(`qwindowswindow.cpp:3415-3427`):

- It reads the live mouse buttons with `GetAsyncKeyState`.
- Whenever they differ from the last hit test (`oldMouseButtonState`, a
**function-static shared by every window**), it **synthesizes a mouse
press or release** with
`QWindowSystemInterface::handleMouseEvent<SynchronousDelivery>` at the
cursor, anywhere in the window.

Windows sends `WM_NCHITTEST` around a real click, so the click arrives
twice:
- The ☰ menu opens on the first press (`popup()` on `pressed`), the
second press lands outside it and dismisses it, and the replay guard
stops it reopening.
- The panadapter's `exec()` context menu is closed the same way.
- The extra press/release pairs break double-click detection, so
double-click to tune never fires.

This also explains two puzzles from the reproduction on the issue:
- **The agent automation bridge's own click verbs never reproduced it:**
they deliver Qt events directly and never cause a `WM_NCHITTEST`.
- **One earlier real-click run passed:** the shared static makes the
outcome depend on hit-test timing.

## How

`QWindowsContext` runs the window's own `nativeEvent()` before its
hit-test switch (`qwindowscontext.cpp:858` vs `:1019`). Under the
expanded client area, `MainWindow::nativeEvent()` now answers
`WM_NCHITTEST` itself, with no synthetic events:
- the resize codes (`HTLEFT` … `HTBOTTOMRIGHT`) on a band of
`SM_CXSIZEFRAME + SM_CXPADDEDBORDER` at the window edges, the same band
Qt used;
- no band while maximized or fullscreen;
- `HTCLIENT` everywhere else.

`WindowChrome::expandedFrameHit()` is the pure part. `window-chrome.md`
now says edge resize on this path rests on this handler. It applies on
Windows 10 and 11 alike, since the Qt branch doesn't depend on the OS
version.

**Popped-out panadapters** use plain `Qt::FramelessWindowHint`, which Qt
hit-tests on its ordinary path. They aren't affected and aren't changed
(proven below).

+114 / −6 in 4 files; every behaviour change is inside `#ifdef
Q_OS_WIN`.

## Testing

### Proof with the agent automation bridge, using real OS input

Run on Windows 10 22H2 (Intel HD 520), FLEX-6600 connected, AetherSDR UI
scale 85%, your real settings with Frameless Window on. Builds came from
the fork's Windows Installer workflow (Qt 6.12):
- **Before:** `main` `e1785e78`, run 37902283037.
- **After:** this branch `1c51a580`, run 37902771818.

Method:
- Clicks are **`SendInput`** at positions from `dump_tree`, with
physical = Qt logical × 0.85.
- Popup detection: the process's visible `Qt…PopupSaveBits` windows, 300
ms after each click.
- Tune detection: the slice frequency read back through the bridge after
each double-click.
- The popped-out pan was made with `pan float` and targeted by its
window title. Its click points sit below the slice flag; my first
popped-out run was invalid because the click landed on the flag.

| Target | Action | Before (main) | After (this PR) |
|---|---|---|---|
| Main window | ☰ menu stays open | **0 / 5** | **5 / 5** |
| Main window | Panadapter right-click menu stays open | **0 / 5** | **5
/ 5** |
| Main window | Double-click tunes the slice | **0 / 5** | **5 / 5** |
| Popped-out pan | Right-click menu stays open | 5 / 5 | 5 / 5 |
| Popped-out pan | Double-click tunes the slice | 5 / 5 | 5 / 5 |

**Edge resize still works on the fix build.** Real `SendInput` drags,
with the window rect from `GetWindowRect`:

| Edge | `WM_NCHITTEST` there | Drag | Window before → after |
|---|---|---|---|
| Bottom | 15 (`HTBOTTOM`) | down 30 | 1240×451 → **1240×481** |
| Bottom | 15 | up 30 | 1240×481 → **1240×451** |
| Left | 10 (`HTLEFT`) | left 30 | 327 x, 1210 w → **297 x, 1240 w** |
| Left | 10 | right 30 | 297 x, 1240 w → **327 x, 1210 w** |

Three earlier drags didn't resize because the press landed on another
window (the floating pan, then the floating TX Controls).
`WindowFromPoint` confirmed that, and those drags are excluded.

### Unit test (local, Qt 6.12.0 pinned, MSVC 2022, offscreen)

The new `expandedFrameHit` checks in `unified_title_bar_test` all pass:
- client area at the ☰ corner, mid-window, and one pixel past the band;
- every edge and corner;
- no band while maximized;
- a point outside the window.

**Mutation-checked:**
- An off-by-one on the band fails "the first pixel past the band is
client area".
- An always-client answer fails every edge and corner check.

One existing check in the same binary, *"four radios fit whole in an
1800 px bar"*, fails in my local Windows offscreen run **with and
without this change** (also on plain `main`). CI on `main` is green.

### Not verified

- **Windows 11.** The Qt branch is OS-independent, but only Windows 10
was available.
- **Snap Layouts** (#6224). Unchanged: nothing returned `HTMAXBUTTON`
before either.
- **Top-edge resize** under the title bar. The band covers the top too,
as Qt's did; only the bottom and left edges were dragged.

### Agent automation bridge: gaps found

- **Click verbs that go through Windows.** The bridge's `clickAt`,
`rightClick` and `doubleClick` deliver Qt events and skip
`WM_NCHITTEST`, so they can't reproduce input bugs below Qt. A
real-input mode (`SendInput` at a widget's centre) would let the bridge
prove this class of bug on its own.
- **Non-ASCII targets.** The MCP wrapper garbles non-ASCII target names
(`Theme Editor…`); plain tooltips are a workaround.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: CJ Johnson <chibondking@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
jensenpat pushed a commit that referenced this pull request Oct 10, 2026
#6230)

Refs #6229, Refs #5198.

## Limits first

- **Nothing was keyed for this PR and nothing was observed on a radio.**
The evidence is 30 existing captures of one radio and socket-free tests
that replay three of them.
- **The guard rests on one event.** One forward-power word in 293 211 is
the defect. The marker catches it: 1 of 1, one unit.
- **The marker is incomplete.** 61 responses at key-on are not
measurements; 54 carry a temperature word of 0 and are skipped. The
other 7 carry a wrong non-zero temperature (512 to 1007) and still pass.
Their forward words are 0 to 4, so nothing shows here. A high forward
word in such a response would still be displayed.
- **It does not explain the gauge pinned at 6 W on #5198.** No word in
the captures reaches the gauge top.
- **This corrects my own #6019.** The window maximum keeps every sample,
so it also keeps one that is not a measurement.
- The cause in the radio is not established. Other units, other gateware
versions, Linux and Windows hosts are not measured.
- Only the forward-power window skips a marked response. It still
reaches the last-value fields. If the 10 Hz publish lands while it is
the last value (1.3 to 2.0 ms per key-on in the three replayed
captures), the raw temperature row shows 0 for one publish and the
smoothed PA temperature steps toward −50 °C through its α = 0.2 pole: 18
°C down from 40 °C, 5.9 °C still missing five publishes later, under 1
°C after 13. The forward word 2944 can likewise be the last value for
661 µs and reach the instantaneous forward row and the SWR pair. Whether
to gate these at the receive loop instead is open for the maintainer
(#6229 triage).
- The new fixture header is 64 KB of recorded words.

## What happens

For about 2 ms after the T/R switch-over, the radio's slow-ADC words in
RADDR 1 and RADDR 2 are not measurements (#6229 has the counts). In one
of 34 recorded key-ons the forward word was 2944 counts on a carrier of
1649. `ForwardPowerWindow` took it as the window maximum and the peak
hold showed 3.82 W on a 1.23 W carrier, falling over 3 s.

## The change

`ForwardPowerWindow::observe` skips a non-ACK RADDR-1 response whose
temperature word, DATA[31:16], is 0. The rule is the free predicate
`isNonMeasurementRaddr1()` in `MetisProtocol.h`. The sensor formula puts
0 °C at 0.5 V, so 0 counts is 0 V, which no working sensor reads. The
window counts what it skips; the count rides the telemetry emit as
`forwardPowerSkipped` and the backend publishes its session total as one
Radio Health row, `forwardPowerSkippedTotal`. Four source files:
`MetisProtocol.h`, `MetisClient.cpp`, `Hl2Backend.h`, `Hl2Backend.cpp`.

- No timer and no threshold: the marker is in the same response as the
forward word.
- A temperature word of 0 occurs in 54 responses at 27 of 34 key-ons,
1.74 to 4.05 ms after the first EP2 frame with MOX, and in none of 292
842 responses elsewhere, nor in 516 820 receive-only ones.
- The skipped responses come 16 ms or more before the first RF sample
(20.9 ms on TUNE), so no real sample is lost.
- If every response of a window is skipped (a unit whose temperature
channel reads 0), the peak is `nullopt` and `publishTelemetry` falls
back to the last value, as before #6019: low on speech, never high.
`forwardPowerSkippedTotal` then rises all the time, which is how that
unit shows.
- `forwardPowerRaw`, SWR, temperature, reverse power, bias and the
existing Radio Health rows are unchanged. One row is new:
`forwardPowerSkippedTotal`, "Forward responses skipped, not measurements
(since connect)", absent until a RADDR-1 response arrived. The `HL2
directional` debug line prints the window's skipped count. Nothing else
above the backend seam, no capability, no setting.

Options not built: a wider marker (temperature word jumped by more than
a threshold); a fixed blanking time after key-on (needs a time per
unit); dropping the marked response from the last-value fields too,
which the #6229 triage asks for at the receive loop. It protects the
last-value forward word, the SWR pair and the temperature pole, and a
unit that always reads 0 would then lose forward power, SWR and
temperature outright. Left for the maintainer; with the predicate it is
one call. Rejecting a lone sample above both neighbours, or a median at
the head of the over, would discard real one-sample speech peaks, which
#6019 exists to catch.

## Tests

Socket-free. `tests/Hl2KeyOnRaddr1Fixture.h` holds the recorded RADDR-1
words of three key-ons (2789 responses; captures named with their sha256
in the header). The replay feeds them through
`MetisClient::handleDatagram` and `Hl2Backend::publishTelemetry` at ten
phases of the 100 ms publish window.

| replay | a plain window maximum shows | this PR shows |
|---|---|---|
| 1 kHz tone at 40 %, the over with the lone word, first second | 3.822
W | 1.211 W, the carrier's own maximum in that second |
| TUNE 100 %, first 0.4 s | 5.715 W | 5.715 W, every window equal |
| TUNE 10 %, first 0.4 s | 0.879 W | 0.879 W, every window equal |

- `hl2_backend_seam_test`: 86 checks, 16 new. The tone over never
displays above its carrier; both TUNE key-ons publish the plain maximum
in every window, so the rise is not delayed by any window; a
**synthetic** speech-like envelope with one-sample peaks at 190
responses a second still wins every window. The health row is absent,
then 0, then the session total; the tone replay shows exactly the
recording's 2 marked responses and the synthetic speech replay shows 0.
- `hl2_metis_protocol_test`: 15 new checks and 2 `static_assert`s. A
marked response is neither peak nor counted; the response after it is
kept; a lone sample far above both neighbours is kept; a temperature
word of 1 is not a marker; a window of marked responses only has no
peak. The predicate alone: an ACK, RADDR 0, 2 and 3, and any non-zero
temperature word are not marked. The skipped count rises on a marked
response and on nothing else; a window of marked responses only shows a
count and no peak.
- `hl2_fwd_window_wiring_test`: 5 new checks: the emit carries the
skipped count and the next window starts at zero. Its existing checks,
`hl2_tx_level_policy_test`, and the existing checks in the two files
above are unchanged and pass.

Full suite on macOS (Qt 6.12.0) at `ba223304`, on `main` `5f09371e`: 646
tests, 643 pass. Three fail at `-j8`, and clean `main` `5f09371e` fails
the same three on this Mac:

- `vkamp_connection_test`: #5254; passes serially here.
- `peripheral_auth_dialog_test`: #6008; this diff touches no dialog
file.
- `unified_title_bar_test`: two checks ("Alt+F still opens the File menu
with the menu bar hidden", "opening a menu rounds the submenus added to
it since"), the same two on clean `main`. New with #6198; this diff
touches no GUI file.

## Mutations

Each a scratch commit on `ba223304`, built and run through the full
suite, never pushed. Red checks in the three HL2 tests:

- Guard removed: the tone replay check is red (3.822 W shown) and the
replay's health count, plus 8 leaf and wiring checks.
- Guard widened to normal temperature words (`< 2048`): both TUNE
replays, the synthetic speech check and both replay health counts are
red, plus 16 leaf and wiring checks.
- Guard also drops the response after a marked one: 3 leaf and wiring
checks are red. **The replays stay green**, because in the recordings
that response carries forward 0 to 4.
- Predicate flipped: 6 seam checks and 20 leaf and wiring checks.
- Skipped count not incremented: the tone replay's health count and 4
leaf and wiring checks.
- Skipped count also incremented on a kept response: both replay health
counts and 4 leaf and wiring checks.
- Health row reading the response total instead: 6 seam checks.

The two predicate mutations also delete the two `static_assert`s, which
otherwise stop the build.

## Not verified

- Anything on a radio with this build. Nothing was keyed.
- Any second unit. A capture of 20 or more key-ons at 384 kHz from
another station would show whether its marked responses look the same.
- Windows and Linux builds (CI).
- The Radio Health dialog and the bridge `health` reply with the new
row: the app was not launched; the row is tested through
`healthSnapshot()`.
- `docs/HERMES.md` is not updated. `docs/automation-bridge.md` has one
paragraph on the new row.

Static checks: every step of the "Static checks" job in
`static-checks.yml` was run locally and is green at `ba223304`. The
sanitizer configure job needs the CI image and was not run.

73, ON8ST

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 11, 2026
…remote operation over CGNAT (#6389)

## Summary

Release prep for **v26.10.2**: the six release files plus the Stable
docs snapshot. Metadata and docs only; no source, test or CI changes.

Cutoff: `origin/main` at `46943eb3c` (#6083, merged 2026-10-11).
Anything that merges after it is folded in before the tag.

**106 merged changes** from **15 human contributors**, AetherClaude (2)
and Dependabot (0). First-time contributor: **@dacacioa**. This was
confirmed by checking each login for a merged PR before the previous
tag, not from `author_association`.

## Changelog

The range `v26.10.1..46943eb` was mapped through the commit-to-PR API
(`repos/…/commits/<sha>/pulls`), not from commit subjects. It holds 106
PRs and no direct commits, and every one is cited in the section.
`v26.10.1` is an ancestor of `main`, so nothing is excluded. #6083
merged before the branch was cut, so it is inside the range and needs no
unmerged-PR banner.

The notes lead with:
- the unified 52 px title bar (#6198);
- docs.aethersdr.com (#6286, #6298, #6294);
- remote operation over CGNAT through the in-radio tailnet shim (#6273,
#6318, #6325, #6319);
- the canon windows (#6227, #6334, #6339, #6340, #6341, #6364, #6372,
#6386).

Then come DVK import, FFT Smooth/De-comb, TCI tune and receiver events,
RTL-SDR multi-receiver delivery, HL2 and ANAN-G2 state memory, the
IC-7300MK2 fixes, the first clean sanitizer runs (stated beside #6350's
TSan red) and Authenticode signing.

Numbers come from the PR bodies. "On upgrading" callouts cover the
behavior changes the bodies flagged:
- HL2 receive audio is 12.04 dB quieter (#6254);
- HL2 host CW is +6.02 dB at a fixed drive (#5975);
- ANAN AGC-T is remapped (#6282);
- sleep prevention is on by default (#6195);
- the Tailscale MTU cap has no opt-out (#6319);
- IC-7300MK2 Auto SQL is withdrawn (#6184).

DeepFist (#5953, #6103) is described as applying only to
`ENABLE_DEEPFIST_EXPERIMENT` builds.

## Docs refresh, not just version strings

- **README**:
- Highlights: FFT Smooth/De-comb on the spectrum line; AetherMap naming;
remote over CGNAT and TCI tune/receiver events on the SmartLink/TCI
line; pre-NR Copy Assist; a new docs.aethersdr.com line.
- Supported Hardware: the KPA1500, marked as not yet validated on
hardware.
- Other radio families: HL2 state restore extended; ANAN-G2 gains AGC-T,
NB2, supply voltage/link and per-radio memory; RTL-SDR now "bundled in
every official package", with WFM stereo, PPM, per-receiver SQL/meters
and DAX/TCI feeds, and multi-receiver explicitly an evaluation.
- Roadmap section: the CTR2 line advanced and a visual-canon line added.
  - Maturity labels are unchanged.
- **ROADMAP**:
- In flight: a v26.10.2 sentence each for aetherd, Icom, ANAN-G2,
RTL-SDR (remaining is now production multi-receiver qualification and
digital FM, RFC #6079), Hermes-Lite 2 and the CTR2 relay (RFC #6332
extensions). A new entry for the visual canon (RFC #6226 / #6239).
  - Queued: RFC #4214 (approved 2026-10-09) added.
- Open RFCs, checked against `label:rfc`: #4214 leaves (approved); #4764
and #4861 leave (shipped by #6198 and #6216); #6346, #6232, #6212 and
#6209 join. #6332 is approved and folds into the CTR2 entry.
- Recently shipped: nine new `(v26.10.2)` entries, still within the
v26.10.x cycle, so no month-rollover trim.
- Backends (six: anan, flex, hl2, icom, rtl, sim) and NR engines (seven)
were checked against the tree; both are unchanged.

## Judgment calls flagged for the maintainer

All of these were decided through AskUserQuestion:
- Version **26.10.2**, date **2026-10-11**, opened ready for review.
- **#6350**, the TSan red in the weekly sanitizer lane: proceed, and
name it below.
- RFCs **#4764** and **#4861** come off Open RFCs as shipped. The issues
themselves are left open for you to close.
- RFC **#4214** goes under Queued (next cycle).
- A **visual canon** In-flight entry, plus a README Roadmap line.
- The **KPA1500** is listed in README Supported Hardware with "not yet
validated on hardware".
- Including **#6083**: you asked for it, and it merged before the
cutoff, so it ships as an ordinary part of the range.

## Known failing on `main`

- **`ctr2_hid_thread_port_test`** fails in the weekly TSan lane (runs of
2026-10-08 and 2026-10-10; #6350). `main`'s CI and Full Suite were green
at `414082197`. The CHANGELOG headline and intro say so: the lanes
achieved their first clean runs this cycle, and the test remains failing
under TSan. This prep does not fix the test, and the release ships with
it red in that lane.

## Scope

| file | change |
|---|---|
| `CMakeLists.txt` | `project(AetherSDR VERSION 26.10.2)` |
| `README.md` | Current version line, plus the prose refresh above |
| `AGENTS.md` | Current version line |
| `CHANGELOG.md` | new `[v26.10.2] — 2026-10-11` section; `[Unreleased]`
stays and stays empty; history below byte-identical |
| `packaging/linux/io.github.aethersdr.aethersdr.metainfo.xml` |
`<release version="26.10.2" date="2026-10-11"/>` first in `<releases>` |
| `ROADMAP.md` | cycle heading → post-v26.10.2, plus the prose refresh
above |
| `docs/user/versioned_docs/version-stable/**`,
`docs/user/versioned_sidebars/version-stable-sidebars.json`,
`docs/user/stable-version.json`,
`docs/user/src/components/LogAnalyzer/rules.json` | docs snapshot
(`tools/docs/snapshot_stable.py 26.10.2`), in its own commit. This is
the first snapshot since #6286. |

## Validation

Output of `check_release_prep.py --mapping mapping.json` at the branch
head: 30 checks, 1 FAIL (explained below), 1 WARN (the snapshot files,
listed in the scope table).

```
PASS v26.10.1 is an ancestor of origin/main
PASS all six version spots say 26.10.2
PASS CHANGELOG heading date equals metainfo release date
PASS appstreamcli validate
PASS metainfo <release> list is date-descending with unique versions
PASS `## [Unreleased]` stays and stays empty
PASS `## [v26.10.2]` sits directly under `[Unreleased]`
PASS the previous section is `[v26.10.1]`
PASS CHANGELOG diff is exactly the inserted section
PASS new section opens with a `### <headline>`
PASS intro sentence has the house shape
PASS new section has a `### Contributors` block
PASS release_contents.py --check (citations, intro and Contributors counts)
     PRs in range: 106  cited: 106; intro 106/15 matches; Contributors (16 entries) matches; first-time ['dacacioa']
     citations outside the range: #4764, #4861, #5468, #6192, #6226, #6271, #6332 (RFCs), #6238, #6350 (issues)
PASS code fences balance / no duplicated table headers or doubled `---` (README, ROADMAP, new section)
PASS every local link in README.md and ROADMAP.md resolves
PASS no heading removed from README/ROADMAP is still linked elsewhere
PASS Recently shipped `(vX)` markers run newest-first and lead with the new version
PASS Recently shipped intro names the current cycle (v26.10.x)
FAIL only the four current-version lines lose the `26.10.1` string outside CHANGELOG
     historical mentions must stay: "label": "26.10.1"
PASS diff touches no src/, tests/, third_party/ or .github/workflows/ path
PASS all six release files are in the diff
WARN files beyond the six (the docs snapshot)
PASS git diff --check
```

- **The one FAIL is the snapshot's version label, not a historical
mention.** `docs/user/stable-version.json`'s `"label"` is the docs
site's current Stable version, and `snapshot_stable.py` rewrites it by
design. The validator predates #6286 and knows only the four
current-version lines. Run on the six-file commit alone, the same check
passes (29 checks, 0 FAIL, 0 WARN).
- The docs build passes: `npm ci && npm run build` in `docs/user`. `node
tools/docs/test_log_rules.mjs` reports 707 checks passed for 45 rules.
The line moves it reports were already on `main`.
- Four merged PRs (#6340, #6341, #6364, #6372) named screenshots to
recapture after merge (`psk-reporter-sidebar.png`, `menu-tools.png`,
`connect-dialog.png`, `rx-agc-calibration.png`), and #6386 named the
panadapter shots (`panadapter.png` and the main-window captures, which
still show the old title strip). They were not recaptured, so the Stable
snapshot carries the older images.
- No configure, build or test was run for this prep; it is metadata
only, and CI builds it.
- `main` CI at the cutoff `46943eb3c`: Docs and CodeQL - Code Quality
are green; CI, Full Suite, CodeQL and the System Libraries Canary were
still running when this was opened. At `414082197`, every one of them
was green.

After merge: tag the resulting **main** commit, not this branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jensenpat pushed a commit that referenced this pull request Oct 14, 2026
Fixes #6443.

On macOS with GPU spectrum enabled, restarting with the applet panel
saved on the left explicitly hid the startup B1 spectrum widget. B1
stayed blank after connecting, while a later-created B2 worked. The same
build started normally with the panel saved on the right.

`buildUI()` creates the placeholder B1 before restoring the saved dock
side. That restore calls `refreshAfterLayoutShift()` before the main
window is shown. `refreshAfterReparent()` saved `isVisible()`,
temporarily hid the spectrum, then skipped `show()` because the ancestor
window was still hidden. Qt retained the spectrum's explicit hide flag
after the main window appeared.

This change preserves `isHidden()` across the temporary hide/show cycle,
and skips the macOS native surface refresh while the stack's top-level
window is hidden. Live dock changes retain their existing Metal refresh.
The user guide documents dock-side persistence. Windows/Linux rendering
paths are unchanged.

## Proof

The agent automation bridge exercised the actual app startup with fresh
settings profiles and the built-in demo source. Each profile seeded
`AppletPanelDockedLeft` before launch; this was not an in-session dock
change standing in for startup. TX automation was pinned off, and no
live radio was connected.

| Build and action | B1 `get rhi` reading |
|---|---|
| Unchanged upstream/main, saved LEFT | `panIndex: 0`, `visible: false`,
`gpu: true`, `renderer: "QRhi initializing"` |
| Unchanged upstream/main, saved RIGHT | `visible: true`, `renderer:
"GPU QRhi (Metal; Apple M3 Max)"` |
| Fixed, saved LEFT | `visible: true`, `rendererFailed: false`,
`renderer: "GPU QRhi (Metal; Apple M3 Max)"`, `1260 × 732` |
| Fixed, saved RIGHT | `visible: true`, `rendererFailed: false` |
| Fixed, live RIGHT/LEFT dock moves and panel hide/show | B1 remained
visible, `rendererFailed: false` |

`applet state` verified the actual dock side and splitter ordering.
`dumpTree` and `grab pan-visible 0` captured the blank baseline and
rendered fixed spectrum, waterfall scale, VFO flag and overlay controls.
`get transmit` confirmed `transmitting: false`. Each owned app was
stopped and the shared radio lock released after the run. The agent
automation bridge was sufficient; no gaps found.

Before — saved-left startup on unchanged upstream/main:

![Blank B1 before the
fix](https://github.com/user-attachments/assets/2b4f9656-7fb3-47b7-8fbe-3a02906edb69)

After — the same saved-left startup on the fixed build:

![Rendered B1 after the
fix](https://github.com/user-attachments/assets/651f4057-b4e7-472d-8ecb-32af49247eea)

Proof images are GitHub attachments, independent of the fork's branches,
and are absent from this PR's diff.

## Validation

- Complete macOS ARM64 AetherSDR and aetherd products built with the
local ARM64 toolchain, RADE and GPU spectrum enabled. Host/system
processors and final executable verified ARM64; no RNNoise x86 sources
in the Ninja graph.
- `native_widget_topology_test`, `floating_restore_policy_test` and
`window_show_state_test`: 3/3 passed. These are neighboring coverage;
the before/after startup regression proof is the real macOS GPU bridge
run above.
- User guide: `npm ci && npm run build` passed. Generated reference
check, log-rule checks (707 checks), strict test registration and
accessibility checks passed. Existing log-rule line-number drift was
reported without rule failures.
- Independent read-only review found no actionable defects in the
refresh helper's other float/dock/canvas call sites.
- Local baseline and fixed launches emitted existing duplicate QtCore
class warnings from the Homebrew/pinned Qt dependency mix; the checks
above and Metal rendering completed. Linux/Windows builds, the full test
suite and reporter FLEX-6400 hardware confirmation were not run for this
macOS-only repair.

Regression introduced by #6198; its broader platform follow-up is #6224.
No visual design, UX defaults, protocol or transmit behavior changes.

Generated with OpenAI Codex.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants