S-100 chrome theme variants — feasibility spike
Status: read-only research spike. No code changes, no commits. The output of this note is a go / no-go recommendation for the user to decide whether to invest in Phase 2.
Question: can we add S100Day, S100Dusk, and S100Night as
Avalonia ThemeVariants that recolour the entire viewer chrome (title bar,
panels, buttons, charts, dialogs) using S-100-tuned palettes, sitting
alongside Avalonia's stock Light and Dark?
Headline answer: Go — minimum viable scope. Ship S100Night as
the only new chrome variant in a single PR. Light and Dark chrome stay
as-is; Day and Dusk S-100 chrome variants are deferred. Chrome drives
map palette by default (S100Night → Night map; Light/Dark → Day map),
with the map palette dropdown still freely editable for one-off
overrides. Estimate ~3.25 engineer-days. The plumbing is straightforward
(Avalonia 11 supports custom ThemeVariants with inheritance fallback;
ShadUI 0.1.6 publishes ~30 Color keys we override sparsely), and the
maintenance footprint of one extra variant is bounded.
1. Avalonia 11's custom-variant story
Avalonia 11 (we're on 11.3.x — see Directory.Packages.props) supports
arbitrary theme variants out of the box. The relevant primitive is
Avalonia.Styling.ThemeVariant, source at
src/Avalonia.Base/Styling/ThemeVariant.cs:
public sealed record ThemeVariant
{
public ThemeVariant(object key, ThemeVariant? inheritVariant) { … }
public object Key { get; }
public ThemeVariant? InheritVariant { get; }
public static ThemeVariant Default { get; }
public static ThemeVariant Light { get; }
public static ThemeVariant Dark { get; }
}
Two facts matter:
Custom keys are first-class. A new
ThemeVariant("S100Night", …)is a valid value forApplication.RequestedThemeVariantand propagates throughActualThemeVariantexactly like the built-ins. The theme variants guide explicitly says variant keys can be anything the developer chooses;Defaultis reserved as the fallback bucket.InheritVariantis the killer feature. A custom variant can name another variant as its inheritance root: when aDynamicResourcelookup misses in the custom variant's dictionary, Avalonia walks the inherit chain instead of falling back toDefault. SoS100Nightdeclared asnew ThemeVariant("S100Night", ThemeVariant.Dark)automatically picks up every ShadUI Dark color we don't explicitly override. We can ship a sparse override dictionary — only the dozen-or-so keys we actually want to retune — without losing styling for anything else.
The minimum viable resource dictionary for one custom variant is roughly this (App.axaml-level):
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="S100Night">
<Color x:Key="WindowBackgroundColor">#0A0000</Color>
<Color x:Key="ForegroundColor">#FFB0A0</Color>
<!-- … other overrides … -->
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
Plus, in code, registration of the variant + its inherit chain (the XAML key
"S100Night" is matched by string against the Key of whatever
ThemeVariant instance we hand to RequestedThemeVariant). The inherit
behaviour does require constructing the variant programmatically:
public static readonly ThemeVariant S100Night = new("S100Night", ThemeVariant.Dark);
public static readonly ThemeVariant S100Dusk = new("S100Dusk", ThemeVariant.Dark);
public static readonly ThemeVariant S100Day = new("S100Day", ThemeVariant.Light);
Application.Current!.RequestedThemeVariant = S100Night;
That's the entire registration story. There is no allow-list; no opt-in API ShadUI or any other library has to provide.
2. ShadUI's theming model
Source: accntech/shad-ui on GitHub (and shipped binaries at
~/.nuget/packages/shadui/0.1.6/). The relevant files:
src/ShadUI/Themes/ShadTheme.axamlsrc/ShadUI/Themes/Light.axamlsrc/ShadUI/Themes/Dark.axamlsrc/ShadUI/Controls/Constants.axamlsrc/ShadUI/Controls/Resources.axaml(merges every per-control resource file)
ShadTheme.axaml is dead simple:
<Styles x:Class="ShadUI.ShadTheme" …>
<Styles.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://ShadUI/Controls/Constants.axaml" />
<ResourceInclude Source="avares://ShadUI/Controls/Resources.axaml" />
</ResourceDictionary.MergedDictionaries>
<ResourceDictionary.ThemeDictionaries>
<ResourceInclude Source="/Themes/Light.axaml" x:Key="Light" />
<ResourceInclude Source="/Themes/Dark.axaml" x:Key="Dark" />
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Styles.Resources>
<StyleInclude Source="avares://ShadUI/Controls/Styles.axaml" />
</Styles>
Two consequences:
- ShadUI is unaware of any variant outside Light/Dark. It has no
conditional logic, no
[Theme]attribute scan, no extensibility hook to register a third variant. Light and Dark are hardcodedx:Keys in the package's ownThemeDictionaries. - But that doesn't matter, because Avalonia's resource resolution walks
every
ThemeDictionariesin the merged tree. If we add anS100Nightdictionary at App level, controls inside ShadUI's templates using{DynamicResource WindowBackgroundColor}resolve against the combined set: ShadUI's Light + ShadUI's Dark + our S100Night. When the current variant isS100Night, our entry wins; for any key we omit, Avalonia falls throughInheritVariant.Darkto ShadUI's Dark dictionary.
ShadUI resource keys
Counted from Light.axaml / Dark.axaml: ~30 distinct Color keys
(many with …Color60, …Color20 opacity siblings derived from a base).
Grouped:
| Category | Keys |
|---|---|
| Surface / window | WindowBackgroundColor, TitleBarBackgroundColor, SidebarBackgroundColor, CardBackgroundColor, BackgroundColor |
| Dialog | DialogBackgroundColor, DialogOverlayColor, BusyAreaOverlayColor |
| Text | ForegroundColor, ForegroundLeadColor, MutedColor |
| Border / outline | BorderColor, BorderColor60, BorderColor30, OutlineColor |
| Theme accent (button) | PrimaryColor + Color75/50/10, PrimaryForegroundColor, SecondaryColor + 75/50, SecondaryForegroundColor |
| Destructive | DestructiveColor + 75/50/10, DestructiveForegroundColor |
| Status | InfoColor, SuccessColor, WarningColor, ErrorColor (each with 60/20/10/5 alpha siblings) |
| Ghost / hover / select | GhostColor, GhostHoverColor, GhostHoverColor50, WindowButtonHoverColor, SelectionColor |
| Switch / tab | SwitchBackgroundColor, SwitchForegroundColor, TabItemSelectedColor, TabItemsBackgroundColor |
Important: ShadUI publishes Color keys, not IBrush keys. Per-control
templates instantiate brushes from the colors via ordinary
<SolidColorBrush Color="{DynamicResource WindowBackgroundColor}" />
patterns. Our overrides therefore need to be Color values too.
AccentBrush — heavily used in the viewer — is not a ShadUI key. It's
a viewer-owned SolidColorBrush defined in MainWindow.axaml and mutated
by MainWindow.axaml.cs when SettingsViewModel.AccentColor changes. It
sits outside ShadUI's theme system today; that's actually convenient,
because the user-chosen accent should stay constant across chrome variants.
Fallback behaviour for keys we don't override
Confirmed by reading ThemeDictionaries semantics: missing keys traverse the
InheritVariant chain. So a sparse S100Night dictionary that overrides
only WindowBackgroundColor, TitleBarBackgroundColor,
SidebarBackgroundColor, CardBackgroundColor, DialogBackgroundColor,
ForegroundColor, ForegroundLeadColor, MutedColor, BorderColor,
OutlineColor, BusyAreaOverlayColor, TabItemsBackgroundColor (≈12
keys) gets us a coherent night palette, with Status/Switch/Tab/Ghost
inheriting from Dark. This is the minimum viable scope.
3. Mapping S-100 portrayal palette → ShadUI keys
There is no clean spec-derived mapping. The S-100 portrayal colour profile
(src/EncDotNet.S100.Specifications/content/S101/pc/ColorProfiles/colorProfile.xml)
defines tokens for map features — CHBLK, CHWHT, LANDA, DEPVS,
UIBCK, UINFD, etc. The four UI… tokens (UIBCK, UINFD, UINFF,
UINFR) are explicitly the closest things to chrome colours, but they're
intended for the "information area" the ECDIS pen draws inside the map
viewport, not for OS-level chrome like a title bar or a checkbox track.
The token set has nothing for "panel border radius accent", "input focus
ring", "dialog overlay alpha".
So the mapping is unavoidably hand-picked, taking inspiration from S-100 conventions (white-on-black at night, warm-dim at dusk, flat white for day) rather than mechanically derived. The palettes below are sketches to anchor discussion — not a spec.
| Key | Avalonia Light | Avalonia Dark | S100Day (proposed) | S100Dusk (proposed) | S100Night (proposed) |
|---|---|---|---|---|---|
WindowBackgroundColor |
#FFFFFF |
#0A0A0A |
#FAFAF7 |
#1F1A14 |
#000000 |
TitleBarBackgroundColor |
#FFFFFF |
#0A0A0A |
#F0F0EC |
#231D16 |
#0A0000 |
SidebarBackgroundColor |
#FFFFFF |
#0A0A0A |
#F5F5F0 |
#1A1610 |
#080000 |
CardBackgroundColor |
#FFFFFF |
#171717 |
#FFFFFF |
#2A2218 |
#100404 |
DialogBackgroundColor |
#F2F2F2 |
#0A0A0A |
#F5F5F0 |
#231D16 |
#0A0000 |
ForegroundColor |
#18181B |
#E5E5E5 |
#18181B |
#D9C4A8 |
#FFB0A0 |
ForegroundLeadColor |
#71717A |
#A1A1AA |
#5A5A60 |
#A8927A |
#C88478 |
MutedColor |
#71717A |
#A0A0A9 |
#71717A |
#8A7560 |
#8A4848 |
BorderColor |
#CECECE |
#353535 |
#D8D6D0 |
#3A3024 |
#280808 |
OutlineColor |
#E5E5E5 |
#3E3E3E |
#E0DED8 |
#3F3528 |
#301010 |
BusyAreaOverlayColor |
#F2F2F2 |
#1971717A |
#F2F2F2 |
#19A8927A |
#19FF6464 |
TabItemsBackgroundColor |
#E8E8E8 |
#27272A |
#EAE8E2 |
#2F271D |
#1A0606 |
Notes on the palettes:
- S100Day is barely distinguishable from Avalonia Light (slightly warmer off-white). Honest assessment: this variant earns its keep almost entirely for consistency — i.e. so the chrome-variant selector has a Day option matching the map-palette selector — not because it adds visible value. We could legitimately decide S100Day = ThemeVariant.Light and skip the custom dictionary entirely.
- S100Dusk is a warm-dim brown/amber palette — there's no Avalonia equivalent. This one does add real value if you use the viewer at twilight on a real bridge. It's the second-most-justifiable variant.
- S100Night is the standard ECDIS night: near-black background,
red-shifted foreground (
#FFB0A0≈ a muted salmon, not pure red, to remain readable), all status colours wash out toward red. This is the variant that actually preserves dark-adapted vision and is the entire raison d'être of the spike. - Status colours (
InfoColor,SuccessColor,WarningColor,ErrorColor) deserve special thought for Night: pure blue#2979FFdestroys night vision worse than chrome white does. A correct Night palette pushes Info → dim cyan or even amber, Success → muted green, Warning → amber, Error → deep red. Spec doesn't dictate this — we're inventing it. Visual review with a hardware partner would matter. - We should not override
PrimaryColor/SecondaryColor. Those are ShadUI's "filled button" colours and pull double-duty as accent surfaces. The viewer already routes its accent through theAccentBrushresource, which is independent of theme. Keeping S100 chrome variants accent-agnostic is a feature.
4. Existing in-repo theme-aware code
The viewer's binary IsDarkTheme model is small but pervasive enough that
adding 3 more variants requires care.
IThemeService / ThemeService
internal interface IThemeService
{
bool IsDarkTheme { get; }
bool ToggleTheme();
}
The contract is binary. Adding 3+ variants requires either:
- Migrate to enum (
ChromeTheme.Light | Dark | S100Day | S100Dusk | S100Night) and break the contract, or - Add a parallel enum-flavoured API (
ChromeTheme Current { get; },void SetTheme(ChromeTheme)) and leaveIsDarkThemeas a derived convenience for callers that only need the binary signal.
Recommend the second: derived IsDarkTheme keeps MeasureOverlayAppearanceProvider
and CompassRoseView working unchanged in their first cut. IsDarkTheme
returns true for Dark, S100Dusk, and S100Night; false for
Light and S100Day. That's a reasonable simplification: the overlay and
compass were tuned for "is the canvas dark?", which is exactly what these
variants express.
MeasureOverlayAppearanceProvider
Reads _settings.AccentColor + _theme.IsDarkTheme, raises a single
Changed event when either source updates. Already subscribes to
Application.ActualThemeVariantChanged, so the moment we set
RequestedThemeVariant = S100Night, the overlay re-evaluates. No
behavioural change required, assuming we keep the binary IsDarkTheme
projection. (Phase 3 polish: derive a more nuanced "overlay tint" from the
variant — pure white text overlays look harsh on a near-black night chrome
because they're brighter than the ECDIS palette wants.)
CompassRoseView
Currently does its own variant check:
var isDark = ActualThemeVariant == ThemeVariant.Dark;
That breaks the moment we set the variant to S100Night, because
ActualThemeVariant != ThemeVariant.Dark. The fix is mechanical:
var t = ActualThemeVariant;
var isDark = t == ThemeVariant.Dark || t.Key is "S100Night" or "S100Dusk";
…or better, route through IThemeService.IsDarkTheme so this lives in one
place. Since CompassRoseView is a Control, not a VM, we'd have to inject
or look up the service — the cleaner fix is probably to keep the inline
check but generalise it once.
Post-PR-N1 chart VMs
After PR-N1 lands, station chart view-models subscribe to
ActualThemeVariantChanged and recolour their LiveCharts2 series. Same
fix: replace any equality check against ThemeVariant.Dark with
"is this a dark variant" predicate. The chart re-render path already
exists; we're just feeding it a finer signal.
Other subtle consumers
MainWindow.axamldefinesAccentBrushoutside anyThemeDictionaries. It will resolve correctly under all variants — but it is not variant-aware, which is what we want (accent is a separate axis from chrome variant).App.axamlSlider template overrides use{DynamicResource AccentBrush}— variant-agnostic, fine.ViewerSettings.ColorProfileis a string already ("Day","Dusk","Night"). A newChromeThemesetting needs its own key; reusingColorProfilewould couple the two axes prematurely (see §5).
5. Linkage with the map palette
After discussion, the model is: chrome theme is primary; map palette follows chrome by default, but the user can override the map palette to any value to test arbitrary combinations. Reversed from the original draft — chrome is the driver, not the map.
Rationale (paraphrasing the user): Light / Dark are system-level preferences ("I want all my apps to look consistent on this machine"), not operational nautical preferences. A user switching the machine to Dark mode is not declaring intent to navigate at night. So Light and Dark chrome both map to Day map palette by default. The S-100 chrome variants (just S100Night for the first cut) are operational preferences and so do drive the map palette.
Default coupling
| Chrome variant | Default map palette |
|---|---|
Light |
Day |
Dark |
Day |
S100Night |
Night |
(future) S100Dusk |
Dusk |
(future) S100Day |
Day |
Note: there is no chrome variant that defaults the map to anything other than its same-named palette (or Day for Light/Dark). The mapping is one-way deterministic on chrome change.
Override semantics
- The map palette has its own UI control (today's
SettingsViewModel. SelectedPalettedropdown) that stays visible. Users can change it freely at any time. - When the user picks a chrome variant, the map palette is reset to the default for that variant (per the table above), overwriting any prior manual map-palette selection. This is the "set chrome → map follows" half.
- Once chrome is set, the user can re-select a different map palette and it sticks until the next chrome change. We do not introduce a persistent "decouple" toggle — the override is implicit (just change the map dropdown after picking chrome).
- This is honest about the user's mental model: chrome is the rare, deliberate switch ("I'm now operating at night"); map palette is the fine-tuning ("…and I want to see what this chart looks like in Dusk mode under that chrome").
Future: an explicit "dark map" palette
Deferred. The user noted that if Dark chrome eventually wants a map that's muted to match (rather than the standard Day palette under a dark frame, which can be jarringly bright), we'd add a new map palette that's a desaturated Day rather than Night. That's a separate piece of work; out of scope here.
Persistence
ViewerSettings.ColorProfile already persists Day | Dusk | Night as
a string for the map palette. Add a new ViewerSettings.ChromeTheme
string (e.g. "Light" | "Dark" | "S100Night") that persists the chrome
variant. The two are independent settings — we do not reuse
ColorProfile for chrome.
When the user changes chrome, both settings update in a single
transaction (chrome → ChromeTheme; default-mapped map palette →
ColorProfile). When the user changes only the map palette, only
ColorProfile updates. On startup, both are restored independently —
so a user who picked S100Night chrome and then overrode to Day map
gets exactly that combination back next launch.
Implementation note
SettingsViewModel.SelectedPalette is already the right setter
location for map-palette changes (it raises PaletteChanged which
DatasetLoaderService listens to for re-render). Add a sibling
SelectedChromeTheme property; its setter:
- Writes
ViewerSettings.ChromeTheme. - Sets
Application.Current!.RequestedThemeVariant. - Sets
SelectedPaletteto the default for that chrome variant (which in turn triggers the existing map re-render path).
Step 3 is what makes the coupling work. It's a normal SelectedPalette
write, so the existing change-notification chain handles map re-render
exactly as it does today.
6. Effort estimate
Estimates are engineer-days for an engineer already familiar with the viewer. Includes implementation, tests, doc updates. Excludes design review and visual QA across platforms.
| Phase | Min viable (Night only) | Full (Day + Dusk + Night) | Polish (status colours, focus rings, edge cases) |
|---|---|---|---|
Register ThemeVariant instances + App.axaml ThemeDictionaries skeleton |
0.25 | 0.5 | — |
| Author override resource dictionary (12 keys per variant) | 0.5 | 1.5 | +1 (status palette, opacity siblings) |
Migrate IThemeService (add enum-flavoured API; keep IsDarkTheme derived) |
0.5 | 0.75 | — |
Wire SettingsViewModel chrome-drives-map coupling + ChromeTheme persistence |
0.5 | 0.5 | — |
Touch up CompassRoseView + MeasureOverlayAppearanceProvider for non-Dark "darks" |
0.25 | 0.25 | +0.5 (variant-specific overlay tints) |
| Update PR-N1 chart VMs to react to non-Dark dark variants | 0.25 | 0.25 | — |
| Settings UI (dropdown / radio for chrome theme; visibility of override toggle) | 0.25 | 0.5 | — |
| Strings.resx entries + tooltips (per repo viewer rules) | 0.25 | 0.5 | — |
| Manual visual QA across macOS/Windows/Linux | 0.5 | 1 | +1 |
| Total | ~3.25 days | ~5.75 days | +~2.5 days |
These are honest "engineer-day" estimates, not optimistic ones. The visual-QA item is the one most likely to balloon: every dialog / popup / data-grid / focus state in the app needs eyeballing under the new variants because no automated coverage exists today (see §7).
7. Risks and open questions
No visual regression coverage. Panel inventory PR #125 noted this gap. Adding three new variants triples the surface that has to be manually verified each time ShadUI updates. Mitigation: commission a visual-regression harness before merging Phase 2; or accept that chrome variants will rot quietly.
ShadUI updates can introduce new keys we don't override. Each new key with no S100 override silently inherits from Dark (which may or may not be appropriate for Night). Mitigation: add a CI check that diffs ShadUI's
Light.axamlbetween releases and flags new keys; or accept gradual drift.Status colours under Night are an open palette question. Pure blue Info on a near-black background reflects exactly the night-vision problem ECDIS Night mode exists to solve. We have to pick replacement hues without an IHO spec to cite. Open question: do we want a maritime-domain expert (or at least an existing reference ECDIS) to sanity-check Dusk/Night status hues before we ship?
Day chrome adds limited value. Avalonia Light covers ~95% of the user-visible Day experience already. Authoring an S100Day dictionary exists mostly to make the selector symmetric. Mitigation: alias S100Day → ThemeVariant.Light at the variant level (no separate dictionary). The user's slaving UI then maps
SelectedPalette.Day→ThemeVariant.Light,Dusk→S100Dusk,Night→S100Night.Settings persistence migration.
ViewerSettings.ColorProfilealready persists Day/Dusk/Night. A separateChromeThemesetting (when decoupled) is new and needs default-value handling for users upgrading from versions that didn't have it. Trivial but easy to forget.AccentBrushinteraction with red-friendly Night. The user can currently pick a bright cyan accent. Under S100Night that bright cyan is exactly what Night chrome is designed to suppress. Open question: do we (a) honour the user's accent regardless, (b) silently desaturate it under Night, or (c) show a warning in settings? Recommend (a): the user owns their accent. Document the gotcha.ThemeVariantScopefor one-off light surfaces. The map canvas is always inside its own scope (Mapsui owns its own rendering). But pop-out windows, file dialogs, and OS-native dialogs may not honour custom variants on every platform. Open question: what does macOS's native title bar do underThemeVariant("S100Night", inherit: Dark)? Looking atPlatformThemeVariantoperator: a custom variant inheriting from Dark casts toPlatformThemeVariant.Dark, so OS-level chrome should follow Dark. Worth verifying on hardware.LiveCharts2 + Mapsui are independent of
ThemeVariantuntil we wire them. Mapsui's tile / vector layers don't read Avalonia resources at all — they're driven by the existingPaletteChangedre-render path, which is map-palette-axis, not chrome-axis. Slaving the two axes (§5) is what reconciles that. After PR-N1, chart VMs subscribe to chrome theme changes; the new variants are fine for them as long asIsDarkThemestays a meaningful predicate.CompassRoseView's inlineThemeVariant.Darkcheck is one of probably several. A pre-merge audit (grep -rn "ThemeVariant\."insrc/EncDotNet.S100.Viewer) would surface them all. The spike found one; there may be 2–4 more.The "S100" prefix on the ThemeVariant key is permanent. If we ever rebrand or consolidate, persisted user settings reference these strings. Worth getting the names right the first time.
8. Recommendation
Go — minimum viable scope. Ship a single PR that adds S100Night as
the only new chrome variant, with chrome-drives-map coupling.
Scope of the first PR:
One new chrome variant:
S100Night. Registered asnew ThemeVariant("S100Night", inheritVariant: ThemeVariant.Dark). Roughly 12 overridden keys (the surface/text/border set in §3); status colours inherit from Dark for now (call out in the PR description as a known follow-up — Info blue on near-black needs revisiting once we see it in real use).No bespoke S100Day or S100Dusk in this PR. Avalonia Light covers Day. Dark covers Dusk well enough to defer the work. The chrome selector exposes three options: Light, Dark, S100Night.
Wire chrome-drives-map coupling per §5. Picking S100Night also sets the map palette to Night; picking Light or Dark sets it to Day. The map palette dropdown stays visible and editable for the override-after-the-fact case. New
ViewerSettings.ChromeThemesetting persists chrome independently ofColorProfile.Migrate
IThemeServiceto expose both the variant and a derivedIsDarkTheme(true forDarkandS100Night).CompassRoseViewand any other inlineThemeVariant.Darkequality checks switch to theIsDarkThemepredicate so they don't go pale-on-pale under S100Night.Strings.resx entries + tooltips for the new selector per the viewer instructions file. (
viewer.instructions.mdrequires every user-visible string in resx and every button to have a tooltip.)Manual visual QA on macOS (the maintainer's primary OS) is enough for a first ship. Windows/Linux are tested when a user on those platforms exercises the feature. Don't block on a regression harness — see §7.1.
Estimate: ~3.25 engineer-days, per §6 "Min viable" column.
What to defer
- S100Dusk. Promote to a custom variant in a follow-up if users report Dark-as-Dusk looks wrong (probable: Dusk wants warm tones, Dark is cool-neutral). By then we'll know which keys actually matter.
- S100Day. Skip indefinitely; an extra dictionary nobody can distinguish from Avalonia Light is pure overhead.
- A muted "dark map" palette (per the user's note in §5). Separate piece of work, separate spec discussion.
- Bespoke night-tuned status colours (Info / Success / Warning / Error). Ship inheriting from Dark; refine in a follow-up after visual review.
- Visual regression harness. Worth doing eventually but not as a blocker for one variant.
What would change the call
This is a Go because the cost is bounded (~3 days), the win is real (actual dark-adapted vision support, the only thing S-100 Night genuinely needs that Avalonia Dark doesn't provide), and the maintenance footprint of one extra variant is modest. If the team ever decides to go all the way to all three S-100 chrome variants plus night-tuned status colours plus visual regression coverage, the cost becomes more like 8–10 engineer-days for a relatively small UX win, at which point "ship Phase 1 only" becomes the defensible answer. The recommendation above explicitly avoids that escalation by capping Phase 2 scope at S100Night-only.
Appendix: sources
- Avalonia 11 theme variants guide: https://docs.avaloniaui.net/docs/guides/styles-and-resources/how-to-use-theme-variants
Avalonia.Styling.ThemeVariantsource (11.3.11): https://github.com/AvaloniaUI/Avalonia/blob/release/11.3.11/src/Avalonia.Base/Styling/ThemeVariant.cs- ShadUI 0.1.6 ShadTheme + Light + Dark dictionaries: https://github.com/accntech/shad-ui/tree/main/src/ShadUI/Themes
- Local NuGet cache:
~/.nuget/packages/shadui/0.1.6/lib/netstandard2.1/ShadUI.dll - In-repo theme-aware code surveyed:
src/EncDotNet.S100.Viewer/Services/IThemeService.cssrc/EncDotNet.S100.Viewer/Services/ThemeService.cssrc/EncDotNet.S100.Viewer/Services/MeasureOverlayAppearanceProvider.cssrc/EncDotNet.S100.Viewer/Views/CompassRoseView.cssrc/EncDotNet.S100.Viewer/App.axamlsrc/EncDotNet.S100.Viewer/MainWindow.axaml(AccentBrushresource)src/EncDotNet.S100.Viewer/ViewerSettings.cssrc/EncDotNet.S100.Viewer/ViewModels/SettingsViewModel.cssrc/EncDotNet.S100.Viewer/Services/DatasetLoaderService.cs(PaletteChangedre-render hook)
- S-100 portrayal palette source:
src/EncDotNet.S100.Specifications/content/S101/pc/ColorProfiles/colorProfile.xml