Skip to content

Theming

Every control template in Navius.Wpf.Primitives and Navius.Wpf.Ui reads its colors and corner radii through Navius.* DynamicResource keys, never a literal value. Button.xaml, for example, sets Background from {DynamicResource Navius.Primary} and the button’s corner radius from {DynamicResource Navius.Radius.Control}.

Three dictionaries under src/Navius.Wpf.Primitives/Themes/ supply those keys: Tokens.Light.xaml, Tokens.Dark.xaml, and Tokens.HighContrast.xaml. Each carries the same key set: a Navius.Tokens.Theme marker string ("Light", "Dark", or "HighContrast"), nineteen SolidColorBrush entries (Navius.Background, Navius.Foreground, Navius.Card, Navius.CardForeground, Navius.Popover, Navius.PopoverForeground, Navius.Primary, Navius.PrimaryForeground, Navius.Secondary, Navius.SecondaryForeground, Navius.Muted, Navius.MutedForeground, Navius.Accent, Navius.AccentForeground, Navius.Destructive, Navius.DestructiveForeground, Navius.Border, Navius.Input, Navius.Ring), and three CornerRadius entries (Navius.Radius.Small, Navius.Radius.Control, Navius.Radius.Card).

That key-set match is not just convention: tests/Navius.Wpf.Tests/ThemeManagerTests.cs’s TokenDictionary_HighContrast_CoversSameKeysAsLight loads both Tokens.Light.xaml and Tokens.HighContrast.xaml and asserts their key sets are equal, so an addition to one dictionary that is not mirrored in the other fails the test suite rather than surfacing as a missing token at runtime.

Navius.Wpf.Primitives.Theming.ThemeManager (src/Navius.Wpf.Primitives/Theming/ThemeManager.cs) owns which token dictionary is live:

  • Apply(NaviusTheme theme) applies a theme to Application.Current.Resources. Apply(NaviusTheme theme, ResourceDictionary scope) applies it to an explicit scope instead (a single window, or a test’s isolated dictionary). Both walk the scope’s MergedDictionaries backward, remove any dictionary carrying the Navius.Tokens.Theme marker key, then add a freshly loaded Themes/Tokens.{theme}.xaml in its place, so repeated calls swap the token set instead of stacking dictionaries.
  • Current is the last-applied NaviusTheme (Light, Dark, or HighContrast), defaulting to Light.
  • ThemeChanged fires after every swap, including the high-contrast enter/restore transitions below, so a consumer that reads token values in code (rather than through DynamicResource) has a signal to re-resolve them.
  • EnableSystemHighContrastSync() is opt-in and idempotent (a second call is a no-op). It subscribes to SystemParameters.StaticPropertyChanged and checks SystemParameters.HighContrast immediately on enable and again on every subsequent change. When the OS flag turns on, it records whatever theme was active immediately before (unless already in a high-contrast state) and applies NaviusTheme.HighContrast. When the flag turns back off, it re-applies whatever theme was recorded before the switch, restoring the prior theme rather than falling back to a fixed default. Both transitions go through the same Apply path, so ThemeChanged fires for them like any other theme change.

Because every template binds tokens with DynamicResource rather than StaticResource, swapping the token dictionary at runtime re-paints every live control immediately, with no rebuild of the visual tree. An app (or a vendored copy of a control) can override a single token by inserting its own dictionary above the Navius one, or by setting the key directly in a narrower scope:

<Window.Resources>
<SolidColorBrush x:Key="Navius.Primary" Color="#2954FF" />
</Window.Resources>

Because DynamicResource re-resolves on lookup, this only needs to be declared once; it does not need to be re-applied when ThemeManager.Apply runs on an ancestor scope, since resource lookup walks up the tree and stops at the first dictionary that defines the key.

Windows high contrast is a native accessibility mode the web port never had to handle: browsers own high-contrast rendering themselves, but a WPF app’s SolidColorBrush values are exactly what a high-contrast theme needs to replace with the OS’s own palette. Tokens.HighContrast.xaml maps every Navius.* brush key to a SolidColorBrush whose Color is bound with DynamicResource to a SystemColors.*ColorKey, never a literal color, so a live OS palette change repaints the brush without an app restart or another Apply call. Per ADR-0007’s mapping:

Navius token SystemColors key
Background / Foreground Window / WindowText
Card / Popover (+Foreground) Window / WindowText
Primary / PrimaryForeground Highlight / HighlightText
Secondary / Accent (+Foreground) Control / ControlText
Muted / MutedForeground Control / GrayText
Destructive / DestructiveForeground WindowText / Window
Border / Input WindowText
Ring (focus) Highlight

Navius.Radius.Small/Control/Card are kept identical to the Light and Dark dictionaries; ADR-0007 notes there is no high-contrast-specific corner-radius concern. There is no SystemColors key for a danger or destructive color, so Navius.Destructive falls back to WindowText; destructive affordances need to carry their meaning through icon or label rather than hue while high contrast is active.

Applying NaviusTheme.HighContrast is opt-in in both directions: nothing switches to it automatically just because the token dictionary exists. A consumer either calls ThemeManager.Apply(NaviusTheme.HighContrast) directly (the same call used for Light or Dark), or calls EnableSystemHighContrastSync() once to have ThemeManager track SystemParameters.HighContrast for the lifetime of the app and switch automatically as the OS setting changes.

The same Checkbox Gallery page rendered at the pinned commit under each theme dictionary.

Checkbox Gallery page in the light theme

Checkbox Gallery page in the dark theme

Checkbox Gallery page in the high contrast theme