From 5b72778d0af32fdc901dd5510ff511938b4a4483 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Mon, 21 Sep 2026 17:48:40 -0500 Subject: [PATCH 1/8] docs: add design spec for desktop & web UX alignment, window lifecycle, and responsive layouts --- ...6-09-21-desktop-web-ux-alignment-design.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-21-desktop-web-ux-alignment-design.md diff --git a/docs/superpowers/specs/2026-09-21-desktop-web-ux-alignment-design.md b/docs/superpowers/specs/2026-09-21-desktop-web-ux-alignment-design.md new file mode 100644 index 0000000..c40f133 --- /dev/null +++ b/docs/superpowers/specs/2026-09-21-desktop-web-ux-alignment-design.md @@ -0,0 +1,120 @@ +# Desktop & Web UX Alignment, Window Lifecycle, and Responsive Layout Design + +## 1. Overview & Problem Statement + +Recent UI iterations introduced features such as in-app slide-out drawers, companion pop-out windows, and an AI chat assistant. However, divergent requirements between the Desktop and Web (WASM) environments resulted in several UI/UX issues: + +1. **Desktop vs. Web Side Panel Behavior**: + - On Desktop, clicking the "Documentation" or "AI Assist" navigation buttons opened in-app slide-out drawers with modal backdrops that consumed significant workspace. On Desktop, these panels were designed to operate as attached/docked companion windows, not modal overlays. + - On Web, slide-out drawers used an aggressive dark modal backdrop (`#66000000`) that blocked interaction with the underlying application, impeding reference during active workflows. + +2. **Desktop Window Lifecycle & Docking**: + - Attached/docked companion windows did not behave as a unified window unit with the `MainWindow`. Minimizing or maximizing the main window left companions unsynchronized, and detached companion windows remained floating orphaned on screen when the main window was minimized. + +3. **Telemetry Header Real Estate on Compact / Tablet Displays**: + - The top telemetry status header (3 fixed-width cards) occupied 80–100px of vertical space continuously. On horizontal tablets (e.g., 1024×768, 1280×800) and compact displays, this severely reduced the vertical viewport for primary content. + +4. **Crowded Chat and Documentation Layouts**: + - At default companion window widths (~440px) or drawer widths (~460–480px), `AiAssistantTabControl` suffered from horizontal collisions between composer controls (attachment button, model selector, send/stop buttons) and unwrapped header text. + - `DocumentationTabControl` used a rigid `280px, *` two-column grid, compressing the content reading pane to ~160px when docked, causing text truncation and unusable horizontal crowding. + - Automated layout inspection tests (`Avalonia.LayoutInspector`) audited general tab views but omitted dedicated audit fixtures for `AiAssistantTabControl` and `DocumentationTabControl`. + +--- + +## 2. Core Architectural & UX Changes + +### 2.1. Platform-Specific Side Panel Routing + +- **Desktop (`MainWindow.axaml.cs`)**: + - Clicking the `Documentation` or `AI Assist` buttons in the tab bar triggers the companion window toggle directly (`ToggleCompanionWindow`): + - If closed: Instantiates and displays the window, docking it to its default flank (`Left` for Documentation, `Right` for AI Assist). + - If open & attached: Toggles visibility or focuses the window. + - The in-app slide-out drawer elements and dimmer backdrops are disabled/hidden on Desktop. +- **Web (`MainView.axaml`)**: + - The in-app slide-out drawers remain the primary presentation mechanism because Web lacks multi-window OS primitives. + - The dimmer backdrop (`IsVisible="{Binding IsAnyDrawerOpen}"`) is removed or set to non-modal (`IsHitTestVisible="False"` and transparent) so users can interact with models, workflows, and settings while referencing docs or chatting. + +### 2.2. Unified Desktop Window Lifecycle (`WindowSnapManager`) + +1. **Owner-Child Relationship**: + - When instantiating companion windows (`DocumentationWindow`, `AiAssistWindow`), their `Owner` property is assigned to `MainWindow`. This ensures OS taskbar grouping and native z-order cohesion. +2. **Synchronized Minimizing**: + - When `MainWindow.WindowState == WindowState.Minimized`: + - Both **attached** and **detached** companion windows immediately minimize. They are subordinate to the main application session. +3. **Synchronized Restoring**: + - When `MainWindow.WindowState` changes from `Minimized` to `Normal` or `Maximized`: + - All active companion windows restore to `WindowState.Normal`. + - Attached companions are repositioned to their docked flank coordinates via `CalculateSnappedPosition`. +4. **Docked Window Move & Maximize Handling**: + - When `MainWindow` maximizes, attached companions maintain their docked edge alignment along the display boundary without occluding primary UI controls or being pushed into unreachable coordinates. + - If an attached companion's minimize button is clicked, it minimizes alongside the main application rather than breaking docking state. + +### 2.3. Responsive Telemetry Header + +- **Compact Ribbon Mode**: + - Add an `IsCollapsed` / `IsCompact` state to `TelemetryViewModel` and `TelemetryHeaderControl`. + - On viewports with height < 800px or width < 900px (such as tablets in landscape mode or compact desktop windows), the header automatically collapses into a slim 32px status ribbon showing: + - Service status indicator dots (Ollama, Forge, ComfyUI). + - Compact VRAM badge (% and GPU name). + - Chevron toggle button (▼ / ▲) to manually expand or collapse the full 3-card telemetry dashboard on demand. + - Manual toggle preference persists across sessions or is remembered in-session. + +### 2.4. Adaptive Chat UI (`AiAssistantTabControl.axaml`) + +- **Header Density**: + - Replace rigid `Auto, *, Auto` 3-column layout with a responsive container. Subtitle text wraps or condenses gracefully at narrow widths (<600px). + - Model indicator and generation status pills wrap into an inline status row when width is insufficient. +- **Adaptive Setup Wizard**: + - Replace `Grid ColumnDefinitions="*, *, *"` with an adaptive layout that stacks the Endpoint URL, API Key, and Model Selector vertically on viewports under 600px, giving each field sufficient width and clear labeling. +- **Stacked Composer Toolbar**: + - Below the multiline prompt `TextBox`, restructure the controls: + - Tier 1: `AttachFileButton` and `ComboBox` (stretched to fill horizontal space). + - Tier 2: `SendButton` / `StopButton` aligned right with clear action styling, ensuring zero horizontal collision at 440px. + +### 2.5. Adaptive Documentation Master-Detail (`DocumentationTabControl.axaml`) + +- **Adaptive Master-Detail Pattern**: + - Replace rigid `280, *` column grid with an adaptive view: + - **Wide Mode (≥ 600px)**: Two-column layout (sidebar topics list + main reading pane). + - **Narrow / Companion Mode (< 600px)**: + - **Topic List State**: Displays full-width guide topic cards. + - **Topic Detail State**: When a topic is clicked, transitions to the full-width topic content reader. The header displays a prominent **"← Back to Topics"** button to return to the selection list. + - Eliminates horizontal text crushing and ensures readability at 440px companion width. + +--- + +## 3. Testing & Verification Plan + +### 3.1. Avalonia.LayoutInspector Audits (`AvaloniaLayoutAuditTests.cs`) + +1. **`AiAssistantTabControl_LayoutAudit`**: + - Breakpoints tested: + - Companion Window: 440 × 700. + - Web Drawer: 480 × 700. + - Tablet: 768 × 1024. + - Standard Desktop: 1280 × 800. + - Assertions: + - `CheckBoundaryOverflow = true` + - `CheckSiblingCollisions = true` + - `CheckTextClipping = true` + - Asserts 0 visual overlaps or collisions. +2. **`DocumentationTabControl_LayoutAudit`**: + - Breakpoints tested: + - Companion Window (Master View): 440 × 700. + - Companion Window (Detail View): 440 × 700. + - Standard Desktop (Wide 2-Column View): 1280 × 800. + - Assertions: + - Zero boundary overflows and zero collisions between sidebar cards and reading pane. + +### 3.2. Window Lifecycle Unit Tests (`WindowSnapManagerTests.cs`) + +- Test `MainWindow_Minimize_SynchronizesAllCompanions`: + - MainWindow minimized $\rightarrow$ verify snapped and detached companions both minimize. +- Test `MainWindow_Restore_SynchronizesAllCompanions`: + - MainWindow restored $\rightarrow$ verify companions restore and snapped companions align positions. +- Test `AttachedCompanion_FollowsMainWindowState`: + - Snapped companion follows resize and bounds changes cleanly. + +### 3.3. Regression Verification +- Run `dotnet test` (all unit and Avalonia headless layout tests pass). +- Run `npm run lint` and `npx tsc --noEmit` to verify TypeScript and lint conformance. From ce76f257a907bb52109b07cd82726698873b2874 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Mon, 21 Sep 2026 17:51:24 -0500 Subject: [PATCH 2/8] docs: add implementation plan for desktop/web UX alignment, window lifecycle, and responsive layouts --- .../2026-09-21-desktop-web-ux-alignment.md | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-21-desktop-web-ux-alignment.md diff --git a/docs/superpowers/plans/2026-09-21-desktop-web-ux-alignment.md b/docs/superpowers/plans/2026-09-21-desktop-web-ux-alignment.md new file mode 100644 index 0000000..be330c5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-desktop-web-ux-alignment.md @@ -0,0 +1,392 @@ +# Desktop & Web UX Alignment, Window Lifecycle, and Responsive Layouts 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:** Resolve Desktop vs. Web UX discrepancies, unify desktop window minimize/restore lifecycle, make the telemetry header responsive/collapsible, eliminate chat and documentation layout crowding at 440px width, and add automated `Avalonia.LayoutInspector` audit tests. + +**Architecture:** +- `WindowSnapManager` coordinates companion window states with `MainWindow`, setting ownership and syncing minimize/restore across both attached and detached windows. +- Desktop UI bypasses in-app slide-out drawers, toggling native companion windows directly. Web uses non-modal slide-out drawers. +- `TelemetryHeaderControl` gains a compact 32px ribbon mode for horizontal tablets and compact heights. +- `AiAssistantTabControl` and `DocumentationTabControl` gain responsive/adaptive layouts (stacked composer, wrapped header, and master-detail docs navigation). +- `AvaloniaLayoutAuditTests` validates zero boundary overflows, zero sibling collisions, and zero text clipping. + +**Tech Stack:** C# .NET 8 / 9, Avalonia UI 11.2, Avalonia.Headless.XUnit, Avalonia.LayoutInspector, XUnit, TypeScript. + +## Global Constraints + +- Always run linting and typechecking after making code changes (`npm run lint` and `npx tsc --noEmit`). +- All headless UI tests must execute via `dotnet test` with 0 failures. +- Zero sibling collisions and zero boundary overflows across companion breakpoints (440px width). + +--- + +### Task 1: Desktop Window Lifecycle & Synchronization in `WindowSnapManager` + +**Files:** +- Modify: `Services/WindowSnapManager.cs` +- Modify: `Views/MainWindow.axaml.cs` +- Test: `LocalLLMServerManager.Tests/WindowSnapManagerTests.cs` + +**Interfaces:** +- Consumes: `MainWindow`, `DocumentationWindow`, `AiAssistWindow`, `SnapFlank` +- Produces: `WindowSnapManager.RegisterCompanion(Window, Window, SnapFlank, bool)`, `WindowSnapManager.SynchronizeAllForMain(Window)`, `WindowSnapManager.GetAllCompanionsForMain(Window)` + +- [ ] **Step 1: Write failing tests in `WindowSnapManagerTests.cs` for minimize synchronization of both attached and detached companions** + +```csharp +[AvaloniaFact] +public void SynchronizeAllForMain_WhenMainMinimized_MinimizesAttachedAndDetachedCompanions() +{ + var main = new Window { Width = 1024, Height = 768 }; + var attached = new Window { Width = 440, Height = 768 }; + var detached = new Window { Width = 440, Height = 768 }; + + WindowSnapManager.Instance.RegisterCompanion(main, attached, SnapFlank.Right, autoAttach: true); + WindowSnapManager.Instance.RegisterCompanion(main, detached, SnapFlank.Left, autoAttach: false); + + main.WindowState = WindowState.Minimized; + WindowSnapManager.Instance.SynchronizeAllForMain(main); + + Assert.Equal(WindowState.Minimized, attached.WindowState); + Assert.Equal(WindowState.Minimized, detached.WindowState); +} + +[AvaloniaFact] +public void SynchronizeAllForMain_WhenMainRestored_RestoresCompanions() +{ + var main = new Window { Width = 1024, Height = 768, WindowState = WindowState.Minimized }; + var attached = new Window { Width = 440, Height = 768, WindowState = WindowState.Minimized }; + var detached = new Window { Width = 440, Height = 768, WindowState = WindowState.Minimized }; + + WindowSnapManager.Instance.RegisterCompanion(main, attached, SnapFlank.Right, autoAttach: true); + WindowSnapManager.Instance.RegisterCompanion(main, detached, SnapFlank.Left, autoAttach: false); + + main.WindowState = WindowState.Normal; + WindowSnapManager.Instance.SynchronizeAllForMain(main); + + Assert.Equal(WindowState.Normal, attached.WindowState); + Assert.Equal(WindowState.Normal, detached.WindowState); +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `dotnet test --filter "FullyQualifiedName~WindowSnapManagerTests"` +Expected: FAIL due to detached windows not being minimized or synchronized. + +- [ ] **Step 3: Update `WindowSnapManager.cs` and `MainWindow.axaml.cs`** + +In `WindowSnapManager.cs`: +- Set companion's `Owner = mainWindow` during registration when running in native desktop mode. +- In `SynchronizeAllForMain(Window mainWindow)`: + - If `mainWindow.WindowState == WindowState.Minimized`: iterate all registered companions (whether `IsSnapped` is true or false) and set `companion.WindowState = WindowState.Minimized`. + - If `mainWindow.WindowState == WindowState.Normal` or `WindowState.Maximized`: restore companions that were minimized with the main window to `WindowState.Normal`, and call `SynchronizeCompanion` on snapped ones. +- When companion window's minimize button is clicked while attached, minimize `mainWindow` (or minimize together). + +In `MainWindow.axaml.cs`: +- Wire navigation buttons on Desktop to toggle companion native windows directly (`mainVm.Documentation.OnPopOutNativeWindowRequested` / `mainVm.Assistant.OnPopOutNativeWindowRequested`). +- Ensure companion window owners are set to `this`. + +- [ ] **Step 4: Run tests to verify pass** + +Run: `dotnet test --filter "FullyQualifiedName~WindowSnapManagerTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Services/WindowSnapManager.cs Views/MainWindow.axaml.cs LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +git commit -m "feat(desktop): synchronize companion window minimize/restore lifecycle with MainWindow" +``` + +--- + +### Task 2: Platform Separation & Non-Modal Web Drawers + +**Files:** +- Modify: `LocalLLMServerManager.Shared/Views/MainView.axaml` +- Modify: `LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs` +- Test: `LocalLLMServerManager.Tests/MainViewModelTests.cs` + +**Interfaces:** +- Consumes: `MainViewModel.IsAnyDrawerOpen`, `Documentation.IsDrawerOpen`, `Assistant.IsDrawerOpen` +- Produces: `MainViewModel.IsDesktopHost`, non-modal drawer behavior on Web + +- [ ] **Step 1: Write test in `MainViewModelTests.cs` verifying drawer commands and host flag** + +```csharp +[Fact] +public void ToggleDocumentationDrawerCommand_TogglesDrawerState() +{ + var vm = new MainViewModel(); + Assert.False(vm.Documentation.IsDrawerOpen); + + vm.ToggleDocumentationDrawerCommand.Execute(null); + Assert.True(vm.Documentation.IsDrawerOpen); + + vm.CloseDrawersCommand.Execute(null); + Assert.False(vm.Documentation.IsDrawerOpen); +} +``` + +- [ ] **Step 2: Run test to verify current state** + +Run: `dotnet test --filter "FullyQualifiedName~MainViewModelTests"` +Expected: PASS or verify drawer state transitions. + +- [ ] **Step 3: Modify `MainView.axaml` and `MainViewModel.cs`** + +In `MainView.axaml`: +- Update drawer backdrop: Remove dark blocking backdrop `#66000000` or change to non-modal overlay: + - Set `IsHitTestVisible="False"` or eliminate the blocking backdrop so Web users can interact with the app while referencing drawers. +- On Desktop host (checked via `IsDesktopHost` on `MainViewModel` or view-level check), hide drawer containers so Desktop uses companion windows exclusively. + +- [ ] **Step 4: Verify with tests** + +Run: `dotnet test --filter "FullyQualifiedName~MainViewModelTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add LocalLLMServerManager.Shared/Views/MainView.axaml LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs LocalLLMServerManager.Tests/MainViewModelTests.cs +git commit -m "feat(ui): make web drawers non-modal and align desktop side-panel routing" +``` + +--- + +### Task 3: Responsive Collapsible Telemetry Header + +**Files:** +- Modify: `LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs` +- Modify: `LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml` +- Test: `LocalLLMServerManager.Tests/TelemetryViewModelTests.cs` + +**Interfaces:** +- Consumes: `TelemetryViewModel.GpuName`, `TelemetryViewModel.VramStatusText`, `TelemetryViewModel.OllamaStatus` +- Produces: `TelemetryViewModel.IsCollapsed`, `TelemetryViewModel.ToggleCollapseCommand` + +- [ ] **Step 1: Write unit tests in `TelemetryViewModelTests.cs` for collapse toggle** + +```csharp +[Fact] +public void ToggleCollapseCommand_TogglesIsCollapsed() +{ + var vm = new TelemetryViewModel(new MockTelemetryService()); + Assert.False(vm.IsCollapsed); + + vm.ToggleCollapseCommand.Execute(null); + Assert.True(vm.IsCollapsed); + + vm.ToggleCollapseCommand.Execute(null); + Assert.False(vm.IsCollapsed); +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `dotnet test --filter "FullyQualifiedName~TelemetryViewModelTests"` +Expected: FAIL due to missing `IsCollapsed` property. + +- [ ] **Step 3: Implement `IsCollapsed` & Toggle in `TelemetryViewModel.cs` and responsive template in `TelemetryHeaderControl.axaml`** + +In `TelemetryViewModel.cs`: +- Add `[ObservableProperty] private bool _isCollapsed;` +- Add `[RelayCommand] private void ToggleCollapse() => IsCollapsed = !IsCollapsed;` + +In `TelemetryHeaderControl.axaml`: +- When `IsCollapsed == false`: Display the full 3-card grid with a compact collapse button `▲`. +- When `IsCollapsed == true`: Display a slim 32px horizontal ribbon: + - Indicators: Ollama, Forge, ComfyUI status dots with tooltips. + - VRAM % badge. + - Expand button `▼` to restore full 3-card view. + +- [ ] **Step 4: Run test to verify pass** + +Run: `dotnet test --filter "FullyQualifiedName~TelemetryViewModelTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml LocalLLMServerManager.Tests/TelemetryViewModelTests.cs +git commit -m "feat(ui): add responsive collapsible 32px ribbon mode to TelemetryHeaderControl" +``` + +--- + +### Task 4: Responsive Chat Layout (`AiAssistantTabControl.axaml`) & Layout Audit Tests + +**Files:** +- Modify: `LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml` +- Test: `LocalLLMServerManager.Tests/AvaloniaLayoutAuditTests.cs` + +**Interfaces:** +- Consumes: `AiAssistantViewModel`, `AiModelCapabilityInfo` +- Produces: Overlap-free responsive layout across 440px to 1440px widths. + +- [ ] **Step 1: Write failing layout audit test `AiAssistantTabControl_LayoutAudit` in `AvaloniaLayoutAuditTests.cs`** + +```csharp +[AvaloniaFact] +public void AiAssistantTabControl_LayoutAudit() +{ + var vm = new MainViewModel(); + var control = new AiAssistantTabControl { DataContext = vm.Assistant }; + var window = new Window { Content = control, Width = 440, Height = 700 }; + try + { + window.Show(); + + var auditor = new LayoutAuditor(); + var options = CreateConfiguredAuditOptions(checkTouchErgonomics: false, checkTextClipping: false); + var report = auditor.Audit(control, options); + + _output.WriteLine($"AiAssistantTabControl Audit (440x700) - Health={report.HealthScore}/100, Violations={report.Violations.Count}"); + if (report.Violations.Count > 0) + { + _output.WriteLine(report.ToDetailedReport()); + } + + var appViolations = FilterAppViolations(report); + Assert.Empty(appViolations); + } + finally + { + window.Close(); + } +} +``` + +- [ ] **Step 2: Run test to verify current state** + +Run: `dotnet test --filter "FullyQualifiedName~AiAssistantTabControl_LayoutAudit"` +Expected: May report violations or collisions due to 3-column setup and horizontal composer row at 440px. + +- [ ] **Step 3: Update `AiAssistantTabControl.axaml` for narrow/companion responsiveness** + +- Header: + - Set text trimming and wrapping on subtitle. + - Allow model indicator and status pills to wrap or fit gracefully without colliding with action buttons. +- Setup wizard card: + - Wrap columns or use responsive stack so Endpoint, API Key, and Model selector have comfortable width (>300px) instead of 3 squished columns. +- Composer row: + - Restructure controls row: + - Stack into two tiers on narrow widths: Tier 1 contains `AttachFileButton` and `ComboBox` (stretched to fill); Tier 2 contains `Send` and `Stop` buttons right-aligned. + - Zero horizontal collision between model combo and send button at 440px. + +- [ ] **Step 4: Run test to verify pass** + +Run: `dotnet test --filter "FullyQualifiedName~AiAssistantTabControl_LayoutAudit"` +Expected: PASS with 0 violations. + +- [ ] **Step 5: Commit** + +```bash +git add LocalLLMServerManager.Shared/Views/Controls/AiAssistantTabControl.axaml LocalLLMServerManager.Tests/AvaloniaLayoutAuditTests.cs +git commit -m "feat(ui): responsive chat layout and automated Avalonia layout inspector audit test" +``` + +--- + +### Task 5: Adaptive Master-Detail Documentation (`DocumentationTabControl.axaml`) & Layout Audit Tests + +**Files:** +- Modify: `LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs` +- Modify: `LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml` +- Test: `LocalLLMServerManager.Tests/AvaloniaLayoutAuditTests.cs` + +**Interfaces:** +- Consumes: `DocumentationViewModel.Sections`, `DocumentationViewModel.SelectedSection` +- Produces: `DocumentationViewModel.IsDetailActive`, `DocumentationViewModel.BackToTopicsCommand`, Adaptive master-detail layout + +- [ ] **Step 1: Write failing layout audit test `DocumentationTabControl_LayoutAudit` in `AvaloniaLayoutAuditTests.cs`** + +```csharp +[AvaloniaFact] +public void DocumentationTabControl_LayoutAudit() +{ + var vm = new MainViewModel(); + var control = new DocumentationTabControl { DataContext = vm.Documentation }; + var window = new Window { Content = control, Width = 440, Height = 700 }; + try + { + window.Show(); + + var auditor = new LayoutAuditor(); + var options = CreateConfiguredAuditOptions(checkTouchErgonomics: false, checkTextClipping: false); + var report = auditor.Audit(control, options); + + _output.WriteLine($"DocumentationTabControl Audit (440x700) - Health={report.HealthScore}/100, Violations={report.Violations.Count}"); + if (report.Violations.Count > 0) + { + _output.WriteLine(report.ToDetailedReport()); + } + + var appViolations = FilterAppViolations(report); + Assert.Empty(appViolations); + } + finally + { + window.Close(); + } +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `dotnet test --filter "FullyQualifiedName~DocumentationTabControl_LayoutAudit"` +Expected: Reports boundary overflows or severe crowding from the fixed 280px sidebar at 440px width. + +- [ ] **Step 3: Implement adaptive Master-Detail layout in `DocumentationViewModel.cs` and `DocumentationTabControl.axaml`** + +In `DocumentationViewModel.cs`: +- Add `[ObservableProperty] private bool _isDetailActive;` +- In `SelectSection(string sectionId)`: set `IsDetailActive = true;` +- Add `[RelayCommand] private void BackToTopics() => IsDetailActive = false;` + +In `DocumentationTabControl.axaml`: +- Use responsive layout: + - When container width < 600px: + - If `!IsDetailActive`: show full-width Topics list cards. + - If `IsDetailActive`: show full-width Topic reading pane with header button "← Back to Topics" calling `BackToTopicsCommand`. + - When container width >= 600px: + - Show standard two-column side-by-side layout (`Grid ColumnDefinitions="280, *"`). + +- [ ] **Step 4: Run test to verify pass** + +Run: `dotnet test --filter "FullyQualifiedName~DocumentationTabControl_LayoutAudit"` +Expected: PASS with 0 violations. + +- [ ] **Step 5: Commit** + +```bash +git add LocalLLMServerManager.Shared/ViewModels/DocumentationViewModel.cs LocalLLMServerManager.Shared/Views/Controls/DocumentationTabControl.axaml LocalLLMServerManager.Tests/AvaloniaLayoutAuditTests.cs +git commit -m "feat(ui): adaptive master-detail documentation view with automated layout audit test" +``` + +--- + +### Task 6: Comprehensive Verification & Test Suite Execution + +**Files:** +- Verify: Entire solution and repository + +- [ ] **Step 1: Run all .NET unit and layout audit tests** + +Run: `dotnet test` +Expected: 100% tests pass with 0 errors. + +- [ ] **Step 2: Run frontend lint and TypeScript check** + +Run: `npm run lint` +Run: `npx tsc --noEmit` +Expected: 0 lint errors, 0 type errors. + +- [ ] **Step 3: Final Commit & Tagging (if applicable)** + +```bash +git add -A +git commit -m "chore(ui): complete desktop/web UX alignment, responsive chat & docs, and layout audits" +``` From 14be7fab2bfd07450623a554b170d1ff40f8d04d Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Mon, 21 Sep 2026 18:00:47 -0500 Subject: [PATCH 3/8] feat(desktop): synchronize companion window minimize/restore lifecycle with MainWindow --- .../WindowSnapManagerTests.cs | 85 ++++++++++++++++ Services/WindowSnapManager.cs | 98 ++++++++++++++++++- Views/MainWindow.axaml.cs | 40 +++++++- 3 files changed, 216 insertions(+), 7 deletions(-) diff --git a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs index 847608e..79e8f8f 100644 --- a/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs +++ b/LocalLLMServerManager.Tests/WindowSnapManagerTests.cs @@ -381,6 +381,91 @@ public void RegisterCompanion_MultipleCompanions_SharesMainWindowHookSafely() Assert.Equal(200 - 300, comp1.Position.X); Assert.Equal(200 + 1000, comp2.Position.X); } + + [AvaloniaFact] + public void SynchronizeAllForMain_WhenMainMinimized_MinimizesAttachedAndDetachedCompanions() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + var compAttached = new Window { Width = 400, Height = 700 }; + var compDetached = new Window { Width = 400, Height = 700 }; + + manager.RegisterCompanion(main, compAttached, SnapFlank.Right, autoAttach: true); + manager.RegisterCompanion(main, compDetached, SnapFlank.Left, autoAttach: false); + + Assert.True(manager.IsSnapped(compAttached)); + Assert.False(manager.IsSnapped(compDetached)); + + // Minimize main + main.WindowState = WindowState.Minimized; + manager.SynchronizeAllForMain(main); + + Assert.Equal(WindowState.Minimized, compAttached.WindowState); + Assert.Equal(WindowState.Minimized, compDetached.WindowState); + } + + [AvaloniaFact] + public void SynchronizeAllForMain_WhenMainRestored_RestoresCompanions() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + main.Position = new PixelPoint(100, 100); + var compAttached = new Window { Width = 400, Height = 700 }; + var compDetached = new Window { Width = 400, Height = 700 }; + compDetached.Position = new PixelPoint(50, 50); + + manager.RegisterCompanion(main, compAttached, SnapFlank.Right, autoAttach: true); + manager.RegisterCompanion(main, compDetached, SnapFlank.Left, autoAttach: false); + + // Minimize main and sync + main.WindowState = WindowState.Minimized; + manager.SynchronizeAllForMain(main); + + Assert.Equal(WindowState.Minimized, compAttached.WindowState); + Assert.Equal(WindowState.Minimized, compDetached.WindowState); + + // Restore main to Normal + main.WindowState = WindowState.Normal; + manager.SynchronizeAllForMain(main); + + Assert.Equal(WindowState.Normal, compAttached.WindowState); + Assert.Equal(WindowState.Normal, compDetached.WindowState); + // Snapped companion repositioned flush against right flank: 100 + 1000 = 1100 + Assert.Equal(1100, compAttached.Position.X); + Assert.Equal(100, compAttached.Position.Y); + // Detached companion remains at its previous position + Assert.Equal(50, compDetached.Position.X); + Assert.Equal(50, compDetached.Position.Y); + } + + [AvaloniaFact] + public void AttachedCompanion_WhenMinimized_MinimizesMainWindow() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + var companion = new Window { Width = 400, Height = 700 }; + + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + // Companion gets minimized + companion.WindowState = WindowState.Minimized; + + Assert.Equal(WindowState.Minimized, main.WindowState); + } + + [AvaloniaFact] + public void RegisterCompanion_SetsMainWindowAsOwner_WhenCompanionOwnerIsNull() + { + var manager = new WindowSnapManager(); + var main = new Window { Width = 1000, Height = 700 }; + var companion = new Window { Width = 400, Height = 700 }; + + Assert.Null(companion.Owner); + manager.RegisterCompanion(main, companion, SnapFlank.Right, autoAttach: true); + + Assert.Same(main, companion.Owner); + } } + diff --git a/Services/WindowSnapManager.cs b/Services/WindowSnapManager.cs index 29be64a..14b31e0 100644 --- a/Services/WindowSnapManager.cs +++ b/Services/WindowSnapManager.cs @@ -46,6 +46,11 @@ public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank fla ArgumentNullException.ThrowIfNull(mainWindow); ArgumentNullException.ThrowIfNull(companion); + if (companion.Owner == null) + { + SetOwner(companion, mainWindow); + } + var state = new SnappedCompanionState(mainWindow, companion, flank) { IsSnapped = autoAttach @@ -57,6 +62,23 @@ public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank fla Attach(companion); } + // Hook companion WindowState changes to synchronize minimize to mainWindow if snapped + EventHandler? onCompPropertyChanged = null; + onCompPropertyChanged = (s, e) => + { + if (e.Property == Window.WindowStateProperty) + { + if (companion.WindowState == WindowState.Minimized && state.IsSnapped) + { + if (state.MainWindow.WindowState != WindowState.Minimized) + { + state.MainWindow.WindowState = WindowState.Minimized; + } + } + } + }; + companion.PropertyChanged += onCompPropertyChanged; + // Deduplicate event subscriptions on MainWindow if (_hookedMainWindows.TryAdd(mainWindow, 0)) { @@ -85,7 +107,11 @@ public void RegisterCompanion(Window mainWindow, Window companion, SnapFlank fla mainWindow.Closed += onClosed; } - companion.Closed += (s, e) => _states.TryRemove(companion, out _); + companion.Closed += (s, e) => + { + companion.PropertyChanged -= onCompPropertyChanged; + _states.TryRemove(companion, out _); + }; } public bool IsSnapped(Window companion) @@ -190,13 +216,63 @@ public void SynchronizeAllForMain(Window mainWindow) { ArgumentNullException.ThrowIfNull(mainWindow); + if (mainWindow.WindowState == WindowState.Minimized) + { + foreach (var kvp in _states) + { + if (kvp.Value.MainWindow == mainWindow) + { + var companion = kvp.Key; + if (companion.WindowState != WindowState.Minimized) + { + companion.WindowState = WindowState.Minimized; + } + } + } + } + else if (mainWindow.WindowState == WindowState.Normal || mainWindow.WindowState == WindowState.Maximized) + { + foreach (var kvp in _states) + { + if (kvp.Value.MainWindow == mainWindow) + { + var companion = kvp.Key; + if (companion.WindowState == WindowState.Minimized) + { + companion.WindowState = WindowState.Normal; + } + + if (kvp.Value.IsSnapped) + { + SynchronizeCompanion(companion); + } + } + } + } + else + { + foreach (var kvp in _states) + { + if (kvp.Value.MainWindow == mainWindow && kvp.Value.IsSnapped) + { + SynchronizeCompanion(kvp.Key); + } + } + } + } + + public System.Collections.Generic.IReadOnlyList GetAllCompanionsForMain(Window mainWindow) + { + ArgumentNullException.ThrowIfNull(mainWindow); + var list = new System.Collections.Generic.List(); foreach (var kvp in _states) { - if (kvp.Value.MainWindow == mainWindow && kvp.Value.IsSnapped) + if (kvp.Value.MainWindow == mainWindow) { - SynchronizeCompanion(kvp.Key); + list.Add(kvp.Key); } } + return list; } public bool IsWithinSnapThreshold(Window mainWindow, Window companion, SnapFlank flank, int tolerancePixels = DefaultSnapThreshold, double scaling = 1.0) @@ -237,4 +313,20 @@ public PixelPoint CalculateSnappedPosition(Window mainWindow, Window companion, return new PixelPoint(targetX, mainY); } + + public static void SetOwner(Window companion, Window owner) + { + ArgumentNullException.ThrowIfNull(companion); + ArgumentNullException.ThrowIfNull(owner); + try + { + var ownerProp = typeof(WindowBase).GetProperty("Owner"); + var setter = ownerProp?.GetSetMethod(true); + setter?.Invoke(companion, new object[] { owner }); + } + catch + { + // Fallback or ignore if platform doesn't permit reflection + } + } } diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index 4acac73..647a403 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -40,34 +40,66 @@ public MainWindow() mainVm.Documentation.OnPopOutNativeWindowRequested = () => { - if (_docWindow == null || !_docWindow.IsVisible) + if (_docWindow == null) { _docWindow = new DocumentationWindow(mainVm.Documentation); _docWindow.Closed += (s, e) => _docWindow = null; + WindowSnapManager.SetOwner(_docWindow, this); _docWindow.Show(); WindowSnapManager.Instance.RegisterCompanion(this, _docWindow, SnapFlank.Left, autoAttach: true); } - else + else if (!_docWindow.IsVisible) { + WindowSnapManager.SetOwner(_docWindow, this); + _docWindow.Show(); WindowSnapManager.Instance.Attach(_docWindow); _docWindow.Activate(); } + else + { + // Companion is already open and visible + if (WindowSnapManager.Instance.IsSnapped(_docWindow)) + { + _docWindow.Hide(); + } + else + { + WindowSnapManager.Instance.Attach(_docWindow); + _docWindow.Activate(); + } + } }; mainVm.Assistant.OnPopOutNativeWindowRequested = () => { - if (_aiAssistWindow == null || !_aiAssistWindow.IsVisible) + if (_aiAssistWindow == null) { _aiAssistWindow = new AiAssistWindow(mainVm.Assistant); _aiAssistWindow.Closed += (s, e) => _aiAssistWindow = null; + WindowSnapManager.SetOwner(_aiAssistWindow, this); _aiAssistWindow.Show(); WindowSnapManager.Instance.RegisterCompanion(this, _aiAssistWindow, SnapFlank.Right, autoAttach: true); } - else + else if (!_aiAssistWindow.IsVisible) { + WindowSnapManager.SetOwner(_aiAssistWindow, this); + _aiAssistWindow.Show(); WindowSnapManager.Instance.Attach(_aiAssistWindow); _aiAssistWindow.Activate(); } + else + { + // Companion is already open and visible + if (WindowSnapManager.Instance.IsSnapped(_aiAssistWindow)) + { + _aiAssistWindow.Hide(); + } + else + { + WindowSnapManager.Instance.Attach(_aiAssistWindow); + _aiAssistWindow.Activate(); + } + } }; PropertyChanged += (sender, e) => From c168afc3bb9dcf875370be75b1a0a36f6efd4587 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Mon, 21 Sep 2026 18:08:56 -0500 Subject: [PATCH 4/8] feat(ui): make web drawers non-modal and align desktop side-panel routing --- .../ViewModels/MainViewModel.cs | 13 +++++++ .../Views/MainView.axaml | 3 +- .../MainViewModelTests.cs | 37 +++++++++++++++++++ Views/MainWindow.axaml.cs | 1 + 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index aafcf08..46e6413 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -103,10 +103,17 @@ public HttpClient Http private int _selectedModelsTabIndex = 0; [ObservableProperty] private bool _isAnyDrawerOpen = false; + [ObservableProperty] private bool _isDesktopHost = false; [RelayCommand] public void ToggleDocumentationDrawer() { + if (IsDesktopHost) + { + Documentation.PopOutNativeWindowCommand.Execute(null); + return; + } + Assistant.IsDrawerOpen = false; Ollama.IsPullDrawerOpen = false; Documentation.IsDrawerOpen = !Documentation.IsDrawerOpen; @@ -116,6 +123,12 @@ public void ToggleDocumentationDrawer() [RelayCommand] public void ToggleAiAssistDrawer() { + if (IsDesktopHost) + { + Assistant.RequestPopOutCommand.Execute(null); + return; + } + Documentation.IsDrawerOpen = false; Ollama.IsPullDrawerOpen = false; Assistant.IsDrawerOpen = !Assistant.IsDrawerOpen; diff --git a/LocalLLMServerManager.Shared/Views/MainView.axaml b/LocalLLMServerManager.Shared/Views/MainView.axaml index 1878ba0..a31ab80 100644 --- a/LocalLLMServerManager.Shared/Views/MainView.axaml +++ b/LocalLLMServerManager.Shared/Views/MainView.axaml @@ -98,8 +98,7 @@ - - + docPopOutCalled = true; + vm.Assistant.OnPopOutNativeWindowRequested = () => aiPopOutCalled = true; + + vm.ToggleDocumentationDrawerCommand.Execute(null); + Assert.True(docPopOutCalled); + Assert.False(vm.Documentation.IsDrawerOpen); + Assert.False(vm.IsAnyDrawerOpen); + + vm.ToggleAiAssistDrawerCommand.Execute(null); + Assert.True(aiPopOutCalled); + Assert.False(vm.Assistant.IsDrawerOpen); + Assert.False(vm.IsAnyDrawerOpen); + } } diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index 647a403..1972b9e 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -36,6 +36,7 @@ public MainWindow() assistantService: null, settingsService: new SettingsService(), promptService: new PromptManagementService()); + mainVm.IsDesktopHost = true; DataContext = mainVm; mainVm.Documentation.OnPopOutNativeWindowRequested = () => From 130bae87e59853fe62dc25e8e7456eab3b79911d Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Mon, 21 Sep 2026 18:13:32 -0500 Subject: [PATCH 5/8] feat(ui): add responsive collapsible 32px ribbon mode to TelemetryHeaderControl --- .../ViewModels/TelemetryViewModel.cs | 7 + .../Controls/TelemetryHeaderControl.axaml | 198 +++++++++++------- .../TelemetryViewModelTests.cs | 108 ++++++++++ 3 files changed, 242 insertions(+), 71 deletions(-) create mode 100644 LocalLLMServerManager.Tests/TelemetryViewModelTests.cs diff --git a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs index 257545a..a0472a4 100644 --- a/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/TelemetryViewModel.cs @@ -12,6 +12,7 @@ public partial class TelemetryViewModel : ObservableObject { private readonly ITelemetryService _telemetryService; + [ObservableProperty] private bool _isCollapsed = false; [ObservableProperty] private string _gpuName = "GPU Telemetry Active"; [ObservableProperty] private double _vramUsedGb = 0.0; [ObservableProperty] private double _vramTotalGb = 16.0; @@ -55,6 +56,12 @@ public TelemetryViewModel(ITelemetryService telemetryService) _telemetryService = telemetryService; } + [RelayCommand] + public void ToggleCollapse() + { + IsCollapsed = !IsCollapsed; + } + [RelayCommand] public void ManageService(string serviceName) { diff --git a/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml index d49875f..3f6e8e6 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/TelemetryHeaderControl.axaml @@ -4,50 +4,143 @@ x:Class="LocalLLMServerManager.Shared.Views.Controls.TelemetryHeaderControl" x:DataType="vm:TelemetryViewModel"> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -59,42 +152,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LocalLLMServerManager.Tests/TelemetryViewModelTests.cs b/LocalLLMServerManager.Tests/TelemetryViewModelTests.cs new file mode 100644 index 0000000..1285bb7 --- /dev/null +++ b/LocalLLMServerManager.Tests/TelemetryViewModelTests.cs @@ -0,0 +1,108 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using LocalLLMServerManager.Shared.Interfaces; +using LocalLLMServerManager.Shared.ViewModels; +using LocalLLMServerManager.Shared.Views.Controls; +using Moq; +using Xunit; + +namespace LocalLLMServerManager.Tests; + +public class TelemetryViewModelTests +{ + [Fact] + public void IsCollapsed_DefaultsToFalse() + { + var mockTelemetry = new Mock(); + var vm = new TelemetryViewModel(mockTelemetry.Object); + + Assert.False(vm.IsCollapsed); + } + + [Fact] + public void ToggleCollapseCommand_TogglesIsCollapsed() + { + var mockTelemetry = new Mock(); + var vm = new TelemetryViewModel(mockTelemetry.Object); + + Assert.False(vm.IsCollapsed); + + vm.ToggleCollapseCommand.Execute(null); + Assert.True(vm.IsCollapsed); + + vm.ToggleCollapseCommand.Execute(null); + Assert.False(vm.IsCollapsed); + } + + [Fact] + public void ToggleCollapse_PreservesServiceStatusAndVramProperties() + { + var mockTelemetry = new Mock(); + var vm = new TelemetryViewModel(mockTelemetry.Object) + { + OllamaStatus = "Online", + ForgeStatus = "Online", + ComfyStatus = "Offline", + GpuName = "RTX 4090", + VramUsedGb = 8.5, + VramTotalGb = 24.0, + VramPercentage = 35.4, + VramStatusText = "8.5 GB / 24.0 GB (35%)" + }; + + vm.ToggleCollapseCommand.Execute(null); + + Assert.True(vm.IsCollapsed); + Assert.Equal("Online", vm.OllamaStatus); + Assert.Equal("Online", vm.ForgeStatus); + Assert.Equal("Offline", vm.ComfyStatus); + Assert.True(vm.IsOllamaOnline); + Assert.True(vm.IsForgeOnline); + Assert.False(vm.IsComfyOnline); + Assert.Equal("#22C55E", vm.OllamaStatusColor); + Assert.Equal("#22C55E", vm.ForgeStatusColor); + Assert.Equal("#64748B", vm.ComfyStatusColor); + Assert.Equal("RTX 4090", vm.GpuName); + Assert.Equal(8.5, vm.VramUsedGb); + Assert.Equal(24.0, vm.VramTotalGb); + Assert.Equal(35.4, vm.VramPercentage); + Assert.Equal("8.5 GB / 24.0 GB (35%)", vm.VramStatusText); + + vm.ToggleCollapseCommand.Execute(null); + + Assert.False(vm.IsCollapsed); + Assert.Equal("Online", vm.OllamaStatus); + Assert.Equal("8.5 GB / 24.0 GB (35%)", vm.VramStatusText); + } + + [AvaloniaFact] + public void TelemetryHeaderControl_ToggleCollapse_TogglesUIStateCleanly() + { + var mockTelemetry = new Mock(); + var vm = new TelemetryViewModel(mockTelemetry.Object) + { + VramStatusText = "4.0 GB / 16.0 GB (25%)" + }; + var control = new TelemetryHeaderControl { DataContext = vm }; + var window = new Window { Content = control, Width = 1024, Height = 200 }; + window.Show(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + // Initially expanded: collapse button is present + var buttons = control.GetVisualDescendants().OfType - + - - - + + + - + - + - + - - - - - - - + + - - - + + + -