diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..b1d3296
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,13 @@
+# RigSwitch Workspace Guidelines
+
+## Verification & Visual Screenshot Testing
+- Run `dotnet test --nologo`, `npm run lint`, and `npx tsc --noEmit` after code changes.
+- When modifying WPF views (`MainSettingsWindow.xaml`, `FluentTheme.xaml`) or layout ViewModels:
+ 1. Regenerate the documentation screenshots via `dotnet test --filter "FullyQualifiedName~UiScreenshotCaptureTests"`.
+ 2. Visually inspect all 5 images in `docs/screenshots/*.png` using `view_file` to confirm zero unwanted vertical scrollbars and zero badge/text clipping at `1240x840`.
+- For headless WPF `RenderTargetBitmap` capture in STA unit tests, render `(FrameworkElement)window.Content` (ensuring the root `Grid` has an explicit `Background` brush and calling `Measure`, `Arrange`, and `UpdateLayout`) rather than rendering the `Window` instance directly, which produces a black bitmap without an active HWND.
+
+## Domain & Testing Invariants
+- `new UserSettings()` pre-populates 3 default `DeskPresets` and 3 default `RigPresets`. Tests that construct custom preset lists on a `UserSettings` instance must call `.Clear()` on `DeskPresets` / `RigPresets` before adding test presets.
+- Windows CoreAudio COM calls (`IMMDeviceEnumerator`, `IPolicyConfig`) invoked from threadpool threads must call `CoInitializeEx(IntPtr.Zero, COINIT_MULTITHREADED)`.
+- `AudioEndpointWatchdogService` must suppress automatic re-assertion while a profile switch is in progress (`BeginProfileSwitch` / `EndProfileSwitch`) and must never re-assert a target endpoint whose state is `NotPresent` (unplugged).
diff --git a/Directory.Build.props b/Directory.Build.props
index ef5220f..b3c7f8d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -6,5 +6,6 @@
true
13.0
latest-recommended
+ 1.5.0
diff --git a/docs/screenshots/01_presets_view.png b/docs/screenshots/01_presets_view.png
index 91c2202..2635cc2 100644
Binary files a/docs/screenshots/01_presets_view.png and b/docs/screenshots/01_presets_view.png differ
diff --git a/docs/screenshots/02_displays_topology_view.png b/docs/screenshots/02_displays_topology_view.png
index 91c2202..0994b3d 100644
Binary files a/docs/screenshots/02_displays_topology_view.png and b/docs/screenshots/02_displays_topology_view.png differ
diff --git a/docs/screenshots/03_audio_dac_lockout_view.png b/docs/screenshots/03_audio_dac_lockout_view.png
index 91c2202..45dece0 100644
Binary files a/docs/screenshots/03_audio_dac_lockout_view.png and b/docs/screenshots/03_audio_dac_lockout_view.png differ
diff --git a/docs/screenshots/04_device_nicknames_view.png b/docs/screenshots/04_device_nicknames_view.png
index 91c2202..cec2c8b 100644
Binary files a/docs/screenshots/04_device_nicknames_view.png and b/docs/screenshots/04_device_nicknames_view.png differ
diff --git a/docs/screenshots/05_hotkeys_and_system_view.png b/docs/screenshots/05_hotkeys_and_system_view.png
index 91c2202..8b38d06 100644
Binary files a/docs/screenshots/05_hotkeys_and_system_view.png and b/docs/screenshots/05_hotkeys_and_system_view.png differ
diff --git a/docs/superpowers/plans/2026-10-13-ui-topology-audio-redesign.md b/docs/superpowers/plans/2026-10-13-ui-topology-audio-redesign.md
new file mode 100644
index 0000000..9b05f54
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-13-ui-topology-audio-redesign.md
@@ -0,0 +1,192 @@
+# UI, Topology & Ranked Audio Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Per-preset sidebar pages with a 2D monitor topology canvas, ranked audio/mic priority lists, a "Relevant Devices" audio filter, a `+ Add Nickname` workflow, a larger default window and a visible version.
+
+**Architecture:** Extend `WorkstationPreset` with backward-compatible `MonitorPlacements`, `AudioPriorityIds`, `MicrophonePriorityIds`. Core services (`ProfileSwitchCoordinator`, `AudioEndpointWatchdogService`) resolve the first `Active` entry of the ranked list. The WPF layer gains per-preset page ViewModels; the monolithic XAML is split into per-page UserControls where new.
+
+**Tech Stack:** .NET / WPF (Fluent), xUnit, Windows CCD + CoreAudio P/Invoke.
+
+## Global Constraints
+
+- Spec: `docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md` (source of truth).
+- Default window `1240×840`, `MinWidth=1040`, `MinHeight=680`.
+- Version shown as `vX.Y.Z` in header and sidebar footer.
+- Existing configs (`PrimaryAudioId/SecondaryAudioId/FallbackAudioId`, mic equivalents, `TargetMonitorIds`) must load and keep working (migration, no data loss).
+- Audio page keeps both Fluent toggles per row: `Win Menu` (left) and `Exclude / Bass Shaker Lockout` (right).
+- After every code change run: `dotnet build`, `dotnet test`, `npm run lint`, `npx tsc --noEmit`.
+- Validate UI with `MainSettingsWindow.CaptureScreenshotAsync` + `view_file` before claiming done.
+- Preserve unrelated comments/docstrings.
+
+---
+
+### Task 1: Commit audio stutter fix (DONE)
+
+Committed as `733a987` on `fix/audio-stutter-watchdog`.
+
+---
+
+### Task 2: Preset model — placements and ranked lists
+
+**Files:**
+- Modify: `src/RigSwitch.Core/Models/WorkstationPreset.cs`
+- Create: `src/RigSwitch.Core/Models/MonitorPlacement.cs`
+- Modify: `src/RigSwitch.Core/Models/DisplayDeviceInfo.cs` (add `PositionX`, `PositionY`, `Width`, `Height`)
+- Test: `tests/RigSwitch.Tests.Unit/WorkstationPresetRankingTests.cs`
+
+**Interfaces:**
+- Produces:
+ - `public sealed record MonitorPlacement(string MonitorId, int X, int Y, bool IsPrimary, PresetHdrMode HdrMode)`
+ - `WorkstationPreset.MonitorPlacements : List`
+ - `WorkstationPreset.AudioPriorityIds : List`, `MicrophonePriorityIds : List`
+ - `IReadOnlyList WorkstationPreset.GetEffectiveAudioPriorityList()` / `GetEffectiveMicrophonePriorityList()` — returns the explicit list if non-empty, else `[Primary, Secondary, Fallback]` with blanks removed and case-insensitive de-dup.
+ - `DisplayDeviceInfo.PositionX/PositionY/Width/Height : int { get; init; }`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using RigSwitch.Core.Models;
+using Xunit;
+
+namespace RigSwitch.Tests.Unit;
+
+public class WorkstationPresetRankingTests
+{
+ [Fact]
+ public void EffectiveAudioList_FallsBackToLegacyFields()
+ {
+ var p = new WorkstationPreset { PrimaryAudioId = "a", SecondaryAudioId = "", FallbackAudioId = "A" };
+ Assert.Equal(new[] { "a" }, p.GetEffectiveAudioPriorityList());
+ }
+
+ [Fact]
+ public void EffectiveAudioList_PrefersExplicitList()
+ {
+ var p = new WorkstationPreset { PrimaryAudioId = "legacy", AudioPriorityIds = ["x", "y"] };
+ Assert.Equal(new[] { "x", "y" }, p.GetEffectiveAudioPriorityList());
+ }
+
+ [Fact]
+ public void EffectiveMicList_FallsBackToLegacyFields()
+ {
+ var p = new WorkstationPreset { PrimaryMicrophoneId = "m1", FallbackMicrophoneId = "m2" };
+ Assert.Equal(new[] { "m1", "m2" }, p.GetEffectiveMicrophonePriorityList());
+ }
+}
+```
+
+- [ ] **Step 2:** Run `dotnet test --filter WorkstationPresetRankingTests` — expect compile FAIL.
+- [ ] **Step 3: Implement.** In `WorkstationPreset` add:
+
+```csharp
+public List MonitorPlacements { get; set; } = [];
+public List AudioPriorityIds { get; set; } = [];
+public List MicrophonePriorityIds { get; set; } = [];
+
+public IReadOnlyList GetEffectiveAudioPriorityList() =>
+ ResolveRanking(AudioPriorityIds, PrimaryAudioId, SecondaryAudioId, FallbackAudioId);
+
+public IReadOnlyList GetEffectiveMicrophonePriorityList() =>
+ ResolveRanking(MicrophonePriorityIds, PrimaryMicrophoneId, SecondaryMicrophoneId, FallbackMicrophoneId);
+
+private static IReadOnlyList ResolveRanking(List explicitList, params string[] legacy)
+{
+ IEnumerable source = explicitList is { Count: > 0 } ? explicitList : legacy;
+ return source.Where(s => !string.IsNullOrWhiteSpace(s))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+}
+```
+
+`MonitorPlacement.cs`: the record above in namespace `RigSwitch.Core.Models` (`using RigSwitch.Core.Enums;`). Add the four int props to `DisplayDeviceInfo`.
+- [ ] **Step 4:** Re-run tests — PASS. Also verify existing config JSON deserialization tests still pass (`dotnet test`).
+- [ ] **Step 5:** `git add -A; git commit -m "feat: add ranked audio lists and monitor placements to presets"`
+
+---
+
+### Task 3: CCD positions — read, capture and apply placements
+
+**Files:**
+- Modify: `src/RigSwitch.Infrastructure/Windows/Ccd/WindowsDisplayConfigurationService.cs` (populate `PositionX/Y/Width/Height` in `EnumerateDisplaysAsync`; apply `MonitorPlacements` positions in `ApplyDisplayTopologyAsync`, falling back to current behaviour when empty)
+- Modify: `src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs` (pass preset placements to topology apply)
+- Modify: `IDisplayConfigurationService` (add optional `IReadOnlyList? placements = null` parameter; update fakes in tests)
+- Test: `tests/RigSwitch.Tests.Unit/ProfileSwitchCoordinatorPlacementTests.cs`
+
+**Interfaces:**
+- Consumes: Task 2 `MonitorPlacements`.
+- Produces: `ApplyDisplayTopologyAsync(IReadOnlyList monitorIds, string primaryId, IReadOnlyList? placements, CancellationToken ct)` (match the existing signature's naming when editing; only append `placements`).
+
+- [ ] **Step 1:** Write a test with the existing fake display service asserting that switching to a preset with two `MonitorPlacements` forwards them (record the argument on the fake) and that a legacy preset forwards `null`/empty.
+- [ ] **Step 2:** Run it, expect FAIL (compile).
+- [ ] **Step 3:** Implement parameter plumbing in interface, Windows service, coordinator and all test fakes. In the Windows service, after cloning modes, for each placement matching a target `monitorId` set `sourceMode.position = new POINTL { x = p.X, y = p.Y }`; the primary placement must be normalized to (0,0) by shifting all others by its offset.
+- [ ] **Step 4:** `dotnet test` PASS.
+- [ ] **Step 5:** Commit `feat: apply per-preset monitor placements via CCD`.
+
+---
+
+### Task 4: Ranked audio routing in coordinator and watchdog
+
+**Files:**
+- Modify: `src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs` (resolve route target and `ToggleAudioOutputModeAsync` cycle)
+- Modify: `src/RigSwitch.Infrastructure/Windows/CoreAudio/AudioEndpointWatchdogService.cs` (`ResolveEffectiveTargetEndpoint` uses ranked list)
+- Test: extend `tests/RigSwitch.Tests.Unit/AudioEndpointWatchdogServiceTests.cs`, `ProfileSwitchCoordinatorAudioToggleTests.cs`
+
+**Interfaces:**
+- Consumes: `GetEffectiveAudioPriorityList()`, `GetEffectiveMicrophonePriorityList()`.
+- Produces: behaviour — first `Active`, non-blacklisted endpoint in rank wins; toggle selects the next `Active` endpoint after the current default (wrapping); if ≤1 active, no-op.
+
+- [ ] **Step 1:** Add failing tests: (a) `AudioPriorityIds = [A(NotPresent), B(Active), C(Active)]` → watchdog and switch target B; (b) toggle with default=B moves to C, then back to B; (c) blacklisted top entry skipped.
+- [ ] **Step 2:** Run filtered tests — expect FAIL.
+- [ ] **Step 3:** Replace the Primary/Secondary/Fallback walk in both services with iteration over the effective list (reuse existing `IsEndpointAvailable`). Keep `onIntentionalAudioSwitch` invocation before every set-default call. Apply the same logic to microphones.
+- [ ] **Step 4:** `dotnet test` PASS (all existing tests included).
+- [ ] **Step 5:** Commit `feat: route audio and mic by ranked priority list`.
+
+---
+
+### Task 5: ViewModel layer
+
+**Files:**
+- Modify: `src/RigSwitch.App/ViewModels/MainSettingsViewModel.cs`, `PresetConfigurationItemViewModel.cs`, `DeviceOptionResolver` (in `src/RigSwitch.App/ViewModels/`)
+- Create: `src/RigSwitch.App/ViewModels/PresetMonitorCanvasItemViewModel.cs`, `RankedAudioItemViewModel.cs`, `NicknameItemViewModel.cs`
+- Test: `tests/RigSwitch.Tests.Unit/MainSettingsViewModelRedesignTests.cs`
+
+**Interfaces (Produces):**
+- `MainSettingsViewModel`: `string AppVersionText` (`"v" + assembly InformationalVersion` trimmed of `+hash`), `PresetConfigurationItemViewModel? SelectedPreset`, `bool IsPresetViewSelected`, `NavigateToPresetCommand(PresetConfigurationItemViewModel)`, `AudioDeviceFilter` enum `{ Relevant, All }` + `ShowAllRegistryGhosts` toggle, `ObservableCollection ConfiguredNicknames`, `ObservableCollection UnnicknamedDeviceOptions`, `AddNicknameCommand`, `RemoveNicknameCommand`.
+- `PresetConfigurationItemViewModel`: `ObservableCollection StageMonitors`, `ObservableCollection UnassignedMonitors`, commands `AddMonitorToStage`, `RemoveMonitorFromStage`, `SetPrimary`, `CycleHdr`, `CaptureWindowsLayoutCommand`, `MoveMonitor(double x, double y)` (snaps edges within 24 px); `ObservableCollection AudioRanking` and `MicrophoneRanking` with `MoveUp/MoveDown/Remove`, `AddAudioCommand(DeviceOption)`, and write-back to `AudioPriorityIds` / `MicrophonePriorityIds` / `MonitorPlacements` / `TargetMonitorIds` on save.
+- `RankedAudioItemViewModel`: `Rank`, `DisplayName`, `Status` (`Active Now`, `Ready`, `Offline`).
+- Relevant filter rule: show `Active`, `Disabled`, `Unplugged`, plus any `NotPresent` referenced by a preset ranking, blacklist or nickname.
+
+- [ ] **Step 1:** Write failing tests, one per rule: relevant filter hides unreferenced `NotPresent` but keeps one referenced by a nickname; `UnnicknamedDeviceOptions` excludes already-nicknamed devices; `MoveUp` on rank 2 swaps and persists to `AudioPriorityIds`; `RemoveMonitorFromStage` moves the monitor to `UnassignedMonitors` and drops its placement; `SetPrimary` normalizes the primary to (0,0); `MoveMonitor` snaps to a neighbour's edge; `AppVersionText` matches `^v\d+\.\d+\.\d+`.
+- [ ] **Step 2:** Run — expect FAIL.
+- [ ] **Step 3:** Implement the ViewModels (use the existing `RelayCommand` / observable base the project already uses in `MainSettingsViewModel`). Replace legacy per-preset Primary/Secondary/Fallback dropdown properties on the UI side only; the model keeps legacy fields for compatibility, and on save write `Primary/Secondary/Fallback` from the first three ranking entries.
+- [ ] **Step 4:** `dotnet test` PASS.
+- [ ] **Step 5:** Commit `feat: viewmodels for preset pages, ranking, topology canvas and nicknames`.
+
+---
+
+### Task 6: XAML — window, sidebar, preset page, audio and nickname pages
+
+**Files:**
+- Modify: `src/RigSwitch.App/Views/MainSettingsWindow.xaml` and `.xaml.cs`
+- Create: `src/RigSwitch.App/Views/PresetPageView.xaml/.cs` (monitor stage + tray + rankings), optionally `MonitorStageControl.cs` for drag/snap hit-testing
+- Test: `tests/RigSwitch.Tests.Unit/UiScreenshotCaptureTests.cs` (add per-view captures)
+
+- [ ] **Step 1:** Add failing screenshot test assertions: window `Width==1240`, `Height==840`, `MinWidth==1040`, `MinHeight==680`; version text element present (`AutomationId="AppVersionBadge"`) in header.
+- [ ] **Step 2:** Run — expect FAIL.
+- [ ] **Step 3:** Implement:
+ - Window sizes and version badge (header and sidebar footer bound to `AppVersionText`).
+ - Remove `Presets`, `Displays & Topology` and `Hardware Telemetry Summary` tabs; add sidebar groups `Desk 1..3` and `Sim Rig 1..3` bound to `NavigateToPresetCommand`.
+ - `PresetPageView`: Canvas stage (monitors as draggable `Border` with Thumb drag → `MoveMonitor`), per-monitor buttons `★ Primary (0,0)`, HDR cycle, `✕`; slim "Off in Preset" tray with click/drag-to-stage; `🔍 Identify`, `📸 Capture Windows Layout`; ranked audio/mic cards with `#n`, status pill, `↑ ↓ ✕`, `+ Add device` dropdown.
+ - Audio page: `Relevant Devices` / `All Registry Ghosts` filter bar; keep both Fluent toggles per row.
+ - Nicknames page: `+ Add New Device Nickname` bar (dropdown of `UnnicknamedDeviceOptions`), list of configured nicknames with inline edit and `✕`.
+- [ ] **Step 4:** Build, run tests — PASS.
+- [ ] **Step 5:** Commit `feat: redesigned settings window with per-preset pages`.
+
+---
+
+### Task 7: Visual validation, checks and PR
+
+- [ ] **Step 1:** Run screenshot capture for views: `Desk 1`, `Sim Rig 1`, `Audio & DAC Lockout`, `Device Nicknames`, at default size and at `MinWidth×MinHeight`. `view_file` each PNG; fix clipping or cramped layout, re-capture.
+- [ ] **Step 2:** `dotnet build`, `dotnet test`, `npm run lint`, `npx tsc --noEmit` — all clean.
+- [ ] **Step 3:** `git push -u origin fix/audio-stutter-watchdog`; open PR via `gh pr create` summarising stutter fix + redesign, attaching screenshots.
diff --git a/docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md b/docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md
new file mode 100644
index 0000000..83bf469
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md
@@ -0,0 +1,135 @@
+# RigSwitch Audio Stutter Fix & UI / Topology / Audio Priority Redesign Specification
+
+**Date:** 2026-10-13
+**Branch:** `fix/audio-stutter-watchdog`
+
+---
+
+## 1. Overview & Goals
+
+This specification covers the root-cause fix for background CoreAudio stuttering as well as a cohesive UI and configuration redesign for RigSwitch:
+
+1. **Eliminate Background CoreAudio Stuttering:** Prevent `AudioEndpointWatchdogService` from fighting Windows CoreAudio (`Audiosrv`) when a configured primary audio device is offline (e.g., USB-powered speakers in AUX/`NotPresent` mode), fix `IMMNotificationClient` COM apartment marshaling, and lazy-initialize `MainSettingsWindow` when started minimized to the system tray.
+2. **Spacious Default Window & Visible Version Badge:** Increase the default `MainSettingsWindow` dimensions from `960×700` to `1240×840` (`MinWidth="1040"`, `MinHeight="680"`) so controls are never scrunched on startup, and display the application version (`vX.Y.Z`) in the header bar and sidebar status footer.
+3. **Direct Sidebar Sub-Page Routing per Preset:** Replace the long stacked `Presets` page and redundant `Displays & Topology` / `Hardware Telemetry Summary` tab with direct sidebar navigation routes for each Desk preset (`Desk 1..3`) and Sim Rig preset (`Sim Rig 1..3`).
+4. **Per-Preset 2D Monitor Topology Canvas + Slim "Off in Preset" Tray:** Give each preset its own interactive 2D spatial monitor stage where placing a monitor on the stage enables it in that preset, dragging positions it in 2D space (with edge snapping), clicking `★` sets Primary `(0,0)`, clicking the HDR pill cycles HDR state, and a **"📸 Capture Windows Layout"** button snapshots exact `(X, Y)` coordinates from Windows CCD. Disabled monitors sit in a compact pill tray below the stage.
+5. **Ranked Audio & Microphone Priority Lists:** Replace separate `Primary` / `Secondary` / `Fallback` dropdowns with an ordered priority list per preset. RigSwitch and the CoreAudio Watchdog automatically route to the highest-ranked `Active` (non-blacklisted) endpoint, and the `Toggle Audio` hotkey (`Ctrl+Alt+A`) cycles through the currently `Active` endpoints in the preset's ranked list.
+6. **Smart "Relevant Devices" Audio Filter:** Filter out unconnected Windows `MMDevice` registry ghosts (`NotPresent`) by default unless a device is referenced in a Preset, Blacklist, or Nickname, while preserving the two per-row Fluent toggle switches (`Win Menu Visibility` and `Exclude / Bass Shaker Lockout`) and offering filter tabs for `Relevant Devices`, `Active Only`, `Disabled / Unplugged`, and `All Registry Ghosts`.
+7. **Streamlined `+ Add Nickname` Workflow:** Show only devices that currently have a custom nickname configured (with inline editing and `✕` removal), paired with an **"+ Add New Device Nickname"** bar whose dropdown only lists relevant devices that do not yet have a nickname.
+
+---
+
+## 2. Architecture & Data Model Changes
+
+### 2.1 Backward-Compatible `WorkstationPreset` Models ([`WorkstationPreset.cs`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Models/WorkstationPreset.cs))
+
+To support per-preset 2D spatial monitor layouts and ranked audio/microphone priority lists without breaking existing `settings.json` files:
+
+- **`MonitorPlacement` Model (`RigSwitch.Core.Models.MonitorPlacement`):**
+ ```csharp
+ public sealed class MonitorPlacement
+ {
+ public string MonitorId { get; set; } = string.Empty;
+ public int PositionX { get; set; }
+ public int PositionY { get; set; }
+ public int Width { get; set; } = 1920;
+ public int Height { get; set; } = 1080;
+ public bool IsPrimary { get; set; }
+ public PresetHdrMode HdrMode { get; set; } = PresetHdrMode.Retain;
+ }
+ ```
+- **`WorkstationPreset` Additions:**
+ - `List MonitorPlacements { get; set; } = [];`
+ - Synchronized with `TargetMonitorIds` (where the primary monitor is first in `TargetMonitorIds` for backward compatibility).
+ - `List AudioPriorityIds { get; set; } = [];`
+ - `List MicrophonePriorityIds { get; set; } = [];`
+ - **Automatic Migration Helper (`GetEffectiveAudioPriorityList()` / `GetEffectiveMicrophonePriorityList()`):**
+ - If `AudioPriorityIds` is empty but legacy `PrimaryAudioId`, `SecondaryAudioId`, or `FallbackAudioId` are populated, automatically seeds the ordered list `[PrimaryAudioId, FallbackAudioId, SecondaryAudioId]` (deduplicated, non-empty) and keeps `PrimaryAudioId` / `FallbackAudioId` / `SecondaryAudioId` synchronized on save.
+
+### 2.2 CCD 2D Spatial Positioning ([`WindowsDisplayConfigurationService.cs`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Infrastructure/Windows/Ccd/WindowsDisplayConfigurationService.cs))
+
+- **Reading Live Coordinates & Resolution:**
+ - Extend [`DisplayDeviceInfo`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Models/DisplayDeviceInfo.cs) with optional `PositionX`, `PositionY`, `Width`, and `Height` (defaulting to `0, 0, 1920, 1080`) populated from `mode.modeInfo.sourceMode.position` and `sourceMode.width` / `sourceMode.height` during `EnumerateDisplaysAsync`.
+- **Applying 2D Coordinates:**
+ - Add an overload or optional `IReadOnlyList? placements = null` parameter on `ApplyDisplayTopologyAsync`.
+ - When explicit `MonitorPlacement` coordinates are provided for a preset, set the primary monitor at `(0, 0)` (shifting relative coordinates if needed so primary is `(0, 0)`) and apply each enabled monitor's `(PositionX, PositionY)` to `clonedModes[modeIdx].modeInfo.sourceMode.position`. When no explicit placements exist, retain the linear left-to-right arrangement.
+
+### 2.3 Ranked Audio Routing, Cycling Toggle & Watchdog Enforcement
+
+- **Profile Activation ([`ProfileSwitchCoordinator.SwitchCoreAsync`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs)):**
+ - Evaluates the preset's `GetEffectiveAudioPriorityList()` in order (`#1`, `#2`, `#3`, ...) and selects the first endpoint that is `DevicePresenceState.Active` and not in `BlacklistedAudioEndpointIds`.
+ - Invokes `_onIntentionalAudioSwitch?.Invoke(resolvedAudioId)` **before** calling `_audioDirector.SetDefaultPlaybackEndpointAsync(resolvedAudioId)`.
+- **Cycling Audio Toggle ([`ProfileSwitchCoordinator.ToggleAudioOutputModeAsync`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs)):**
+ - Finds all endpoints in the active preset's `GetEffectiveAudioPriorityList()` that are currently `DevicePresenceState.Active` and not blacklisted.
+ - Advances from the current default playback endpoint to the next active endpoint in that ranked list (wrapping around to index `0`), notifies the watchdog via `_onIntentionalAudioSwitch`, and routes default playback (and corresponding ranked microphone if configured) to that endpoint.
+- **Watchdog Enforcement ([`AudioEndpointWatchdogService.cs`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Infrastructure/Windows/CoreAudio/AudioEndpointWatchdogService.cs)):**
+ - When `EnforcePrimaryAudio` is enabled, resolves the highest-ranked `DevicePresenceState.Active` non-blacklisted endpoint from the active preset's `GetEffectiveAudioPriorityList()`.
+ - Never attempts to force a `NotPresent` or `Unplugged` endpoint (such as USB speakers in AUX mode), and recognizes intentional switches from `SwitchProfileAsync` and `ToggleAudioOutputModeAsync`.
+
+---
+
+## 3. UI & Navigation Specification ([`MainSettingsWindow.xaml`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.App/Views/MainSettingsWindow.xaml))
+
+### 3.1 Window Dimensions, Header & Sidebar Navigation
+- **Window Dimensions:** `Width="1240"`, `Height="840"`, `MinWidth="1040"`, `MinHeight="680"`.
+- **Version Display:** Expose `AppVersionText` (e.g., `v1.4.0`) on [`MainSettingsViewModel`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.App/ViewModels/MainSettingsViewModel.cs) and render it as a subtle pill badge next to `⚡ RigSwitch` in the top header and in the bottom watchdog card of the left navigation sidebar.
+- **Left Sidebar Navigation Rail:**
+ - **🖥️ Desk Presets** section header + 3 preset route items bound to `DeskPresets[0..2]` (showing preset name and a dot badge on the default desk preset).
+ - **🏎️ Sim Rig Presets** section header + 3 preset route items bound to `RigPresets[0..2]` (showing preset name and a dot badge on the default rig preset).
+ - **System & Hardware** section header:
+ - `🔊 Audio & DAC Lockout`
+ - `🏷️ Device Nicknames`
+ - `⌨️ Hotkeys & System`
+ - *(The redundant `Displays & Topology` tab and `Hardware Telemetry Summary` card are removed, as monitor topology and live audio status badges now live directly inside each preset view.)*
+
+### 3.2 Selected Preset Page Layout
+When any Desk or Sim Rig preset is selected in the left sidebar, the main content pane displays that single preset's configuration without requiring nested tab bars:
+1. **Top Identity & Shortcut Bar:**
+ - Preset Name `TextBox`, **Default Preset for Profile** toggle/radio, **Direct Hotkey** recorder (`HotkeyRecorderControl`), and a **"⚡ Activate Preset Now"** button.
+2. **Middle 2-Column Hardware Grid:**
+ - **Left Card — 2D Monitor Topology Stage & Slim "Off in Preset" Tray:**
+ - Header actions: `🔍 Identify Monitors` and `📸 Capture Windows Layout`.
+ - **2D Stage Canvas:** Displays rectangles for each monitor enabled in this preset, positioned proportionally by `(PositionX, PositionY)` and `(Width, Height)`. Users can drag monitor rectangles to reposition (with edge snapping), click `★` to make a monitor Primary `(0,0)`, click the `HDR` badge on a monitor to cycle `Retain` → `Enable HDR` → `Disable HDR`, or click `✕` to disable the monitor for this preset.
+ - **Slim "Off in Preset" Dock:** Compact horizontal wrap strip underneath the canvas showing monitors not enabled in this preset. Clicking `+` (or dragging onto the stage) enables the monitor on the canvas.
+ - **Right Card — Playback Audio Priority Order (Ranked List):**
+ - Header action: Dropdown + `+ Add Device` button (listing relevant un-added playback devices).
+ - Ordered list of ranked playback endpoints (`#1`, `#2`, `#3`, ...) with live status pills (`● Active Now`, `● Ready`, `● Offline`), move `↑` / `↓` buttons, and `✕` remove button.
+ - Compact footer row inside the card for **Playback Volume Behavior** (`Retain System Volume` vs `Custom Volume % + Mute`).
+3. **Bottom 2-Column Auxiliary Grid:**
+ - **Left Card — Microphone Priority Order & Input Level:**
+ - Ranked microphone priority list (`#1`, `#2`, ...) with `+ Add Mic`, `↑` / `↓` reorder, `✕` remove, and custom mic volume slider.
+ - **Right Card — Lifecycle Application Hooks:**
+ - `+ Add Application (.exe)` button and list of launch/terminate hooks for the preset.
+
+### 3.3 Audio & DAC Lockout Page
+- **Filter Segmented Control:**
+ - `Relevant Devices` *(Default)*: Shows any endpoint where `State == Active`, `State == Disabled`, `State == Unplugged`, OR the endpoint ID is referenced in any Preset, Blacklist, or Nickname. Hides unconnected `NotPresent` Windows registry ghosts.
+ - `Active Only`: Shows only `DevicePresenceState.Active` endpoints.
+ - `Disabled / Unplugged`: Shows `Disabled` and `Unplugged` endpoints.
+ - `All Registry Ghosts`: Shows all enumerated endpoints including unconnected `NotPresent` registry entries.
+- **Per-Row Controls:**
+ - Left Fluent toggle switch (`IsVisible`): toggles visibility in the Windows Sound menu.
+ - Device Friendly Name, Adapter Name, and Endpoint ID.
+ - Right Fluent toggle switch (`IsBlacklisted`, Amber style): `Exclude / Bass Shaker DAC` lockout toggle.
+ - Presence state badge (`Active`, `Disabled`, `Unplugged`, `Not Present`).
+
+### 3.4 Device Nicknames Page (`+ Add Nickname` Workflow)
+- **Top `+ Add New Device Nickname` Card:**
+ - `ComboBox` bound to `UnnicknamedDeviceOptions` (containing only **relevant** displays, playback devices, and microphones that do **not** currently have a nickname in `CustomDeviceNicknames`).
+ - `TextBox` for entering the new nickname + **`+ Add Nickname`** button.
+- **Configured Nicknames List:**
+ - Displays only devices that currently have a non-empty nickname.
+ - Each row shows the device type badge (`Display`, `Audio`, `Mic`), original hardware name/ID, an editable `TextBox` for updating the nickname inline, and a **`✕`** button to remove the nickname (which returns the device to `UnnicknamedDeviceOptions`).
+
+---
+
+## 4. Verification & Testing Plan
+
+1. **Unit Tests (`RigSwitch.Tests.Unit`):**
+ - `AudioEndpointWatchdogServiceTests`: Verify offline primary (`NotPresent`) falls back to active ranked device without stutter loops.
+ - `ProfileSwitchCoordinatorAudioToggleTests`: Verify ranked audio priority resolution on profile switch and cycling across active ranked devices on `ToggleAudioOutputModeAsync`.
+ - `MainSettingsViewModel` tests: Verify `Relevant` audio filter hides unreferenced `NotPresent` ghosts while keeping preset-referenced offline devices, verify `+ Add Nickname` only offers un-nicknamed devices and removes/edits existing nicknames, and verify per-preset selection and monitor canvas inclusion/exclusion.
+2. **Visual Verification (`UiScreenshotCaptureTests` & Live Screenshot Inspection):**
+ - Render `MainSettingsWindow` at `1240×840` across `presets`, `audio`, `nicknames`, and `hotkeys` routes, capture PNGs via `CaptureScreenshotAsync`, and inspect them with `view_file` to confirm spacing, typography, version badges, 2D monitor canvas, and toggle switches.
+3. **Build, Lint & Typecheck:**
+ - Run `dotnet test`, `dotnet build --warnaserror`, `npm run lint`, and `npx tsc --noEmit`.
diff --git a/src/RigSwitch.App/App.xaml.cs b/src/RigSwitch.App/App.xaml.cs
index 25dcd88..07282c4 100644
--- a/src/RigSwitch.App/App.xaml.cs
+++ b/src/RigSwitch.App/App.xaml.cs
@@ -86,7 +86,8 @@ protected override async void OnStartup(StartupEventArgs e)
sp.GetRequiredService(),
sp.GetRequiredService(),
sp.GetRequiredService(),
- appHookService: sp.GetRequiredService()));
+ appHookService: sp.GetRequiredService(),
+ onIntentionalAudioSwitch: sp.GetRequiredService().NotifyIntentionalSwitch));
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -101,7 +102,11 @@ protected override async void OnStartup(StartupEventArgs e)
sp.GetRequiredService(),
sp.GetRequiredService(),
sp.GetRequiredService(),
- (view, path) => sp.GetRequiredService().CaptureScreenshotAsync(view, path)));
+ (view, path) =>
+ {
+ var window = Dispatcher.Invoke(() => _mainWindow ??= sp.GetRequiredService());
+ return window.CaptureScreenshotAsync(view, path);
+ }));
builder.Services.AddSingleton();
_host = builder.Build();
@@ -118,7 +123,6 @@ protected override async void OnStartup(StartupEventArgs e)
var settingsStorage = _host.Services.GetRequiredService();
var coordinator = _host.Services.GetRequiredService();
var viewModel = _host.Services.GetRequiredService();
- _mainWindow = _host.Services.GetRequiredService();
_trayIconService = _host.Services.GetRequiredService();
var watchdog = _host.Services.GetRequiredService();
@@ -183,15 +187,6 @@ protected override async void OnStartup(StartupEventArgs e)
{
if (args.Success)
{
- var activePreset = args.ActivePreset;
- var audioTarget = coordinator.CurrentAudioOutputMode == AudioOutputMode.Secondary && !string.IsNullOrWhiteSpace(activePreset?.SecondaryAudioId)
- ? activePreset?.SecondaryAudioId
- : activePreset?.PrimaryAudioId;
- if (!string.IsNullOrWhiteSpace(audioTarget))
- {
- watchdog.NotifyIntentionalSwitch(audioTarget);
- }
-
_trayIconService.UpdateTrayState(args.NewProfile, args.ActivePreset);
_trayIconService.ShowNotification("RigSwitch", $"Switched to {args.NewProfile} setup [{args.ActivePreset?.Name ?? "Default"}].");
}
@@ -237,13 +232,14 @@ protected override async void OnStartup(StartupEventArgs e)
private void ShowSettingsWindow()
{
- if (_mainWindow == null)
+ if (_host == null)
{
return;
}
Dispatcher.Invoke(() =>
{
+ _mainWindow ??= _host.Services.GetRequiredService();
MainWindow = _mainWindow;
_mainWindow.Show();
if (_mainWindow.WindowState == WindowState.Minimized)
diff --git a/src/RigSwitch.App/Styles/FluentTheme.xaml b/src/RigSwitch.App/Styles/FluentTheme.xaml
index ca48542..98d27c1 100644
--- a/src/RigSwitch.App/Styles/FluentTheme.xaml
+++ b/src/RigSwitch.App/Styles/FluentTheme.xaml
@@ -557,6 +557,32 @@
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
@@ -647,7 +789,7 @@
Background="{StaticResource HeaderFooterBg}"
BorderBrush="{StaticResource HeaderFooterBorder}"
BorderThickness="0,0,0,1"
- Padding="22,14">
+ Padding="22,12">
@@ -659,8 +801,23 @@
FontSize="20"
FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"
- Margin="0,0,16,0"
+ Margin="0,0,10,0"
VerticalAlignment="Center" />
+
+
+
+
+
+
+
@@ -788,6 +986,7 @@
Background="{StaticResource MutedCharcoalBg}"
BorderThickness="0"
Padding="0"
+ SelectedIndex="{Binding SelectedPageIndex, Mode=TwoWay}"
SelectionChanged="ContentTabControl_SelectionChanged">
@@ -796,150 +995,26 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
@@ -948,12 +1023,12 @@
-
+
-
-
+
@@ -1001,17 +1076,23 @@
Padding="3"
VerticalAlignment="Center">
-
+
+ Style="{StaticResource FluentFilterInactiveButton}"
+ Margin="0,0,4,0" />
+
@@ -1032,7 +1113,7 @@
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="8"
- Padding="14,12"
+ Padding="14,11"
Margin="0,0,0,8">
@@ -1087,38 +1168,104 @@
-
+
+
+
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+
@@ -1133,7 +1280,16 @@
Text="{Binding CustomNickname, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FluentTextBox}"
VerticalAlignment="Center"
- ToolTip="Enter custom friendly nickname" />
+ Margin="0,0,10,0"
+ ToolTip="Edit custom friendly nickname" />
+
@@ -1145,33 +1301,33 @@
-
+
-
+
-
-
+
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -1179,25 +1335,25 @@
-
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+