diff --git a/SnipIT.ps1 b/SnipIT.ps1 index d1e61e604..d1bf459db 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( @@ -2930,7 +2998,7 @@ $script:SnipEmbeddedXaml = [ordered]@{ + Fill="{DynamicResource TextFillColorPrimaryBrush}"/> @@ -3740,6 +3808,89 @@ 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 + $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. +# 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.ColorConverter]::ConvertFromString($palette.Ground)) + $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 +5884,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 +5946,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 +5975,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 @@ -6570,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( @@ -6635,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) } @@ -6719,6 +6945,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') @@ -6799,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 @@ -7596,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 } @@ -7625,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 @@ -9322,6 +9550,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 +9902,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 +10061,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 @@ -10182,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] @@ -10205,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) @@ -10535,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 2f5461a5c..745a713df 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -215,6 +215,272 @@ 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' + } + 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 + $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 '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 + $applied = Initialize-SnipWindowTheme -Window $window + 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]@{ + 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 + } + 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' + } + 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() + $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' { $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-' + [guid]::NewGuid()) $settingsPath = Join-Path $root 'settings.json' @@ -2543,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' @@ -2557,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 @@ -2622,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)) @@ -2662,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 { @@ -2699,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)) @@ -4322,7 +4655,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 @@ -4464,10 +4800,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/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/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..ffbe5b351 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-fluent-foundation.md @@ -0,0 +1,533 @@ +# 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. 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`. + +--- + +### 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` + +> **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`) + +**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 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/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/src/10-Bootstrap.ps1 b/src/10-Bootstrap.ps1 index 48f8ee73d..d8e73fb18 100644 --- a/src/10-Bootstrap.ps1 +++ b/src/10-Bootstrap.ps1 @@ -460,6 +460,89 @@ 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 + $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. +# 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.ColorConverter]::ConvertFromString($palette.Ground)) + $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/src/40-Preview.ps1 b/src/40-Preview.ps1 index ef199526c..b31575eb2 100644 --- a/src/40-Preview.ps1 +++ b/src/40-Preview.ps1 @@ -300,26 +300,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 @@ -334,9 +362,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 +391,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 @@ -1137,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( @@ -1202,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) } @@ -1286,6 +1361,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') @@ -1366,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 @@ -2163,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 } @@ -2192,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/src/50-Tray.ps1 b/src/50-Tray.ps1 index fac38a4b8..a8fb86d2b 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 @@ -548,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 @@ -899,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] @@ -922,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) @@ -1252,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 051b94f6a..8f5dd2365 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, @@ -283,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, @@ -337,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, @@ -485,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", @@ -715,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}"/>