From 002748d5d0c10f4aac3fde2d103d0ac35c820415 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 14 Aug 2026 20:21:38 +0200 Subject: [PATCH] Give dark-theme syntax colors a perceptual contrast floor The dark palette is hand-authored for C# only; every other highlighting definition -- XML, IL, Asm, and all AvaloniaEdit built-ins -- is derived by inverting HSL lightness. HSL lightness is not perceptual luminance, so the result depended entirely on hue: blue carries a 0.0722 luminance weight, so plain Blue landed at 4.08:1 against the editor canvas, and an already-light source such as Asm's #8080FF inverted downwards to 1.29:1 -- invisible. Reported against XML resources in #3986. Enforcing a 5.5:1 WCAG floor on the converted foreground fixes every affected definition in the one place they all route through, which a per-language palette would not: the AvaloniaEdit built-ins (JSON, Markdown, JS, HTML, CSS, Python) have no palette to author. 5.5 is where the existing CSharpDark values already sit; the 4.5 AA threshold was measured and only moves the reported blue to 4.51. The floor is deliberately foreground-only -- forcing a span background to contrast with the canvas would repaint Asm's #EEEEEE Registers background as a bright block and bury the text on top of it -- and it is measured against the surface the foreground lands on, which is that span background when the colour declares one, so a light-on-dark span cannot be pulled apart into two colours that no longer contrast with each other. The same function's desaturation guard only fired when the inverted lightness stayed below 0.75, so a dark fully saturated source (DarkMagenta) came back light and still fully saturated -- exactly the neon the softening exists to prevent. Only the softening becomes unconditional; the lightness lift paired with it stays scoped to over-saturated colours, because it is not monotone across its own 0.75 boundary and would reorder neighbouring greys. Hyperlinks were a second, unrelated path: nothing ever set TextView.LinkTextForegroundBrush, so the About page and every decompiler-view link used AvaloniaEdit's registered default of pure blue, 1.94:1 on dark. They now share a themed ILSpy.LinkForeground with the metadata table's token cells, which take it from a style rather than a local Foreground so the selected row's white override still wins over the accent fill. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../Metadata/MetadataColumnBuilderTests.cs | 32 +++++ ILSpy.Tests/Themes/LinkColorTests.cs | 93 ++++++++++++ ILSpy.Tests/Themes/ThemeManagerTests.cs | 132 ++++++++++++++++++ ILSpy/App.axaml | 26 ++++ ILSpy/Metadata/MetadataColumnBuilder.cs | 5 +- ILSpy/Themes/ThemeManager.cs | 130 +++++++++++++++-- 6 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 ILSpy.Tests/Themes/LinkColorTests.cs diff --git a/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs b/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs index 6dd88f103..838603d35 100644 --- a/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs +++ b/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs @@ -20,6 +20,9 @@ using System.Linq; using Avalonia.Controls; using Avalonia.Headless.NUnit; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; using AwesomeAssertions; @@ -128,4 +131,33 @@ public class MetadataColumnBuilderTests columns[1].Should().BeOfType("Method is Kind=Token"); columns[2].Should().BeOfType("Name has no [ColumnInfo]"); } + + [AvaloniaTest] + public void Token_Cell_Link_Color_Comes_From_The_Theme_And_Yields_To_The_Selected_Row() + { + // Foreground is inherited, so a local brush on the button would outrank the + // DataGridRow:selected white override and leave the link unreadable on the accent fill -- + // which is exactly the row navigation lands on. The "link" class lets the row win while + // still carrying the themed colour everywhere else. + var grid = new DataGrid { AutoGenerateColumns = false }; + foreach (var column in MetadataColumnBuilder.For()) + grid.Columns.Add(column); + grid.ItemsSource = new[] { new SampleEntryWithToken { RID = 1, Method = 0x06000001 } }; + + var window = new Window { Content = grid, Width = 400, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + var link = grid.GetVisualDescendants().OfType