From aa92c580fc22c1edf20bcf8c9af569aa0559e924 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 16:08:26 +0800 Subject: [PATCH 01/16] docs(plan): Fluent Foundation implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-13-fluent-foundation.md | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-fluent-foundation.md diff --git a/docs/superpowers/plans/2026-08-13-fluent-foundation.md b/docs/superpowers/plans/2026-08-13-fluent-foundation.md new file mode 100644 index 000000000..dd78c1350 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-fluent-foundation.md @@ -0,0 +1,531 @@ +# Fluent Foundation 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:** Every existing SnipIT window renders in the .NET 9 WPF Fluent theme, following system Light/Dark, with the locked 5-color brand palette — zero new dependencies. + +**Architecture:** A pure palette function in `src/00-Core.ps1` (cross-platform testable) feeds a theme-wiring layer in `src/10-Bootstrap.ps1` that sets the experimental `ThemeMode` API via reflection (proven in wayfinder [#30](https://github.com/RandomCodeSpace/snipIT/issues/30)) and overrides Fluent accent resource keys at window scope. Every `Show-*` window creator calls one initializer. Plan 2+ (hub, editor rework) builds on these functions. + +**Tech Stack:** PowerShell 7.5+, WPF on .NET 9 (`PresentationFramework.Fluent` from the shared runtime), no NuGet, no admin. + +## Global Constraints + +- PowerShell 7.5+ only; no 5.1 fallbacks. (`AGENTS.md`) +- Zero external dependencies; generated `SnipIT.ps1` must stay portable (no runtime lookup of `src/`, `xaml/`, network). (`AGENTS.md`) +- **Never edit `SnipIT.ps1` directly.** Change `src/`/`xaml/`, regenerate with `pwsh -NoProfile -File ./Build-SnipIT.ps1`, commit source + generated file together. (`AGENTS.md`) +- Functions use approved `Verb-Noun` PascalCase; `[CmdletBinding()]` + `param()` for >1 argument. (`AGENTS.md`) +- Pure helpers go in `src/00-Core.ps1`; `-CoreOnly` must keep loading on Linux without WPF. (`AGENTS.md`) +- Locked palette (map #16): accent `#035BA3`, neutral `#909496`, positive `#043D19`, destructive `#A60707`, ground/text `#000000`. White only as text-on-accent and light-surface necessity. Dark accent-text lightened to `#5E9BD0` (contrast), dark positive lightened to `#2E6B42`. +- `ThemeMode` wiring rule (spike #30): application-level AND window-level `ThemeMode`, plus window `Background` bound to `ApplicationBackgroundBrush`, else Dark ground stays unpainted. +- `SNIPIT_TEST_MODE=1` suppresses mutex/tray/hotkeys — both harnesses rely on it; do not break dot-source safety. +- All commits on `main` signed; run tests before every commit claim. +- Run interactive tests with `pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1`; pure tests with `pwsh -NoProfile -File ./Test-SnipIT.ps1`. + +--- + +### Task 1: Pure palette contract — `Get-SnipFluentPalette` + +**Files:** +- Modify: `src/00-Core.ps1` (append near `Get-SnipThemeTokens`) +- Test: `Test-SnipIT.ps1` (append a new test group) + +**Interfaces:** +- Produces: `Get-SnipFluentPalette -Mode 'Light'|'Dark'` → `[pscustomobject]` with properties `Mode`, `Accent`, `AccentHover`, `AccentPressed`, `OnAccent`, `AccentText`, `Neutral`, `Positive`, `Destructive`, `Ground`, and `ResourceMap` (hashtable: Fluent resource key → hex string). Task 3 consumes `ResourceMap` verbatim. + +- [ ] **Step 1: Write the failing test** — append to `Test-SnipIT.ps1` following its existing `Describe`/`It` conventions (grep an existing group such as `Get-SnipThemeTokens` for the local assertion helpers first): + +```powershell +Describe 'Get-SnipFluentPalette' { + It 'returns the locked light palette' { + $p = Get-SnipFluentPalette -Mode Light + Assert-Equal $p.Accent '#035BA3' + Assert-Equal $p.OnAccent '#FFFFFF' + Assert-Equal $p.AccentText '#035BA3' + Assert-Equal $p.Positive '#043D19' + Assert-Equal $p.Destructive '#A60707' + Assert-Equal $p.Neutral '#909496' + } + It 'lightens accent text and positive for dark mode contrast' { + $p = Get-SnipFluentPalette -Mode Dark + Assert-Equal $p.Accent '#035BA3' + Assert-Equal $p.AccentText '#5E9BD0' + Assert-Equal $p.Positive '#2E6B42' + Assert-Equal $p.Ground '#000000' + } + It 'maps every override key to a palette hex' { + $p = Get-SnipFluentPalette -Mode Light + foreach ($k in @('AccentFillColorDefaultBrush','AccentButtonBackground', + 'CheckBoxCheckBackgroundFillChecked','ProgressBarForeground', + 'TextOnAccentFillColorPrimaryBrush','AccentTextFillColorPrimaryBrush')) { + if (-not $p.ResourceMap.ContainsKey($k)) { throw "missing key $k" } + if ($p.ResourceMap[$k] -notmatch '^#[0-9A-F]{6}$') { throw "bad hex for $k" } + } + } +} +``` + +(Adapt assertion helper names to what `Test-SnipIT.ps1` actually defines — check the top of the file; it is a bespoke harness, not Pester.) + +- [ ] **Step 2: Run to verify failure** + +Run: `pwsh -NoProfile -File ./Test-SnipIT.ps1` +Expected: FAIL — `Get-SnipFluentPalette` not recognized. (Pure tests run against generated `SnipIT.ps1`; for the red step run with `$env:SNIPIT_SCRIPT_UNDER_TEST='./SnipIT.Dev.ps1'` so the new source function is visible once written, and note the release run stays red until Task 7 regenerates.) + +- [ ] **Step 3: Implement in `src/00-Core.ps1`** + +```powershell +function Get-SnipFluentPalette { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('Light','Dark')] + [string]$Mode + ) + + $isDark = $Mode -eq 'Dark' + $accent = '#035BA3' + $accentHover = if ($isDark) { '#0E68B4' } else { '#024B87' } + $accentPress = if ($isDark) { '#024B87' } else { '#023E70' } + $onAccent = '#FFFFFF' + $accentText = if ($isDark) { '#5E9BD0' } else { '#035BA3' } + $positive = if ($isDark) { '#2E6B42' } else { '#043D19' } + + $map = @{ + AccentFillColorDefaultBrush = $accent + AccentFillColorSecondaryBrush = $accentHover + AccentFillColorTertiaryBrush = $accentPress + AccentFillColorSelectedTextBackgroundBrush = $accent + TextOnAccentFillColorPrimaryBrush = $onAccent + TextOnAccentFillColorSecondaryBrush = $onAccent + AccentTextFillColorPrimaryBrush = $accentText + AccentTextFillColorSecondaryBrush = $accentText + AccentTextFillColorTertiaryBrush = $accentText + AccentButtonBackground = $accent + AccentButtonBackgroundPointerOver = $accentHover + AccentButtonBackgroundPressed = $accentPress + AccentButtonForeground = $onAccent + AccentButtonForegroundPointerOver = $onAccent + AccentButtonForegroundPressed = $onAccent + AccentButtonBorderBrush = $accent + AccentButtonBorderBrushPointerOver = $accentHover + AccentButtonBorderBrushPressed = $accentPress + CheckBoxCheckBackgroundFillChecked = $accent + CheckBoxCheckBackgroundStrokeChecked = $accent + CheckBoxCheckBackgroundFillCheckedPointerOver = $accentHover + CheckBoxCheckBackgroundStrokeCheckedPointerOver = $accentHover + CheckBoxCheckBackgroundFillCheckedPressed = $accentPress + CheckBoxCheckBackgroundStrokeCheckedPressed = $accentPress + CheckBoxCheckGlyphForegroundChecked = $onAccent + CheckBoxCheckGlyphForegroundCheckedPointerOver = $onAccent + CheckBoxCheckGlyphForegroundCheckedPressed = $onAccent + ProgressBarForeground = $positive + } + + [pscustomobject][ordered]@{ + Mode = $Mode + Accent = $accent + AccentHover = $accentHover + AccentPressed = $accentPress + OnAccent = $onAccent + AccentText = $accentText + Neutral = '#909496' + Positive = $positive + Destructive = '#A60707' + Ground = if ($isDark) { '#000000' } else { '#FFFFFF' } + ResourceMap = $map + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `$env:SNIPIT_SCRIPT_UNDER_TEST='./SnipIT.Dev.ps1'; pwsh -NoProfile -File ./Test-SnipIT.ps1` +Expected: PASS (new group green, all pre-existing groups still green). + +- [ ] **Step 5: Update the function-surface baseline** — `tests/baselines/snipit-function-surface.json` lists the exported function names; add `Get-SnipFluentPalette` in sorted position (run the failing baseline check first to see the expected diff format if unsure — it is enforced by `Test-SnipIT.ps1`). + +- [ ] **Step 6: Commit** + +```bash +git add src/00-Core.ps1 Test-SnipIT.ps1 tests/baselines/snipit-function-surface.json +git commit -m "feat(theme): add Get-SnipFluentPalette pure palette contract" +``` + +--- + +### Task 2: System theme detection — `Get-SnipSystemThemeMode` + +**Files:** +- Modify: `src/10-Bootstrap.ps1` +- Test: `Test-SnipIT-Interactive.ps1` (new group `Fluent theme foundation`) + +**Interfaces:** +- Produces: `Get-SnipSystemThemeMode [-Reader ]` → `'Light'` or `'Dark'`. `$Reader` is a test seam returning the registry value (or `$null`); default reader queries `HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize` value `AppsUseLightTheme`. Missing value ⇒ `'Light'`. + +- [ ] **Step 1: Write the failing tests** (interactive harness, because Bootstrap loads WPF assemblies; follow the harness's `Describe`/`It`/`Should-Be` helpers): + +```powershell +Describe 'Fluent theme foundation' { + It 'maps registry app-theme value to a mode' { + Should-Be (Get-SnipSystemThemeMode -Reader { 1 }) 'Light' + Should-Be (Get-SnipSystemThemeMode -Reader { 0 }) 'Dark' + Should-Be (Get-SnipSystemThemeMode -Reader { $null }) 'Light' + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `$env:SNIPIT_SCRIPT_UNDER_TEST='./SnipIT.Dev.ps1'; $env:SNIPIT_TEST_GROUP='Fluent theme foundation'; pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1` +Expected: FAIL — command not found. + +- [ ] **Step 3: Implement in `src/10-Bootstrap.ps1`** + +```powershell +function Get-SnipSystemThemeMode { + [CmdletBinding()] + param( + [scriptblock]$Reader = { + try { + Get-ItemPropertyValue ` + -Path 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' ` + -Name 'AppsUseLightTheme' -ErrorAction Stop + } catch { $null } + } + ) + + $value = & $Reader + if ($null -ne $value -and [int]$value -eq 0) { 'Dark' } else { 'Light' } +} +``` + +- [ ] **Step 4: Run to verify pass** (same command as Step 2). Expected: PASS. + +- [ ] **Step 5: Update `tests/baselines/snipit-function-surface.json`** with `Get-SnipSystemThemeMode`. + +- [ ] **Step 6: Commit** + +```bash +git add src/10-Bootstrap.ps1 Test-SnipIT-Interactive.ps1 tests/baselines/snipit-function-surface.json +git commit -m "feat(theme): detect system Light/Dark app theme" +``` + +--- + +### Task 3: Window theme wiring — `Initialize-SnipWindowTheme` + +**Files:** +- Modify: `src/10-Bootstrap.ps1` +- Test: `Test-SnipIT-Interactive.ps1` (extend group `Fluent theme foundation`) + +**Interfaces:** +- Consumes: `Get-SnipFluentPalette` (Task 1), `Get-SnipSystemThemeMode` (Task 2). +- Produces: `Initialize-SnipWindowTheme -Window [-Mode 'Light'|'Dark']` → returns the applied `[string]` mode. Sets application + window `ThemeMode` via reflection (no-throw when the experimental type is absent — falls back to resource overrides only), binds window `Background` to `ApplicationBackgroundBrush` (explicit black brush for Dark, per spike #30), and writes every `ResourceMap` entry into `$Window.Resources` as a frozen `SolidColorBrush`, plus `SystemAccentColor`/`SystemAccentColorPrimary`/`SystemAccentColorSecondary`/`SystemAccentColorTertiary` as `Color` values. Tasks 4–6 call this from every window creator; Plan 2+ calls it for new windows. + +- [ ] **Step 1: Write the failing tests** (append inside the `Fluent theme foundation` group): + +```powershell + It 'applies palette overrides and reports the mode' { + $window = [System.Windows.Window]::new() + $window.ShowActivated = $false; $window.ShowInTaskbar = $false + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000; $window.Top = -10000 + $applied = Initialize-SnipWindowTheme -Window $window -Mode Dark + Should-Be $applied 'Dark' + $brush = $window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + Should-Be $window.Resources['ProgressBarForeground'].Color.ToString() '#FF2E6B42' + $window.Close() + } + It 'defaults the mode from the system theme seam' { + $window = [System.Windows.Window]::new() + $window.ShowActivated = $false; $window.ShowInTaskbar = $false + $applied = Initialize-SnipWindowTheme -Window $window + Should-BeTrue ($applied -in @('Light','Dark')) + $window.Close() + } +``` + +- [ ] **Step 2: Run to verify failure** (same run command as Task 2). Expected: FAIL — command not found. + +- [ ] **Step 3: Implement in `src/10-Bootstrap.ps1`** + +```powershell +function Initialize-SnipWindowTheme { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Windows.Window]$Window, + [ValidateSet('Light','Dark')] + [string]$Mode + ) + + if (-not $Mode) { $Mode = Get-SnipSystemThemeMode } + $palette = Get-SnipFluentPalette -Mode $Mode + + # Experimental .NET 9 ThemeMode API — reflection only, absent-type safe. + $themeModeType = 'System.Windows.ThemeMode' -as [type] + if ($null -ne $themeModeType) { + try { + $themeMode = [Activator]::CreateInstance($themeModeType, @($Mode)) + $app = [System.Windows.Application]::Current + if ($null -ne $app) { + $app.GetType().GetProperty('ThemeMode').SetValue($app, $themeMode) + } + $Window.GetType().GetProperty('ThemeMode').SetValue($Window, $themeMode) + } catch { + Write-SnipDiag -Message 'ThemeMode apply failed' -ErrorRecord $_ + } + } + + if ($Mode -eq 'Dark') { + $ground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Colors]::Black) + $ground.Freeze() + $Window.Background = $ground + } else { + $Window.SetResourceReference( + [System.Windows.Controls.Control]::BackgroundProperty, + 'ApplicationBackgroundBrush') + } + + foreach ($entry in $palette.ResourceMap.GetEnumerator()) { + $brush = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($entry.Value)) + $brush.Freeze() + $Window.Resources[$entry.Key] = $brush + } + $accentColor = [System.Windows.Media.ColorConverter]::ConvertFromString($palette.Accent) + foreach ($colorKey in 'SystemAccentColor','SystemAccentColorPrimary', + 'SystemAccentColorSecondary','SystemAccentColorTertiary') { + $Window.Resources[$colorKey] = $accentColor + } + + $Mode +} +``` + +- [ ] **Step 4: Run to verify pass.** Expected: PASS. +- [ ] **Step 5: Update the function-surface baseline** with `Initialize-SnipWindowTheme`. +- [ ] **Step 6: Commit** + +```bash +git add src/10-Bootstrap.ps1 Test-SnipIT-Interactive.ps1 tests/baselines/snipit-function-surface.json +git commit -m "feat(theme): window-level Fluent theme wiring with palette overrides" +``` + +--- + +### Task 4: Wire Settings and About windows + +**Files:** +- Modify: `src/50-Tray.ps1` — `Show-SettingsWindow` (window construction, after `XamlReader` load) and `Show-AboutWindow` (same point) +- Test: `Test-SnipIT-Interactive.ps1` (extend group `Fluent theme foundation`) + +**Interfaces:** +- Consumes: `Initialize-SnipWindowTheme` (Task 3). No new symbols produced. + +- [ ] **Step 1: Write the failing tests** — drive through the existing `TestAction` seams (see the `Utility window capture-exclusion lifecycle` group for the context object shape to copy): + +```powershell + It 'themes the Settings window with the brand accent' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-theme-' + [guid]::NewGuid()) + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = (Join-Path $root 'settings.json') + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { param($hwnd,$id) $true } + RegisterWindow = { param($hwnd) } + UnregisterWindow = { param($hwnd) } + } + Show-SettingsWindow -Context $context -TestAction { + param($kit) + $brush = $kit.Window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + & $kit.Close 'UserCancelled' + } | Out-Null + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + It 'themes the About window with the brand accent' { + $context = [pscustomobject]@{ + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + RegisterWindow = { param($hwnd) } + UnregisterWindow = { param($hwnd) } + } + Show-AboutWindow -Context $context -TestAction { + param($kit) + $brush = $kit.Window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + & $kit.Close 'UserCancelled' + } | Out-Null + } +``` + +- [ ] **Step 2: Run to verify failure.** Expected: FAIL — resource lookup returns `$null` (`Should-Be` mismatch). + +- [ ] **Step 3: Implement** — in `Show-SettingsWindow` and `Show-AboutWindow` (both in `src/50-Tray.ps1`), directly after the window object is produced by the `XamlReader` and before it is shown, insert: + +```powershell + [void](Initialize-SnipWindowTheme -Window $window) +``` + +(Match the local variable name each function uses for its window — read the function first; do not rename anything.) + +- [ ] **Step 4: Run to verify pass.** Expected: PASS, plus every pre-existing group in the harness still green (the theme call must not break the black-glass resource lookups these windows still perform — if a legacy `SnipTheme*` resource assertion fails, the fix is ordering: apply `Initialize-SnipWindowTheme` BEFORE `Add-SnipThemeResources` merges legacy dictionaries so legacy keys win where they overlap). + +- [ ] **Step 5: Commit** + +```bash +git add src/50-Tray.ps1 Test-SnipIT-Interactive.ps1 +git commit -m "feat(theme): Fluent-theme the Settings and About windows" +``` + +--- + +### Task 5: Wire FloatingWidget and Preview windows + +**Files:** +- Modify: `src/50-Tray.ps1` — `Show-FloatingWidget`; `src/40-Preview.ps1` — `New-SnipPreviewWindow` (after `$Context.Window = $window`, around line 1288) +- Test: `Test-SnipIT-Interactive.ps1` (extend group `Fluent theme foundation`) + +**Interfaces:** +- Consumes: `Initialize-SnipWindowTheme` (Task 3). No new symbols produced. + +- [ ] **Step 1: Write the failing tests** + +```powershell + It 'themes the floating widget with the brand accent' { + $settings = Get-SnipDefaultSettings + $settings.WidgetVisible = $true + $context = [pscustomobject]@{ + Settings = $settings + SubmitRequest = { param($mode,$delay,$source) } + OpenSettings = { } + AnimationsEnabled = $false + RegisterWindow = { param($hwnd) } + UnregisterWindow = { param($hwnd) } + } + Show-FloatingWidget -Context $context -TestAction { + param($kit) + $brush = $kit.Window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + $kit.Window.Close() + } | Out-Null + } + It 'themes the preview window with the brand accent' { + $bitmap = [System.Drawing.Bitmap]::new(16, 16) + Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + $brush = $kit.Win.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + } | Out-Null + } +``` + +- [ ] **Step 2: Run to verify failure.** Expected: FAIL — `$null` resource. + +- [ ] **Step 3: Implement** — same one-line insertion as Task 4: in `Show-FloatingWidget` after its window is constructed; in `New-SnipPreviewWindow` immediately after `$Context.Window = $window`: + +```powershell + [void](Initialize-SnipWindowTheme -Window $window) +``` + +- [ ] **Step 4: Run to verify pass — then run the ENTIRE interactive suite** (unset `SNIPIT_TEST_GROUP`): the preview harness has hundreds of layout assertions; theme application must not shift native-layout metrics. If a layout test fails on a brush-dependent measurement, report it in the task summary rather than papering over it. + +- [ ] **Step 5: Commit** + +```bash +git add src/50-Tray.ps1 src/40-Preview.ps1 Test-SnipIT-Interactive.ps1 +git commit -m "feat(theme): Fluent-theme the widget and preview windows" +``` + +--- + +### Task 6: Palette-swap audit — themed windows carry no stale accent + +**Files:** +- Test: `Test-SnipIT-Interactive.ps1` (extend group `Fluent theme foundation`) + +**Interfaces:** +- Consumes: `Initialize-SnipWindowTheme` (Task 3). Produces the reusable audit pattern later plans extend. + +- [ ] **Step 1: Write the audit test** — proves overrides actually win over the inherited system accent (the mechanism that made the prototype's purple disappear): + +```powershell + It 'palette overrides shadow the system accent on a themed window' { + $window = [System.Windows.Window]::new() + $window.ShowActivated = $false; $window.ShowInTaskbar = $false + [void](Initialize-SnipWindowTheme -Window $window -Mode Light) + $button = [System.Windows.Controls.Button]::new() + $window.Content = $button + $style = $window.TryFindResource('AccentButtonStyle') + if ($null -ne $style) { $button.Style = $style } + $window.Show() + $window.UpdateLayout() + $accent = $window.Resources['AccentButtonBackground'] + Should-Be $accent.Color.ToString() '#FF035BA3' + if ($null -ne $style) { + $rendered = $button.Background + Should-Be $rendered.Color.ToString() '#FF035BA3' + } + $window.Close() + } +``` + +- [ ] **Step 2: Run — expect PASS immediately** (this is a regression tripwire, not TDD red/green; it fails the day someone reorders resource application). + +- [ ] **Step 3: Commit** + +```bash +git add Test-SnipIT-Interactive.ps1 +git commit -m "test(theme): palette-swap audit pins brand accent over system accent" +``` + +--- + +### Task 7: Regenerate the distribution and run every gate + +**Files:** +- Modify: `SnipIT.ps1` (generated — via the builder only) + +**Interfaces:** none new — this is the release gate for the plan. + +- [ ] **Step 1: Regenerate** + +Run: `pwsh -NoProfile -File ./Build-SnipIT.ps1` + +- [ ] **Step 2: Verify no stale distribution** + +Run: `git diff --stat -- SnipIT.ps1` +Expected: a real diff containing the new functions (then staged); after commit, `git diff --exit-code -- SnipIT.ps1` must be clean. + +- [ ] **Step 3: Full gate run, release mode (no `SNIPIT_SCRIPT_UNDER_TEST`)** + +```bash +pwsh -NoProfile -File ./Test-SnipIT-Build.ps1 +pwsh -NoProfile -File ./Test-SnipIT.ps1 +pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1 +``` + +Expected: all PASS. The Task 1 pure tests that were red against the release file go green here. + +- [ ] **Step 4: Manual smoke** — `pwsh -Sta -File ./SnipIT.ps1`, open Settings + About from the tray, confirm Fluent visuals in the OS's current theme, then toggle Windows Light/Dark and reopen (theme is applied per window open; live re-theming of open windows is Plan 2 scope, not this plan). + +- [ ] **Step 5: Commit** + +```bash +git add SnipIT.ps1 +git commit -m "build: regenerate distribution with Fluent theme foundation" +``` + +--- + +## Out of scope for this plan (later plans) + +- Hub window, Library/History surface, first-run consent card (Plan 2) +- Editor toolbar/props-row/picker rework, command table, filmstrip (Plan 3) +- Continuity capture (pinned overlay, chip bar, morph, WDA gating), pins (Plan 4) +- Keyboard-capture machine, narration wiring, NVDA validation pass (Plan 5) +- Live re-theme of already-open windows on OS theme change From f97c8a8e60fd17c6001b525b019a4202a06ade16 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 16:12:44 +0800 Subject: [PATCH 02/16] feat(theme): add Get-SnipFluentPalette pure palette contract --- Test-SnipIT.ps1 | 27 ++++++++ src/00-Core.ps1 | 68 ++++++++++++++++++++ tests/baselines/snipit-function-surface.json | 6 ++ 3 files changed, 101 insertions(+) diff --git a/Test-SnipIT.ps1 b/Test-SnipIT.ps1 index 78d22f7ce..f457ec1d5 100644 --- a/Test-SnipIT.ps1 +++ b/Test-SnipIT.ps1 @@ -2904,6 +2904,33 @@ It 'routes Undo and Redo only after editor and draft ownership' { -Key Z -Modifiers Ctrl) $null } +Describe 'Get-SnipFluentPalette' +It 'returns the locked light palette' { + $p = Get-SnipFluentPalette -Mode Light + ShouldBe $p.Accent '#035BA3' + ShouldBe $p.OnAccent '#FFFFFF' + ShouldBe $p.AccentText '#035BA3' + ShouldBe $p.Positive '#043D19' + ShouldBe $p.Destructive '#A60707' + ShouldBe $p.Neutral '#909496' +} +It 'lightens accent text and positive for dark mode contrast' { + $p = Get-SnipFluentPalette -Mode Dark + ShouldBe $p.Accent '#035BA3' + ShouldBe $p.AccentText '#5E9BD0' + ShouldBe $p.Positive '#2E6B42' + ShouldBe $p.Ground '#000000' +} +It 'maps every override key to a palette hex' { + $p = Get-SnipFluentPalette -Mode Light + foreach ($k in @('AccentFillColorDefaultBrush','AccentButtonBackground', + 'CheckBoxCheckBackgroundFillChecked','ProgressBarForeground', + 'TextOnAccentFillColorPrimaryBrush','AccentTextFillColorPrimaryBrush')) { + if (-not $p.ResourceMap.ContainsKey($k)) { throw "missing key $k" } + if ($p.ResourceMap[$k] -notmatch '^#[0-9A-F]{6}$') { throw "bad hex for $k" } + } +} + Write-Host "" $total = $script:Pass + $script:Fail $color = if ($script:Fail -eq 0) { 'Green' } else { 'Red' } diff --git a/src/00-Core.ps1 b/src/00-Core.ps1 index 0c1d76d18..c2488d4e0 100644 --- a/src/00-Core.ps1 +++ b/src/00-Core.ps1 @@ -1262,6 +1262,74 @@ function Get-SnipThemeTokens { } } +function Get-SnipFluentPalette { + # Pure Fluent brand palette for one theme mode. + # + # Returns the locked accent/semantic hexes plus ResourceMap: the exact + # Fluent resource keys the WPF theme dictionary overrides, each mapped to + # one of those hexes. Callers apply ResourceMap verbatim — no WPF types + # are touched here so this stays loadable in -CoreOnly on any platform. + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('Light','Dark')] + [string]$Mode + ) + + $isDark = $Mode -eq 'Dark' + $accent = '#035BA3' + $accentHover = if ($isDark) { '#0E68B4' } else { '#024B87' } + $accentPress = if ($isDark) { '#024B87' } else { '#023E70' } + $onAccent = '#FFFFFF' + $accentText = if ($isDark) { '#5E9BD0' } else { '#035BA3' } + $positive = if ($isDark) { '#2E6B42' } else { '#043D19' } + + $map = @{ + AccentFillColorDefaultBrush = $accent + AccentFillColorSecondaryBrush = $accentHover + AccentFillColorTertiaryBrush = $accentPress + AccentFillColorSelectedTextBackgroundBrush = $accent + TextOnAccentFillColorPrimaryBrush = $onAccent + TextOnAccentFillColorSecondaryBrush = $onAccent + AccentTextFillColorPrimaryBrush = $accentText + AccentTextFillColorSecondaryBrush = $accentText + AccentTextFillColorTertiaryBrush = $accentText + AccentButtonBackground = $accent + AccentButtonBackgroundPointerOver = $accentHover + AccentButtonBackgroundPressed = $accentPress + AccentButtonForeground = $onAccent + AccentButtonForegroundPointerOver = $onAccent + AccentButtonForegroundPressed = $onAccent + AccentButtonBorderBrush = $accent + AccentButtonBorderBrushPointerOver = $accentHover + AccentButtonBorderBrushPressed = $accentPress + CheckBoxCheckBackgroundFillChecked = $accent + CheckBoxCheckBackgroundStrokeChecked = $accent + CheckBoxCheckBackgroundFillCheckedPointerOver = $accentHover + CheckBoxCheckBackgroundStrokeCheckedPointerOver = $accentHover + CheckBoxCheckBackgroundFillCheckedPressed = $accentPress + CheckBoxCheckBackgroundStrokeCheckedPressed = $accentPress + CheckBoxCheckGlyphForegroundChecked = $onAccent + CheckBoxCheckGlyphForegroundCheckedPointerOver = $onAccent + CheckBoxCheckGlyphForegroundCheckedPressed = $onAccent + ProgressBarForeground = $positive + } + + [pscustomobject][ordered]@{ + Mode = $Mode + Accent = $accent + AccentHover = $accentHover + AccentPressed = $accentPress + OnAccent = $onAccent + AccentText = $accentText + Neutral = '#909496' + Positive = $positive + Destructive = '#A60707' + Ground = if ($isDark) { '#000000' } else { '#FFFFFF' } + ResourceMap = $map + } +} + function Get-SnipContrastRatio { [CmdletBinding()] param( diff --git a/tests/baselines/snipit-function-surface.json b/tests/baselines/snipit-function-surface.json index 051b94f6a..647f387cf 100644 --- a/tests/baselines/snipit-function-surface.json +++ b/tests/baselines/snipit-function-surface.json @@ -241,6 +241,12 @@ "IsWorkflow": false, "ParamBlock": "" }, + { + "Name": "Get-SnipFluentPalette", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)]\n [ValidateSet('Light','Dark')]\n [string]$Mode\n )" + }, { "Name": "Get-SnipITIconPath", "IsFilter": false, From 9775e9d83bd52da447a57fec11ca306f0a092f50 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 16:19:26 +0800 Subject: [PATCH 03/16] feat(theme): detect system Light/Dark app theme Co-Authored-By: Claude Fable 5 --- Test-SnipIT-Interactive.ps1 | 8 ++++++++ src/10-Bootstrap.ps1 | 19 +++++++++++++++++++ tests/baselines/snipit-function-surface.json | 6 ++++++ 3 files changed, 33 insertions(+) diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index 2f5461a5c..068f5061e 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -215,6 +215,14 @@ function Should-BeGreaterThan { param($Actual, $Min) if ([double]$Actual -le [double]$Min) { throw "Expected > $Min, got $Actual" } } +Describe 'Fluent theme foundation' { + It 'maps registry app-theme value to a mode' { + Should-Be (Get-SnipSystemThemeMode -Reader { 1 }) 'Light' + Should-Be (Get-SnipSystemThemeMode -Reader { 0 }) 'Dark' + Should-Be (Get-SnipSystemThemeMode -Reader { $null }) 'Light' + } +} + Describe 'Settings persistence and diagnostics' { $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-' + [guid]::NewGuid()) $settingsPath = Join-Path $root 'settings.json' diff --git a/src/10-Bootstrap.ps1 b/src/10-Bootstrap.ps1 index 48f8ee73d..4b40221de 100644 --- a/src/10-Bootstrap.ps1 +++ b/src/10-Bootstrap.ps1 @@ -460,6 +460,25 @@ function Uninstall-SnipIT { } } +# Reports the Windows app theme as 'Light' or 'Dark'. The registry read is +# injected through -Reader so tests can pin a value without touching HKCU; +# a missing or unreadable value falls back to 'Light'. +function Get-SnipSystemThemeMode { + [CmdletBinding()] + param( + [scriptblock]$Reader = { + try { + Get-ItemPropertyValue ` + -Path 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' ` + -Name 'AppsUseLightTheme' -ErrorAction Stop + } catch { $null } + } + ) + + $value = & $Reader + if ($null -ne $value -and [int]$value -eq 0) { 'Dark' } else { 'Light' } +} + function New-SnipThemeResources { [CmdletBinding()] param( diff --git a/tests/baselines/snipit-function-surface.json b/tests/baselines/snipit-function-surface.json index 647f387cf..c9f19debe 100644 --- a/tests/baselines/snipit-function-surface.json +++ b/tests/baselines/snipit-function-surface.json @@ -289,6 +289,12 @@ "IsWorkflow": false, "ParamBlock": "param([string]$LocalAppData = $env:LOCALAPPDATA)" }, + { + "Name": "Get-SnipSystemThemeMode", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [scriptblock]$Reader = {\n try {\n Get-ItemPropertyValue `\n -Path 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize' `\n -Name 'AppsUseLightTheme' -ErrorAction Stop\n } catch { $null }\n }\n )" + }, { "Name": "Get-SnipThemeTokens", "IsFilter": false, From cc47bc6edfb45502925793355c00f2d807b34c67 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 16:25:58 +0800 Subject: [PATCH 04/16] feat(theme): window-level Fluent theme wiring with palette overrides Co-Authored-By: Claude Fable 5 --- Test-SnipIT-Interactive.ps1 | 19 +++++++ src/10-Bootstrap.ps1 | 57 ++++++++++++++++++++ tests/baselines/snipit-function-surface.json | 6 +++ 3 files changed, 82 insertions(+) diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index 068f5061e..d9927d89d 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -221,6 +221,25 @@ Describe 'Fluent theme foundation' { Should-Be (Get-SnipSystemThemeMode -Reader { 0 }) 'Dark' Should-Be (Get-SnipSystemThemeMode -Reader { $null }) 'Light' } + It 'applies palette overrides and reports the mode' { + $window = [System.Windows.Window]::new() + $window.ShowActivated = $false; $window.ShowInTaskbar = $false + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000; $window.Top = -10000 + $applied = Initialize-SnipWindowTheme -Window $window -Mode Dark + Should-Be $applied 'Dark' + $brush = $window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + Should-Be $window.Resources['ProgressBarForeground'].Color.ToString() '#FF2E6B42' + $window.Close() + } + It 'defaults the mode from the system theme seam' { + $window = [System.Windows.Window]::new() + $window.ShowActivated = $false; $window.ShowInTaskbar = $false + $applied = Initialize-SnipWindowTheme -Window $window + Should-BeTrue ($applied -in @('Light','Dark')) + $window.Close() + } } Describe 'Settings persistence and diagnostics' { diff --git a/src/10-Bootstrap.ps1 b/src/10-Bootstrap.ps1 index 4b40221de..49d484ec8 100644 --- a/src/10-Bootstrap.ps1 +++ b/src/10-Bootstrap.ps1 @@ -479,6 +479,63 @@ function Get-SnipSystemThemeMode { if ($null -ne $value -and [int]$value -eq 0) { 'Dark' } else { 'Light' } } +# Applies the Fluent palette to one window and returns the mode that was used. +# Sets the experimental .NET 9 ThemeMode through reflection when the type exists +# (older runtimes silently keep the resource overrides only), grounds the window +# background, and writes every ResourceMap entry in as a frozen brush. +function Initialize-SnipWindowTheme { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Windows.Window]$Window, + [ValidateSet('Light','Dark')] + [string]$Mode + ) + + if (-not $Mode) { $Mode = Get-SnipSystemThemeMode } + $palette = Get-SnipFluentPalette -Mode $Mode + + # Experimental .NET 9 ThemeMode API — reflection only, absent-type safe. + $themeModeType = 'System.Windows.ThemeMode' -as [type] + if ($null -ne $themeModeType) { + try { + $themeMode = [Activator]::CreateInstance($themeModeType, @($Mode)) + $app = [System.Windows.Application]::Current + if ($null -ne $app) { + $app.GetType().GetProperty('ThemeMode').SetValue($app, $themeMode) + } + $Window.GetType().GetProperty('ThemeMode').SetValue($Window, $themeMode) + } catch { + Write-SnipDiag -Message 'ThemeMode apply failed' -ErrorRecord $_ + } + } + + if ($Mode -eq 'Dark') { + $ground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Colors]::Black) + $ground.Freeze() + $Window.Background = $ground + } else { + $Window.SetResourceReference( + [System.Windows.Controls.Control]::BackgroundProperty, + 'ApplicationBackgroundBrush') + } + + foreach ($entry in $palette.ResourceMap.GetEnumerator()) { + $brush = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($entry.Value)) + $brush.Freeze() + $Window.Resources[$entry.Key] = $brush + } + $accentColor = [System.Windows.Media.ColorConverter]::ConvertFromString($palette.Accent) + foreach ($colorKey in 'SystemAccentColor','SystemAccentColorPrimary', + 'SystemAccentColorSecondary','SystemAccentColorTertiary') { + $Window.Resources[$colorKey] = $accentColor + } + + $Mode +} + function New-SnipThemeResources { [CmdletBinding()] param( diff --git a/tests/baselines/snipit-function-surface.json b/tests/baselines/snipit-function-surface.json index c9f19debe..0f3054931 100644 --- a/tests/baselines/snipit-function-surface.json +++ b/tests/baselines/snipit-function-surface.json @@ -349,6 +349,12 @@ "IsWorkflow": false, "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Collections.IList]$Annotations\n )" }, + { + "Name": "Initialize-SnipWindowTheme", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)]\n [System.Windows.Window]$Window,\n [ValidateSet('Light','Dark')]\n [string]$Mode\n )" + }, { "Name": "Install-SnipIT", "IsFilter": false, From acca41ef9028a08b7df0b1a5151156a4c5c3bdb8 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 16:32:03 +0800 Subject: [PATCH 05/16] feat(theme): Fluent-theme the Settings and About windows Co-Authored-By: Claude Fable 5 --- Test-SnipIT-Interactive.ps1 | 33 +++++++++++++++++++++++++++++++++ src/50-Tray.ps1 | 2 ++ 2 files changed, 35 insertions(+) diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index d9927d89d..a9f5b0bae 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -240,6 +240,39 @@ Describe 'Fluent theme foundation' { Should-BeTrue ($applied -in @('Light','Dark')) $window.Close() } + It 'themes the Settings window with the brand accent' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-theme-' + [guid]::NewGuid()) + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = (Join-Path $root 'settings.json') + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { param($hwnd,$id) $true } + RegisterWindow = { param($hwnd) } + UnregisterWindow = { param($hwnd) } + } + Show-SettingsWindow -Context $context -TestAction { + param($kit) + $brush = $kit.Window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + & $kit.Close 'UserCancelled' + } | Out-Null + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + It 'themes the About window with the brand accent' { + $context = [pscustomobject]@{ + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + RegisterWindow = { param($hwnd) } + UnregisterWindow = { param($hwnd) } + } + Show-AboutWindow -Context $context -TestAction { + param($kit) + $brush = $kit.Window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + & $kit.Close 'UserCancelled' + } | Out-Null + } } Describe 'Settings persistence and diagnostics' { diff --git a/src/50-Tray.ps1 b/src/50-Tray.ps1 index fac38a4b8..f119797dc 100644 --- a/src/50-Tray.ps1 +++ b/src/50-Tray.ps1 @@ -39,6 +39,7 @@ function Show-SettingsWindow { } finally { $reader.Dispose() } + [void](Initialize-SnipWindowTheme -Window $window) Add-SnipThemeResources -Root $window -Resources $resources | Out-Null $dragHeader = $window.FindName('DragHeader') @@ -390,6 +391,7 @@ function Show-AboutWindow { $reader = [System.Xml.XmlNodeReader]::new($xaml) try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } finally { $reader.Dispose() } + [void](Initialize-SnipWindowTheme -Window $window) Add-SnipThemeResources -Root $window -Resources $resources | Out-Null $window.FindName('VersionText').Text = $metadata.Version From 3d4a15cdd855e1bb3fe9cd8e8b74245b8ca2fc09 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 16:36:55 +0800 Subject: [PATCH 06/16] fix(theme): preserve transparent backgrounds on glass windows Co-Authored-By: Claude Fable 5 --- Test-SnipIT-Interactive.ps1 | 18 ++++++++++++++++++ src/10-Bootstrap.ps1 | 24 +++++++++++++++--------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index a9f5b0bae..3e30680b3 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -240,6 +240,24 @@ Describe 'Fluent theme foundation' { Should-BeTrue ($applied -in @('Light','Dark')) $window.Close() } + It 'leaves a transparent glass window background untouched' { + $window = [System.Windows.Window]::new() + $window.WindowStyle = 'None' + $window.AllowsTransparency = $true + $window.Background = [System.Windows.Media.Brushes]::Transparent + $window.ShowActivated = $false; $window.ShowInTaskbar = $false + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000; $window.Top = -10000 + + $applied = Initialize-SnipWindowTheme -Window $window -Mode Dark + Should-Be $applied 'Dark' + # The XAML-declared transparent ground must survive so AllowsTransparency + # glass surfaces keep their rounded corners instead of an opaque square. + Should-Be $window.Background.Color.ToString() '#00FFFFFF' + # Palette overrides still apply on glass windows. + Should-Be $window.Resources['AccentFillColorDefaultBrush'].Color.ToString() '#FF035BA3' + $window.Close() + } It 'themes the Settings window with the brand accent' { $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-theme-' + [guid]::NewGuid()) $context = [pscustomobject]@{ diff --git a/src/10-Bootstrap.ps1 b/src/10-Bootstrap.ps1 index 49d484ec8..2f46f58a9 100644 --- a/src/10-Bootstrap.ps1 +++ b/src/10-Bootstrap.ps1 @@ -510,15 +510,21 @@ function Initialize-SnipWindowTheme { } } - if ($Mode -eq 'Dark') { - $ground = [System.Windows.Media.SolidColorBrush]::new( - [System.Windows.Media.Colors]::Black) - $ground.Freeze() - $Window.Background = $ground - } else { - $Window.SetResourceReference( - [System.Windows.Controls.Control]::BackgroundProperty, - 'ApplicationBackgroundBrush') + # Glass surfaces (WindowStyle=None + AllowsTransparency) paint their chrome with + # an inner rounded Border and rely on the window itself staying transparent. + # Grounding them would write a Local opaque value over the XAML's Transparent and + # render a square block behind the rounded corners, so skip grounding entirely. + if (-not $Window.AllowsTransparency) { + if ($Mode -eq 'Dark') { + $ground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Colors]::Black) + $ground.Freeze() + $Window.Background = $ground + } else { + $Window.SetResourceReference( + [System.Windows.Controls.Control]::BackgroundProperty, + 'ApplicationBackgroundBrush') + } } foreach ($entry in $palette.ResourceMap.GetEnumerator()) { From 230dd502d01c907b57f7f15e406869c28b0007ef Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 16:51:19 +0800 Subject: [PATCH 07/16] feat(theme): Fluent-theme the widget and preview windows Co-Authored-By: Claude Fable 5 --- Test-SnipIT-Interactive.ps1 | 32 ++++++++++++++++++++++++++++++++ src/40-Preview.ps1 | 1 + src/50-Tray.ps1 | 1 + 3 files changed, 34 insertions(+) diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index 3e30680b3..6f4d1928d 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -291,6 +291,38 @@ Describe 'Fluent theme foundation' { & $kit.Close 'UserCancelled' } | Out-Null } + It 'themes the floating widget with the brand accent' { + $settings = Get-SnipDefaultSettings + $settings.WidgetVisible = $true + $context = [pscustomobject]@{ + Settings = $settings + SubmitRequest = { param($mode,$delay,$source) } + OpenSettings = { } + AnimationsEnabled = $false + RegisterWindow = { param($hwnd) } + UnregisterWindow = { param($hwnd) } + } + Show-FloatingWidget -Context $context -TestAction { + param($kit) + $brush = $kit.Window.Resources['AccentFillColorDefaultBrush'] + Should-Be $brush.Color.ToString() '#FF035BA3' + $kit.Window.Close() + } | Out-Null + } + It 'themes the preview window with the brand accent' { + $bitmap = [System.Drawing.Bitmap]::new(16, 16) + # Show-PreviewWindow stores a TestAction failure in $script:pwTestError from + # inside a GetNewClosure() scriptblock, whose $script: scope is the closure's + # own dynamic module - so throws raised in here never reach the harness. + # Observe the brush into an outer holder and assert after the call instead. + $observed = [pscustomobject]@{ Accent = $null } + Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + $brush = $kit.Win.Resources['AccentFillColorDefaultBrush'] + $observed.Accent = if ($null -eq $brush) { '' } else { $brush.Color.ToString() } + }.GetNewClosure() | Out-Null + Should-Be $observed.Accent '#FF035BA3' + } } Describe 'Settings persistence and diagnostics' { diff --git a/src/40-Preview.ps1 b/src/40-Preview.ps1 index ef199526c..71d6b2457 100644 --- a/src/40-Preview.ps1 +++ b/src/40-Preview.ps1 @@ -1286,6 +1286,7 @@ function New-SnipPreviewWindow { $window.Add_Closed($closedPlacementHandler) $placementState.HandlersAttached = $true $Context.Window = $window + [void](Initialize-SnipWindowTheme -Window $window) $Context.Chrome = $null $actionsPanel = $window.FindName('ActionsPanel') diff --git a/src/50-Tray.ps1 b/src/50-Tray.ps1 index f119797dc..cab9f3134 100644 --- a/src/50-Tray.ps1 +++ b/src/50-Tray.ps1 @@ -550,6 +550,7 @@ function Show-FloatingWidget { $reader = [System.Xml.XmlNodeReader]::new($xaml) try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } finally { $reader.Dispose() } + [void](Initialize-SnipWindowTheme -Window $window) Add-SnipThemeResources -Root $window -Resources $resources | Out-Null $workArea = [System.Windows.SystemParameters]::WorkArea From b37419ca9e9ddc450d57596505460a3148730e89 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 17:14:23 +0800 Subject: [PATCH 08/16] fix(preview): resolve More-menu popup at open time so theming survives template swap Co-Authored-By: Claude Fable 5 --- src/40-Preview.ps1 | 56 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/40-Preview.ps1 b/src/40-Preview.ps1 index 71d6b2457..c5eea7a98 100644 --- a/src/40-Preview.ps1 +++ b/src/40-Preview.ps1 @@ -300,26 +300,50 @@ function Set-SnipPreviewMenuStyle { $NestedState.Handle = [IntPtr]::Zero $NestedState.IsRegistered = $false }.GetNewClosure() + # A MenuItem's ControlTemplate is re-resolved when the menu is opened (a themed + # window and the ContextMenu's own popup tree resolve different templates), which + # replaces PART_Popup and would strand the lifecycle handlers on a dead Popup. + # Rebind the state to whichever Popup the live template currently carries. + $bindNestedPopup = { + param($NestedState) + $livePopup = $NestedState.Item.Template.FindName('PART_Popup',$NestedState.Item) + if ($livePopup -isnot [System.Windows.Controls.Primitives.Popup]) { return } + if ([object]::ReferenceEquals($livePopup,$NestedState.Popup)) { return } + if ($null -ne $NestedState.Popup -and $NestedState.HandlersAttached) { + $NestedState.Popup.Remove_Opened($NestedState.OpenedHandler) + $NestedState.Popup.Remove_Closed($NestedState.ClosedHandler) + } + $livePopup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( + $Context.Resources['SnipPopupAnimation']) + $livePopup.Tag = $NestedState + $NestedState.Popup = $livePopup + if ($NestedState.HandlersAttached) { + $livePopup.Add_Opened($NestedState.OpenedHandler) + $livePopup.Add_Closed($NestedState.ClosedHandler) + # A swap can land after the replacement Popup already opened, so the + # Opened event is gone; register the live HWND directly instead. + if ($livePopup.IsOpen) { & $NestedState.OpenedHandler } + } + }.GetNewClosure() foreach ($menuItem in @($menuItems)) { $menuItem.ApplyTemplate() | Out-Null $popup = $menuItem.Template.FindName('PART_Popup',$menuItem) if ($popup -isnot [System.Windows.Controls.Primitives.Popup]) { continue } - $popup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( - $Context.Resources['SnipPopupAnimation']) $nestedState = [pscustomobject][ordered]@{ Item = $menuItem - Popup = $popup + Popup = $null Handle = [IntPtr]::Zero IsRegistered = $false OpenedHandler = $null ClosedHandler = $null + ItemLoadedHandler = $null HandlersAttached = $true } - $popupForHandler = $popup $stateForHandler = $nestedState $openedHandler = { - $source = if ($null -ne $popupForHandler.Child) { - [System.Windows.PresentationSource]::FromVisual($popupForHandler.Child) + $livePopup = $stateForHandler.Popup + $source = if ($null -ne $livePopup -and $null -ne $livePopup.Child) { + [System.Windows.PresentationSource]::FromVisual($livePopup.Child) } else { $null } if ($source -is [System.Windows.Interop.HwndSource] -and $source.Handle -ne [IntPtr]::Zero -and @@ -334,9 +358,14 @@ function Set-SnipPreviewMenuStyle { }.GetNewClosure() $nestedState.OpenedHandler = $openedHandler $nestedState.ClosedHandler = $closedHandler - $popup.Tag = $nestedState - $popup.Add_Opened($openedHandler) - $popup.Add_Closed($closedHandler) + & $bindNestedPopup $nestedState + # Opening the menu reloads its items; that is where the template swap lands. + $itemLoadedHandler = [System.Windows.RoutedEventHandler]{ + param($sender,$eventArgs) + & $bindNestedPopup $stateForHandler + }.GetNewClosure() + $nestedState.ItemLoadedHandler = $itemLoadedHandler + $menuItem.Add_Loaded($itemLoadedHandler) $nestedStates.Add($nestedState) | Out-Null } $closeNestedPopups = { @@ -358,8 +387,13 @@ function Set-SnipPreviewMenuStyle { } foreach ($nestedState in @($nestedStates)) { if (-not $nestedState.HandlersAttached) { continue } - $nestedState.Popup.Remove_Opened($nestedState.OpenedHandler) - $nestedState.Popup.Remove_Closed($nestedState.ClosedHandler) + if ($null -ne $nestedState.ItemLoadedHandler) { + $nestedState.Item.Remove_Loaded($nestedState.ItemLoadedHandler) + } + if ($null -ne $nestedState.Popup) { + $nestedState.Popup.Remove_Opened($nestedState.OpenedHandler) + $nestedState.Popup.Remove_Closed($nestedState.ClosedHandler) + } $nestedState.HandlersAttached = $false } if ($null -ne $Context.Window -and From 0aabc166ea53a847acc935fe6b3383d9d4244ba1 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 17:23:39 +0800 Subject: [PATCH 09/16] fix(preview): reset nested-popup registration state on template swap Co-Authored-By: Claude Fable 5 --- Test-SnipIT-Interactive.ps1 | 51 +++++++++++++++++++++++++++++++++++++ src/40-Preview.ps1 | 7 +++++ 2 files changed, 58 insertions(+) diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index 6f4d1928d..b445f8219 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -323,6 +323,57 @@ Describe 'Fluent theme foundation' { }.GetNewClosure() | Out-Null Should-Be $observed.Accent '#FF035BA3' } + It 'rebinds nested-popup lifecycle state when the menu template is swapped' { + $bitmap = [System.Drawing.Bitmap]::new(24, 24) + $unregistered = [System.Collections.ArrayList]::new() + $shell = $null + try { + $context = New-SnipPreviewContext -Bitmap $bitmap ` + -UnregisterWindow { param($hwnd) $unregistered.Add($hwnd) | Out-Null }.GetNewClosure() ` + -SetWindowPosition { param($hwnd,$bounds) $true } + $shell = New-SnipPreviewWindow -Context $context + $item = $context.Shell.MoreMenuItems.Color + $stalePopup = $item.Template.FindName('PART_Popup',$item) + $state = $stalePopup.Tag + Should-BeTrue $state.HandlersAttached + # Pretend the outgoing popup was open and its HWND registered when the + # theming template swap lands. + $state.Handle = [IntPtr]4660 + $state.IsRegistered = $true + + $item.Template = [System.Windows.Markup.XamlReader]::Parse(@' + + + + + + + + +'@) + $item.ApplyTemplate() | Out-Null + $item.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.FrameworkElement]::LoadedEvent)) + + $livePopup = $item.Template.FindName('PART_Popup',$item) + Should-BeFalse ([object]::ReferenceEquals($livePopup,$stalePopup)) + # The live popup carries the state and the reduced-motion mapping. + Should-BeTrue ([object]::ReferenceEquals($livePopup.Tag,$state)) + Should-BeTrue ([object]::ReferenceEquals($state.Popup,$livePopup)) + Should-BeTrue $state.HandlersAttached + Should-Be $livePopup.PopupAnimation $context.Resources['SnipPopupAnimation'] + # The stale registration is released, so the new popup can register. + Should-BeFalse $state.IsRegistered + Should-Be ([int64]$state.Handle) 0 + Should-Be $unregistered.Count 1 + Should-Be ([int64]$unregistered[0]) 4660 + } finally { + if ($null -ne $shell -and $shell.Window.IsVisible) { $shell.Window.Close() } + $bitmap.Dispose() + } + } } Describe 'Settings persistence and diagnostics' { diff --git a/src/40-Preview.ps1 b/src/40-Preview.ps1 index c5eea7a98..65c5d0cc0 100644 --- a/src/40-Preview.ps1 +++ b/src/40-Preview.ps1 @@ -313,6 +313,13 @@ function Set-SnipPreviewMenuStyle { $NestedState.Popup.Remove_Opened($NestedState.OpenedHandler) $NestedState.Popup.Remove_Closed($NestedState.ClosedHandler) } + # The outgoing Popup can never raise Closed for us again, so release its HWND + # here. Without this, a swap that lands while the old Popup is registered would + # leave IsRegistered true and the catch-up below would skip the new HWND. + # The outgoing Popup can never raise Closed for us again, so release its HWND + # here. Without this, a swap that lands while the old Popup is registered would + # leave IsRegistered true and the catch-up below would skip the new HWND. + if ($null -ne $NestedState.Popup) { & $unregisterNested $NestedState } $livePopup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( $Context.Resources['SnipPopupAnimation']) $livePopup.Tag = $NestedState From bd3e6c788b8d1320bde0bf41f8ac8cfe47bd46a0 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 17:26:04 +0800 Subject: [PATCH 10/16] style(preview): drop duplicated comment block Co-Authored-By: Claude Fable 5 --- src/40-Preview.ps1 | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/40-Preview.ps1 b/src/40-Preview.ps1 index 65c5d0cc0..689e0fb3b 100644 --- a/src/40-Preview.ps1 +++ b/src/40-Preview.ps1 @@ -316,9 +316,6 @@ function Set-SnipPreviewMenuStyle { # The outgoing Popup can never raise Closed for us again, so release its HWND # here. Without this, a swap that lands while the old Popup is registered would # leave IsRegistered true and the catch-up below would skip the new HWND. - # The outgoing Popup can never raise Closed for us again, so release its HWND - # here. Without this, a swap that lands while the old Popup is registered would - # leave IsRegistered true and the catch-up below would skip the new HWND. if ($null -ne $NestedState.Popup) { & $unregisterNested $NestedState } $livePopup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( $Context.Resources['SnipPopupAnimation']) From d96240830db44485c86be66e8cb3b5e32dbf6c43 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 17:30:05 +0800 Subject: [PATCH 11/16] test(theme): palette-swap audit pins brand accent over system accent Co-Authored-By: Claude Fable 5 --- Test-SnipIT-Interactive.ps1 | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index b445f8219..09dcef3ed 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -233,6 +233,29 @@ Describe 'Fluent theme foundation' { Should-Be $window.Resources['ProgressBarForeground'].Color.ToString() '#FF2E6B42' $window.Close() } + It 'palette overrides shadow the system accent on a themed window' { + # Regression tripwire: the palette must be written after the Fluent + # dictionaries load, so a rendered accent control resolves to the brand + # blue rather than the user's inherited system accent. + $window = [System.Windows.Window]::new() + $window.ShowActivated = $false; $window.ShowInTaskbar = $false + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000; $window.Top = -10000 + [void](Initialize-SnipWindowTheme -Window $window -Mode Light) + $button = [System.Windows.Controls.Button]::new() + $window.Content = $button + $style = $window.TryFindResource('AccentButtonStyle') + if ($null -ne $style) { $button.Style = $style } + $window.Show() + $window.UpdateLayout() + $accent = $window.Resources['AccentButtonBackground'] + Should-Be $accent.Color.ToString() '#FF035BA3' + if ($null -ne $style) { + $rendered = $button.Background + Should-Be $rendered.Color.ToString() '#FF035BA3' + } + $window.Close() + } It 'defaults the mode from the system theme seam' { $window = [System.Windows.Window]::new() $window.ShowActivated = $false; $window.ShowInTaskbar = $false From 63c65001cbc9bd79d93ec38c14d1fdb733e76234 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 17:35:49 +0800 Subject: [PATCH 12/16] build: regenerate distribution with Fluent theme foundation Co-Authored-By: Claude Fable 5 --- SnipIT.ps1 | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 203 insertions(+), 11 deletions(-) diff --git a/SnipIT.ps1 b/SnipIT.ps1 index d1e61e604..f6f8d8eb1 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -1263,6 +1263,74 @@ function Get-SnipThemeTokens { } } +function Get-SnipFluentPalette { + # Pure Fluent brand palette for one theme mode. + # + # Returns the locked accent/semantic hexes plus ResourceMap: the exact + # Fluent resource keys the WPF theme dictionary overrides, each mapped to + # one of those hexes. Callers apply ResourceMap verbatim — no WPF types + # are touched here so this stays loadable in -CoreOnly on any platform. + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('Light','Dark')] + [string]$Mode + ) + + $isDark = $Mode -eq 'Dark' + $accent = '#035BA3' + $accentHover = if ($isDark) { '#0E68B4' } else { '#024B87' } + $accentPress = if ($isDark) { '#024B87' } else { '#023E70' } + $onAccent = '#FFFFFF' + $accentText = if ($isDark) { '#5E9BD0' } else { '#035BA3' } + $positive = if ($isDark) { '#2E6B42' } else { '#043D19' } + + $map = @{ + AccentFillColorDefaultBrush = $accent + AccentFillColorSecondaryBrush = $accentHover + AccentFillColorTertiaryBrush = $accentPress + AccentFillColorSelectedTextBackgroundBrush = $accent + TextOnAccentFillColorPrimaryBrush = $onAccent + TextOnAccentFillColorSecondaryBrush = $onAccent + AccentTextFillColorPrimaryBrush = $accentText + AccentTextFillColorSecondaryBrush = $accentText + AccentTextFillColorTertiaryBrush = $accentText + AccentButtonBackground = $accent + AccentButtonBackgroundPointerOver = $accentHover + AccentButtonBackgroundPressed = $accentPress + AccentButtonForeground = $onAccent + AccentButtonForegroundPointerOver = $onAccent + AccentButtonForegroundPressed = $onAccent + AccentButtonBorderBrush = $accent + AccentButtonBorderBrushPointerOver = $accentHover + AccentButtonBorderBrushPressed = $accentPress + CheckBoxCheckBackgroundFillChecked = $accent + CheckBoxCheckBackgroundStrokeChecked = $accent + CheckBoxCheckBackgroundFillCheckedPointerOver = $accentHover + CheckBoxCheckBackgroundStrokeCheckedPointerOver = $accentHover + CheckBoxCheckBackgroundFillCheckedPressed = $accentPress + CheckBoxCheckBackgroundStrokeCheckedPressed = $accentPress + CheckBoxCheckGlyphForegroundChecked = $onAccent + CheckBoxCheckGlyphForegroundCheckedPointerOver = $onAccent + CheckBoxCheckGlyphForegroundCheckedPressed = $onAccent + ProgressBarForeground = $positive + } + + [pscustomobject][ordered]@{ + Mode = $Mode + Accent = $accent + AccentHover = $accentHover + AccentPressed = $accentPress + OnAccent = $onAccent + AccentText = $accentText + Neutral = '#909496' + Positive = $positive + Destructive = '#A60707' + Ground = if ($isDark) { '#000000' } else { '#FFFFFF' } + ResourceMap = $map + } +} + function Get-SnipContrastRatio { [CmdletBinding()] param( @@ -3740,6 +3808,88 @@ function Uninstall-SnipIT { } } +# Reports the Windows app theme as 'Light' or 'Dark'. The registry read is +# injected through -Reader so tests can pin a value without touching HKCU; +# a missing or unreadable value falls back to 'Light'. +function Get-SnipSystemThemeMode { + [CmdletBinding()] + param( + [scriptblock]$Reader = { + try { + Get-ItemPropertyValue ` + -Path 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' ` + -Name 'AppsUseLightTheme' -ErrorAction Stop + } catch { $null } + } + ) + + $value = & $Reader + if ($null -ne $value -and [int]$value -eq 0) { 'Dark' } else { 'Light' } +} + +# Applies the Fluent palette to one window and returns the mode that was used. +# Sets the experimental .NET 9 ThemeMode through reflection when the type exists +# (older runtimes silently keep the resource overrides only), grounds the window +# background, and writes every ResourceMap entry in as a frozen brush. +function Initialize-SnipWindowTheme { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Windows.Window]$Window, + [ValidateSet('Light','Dark')] + [string]$Mode + ) + + if (-not $Mode) { $Mode = Get-SnipSystemThemeMode } + $palette = Get-SnipFluentPalette -Mode $Mode + + # Experimental .NET 9 ThemeMode API — reflection only, absent-type safe. + $themeModeType = 'System.Windows.ThemeMode' -as [type] + if ($null -ne $themeModeType) { + try { + $themeMode = [Activator]::CreateInstance($themeModeType, @($Mode)) + $app = [System.Windows.Application]::Current + if ($null -ne $app) { + $app.GetType().GetProperty('ThemeMode').SetValue($app, $themeMode) + } + $Window.GetType().GetProperty('ThemeMode').SetValue($Window, $themeMode) + } catch { + Write-SnipDiag -Message 'ThemeMode apply failed' -ErrorRecord $_ + } + } + + # Glass surfaces (WindowStyle=None + AllowsTransparency) paint their chrome with + # an inner rounded Border and rely on the window itself staying transparent. + # Grounding them would write a Local opaque value over the XAML's Transparent and + # render a square block behind the rounded corners, so skip grounding entirely. + if (-not $Window.AllowsTransparency) { + if ($Mode -eq 'Dark') { + $ground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Colors]::Black) + $ground.Freeze() + $Window.Background = $ground + } else { + $Window.SetResourceReference( + [System.Windows.Controls.Control]::BackgroundProperty, + 'ApplicationBackgroundBrush') + } + } + + foreach ($entry in $palette.ResourceMap.GetEnumerator()) { + $brush = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($entry.Value)) + $brush.Freeze() + $Window.Resources[$entry.Key] = $brush + } + $accentColor = [System.Windows.Media.ColorConverter]::ConvertFromString($palette.Accent) + foreach ($colorKey in 'SystemAccentColor','SystemAccentColorPrimary', + 'SystemAccentColorSecondary','SystemAccentColorTertiary') { + $Window.Resources[$colorKey] = $accentColor + } + + $Mode +} + function New-SnipThemeResources { [CmdletBinding()] param( @@ -5733,26 +5883,54 @@ function Set-SnipPreviewMenuStyle { $NestedState.Handle = [IntPtr]::Zero $NestedState.IsRegistered = $false }.GetNewClosure() + # A MenuItem's ControlTemplate is re-resolved when the menu is opened (a themed + # window and the ContextMenu's own popup tree resolve different templates), which + # replaces PART_Popup and would strand the lifecycle handlers on a dead Popup. + # Rebind the state to whichever Popup the live template currently carries. + $bindNestedPopup = { + param($NestedState) + $livePopup = $NestedState.Item.Template.FindName('PART_Popup',$NestedState.Item) + if ($livePopup -isnot [System.Windows.Controls.Primitives.Popup]) { return } + if ([object]::ReferenceEquals($livePopup,$NestedState.Popup)) { return } + if ($null -ne $NestedState.Popup -and $NestedState.HandlersAttached) { + $NestedState.Popup.Remove_Opened($NestedState.OpenedHandler) + $NestedState.Popup.Remove_Closed($NestedState.ClosedHandler) + } + # The outgoing Popup can never raise Closed for us again, so release its HWND + # here. Without this, a swap that lands while the old Popup is registered would + # leave IsRegistered true and the catch-up below would skip the new HWND. + if ($null -ne $NestedState.Popup) { & $unregisterNested $NestedState } + $livePopup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( + $Context.Resources['SnipPopupAnimation']) + $livePopup.Tag = $NestedState + $NestedState.Popup = $livePopup + if ($NestedState.HandlersAttached) { + $livePopup.Add_Opened($NestedState.OpenedHandler) + $livePopup.Add_Closed($NestedState.ClosedHandler) + # A swap can land after the replacement Popup already opened, so the + # Opened event is gone; register the live HWND directly instead. + if ($livePopup.IsOpen) { & $NestedState.OpenedHandler } + } + }.GetNewClosure() foreach ($menuItem in @($menuItems)) { $menuItem.ApplyTemplate() | Out-Null $popup = $menuItem.Template.FindName('PART_Popup',$menuItem) if ($popup -isnot [System.Windows.Controls.Primitives.Popup]) { continue } - $popup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( - $Context.Resources['SnipPopupAnimation']) $nestedState = [pscustomobject][ordered]@{ Item = $menuItem - Popup = $popup + Popup = $null Handle = [IntPtr]::Zero IsRegistered = $false OpenedHandler = $null ClosedHandler = $null + ItemLoadedHandler = $null HandlersAttached = $true } - $popupForHandler = $popup $stateForHandler = $nestedState $openedHandler = { - $source = if ($null -ne $popupForHandler.Child) { - [System.Windows.PresentationSource]::FromVisual($popupForHandler.Child) + $livePopup = $stateForHandler.Popup + $source = if ($null -ne $livePopup -and $null -ne $livePopup.Child) { + [System.Windows.PresentationSource]::FromVisual($livePopup.Child) } else { $null } if ($source -is [System.Windows.Interop.HwndSource] -and $source.Handle -ne [IntPtr]::Zero -and @@ -5767,9 +5945,14 @@ function Set-SnipPreviewMenuStyle { }.GetNewClosure() $nestedState.OpenedHandler = $openedHandler $nestedState.ClosedHandler = $closedHandler - $popup.Tag = $nestedState - $popup.Add_Opened($openedHandler) - $popup.Add_Closed($closedHandler) + & $bindNestedPopup $nestedState + # Opening the menu reloads its items; that is where the template swap lands. + $itemLoadedHandler = [System.Windows.RoutedEventHandler]{ + param($sender,$eventArgs) + & $bindNestedPopup $stateForHandler + }.GetNewClosure() + $nestedState.ItemLoadedHandler = $itemLoadedHandler + $menuItem.Add_Loaded($itemLoadedHandler) $nestedStates.Add($nestedState) | Out-Null } $closeNestedPopups = { @@ -5791,8 +5974,13 @@ function Set-SnipPreviewMenuStyle { } foreach ($nestedState in @($nestedStates)) { if (-not $nestedState.HandlersAttached) { continue } - $nestedState.Popup.Remove_Opened($nestedState.OpenedHandler) - $nestedState.Popup.Remove_Closed($nestedState.ClosedHandler) + if ($null -ne $nestedState.ItemLoadedHandler) { + $nestedState.Item.Remove_Loaded($nestedState.ItemLoadedHandler) + } + if ($null -ne $nestedState.Popup) { + $nestedState.Popup.Remove_Opened($nestedState.OpenedHandler) + $nestedState.Popup.Remove_Closed($nestedState.ClosedHandler) + } $nestedState.HandlersAttached = $false } if ($null -ne $Context.Window -and @@ -6719,6 +6907,7 @@ function New-SnipPreviewWindow { $window.Add_Closed($closedPlacementHandler) $placementState.HandlersAttached = $true $Context.Window = $window + [void](Initialize-SnipWindowTheme -Window $window) $Context.Chrome = $null $actionsPanel = $window.FindName('ActionsPanel') @@ -9322,6 +9511,7 @@ function Show-SettingsWindow { } finally { $reader.Dispose() } + [void](Initialize-SnipWindowTheme -Window $window) Add-SnipThemeResources -Root $window -Resources $resources | Out-Null $dragHeader = $window.FindName('DragHeader') @@ -9673,6 +9863,7 @@ function Show-AboutWindow { $reader = [System.Xml.XmlNodeReader]::new($xaml) try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } finally { $reader.Dispose() } + [void](Initialize-SnipWindowTheme -Window $window) Add-SnipThemeResources -Root $window -Resources $resources | Out-Null $window.FindName('VersionText').Text = $metadata.Version @@ -9831,6 +10022,7 @@ function Show-FloatingWidget { $reader = [System.Xml.XmlNodeReader]::new($xaml) try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } finally { $reader.Dispose() } + [void](Initialize-SnipWindowTheme -Window $window) Add-SnipThemeResources -Root $window -Resources $resources | Out-Null $workArea = [System.Windows.SystemParameters]::WorkArea From b91f0b2a292d17ee105c8e26dfebf17a5552bf47 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 17:43:33 +0800 Subject: [PATCH 13/16] docs(plan): record execution amendments for theme grounding and window wiring --- docs/superpowers/plans/2026-08-13-fluent-foundation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-13-fluent-foundation.md b/docs/superpowers/plans/2026-08-13-fluent-foundation.md index dd78c1350..ffbe5b351 100644 --- a/docs/superpowers/plans/2026-08-13-fluent-foundation.md +++ b/docs/superpowers/plans/2026-08-13-fluent-foundation.md @@ -16,7 +16,7 @@ - Functions use approved `Verb-Noun` PascalCase; `[CmdletBinding()]` + `param()` for >1 argument. (`AGENTS.md`) - Pure helpers go in `src/00-Core.ps1`; `-CoreOnly` must keep loading on Linux without WPF. (`AGENTS.md`) - Locked palette (map #16): accent `#035BA3`, neutral `#909496`, positive `#043D19`, destructive `#A60707`, ground/text `#000000`. White only as text-on-accent and light-surface necessity. Dark accent-text lightened to `#5E9BD0` (contrast), dark positive lightened to `#2E6B42`. -- `ThemeMode` wiring rule (spike #30): application-level AND window-level `ThemeMode`, plus window `Background` bound to `ApplicationBackgroundBrush`, else Dark ground stays unpainted. +- `ThemeMode` wiring rule (spike #30): application-level AND window-level `ThemeMode`, plus window `Background` bound to `ApplicationBackgroundBrush`, else Dark ground stays unpainted. AMENDED during execution: grounding (both Dark explicit brush and Light ApplicationBackgroundBrush reference) is skipped entirely when `$Window.AllowsTransparency` is true — glass windows keep their XAML Transparent background; palette overrides still apply. - `SNIPIT_TEST_MODE=1` suppresses mutex/tray/hotkeys — both harnesses rely on it; do not break dot-source safety. - All commits on `main` signed; run tests before every commit claim. - Run interactive tests with `pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1`; pure tests with `pwsh -NoProfile -File ./Test-SnipIT.ps1`. @@ -214,6 +214,8 @@ git commit -m "feat(theme): detect system Light/Dark app theme" ### Task 3: Window theme wiring — `Initialize-SnipWindowTheme` +> **Execution amendments:** (1) the implementation guards all background grounding with `if (-not $Window.AllowsTransparency)` — see commit 3d4a15c and the glass-window regression test. (2) `Initialize-SnipWindowTheme` sets `Application.Current.ThemeMode` on every call (last-write-wins per window open); already-open windows keep their window-level mode. Future plans adding windows MUST call `Initialize-SnipWindowTheme` for every new window (unwired windows render stock controls with the user's SYSTEM accent once Fluent dictionaries are loaded — the exact leak the audit test guards against), or hoist palette overrides to Application.Resources in a dedicated task. + **Files:** - Modify: `src/10-Bootstrap.ps1` - Test: `Test-SnipIT-Interactive.ps1` (extend group `Fluent theme foundation`) From e8c5826ae862f7e1ba8192ec7ba50deaa8e923ed Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 17:47:44 +0800 Subject: [PATCH 14/16] fix(theme): palette-ground contract + registry cast hardening - Initialize-SnipWindowTheme now builds the Dark ground brush from $palette.Ground via ColorConverter instead of a hardcoded Colors::Black literal, closing the contract gap between the palette and the applied brush (behavior unchanged: Ground is #000000 in dark mode). - Get-SnipSystemThemeMode hardens the registry-value cast with `-as [int]` so a corrupted non-numeric value falls back to 'Light' instead of throwing; adds a regression test pinning that fallback. - Regenerate SnipIT.ps1 from src/ and xaml/ via Build-SnipIT.ps1. --- SnipIT.ps1 | 5 +++-- Test-SnipIT-Interactive.ps1 | 3 +++ src/10-Bootstrap.ps1 | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/SnipIT.ps1 b/SnipIT.ps1 index f6f8d8eb1..ddc797b7a 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -3824,7 +3824,8 @@ function Get-SnipSystemThemeMode { ) $value = & $Reader - if ($null -ne $value -and [int]$value -eq 0) { 'Dark' } else { 'Light' } + $numeric = $value -as [int] + if ($null -ne $value -and $null -ne $numeric -and $numeric -eq 0) { 'Dark' } else { 'Light' } } # Applies the Fluent palette to one window and returns the mode that was used. @@ -3865,7 +3866,7 @@ function Initialize-SnipWindowTheme { if (-not $Window.AllowsTransparency) { if ($Mode -eq 'Dark') { $ground = [System.Windows.Media.SolidColorBrush]::new( - [System.Windows.Media.Colors]::Black) + [System.Windows.Media.ColorConverter]::ConvertFromString($palette.Ground)) $ground.Freeze() $Window.Background = $ground } else { diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index 09dcef3ed..2e13a1dd7 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -221,6 +221,9 @@ Describe 'Fluent theme foundation' { Should-Be (Get-SnipSystemThemeMode -Reader { 0 }) 'Dark' Should-Be (Get-SnipSystemThemeMode -Reader { $null }) 'Light' } + It 'falls back to Light for a corrupted non-numeric registry value' { + Should-Be (Get-SnipSystemThemeMode -Reader { 'garbage' }) 'Light' + } It 'applies palette overrides and reports the mode' { $window = [System.Windows.Window]::new() $window.ShowActivated = $false; $window.ShowInTaskbar = $false diff --git a/src/10-Bootstrap.ps1 b/src/10-Bootstrap.ps1 index 2f46f58a9..d8e73fb18 100644 --- a/src/10-Bootstrap.ps1 +++ b/src/10-Bootstrap.ps1 @@ -476,7 +476,8 @@ function Get-SnipSystemThemeMode { ) $value = & $Reader - if ($null -ne $value -and [int]$value -eq 0) { 'Dark' } else { 'Light' } + $numeric = $value -as [int] + if ($null -ne $value -and $null -ne $numeric -and $numeric -eq 0) { 'Dark' } else { 'Light' } } # Applies the Fluent palette to one window and returns the mode that was used. @@ -517,7 +518,7 @@ function Initialize-SnipWindowTheme { if (-not $Window.AllowsTransparency) { if ($Mode -eq 'Dark') { $ground = [System.Windows.Media.SolidColorBrush]::new( - [System.Windows.Media.Colors]::Black) + [System.Windows.Media.ColorConverter]::ConvertFromString($palette.Ground)) $ground.Freeze() $Window.Background = $ground } else { From 949207801ab743e9c1bdf0e1629cb59650dfa618 Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 22:19:32 +0800 Subject: [PATCH 15/16] fix(theme): readable preview toolbar in dark mode The preview toolbar wrote fixed Win32 SystemColors brushes as local values for its active-tool and More-button chrome. Those brushes never follow the .NET Fluent theme this branch applies, so under Dark the theme's white button ink landed on ControlLight #E3E3E3 (1.28:1) or Control #F0F0F0 (1.14:1) and the glyph and the More label disappeared. The status indicator had the same problem through SystemColors.ControlTextBrushKey, which is always black. Replace all three with DynamicResource references to the palette keys Initialize-SnipWindowTheme injects: the brand accent plate with on-accent ink when active (6.93:1 in both modes), and cleared local values otherwise so the theme style supplies its own subtle fill and ink. Co-Authored-By: Claude Fable 5 --- SnipIT.ps1 | 70 ++++++++++---- Test-SnipIT-Interactive.ps1 | 96 +++++++++++++++++++- scripts/Export-SnipITModules.ps1 | 3 +- src/40-Preview.ps1 | 68 +++++++++++--- tests/baselines/snipit-function-surface.json | 6 ++ xaml/PreviewWindow.xaml | 2 +- 6 files changed, 207 insertions(+), 38 deletions(-) diff --git a/SnipIT.ps1 b/SnipIT.ps1 index ddc797b7a..af1faad28 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -2998,7 +2998,7 @@ $script:SnipEmbeddedXaml = [ordered]@{ + Fill="{DynamicResource TextFillColorPrimaryBrush}"/> @@ -6759,6 +6759,41 @@ function Set-SnipPreviewStatusPresentation { } } +# Paints (or releases) the "this control is the active tool" chrome on one +# toolbar control using theme resource references instead of literal brushes. +# +# The Fluent theme supplies the button ink, and it flips with the mode - white +# in Dark, near-black in Light. Any literal plate therefore has to be legible +# under both, which no fixed Win32 SystemColors brush is. The palette keys that +# Initialize-SnipWindowTheme writes into the window are defined for both modes +# (accent #035BA3 with #FFFFFF ink = 6.9:1), so a DynamicResource reference +# tracks the mode automatically. Releasing clears the local values so the +# control falls back to the theme style's own subtle fill and ink. +function Set-SnipPreviewActiveChrome { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.Control]$Control, + [bool]$Active + ) + + $properties = @( + [System.Windows.Controls.Control]::BackgroundProperty, + [System.Windows.Controls.Control]::BorderBrushProperty, + [System.Windows.Controls.Control]::ForegroundProperty) + if (-not $Active) { + foreach ($property in $properties) { $Control.ClearValue($property) } + return $Control + } + $keys = @( + 'AccentFillColorDefaultBrush', + 'AccentFillColorDefaultBrush', + 'TextOnAccentFillColorPrimaryBrush') + for ($index = 0; $index -lt $properties.Count; $index++) { + $Control.SetResourceReference($properties[$index], $keys[$index]) + } + $Control +} + function Set-SnipPreviewResponsiveMode { [CmdletBinding()] param( @@ -6824,12 +6859,14 @@ function Set-SnipPreviewResponsiveMode { } else { $Context.Shell.MoreButton.Width = 52 } - $Context.Shell.MoreButton.BorderBrush = if ($Context.MoreState.IsActive) { - [System.Windows.SystemColors]::HighlightBrush - } else { [System.Windows.SystemColors]::ControlDarkBrush } - $Context.Shell.MoreButton.Background = if ($Context.MoreState.IsActive) { - [System.Windows.SystemColors]::ControlLightBrush - } else { [System.Windows.SystemColors]::ControlBrush } + # Theme-aware chrome. The classic SystemColors brushes are Win32 fixed + # (ControlLight #E3E3E3, Control #F0F0F0) and never follow the Fluent + # dark theme, so a locally assigned one put the theme's white button ink + # on a near-white plate (1.1:1) and erased the label. Palette resource + # references resolve per mode: accent plate + on-accent ink when active, + # the theme's own subtle fill when not. + Set-SnipPreviewActiveChrome -Control $Context.Shell.MoreButton ` + -Active $Context.MoreState.IsActive | Out-Null [System.Windows.Automation.AutomationProperties]::SetName( $Context.Shell.MoreButton, $Context.MoreState.Name) } @@ -6989,9 +7026,12 @@ function New-SnipPreviewWindow { $moreIndicator.Width = 12; $moreIndicator.Height = 2 $moreIndicator.Margin = [System.Windows.Thickness]::new(5,0,0,0) $moreIndicator.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + # Only ever shown while the More button carries the active-tool accent plate, + # so the indicator uses the on-accent ink rather than the fixed Win32 + # highlight colour (which sat invisibly on the accent in both modes). $moreIndicator.SetResourceReference( [System.Windows.Controls.Border]::BackgroundProperty, - [System.Windows.SystemColors]::HighlightBrushKey) + 'TextOnAccentFillColorPrimaryBrush') $moreIndicator.Visibility = [System.Windows.Visibility]::Collapsed $moreContent.Children.Add($moreIcon) | Out-Null $moreContent.Children.Add($moreName) | Out-Null @@ -7786,14 +7826,13 @@ function Show-PreviewWindow { & $previewContext.CancelDraft } foreach ($buttonName in @('Select','Crop','Pen','Steps')) { - $button = $previewContext.ToolControls[$buttonName] - $button.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) - $button.ClearValue([System.Windows.Controls.Control]::BorderBrushProperty) + Set-SnipPreviewActiveChrome ` + -Control $previewContext.ToolControls[$buttonName] -Active $false | Out-Null } foreach ($splitName in @('ArrowLine','RectangleEllipse','BlurPixelate')) { - $splitButton = $previewContext.SplitControls[$splitName].PrimaryButton - $splitButton.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) - $splitButton.ClearValue([System.Windows.Controls.Control]::BorderBrushProperty) + Set-SnipPreviewActiveChrome ` + -Control $previewContext.SplitControls[$splitName].PrimaryButton ` + -Active $false | Out-Null } switch ($Tool) { 'Highlight' { $highlightBtn.IsChecked = $true } @@ -7815,8 +7854,7 @@ function Show-PreviewWindow { default { $null } } if ($null -ne $activeButton) { - $activeButton.Background = [System.Windows.SystemColors]::ControlLightBrush - $activeButton.BorderBrush = [System.Windows.SystemColors]::HighlightBrush + Set-SnipPreviewActiveChrome -Control $activeButton -Active $true | Out-Null } $previewContext.ActiveTool = $Tool $state.ActiveStudioTool = $Tool diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index 2e13a1dd7..1bfa6c8f0 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -349,6 +349,85 @@ Describe 'Fluent theme foundation' { }.GetNewClosure() | Out-Null Should-Be $observed.Accent '#FF035BA3' } + foreach ($themeCase in @('Dark','Light')) { + It "keeps preview toolbar chrome legible in $themeCase" { + $mode = $themeCase + $bitmap = [System.Drawing.Bitmap]::new(16, 16) + # Show-PreviewWindow swallows TestAction throws (see the accent test + # above), so capture every brush into an outer holder and assert after + # the call returns. + $observed = [pscustomobject]@{ + Ground = $null; MoreIsActive = $null + MoreIdleInk = $null; MoreIdlePlate = $null + MoreActiveInk = $null; MoreActivePlate = $null; MoreIndicator = $null + GlyphInk = $null; GlyphPlate = $null + ActiveToolInk = $null; ActiveToolPlate = $null + StatusDot = $null + } + Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + [void](Initialize-SnipWindowTheme -Window $kit.Win -Mode $mode) + $ground = $kit.Win.Background.Color + # Toolbar plates are translucent over the window ground, so flatten + # them before measuring contrast the way a viewer would see it. + $flatten = { + param($brush) + if ($null -eq $brush) { return $ground } + $color = $brush.Color + $alpha = $color.A / 255.0 + [System.Windows.Media.Color]::FromRgb( + [byte][math]::Round(($color.R * $alpha) + ($ground.R * (1 - $alpha))), + [byte][math]::Round(($color.G * $alpha) + ($ground.G * (1 - $alpha))), + [byte][math]::Round(($color.B * $alpha) + ($ground.B * (1 - $alpha)))) + }.GetNewClosure() + $ink = { param($brush) if ($null -eq $brush) { '' } else { $brush.Color.ToString() } } + $plate = { param($brush) (& $flatten $brush).ToString() }.GetNewClosure() + + $observed.Ground = $ground.ToString() + # Wide keeps every tool on the dock; Crop becomes the active tool. + & $kit.SetResponsiveMode 1400 800 + & $kit.SetStudioTool 'Crop' + $kit.Win.UpdateLayout() + $observed.ActiveToolInk = & $ink $kit.Context.ToolControls.Crop.Foreground + $observed.ActiveToolPlate = & $plate $kit.Context.ToolControls.Crop.Background + $observed.GlyphInk = & $ink $kit.Context.ToolControls.Select.Foreground + $observed.GlyphPlate = & $plate $kit.Context.ToolControls.Select.Background + $observed.MoreIdleInk = & $ink $kit.Context.Shell.MoreButton.Foreground + $observed.MoreIdlePlate = & $plate $kit.Context.Shell.MoreButton.Background + $observed.StatusDot = & $ink $kit.Context.Shell.StatusIndicator.Fill + # Narrow plus a hidden secondary tool is the checked More state. + & $kit.SetResponsiveMode 760 540 + & $kit.SetStudioTool 'Steps' + & $kit.SetResponsiveMode 760 540 + $kit.Win.UpdateLayout() + $observed.MoreIsActive = $kit.MoreState.IsActive + $observed.MoreActiveInk = & $ink $kit.Context.Shell.MoreButton.Foreground + $observed.MoreActivePlate = & $plate $kit.Context.Shell.MoreButton.Background + $observed.MoreIndicator = & $ink $kit.Context.Shell.MoreIndicator.Background + }.GetNewClosure() | Out-Null + + Should-BeTrue $observed.MoreIsActive + # Checked More sits on the brand accent with on-accent ink in both modes. + Should-Be $observed.MoreActivePlate '#FF035BA3' + Should-Be $observed.MoreActiveInk '#FFFFFFFF' + Should-Be $observed.MoreIndicator '#FFFFFFFF' + Should-Be $observed.ActiveToolPlate '#FF035BA3' + Should-Be $observed.ActiveToolInk '#FFFFFFFF' + # No control may repeat the ground as its own ink. + foreach ($pair in @( + [pscustomobject]@{ Name='More checked'; Ink=$observed.MoreActiveInk; Plate=$observed.MoreActivePlate }, + [pscustomobject]@{ Name='More idle'; Ink=$observed.MoreIdleInk; Plate=$observed.MoreIdlePlate }, + [pscustomobject]@{ Name='Select glyph'; Ink=$observed.GlyphInk; Plate=$observed.GlyphPlate }, + [pscustomobject]@{ Name='Crop active'; Ink=$observed.ActiveToolInk; Plate=$observed.ActiveToolPlate }, + [pscustomobject]@{ Name='Status dot'; Ink=$observed.StatusDot; Plate=$observed.Ground })) { + Should-BeFalse ($pair.Ink -eq $pair.Plate) + $ratio = Get-SnipContrastRatio -Foreground $pair.Ink -Background $pair.Plate + if ($ratio -le 4.5) { + throw "$mode $($pair.Name): $($pair.Ink) on $($pair.Plate) is only $ratio`:1" + } + } + } + } It 'rebinds nested-popup lifecycle state when the menu template is swapped' { $bitmap = [System.Drawing.Bitmap]::new(24, 24) $unregistered = [System.Collections.ArrayList]::new() @@ -4509,7 +4588,10 @@ $null = Show-PreviewWindow -Bitmap $bmp -TestAction { $statusIndicator = $kit.Win.FindName('StatusIndicator') Should-BeTrue ($dragHeader -is [System.Windows.Controls.StackPanel]) Should-BeTrue ($statusIndicator -is [System.Windows.Shapes.Ellipse]) - Should-Be $statusIndicator.Fill ([System.Windows.SystemColors]::ControlTextBrush) + # Theme brush, not the fixed Win32 control text colour: the latter is + # always black and vanished on the dark status bar. + Should-BeTrue ([object]::ReferenceEquals( + $statusIndicator.Fill, $kit.Win.TryFindResource('TextFillColorPrimaryBrush'))) Should-Be $kit.Context.ToolControls.Select.FontSize 15 Should-Be $kit.Context.ToolControls.Select.FontFamily.Source 'Segoe UI Symbol' Should-Be $kit.SplitControls.ArrowLine.PrimaryButton.FontSize 15 @@ -4651,10 +4733,14 @@ $null = Show-PreviewWindow -Bitmap $bmp -TestAction { Should-Be $kit.State.ActiveStudioTool 'ArrowLine' Should-Be $kit.MoreState.Name 'Arrow/Line' Should-Be $kit.MoreState.Icon $kit.Context.ToolMetadata.ArrowLine.Icon - Should-Be $kit.Context.Shell.MoreButton.BorderBrush ` - ([System.Windows.SystemColors]::HighlightBrush) - Should-Be $kit.Context.Shell.MoreButton.Background ` - ([System.Windows.SystemColors]::ControlLightBrush) + $accentBrush = $kit.Win.TryFindResource('AccentFillColorDefaultBrush') + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.Shell.MoreButton.BorderBrush, $accentBrush)) + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.Shell.MoreButton.Background, $accentBrush)) + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.Shell.MoreButton.Foreground, + $kit.Win.TryFindResource('TextOnAccentFillColorPrimaryBrush'))) $kit.Context.ToolControls.Select.RaiseEvent( [System.Windows.RoutedEventArgs]::new( [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) diff --git a/scripts/Export-SnipITModules.ps1 b/scripts/Export-SnipITModules.ps1 index ac335a663..8d9989380 100644 --- a/scripts/Export-SnipITModules.ps1 +++ b/scripts/Export-SnipITModules.ps1 @@ -137,7 +137,8 @@ $moduleFunctions = [ordered]@{ 'New-SnipPreviewContext', 'Set-SnipPreviewMenuStyle', 'Connect-SnipPreviewMenuButton', 'Connect-SnipPreviewTransientContextMenu', 'New-SnipSplitControl', 'Set-SnipPropertyIsland', - 'Set-SnipPreviewStatusPresentation', 'Set-SnipPreviewResponsiveMode', + 'Set-SnipPreviewStatusPresentation', 'Set-SnipPreviewActiveChrome', + 'Set-SnipPreviewResponsiveMode', 'Initialize-SnipPreviewAnnotations', 'New-SnipPreviewWindow', 'Show-PreviewWindow' ) diff --git a/src/40-Preview.ps1 b/src/40-Preview.ps1 index 689e0fb3b..b31575eb2 100644 --- a/src/40-Preview.ps1 +++ b/src/40-Preview.ps1 @@ -1175,6 +1175,41 @@ function Set-SnipPreviewStatusPresentation { } } +# Paints (or releases) the "this control is the active tool" chrome on one +# toolbar control using theme resource references instead of literal brushes. +# +# The Fluent theme supplies the button ink, and it flips with the mode - white +# in Dark, near-black in Light. Any literal plate therefore has to be legible +# under both, which no fixed Win32 SystemColors brush is. The palette keys that +# Initialize-SnipWindowTheme writes into the window are defined for both modes +# (accent #035BA3 with #FFFFFF ink = 6.9:1), so a DynamicResource reference +# tracks the mode automatically. Releasing clears the local values so the +# control falls back to the theme style's own subtle fill and ink. +function Set-SnipPreviewActiveChrome { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.Control]$Control, + [bool]$Active + ) + + $properties = @( + [System.Windows.Controls.Control]::BackgroundProperty, + [System.Windows.Controls.Control]::BorderBrushProperty, + [System.Windows.Controls.Control]::ForegroundProperty) + if (-not $Active) { + foreach ($property in $properties) { $Control.ClearValue($property) } + return $Control + } + $keys = @( + 'AccentFillColorDefaultBrush', + 'AccentFillColorDefaultBrush', + 'TextOnAccentFillColorPrimaryBrush') + for ($index = 0; $index -lt $properties.Count; $index++) { + $Control.SetResourceReference($properties[$index], $keys[$index]) + } + $Control +} + function Set-SnipPreviewResponsiveMode { [CmdletBinding()] param( @@ -1240,12 +1275,14 @@ function Set-SnipPreviewResponsiveMode { } else { $Context.Shell.MoreButton.Width = 52 } - $Context.Shell.MoreButton.BorderBrush = if ($Context.MoreState.IsActive) { - [System.Windows.SystemColors]::HighlightBrush - } else { [System.Windows.SystemColors]::ControlDarkBrush } - $Context.Shell.MoreButton.Background = if ($Context.MoreState.IsActive) { - [System.Windows.SystemColors]::ControlLightBrush - } else { [System.Windows.SystemColors]::ControlBrush } + # Theme-aware chrome. The classic SystemColors brushes are Win32 fixed + # (ControlLight #E3E3E3, Control #F0F0F0) and never follow the Fluent + # dark theme, so a locally assigned one put the theme's white button ink + # on a near-white plate (1.1:1) and erased the label. Palette resource + # references resolve per mode: accent plate + on-accent ink when active, + # the theme's own subtle fill when not. + Set-SnipPreviewActiveChrome -Control $Context.Shell.MoreButton ` + -Active $Context.MoreState.IsActive | Out-Null [System.Windows.Automation.AutomationProperties]::SetName( $Context.Shell.MoreButton, $Context.MoreState.Name) } @@ -1405,9 +1442,12 @@ function New-SnipPreviewWindow { $moreIndicator.Width = 12; $moreIndicator.Height = 2 $moreIndicator.Margin = [System.Windows.Thickness]::new(5,0,0,0) $moreIndicator.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + # Only ever shown while the More button carries the active-tool accent plate, + # so the indicator uses the on-accent ink rather than the fixed Win32 + # highlight colour (which sat invisibly on the accent in both modes). $moreIndicator.SetResourceReference( [System.Windows.Controls.Border]::BackgroundProperty, - [System.Windows.SystemColors]::HighlightBrushKey) + 'TextOnAccentFillColorPrimaryBrush') $moreIndicator.Visibility = [System.Windows.Visibility]::Collapsed $moreContent.Children.Add($moreIcon) | Out-Null $moreContent.Children.Add($moreName) | Out-Null @@ -2202,14 +2242,13 @@ function Show-PreviewWindow { & $previewContext.CancelDraft } foreach ($buttonName in @('Select','Crop','Pen','Steps')) { - $button = $previewContext.ToolControls[$buttonName] - $button.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) - $button.ClearValue([System.Windows.Controls.Control]::BorderBrushProperty) + Set-SnipPreviewActiveChrome ` + -Control $previewContext.ToolControls[$buttonName] -Active $false | Out-Null } foreach ($splitName in @('ArrowLine','RectangleEllipse','BlurPixelate')) { - $splitButton = $previewContext.SplitControls[$splitName].PrimaryButton - $splitButton.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) - $splitButton.ClearValue([System.Windows.Controls.Control]::BorderBrushProperty) + Set-SnipPreviewActiveChrome ` + -Control $previewContext.SplitControls[$splitName].PrimaryButton ` + -Active $false | Out-Null } switch ($Tool) { 'Highlight' { $highlightBtn.IsChecked = $true } @@ -2231,8 +2270,7 @@ function Show-PreviewWindow { default { $null } } if ($null -ne $activeButton) { - $activeButton.Background = [System.Windows.SystemColors]::ControlLightBrush - $activeButton.BorderBrush = [System.Windows.SystemColors]::HighlightBrush + Set-SnipPreviewActiveChrome -Control $activeButton -Active $true | Out-Null } $previewContext.ActiveTool = $Tool $state.ActiveStudioTool = $Tool diff --git a/tests/baselines/snipit-function-surface.json b/tests/baselines/snipit-function-surface.json index 0f3054931..7b4ce2888 100644 --- a/tests/baselines/snipit-function-surface.json +++ b/tests/baselines/snipit-function-surface.json @@ -733,6 +733,12 @@ "IsWorkflow": false, "ParamBlock": "param(\n [Parameter(Mandatory)] $RectangleControl,\n $Intersection\n )" }, + { + "Name": "Set-SnipPreviewActiveChrome", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Windows.Controls.Control]$Control,\n [bool]$Active\n )" + }, { "Name": "Set-SnipPreviewMenuStyle", "IsFilter": false, diff --git a/xaml/PreviewWindow.xaml b/xaml/PreviewWindow.xaml index 727015b2f..9fd4e7886 100644 --- a/xaml/PreviewWindow.xaml +++ b/xaml/PreviewWindow.xaml @@ -69,7 +69,7 @@ + Fill="{DynamicResource TextFillColorPrimaryBrush}"/> From e13ed0db3525bbb14507f5fee0437624820eb9ca Mon Sep 17 00:00:00 2001 From: Amit Date: Thu, 13 Aug 2026 22:31:49 +0800 Subject: [PATCH 16/16] fix(tray): dark context-menu rendering follows system theme The tray menu is a WinForms ContextMenuStrip, so it never inherits the WPF Fluent theme the windows get. It painted the same owner-drawn champagne-on black chrome no matter what the system app theme was. Add a -ThemeMode seam defaulting to Get-SnipSystemThemeMode, re-read on every construction because the tray rebuilds its menu per open. Dark now paints the Fluent dark flyout surface (#202020 with #FFFFFF ink) and selects rows with the brand accent #035BA3 under on-accent ink, propagated to every nested dropdown and to the owner-drawn check margin. High contrast still overrides both modes, and Light keeps the existing rendering unchanged. Co-Authored-By: Claude Fable 5 --- SnipIT.ps1 | 22 +++++- Test-SnipIT-Interactive.ps1 | 75 ++++++++++++++++++-- src/50-Tray.ps1 | 22 +++++- tests/baselines/snipit-function-surface.json | 2 +- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/SnipIT.ps1 b/SnipIT.ps1 index af1faad28..d1bf459db 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -10413,9 +10413,18 @@ function Set-SnipHotkeyBinding { } } +# The tray menu is WinForms, so it never inherits the WPF Fluent theme that +# Initialize-SnipWindowTheme applies to every window. -ThemeMode re-reads the +# system app theme on each construction (the tray rebuilds its menu per open), +# and Dark swaps the owner-drawn palette for the Fluent dark surface with the +# brand accent selection. High contrast still wins over both modes. function New-SnipTrayMenu { [CmdletBinding()] - param([Parameter(Mandatory)] $Context) + param( + [Parameter(Mandatory)] $Context, + [ValidateSet('Light','Dark')] + [string]$ThemeMode = (Get-SnipSystemThemeMode) + ) foreach ($requiredService in 'SubmitRequest','OpenSettings','OpenAbout','Exit') { $property = $Context.PSObject.Properties[$requiredService] @@ -10436,6 +10445,16 @@ function New-SnipTrayMenu { $accentColor = [System.Drawing.SystemColors]::Highlight $accentTextColor = [System.Drawing.SystemColors]::HighlightText $borderColor = [System.Drawing.SystemColors]::ActiveBorder + } elseif ($ThemeMode -eq 'Dark') { + # Fluent dark flyout surface with on-accent ink on selection + # (#FFFFFF on #035BA3 is 6.9:1); the same subtle white hairline the + # light path uses keeps the border visible on the dark plate. + $fluent = Get-SnipFluentPalette -Mode Dark + $backgroundColor = [System.Drawing.ColorTranslator]::FromHtml('#202020') + $textColor = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') + $accentColor = [System.Drawing.ColorTranslator]::FromHtml($fluent.Accent) + $accentTextColor = [System.Drawing.ColorTranslator]::FromHtml($fluent.OnAccent) + $borderColor = [System.Drawing.Color]::FromArgb(0x59, 255, 255, 255) } else { $backgroundColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PageBlack) $textColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PrimaryText) @@ -10766,6 +10785,7 @@ function New-SnipTrayMenu { $state = [pscustomobject]@{ Palette = $palette + ThemeMode = $ThemeMode HighContrast = $highContrast OwnerDrawn = $true PrimaryOrder = [string[]]@('Smart','Full','Window','Settings','About','Exit') diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index 1bfa6c8f0..745a713df 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -2809,10 +2809,13 @@ Describe 'Owner-drawn tray presentation' { OpenAbout = { } Exit = { } } - $menu = New-SnipTrayMenu -Context $context + # Pinned to Light so the locked champagne palette is asserted regardless + # of the host machine's system theme. + $menu = New-SnipTrayMenu -Context $context -ThemeMode Light try { Should-BeTrue ($menu -is [System.Windows.Forms.ContextMenuStrip]) Should-BeTrue ($menu.Renderer -is [System.Windows.Forms.ToolStripProfessionalRenderer]) + Should-Be $menu.Tag.ThemeMode 'Light' Should-Be $menu.Tag.Palette.PageBlack '#030405' Should-Be $menu.Tag.Palette.PrimaryText '#F5F1E8' Should-Be $menu.Tag.Palette.Accent '#D8C8A5' @@ -2823,6 +2826,70 @@ Describe 'Owner-drawn tray presentation' { } } + It 'follows the system theme seam and applies dark chrome for Dark' { + $settings = Get-SnipDefaultSettings + $settings.WidgetVisible = $true + $newContext = { + [pscustomobject]@{ + Settings = $settings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + HighContrast = $false + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + }.GetNewClosure() + + $dark = New-SnipTrayMenu -Context (& $newContext) -ThemeMode Dark + try { + Should-Be $dark.Tag.ThemeMode 'Dark' + Should-Be $dark.Tag.Palette.PageBlack '#202020' + Should-Be $dark.Tag.Palette.PrimaryText '#FFFFFF' + Should-Be $dark.Tag.Palette.Accent '#035BA3' + Should-Be $dark.Tag.Palette.AccentText '#FFFFFF' + Should-Be $dark.Tag.CheckPalette.Fill.ToArgb() ` + ([System.Drawing.ColorTranslator]::FromHtml('#035BA3').ToArgb()) + Should-Be $dark.Tag.CheckPalette.Glyph.ToArgb() ` + ([System.Drawing.ColorTranslator]::FromHtml('#FFFFFF').ToArgb()) + Should-Be $dark.BackColor.ToArgb() ` + ([System.Drawing.ColorTranslator]::FromHtml('#202020').ToArgb()) + Should-Be $dark.ForeColor.ToArgb() ([System.Drawing.Color]::White.ToArgb()) + Should-BeTrue ($dark.Renderer -is [System.Windows.Forms.ToolStripProfessionalRenderer]) + foreach ($lifecycle in $dark.Tag.DropDownLifecycles) { + Should-Be $lifecycle.DropDown.BackColor.ToArgb() $dark.BackColor.ToArgb() + Should-Be $lifecycle.DropDown.ForeColor.ToArgb() $dark.ForeColor.ToArgb() + } + # Resting text and the selected-row ink both stay readable. + Should-BeGreaterThan (Get-SnipContrastRatio ` + -Foreground $dark.Tag.Palette.PrimaryText ` + -Background $dark.Tag.Palette.PageBlack) 4.5 + Should-BeGreaterThan (Get-SnipContrastRatio ` + -Foreground $dark.Tag.Palette.AccentText ` + -Background $dark.Tag.Palette.Accent) 4.5 + } finally { + $dark.Dispose() + } + + # Light keeps the established owner-drawn rendering untouched. + $light = New-SnipTrayMenu -Context (& $newContext) -ThemeMode Light + try { + Should-Be $light.Tag.ThemeMode 'Light' + Should-Be $light.Tag.Palette.PageBlack '#030405' + Should-Be $light.Tag.Palette.Accent '#D8C8A5' + } finally { + $light.Dispose() + } + + # No -ThemeMode: the menu is rebuilt per construction from the live seam. + $defaulted = New-SnipTrayMenu -Context (& $newContext) + try { + Should-Be $defaulted.Tag.ThemeMode (Get-SnipSystemThemeMode) + } finally { + $defaulted.Dispose() + } + } + It 'uses system palette colors in High Contrast while retaining owner drawing' { $context = [pscustomobject]@{ Settings = Get-SnipDefaultSettings @@ -2888,7 +2955,7 @@ Describe 'Owner-drawn tray presentation' { OpenAbout = { } Exit = { } } - $menu = New-SnipTrayMenu -Context $context + $menu = New-SnipTrayMenu -Context $context -ThemeMode Light $bitmap = $null try { $menu.Show([System.Drawing.Point]::new(-10000, -10000)) @@ -2928,7 +2995,7 @@ Describe 'Owner-drawn tray presentation' { OpenAbout = { } Exit = { } } - $menu = New-SnipTrayMenu -Context $context + $menu = New-SnipTrayMenu -Context $context -ThemeMode Light $bitmap = [System.Drawing.Bitmap]::new(28, 28) $graphics = [System.Drawing.Graphics]::FromImage($bitmap) try { @@ -2965,7 +3032,7 @@ Describe 'Owner-drawn tray presentation' { OpenAbout = { } Exit = { } } - $menu = New-SnipTrayMenu -Context $context + $menu = New-SnipTrayMenu -Context $context -ThemeMode Light $bitmap = $null try { $menu.Show([System.Drawing.Point]::new(-10000, -10000)) diff --git a/src/50-Tray.ps1 b/src/50-Tray.ps1 index cab9f3134..a8fb86d2b 100644 --- a/src/50-Tray.ps1 +++ b/src/50-Tray.ps1 @@ -902,9 +902,18 @@ function Set-SnipHotkeyBinding { } } +# The tray menu is WinForms, so it never inherits the WPF Fluent theme that +# Initialize-SnipWindowTheme applies to every window. -ThemeMode re-reads the +# system app theme on each construction (the tray rebuilds its menu per open), +# and Dark swaps the owner-drawn palette for the Fluent dark surface with the +# brand accent selection. High contrast still wins over both modes. function New-SnipTrayMenu { [CmdletBinding()] - param([Parameter(Mandatory)] $Context) + param( + [Parameter(Mandatory)] $Context, + [ValidateSet('Light','Dark')] + [string]$ThemeMode = (Get-SnipSystemThemeMode) + ) foreach ($requiredService in 'SubmitRequest','OpenSettings','OpenAbout','Exit') { $property = $Context.PSObject.Properties[$requiredService] @@ -925,6 +934,16 @@ function New-SnipTrayMenu { $accentColor = [System.Drawing.SystemColors]::Highlight $accentTextColor = [System.Drawing.SystemColors]::HighlightText $borderColor = [System.Drawing.SystemColors]::ActiveBorder + } elseif ($ThemeMode -eq 'Dark') { + # Fluent dark flyout surface with on-accent ink on selection + # (#FFFFFF on #035BA3 is 6.9:1); the same subtle white hairline the + # light path uses keeps the border visible on the dark plate. + $fluent = Get-SnipFluentPalette -Mode Dark + $backgroundColor = [System.Drawing.ColorTranslator]::FromHtml('#202020') + $textColor = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') + $accentColor = [System.Drawing.ColorTranslator]::FromHtml($fluent.Accent) + $accentTextColor = [System.Drawing.ColorTranslator]::FromHtml($fluent.OnAccent) + $borderColor = [System.Drawing.Color]::FromArgb(0x59, 255, 255, 255) } else { $backgroundColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PageBlack) $textColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PrimaryText) @@ -1255,6 +1274,7 @@ function New-SnipTrayMenu { $state = [pscustomobject]@{ Palette = $palette + ThemeMode = $ThemeMode HighContrast = $highContrast OwnerDrawn = $true PrimaryOrder = [string[]]@('Smart','Full','Window','Settings','About','Exit') diff --git a/tests/baselines/snipit-function-surface.json b/tests/baselines/snipit-function-surface.json index 7b4ce2888..8f5dd2365 100644 --- a/tests/baselines/snipit-function-surface.json +++ b/tests/baselines/snipit-function-surface.json @@ -503,7 +503,7 @@ "Name": "New-SnipTrayMenu", "IsFilter": false, "IsWorkflow": false, - "ParamBlock": "param([Parameter(Mandatory)] $Context)" + "ParamBlock": "param(\n [Parameter(Mandatory)] $Context,\n [ValidateSet('Light','Dark')]\n [string]$ThemeMode = (Get-SnipSystemThemeMode)\n )" }, { "Name": "Read-SnipSettings",