Vier Befunde der Sichtpruefung behoben: Farbschema, Grids, Diagramme, Kontrast

#383 Farbschema "System" kehrte sein Versprechen um. ThemeManager.IsDark verglich
ActualThemeVariant der Anwendung mit Dark; bei "System" steht dort Default, waehrend
die Fenster selbst nach hell oder dunkel aufloesen. IsDark war damit im
Auslieferungszustand immer false - Symbol, Beschriftung und Toggle() haengen alle an
diesem Wert. Gelesen wird jetzt die angeforderte Variante, bei "System" die
Betriebssystem-Einstellung. Derselbe Fehler steckte in Brush(): die Methode suchte bei
"System" in den hellen Farben, waehrend dunkel gezeichnet wurde - betroffen war genau
das, wofuer es sie gibt (Zeilenfarben, KPI-Vorzeichen). Neu ist ausserdem, dass eine
Umstellung am Betriebssystem ThemeChanged ausloest, solange "System" gilt.

#384 Sternspalten quetschten die Endspalten weg. Der Merksatz aus #56 galt bisher nur
fuer gridTraders. Angewandt auf gridOpen und gridClosed sowie auf die uebrigen Grids,
deren Spaltensumme den sichtbaren Bereich uebersteigt (gridTrades, gridJobs, gridLedger,
gridCandidates, gridPositions, gridHistory). Auto statt Stern, dazu MaxWidth 360 - anders
als "Name" in #56 traegt "Markt" ganze Fragesaetze und wuerde die Zahlenspalten sonst aus
dem Blick schieben. Grids mit weniger Spalten als Platz behalten ihre Sternspalte.

#385 Gewinntage wurden rot gezeichnet. Eine ColumnSeries hat genau ein Fill fuer alle
ihre Balken; gefaerbt wurde deshalb nach der Summe ueber alle Tage. Jetzt zwei Reihen an
denselben Positionen - Gewinne in der einen, Verluste in der anderen, Luecken als null.
"PnL je Modul" faerbt ebenfalls nach Vorzeichen: zwei Diagramme nebeneinander mit
verschiedenen Leseweisen waeren schlechter als beide ohne.

#386 Drei Kleinigkeiten. Die Equity-Achse beschriftete zehn Teilstriche mit zwei Daten -
unter drei Tagen kommt jetzt die Uhrzeit dazu, darueber gilt hoechstens ein Teilstrich je
Tag. "Deaktivierte Module" sah aus, als fehle das Bedienelement: leere Nur-Lese-Felder
zeigen jetzt "—" in einer abgesetzten Flaeche. Die Abschnittsbeschriftungen der
Seitenleiste lagen bei 3,65:1 statt der geforderten 4,5:1 (im Bild sogar 1,83:1, weil
10 px mit Sperrung ausduennen) - AppCaptionTextBrush erreicht 4,61:1 hell und 6,21:1
dunkel und bleibt deutlich leiser als die Eintraege darunter.

Build gruen, 486 Tests gruen. Die Sichtpruefung auf der Linux-VM steht noch aus; die
drei rein visuellen Punkte sind aus dem Quelltext begruendet, nicht im Bild belegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-03 10:17:49 +02:00
co-authored by Claude Opus 5
parent 53231d62bc
commit 28b3aedec7
10 changed files with 176 additions and 45 deletions
+11 -1
View File
@@ -409,11 +409,21 @@
</Style>
<!-- Abschnittsüberschrift in der Seitenleiste. -->
<!--
Abschnittsbeschriftungen der Seitenleiste („UEBERSICHT", „MODULE", „SYSTEM").
Sie sollen ordnen, nicht gelesen werden – zurueckhaltend ist Absicht. Mit
AppMutedTextBrush ging das zu weit: 3,65:1 auf der Seitenleistenflaeche, und weil
10px mit Sperrung beim Zeichnen ausduennen, kamen im Bild nur 1,83:1 an (Befund #386).
WCAG verlangt 4,5:1 fuer kleinen Text. AppCaptionTextBrush liegt bei 4,61:1 (hell) und
6,21:1 (dunkel) und bleibt damit deutlich leiser als die Eintraege darunter (8,8:1);
das Medium-Gewicht haelt die gezeichneten Pixel naeher an der gesetzten Farbe.
-->
<Style Selector="TextBlock.navgroup">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1.4" />
<Setter Property="Margin" Value="9,0,0,6" />
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{DynamicResource AppCaptionTextBrush}" />
</Style>
<!-- ═══ Benachrichtigungsleiste ═══════════════════════════════════════════════
@@ -137,8 +137,15 @@
<Grid Classes="fieldRow" ColumnDefinitions="210,*">
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="{Binding Label}" />
<StackPanel Grid.Column="1">
<SelectableTextBlock Text="{Binding Value}" VerticalAlignment="Center"
<!-- Abgesetzte Fläche mit Rahmen: sonst sieht die Zeile aus,
als fehle ihr das Bedienelement (Befund #386). -->
<Border Background="{DynamicResource AppSurfaceAltBrush}"
BorderBrush="{DynamicResource AppBorderBrush}"
BorderThickness="1" CornerRadius="3"
Padding="6,3" HorizontalAlignment="Left" MinWidth="160">
<SelectableTextBlock Text="{Binding DisplayValue}" VerticalAlignment="Center"
Foreground="{DynamicResource AppReadOnlyTextBrush}" />
</Border>
<TextBlock Classes="fieldHint" Text="{Binding Description}"
IsVisible="{Binding HasDescription}" />
</StackPanel>
@@ -2,7 +2,9 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Styling;
using Avalonia.Threading;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
@@ -23,13 +25,37 @@ namespace PolyTrader.App.Avalonia.Shell
private static ServerSettings? _settings;
private static string? _settingsPath;
private static TerminalLogger? _logger;
private static bool _colorHookAttached;
/// <summary>Feuert nach jedem Wechsel – Fenster zeichnen daraufhin ihre Code-Farben neu.</summary>
public static event Action? ThemeChanged;
/// <summary>Ist gerade das dunkle Schema aktiv (auch wenn es über „System" kam)?</summary>
public static bool IsDark =>
global::Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
/// <remarks>
/// Gelesen wird die angeforderte Variante, nicht <c>ActualThemeVariant</c> der Anwendung:
/// Bei „System" steht dort <see cref="ThemeVariant.Default"/> – die Fenster lösen das
/// selbst nach hell oder dunkel auf, die Anwendung erfährt davon nichts. Der Vergleich mit
/// <see cref="ThemeVariant.Dark"/> lieferte deshalb im Auslieferungszustand immer
/// <c>false</c>, obwohl dunkel gezeichnet wurde: Beschriftung, Symbol und
/// <see cref="Toggle"/> sagten geschlossen das Gegenteil dessen, was zu sehen war.
/// </remarks>
public static bool IsDark
{
get
{
var app = global::Avalonia.Application.Current;
if (app == null) return false;
// Feste Wahl: die Antwort steht bereits in der angeforderten Variante.
var requested = app.RequestedThemeVariant;
if (requested == ThemeVariant.Dark) return true;
if (requested == ThemeVariant.Light) return false;
// „System": Die Betriebssystem-Einstellung ist die Quelle, aus der auch die Fenster
// auflösen – also dort nachsehen statt auf Anwendungsebene zu raten.
return app.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark;
}
}
/// <summary>
/// Einmalig beim Start aufrufen: übernimmt das gespeicherte Schema und merkt sich, wohin
@@ -41,6 +67,28 @@ namespace PolyTrader.App.Avalonia.Shell
_settingsPath = settingsPath;
_logger = logger;
ApplyVariant(settings.Theme);
HookSystemColorChanges();
}
/// <summary>
/// Bei „System" wechselt das Schema, ohne dass jemand bei uns klickt. Avalonia stellt die
/// Standard-Steuerelemente dann selbst um – die im Code gesetzten Farben und die Anzeige in
/// der Kopf- und Statusleiste bleiben aber stehen, bis <see cref="ThemeChanged"/> feuert.
/// </summary>
private static void HookSystemColorChanges()
{
if (_colorHookAttached) return;
var platform = global::Avalonia.Application.Current?.PlatformSettings;
if (platform == null) return;
platform.ColorValuesChanged += (_, _) =>
{
// Nur wer dem Betriebssystem folgt, ist von dessen Umstellung betroffen.
if (_settings?.Theme != AppThemeVariant.System) return;
Dispatcher.UIThread.Post(() => ThemeChanged?.Invoke());
};
_colorHookAttached = true;
}
/// <summary>Wechselt hell ↔ dunkel und speichert die Wahl. „System" wird dabei zu einer festen Wahl.</summary>
@@ -92,7 +140,12 @@ namespace PolyTrader.App.Avalonia.Shell
public static IBrush Brush(string key)
{
var app = global::Avalonia.Application.Current;
if (app != null && app.TryFindResource(key, app.ActualThemeVariant, out object? value) && value is IBrush b)
// Nicht ActualThemeVariant: bei „System" ist das Default, und die Ressourcensuche
// liefert dann die hellen Farben, während die Fenster dunkel zeichnen. IsDark kennt
// die tatsächlich gezeichnete Variante – dieselbe Quelle wie für Symbol und Beschriftung.
var variant = IsDark ? ThemeVariant.Dark : ThemeVariant.Light;
if (app != null && app.TryFindResource(key, variant, out object? value) && value is IBrush b)
return b;
// Unbekannter Schlüssel ist ein Programmierfehler, darf die Oberfläche aber nicht kippen.
@@ -71,29 +71,46 @@ namespace PolyTrader.App.Avalonia.ViewModels
}
};
// Ohne Trades hat die Achse keine Werte. LiveCharts nahm dann einen Standardbereich um
// 0 an - und weil Tick 0 dem 01.01.0001 entspricht, stand an der Achse zwoelfmal
// "01.01." (Befund B-8 des Linux-Sichttests). Jetzt spannt sie im Leerzustand den
// tatsaechlich gewaehlten Zeitraum auf.
DateTime? emptyFrom = null, emptyTo = null;
if (curve.Count == 0)
{
emptyTo = DateTime.UtcNow.Date.AddDays(1);
emptyFrom = (since ?? emptyTo.Value.AddDays(-30)).Date;
if (emptyFrom >= emptyTo) emptyFrom = emptyTo.Value.AddDays(-1);
}
// LiveCharts setzt rund zehn Teilstriche, gleich wie kurz der Zeitraum ist. Bei sieben
// Trades an zwei Tagen stand deshalb fuenfmal "25.08." und fuenfmal "26.08." an der
// Achse - fachlich richtig, gelesen aber als Fehler (Befund #386). Zwei Wege zu einem
// Ziel: keine zwei Teilstriche mit derselben Beschriftung.
TimeSpan span = curve.Count > 0
? curve[^1].At - curve[0].At
: emptyTo!.Value - emptyFrom!.Value;
bool shortSpan = span < TimeSpan.FromDays(3);
var axis = new Axis
{
// Ticks kommen als DateTime-Ticks – zurueckrechnen und kurz formatieren.
// Die Bereichspruefung ist Pflicht: LiveCharts fragt auch Werte ausserhalb des
// DateTime-Bereichs ab, und new DateTime(...) wuerde dort werfen.
Labeler = value => value >= 0 && value <= DateTime.MaxValue.Ticks
? new DateTime((long)value).ToString("dd.MM.")
? new DateTime((long)value).ToString(shortSpan ? "dd.MM. HH:mm" : "dd.MM.")
: string.Empty,
UnitWidth = TimeSpan.FromDays(1).Ticks,
LabelsRotation = 0
// Kurzer Zeitraum: die Uhrzeit unterscheidet die Teilstriche. Laengerer Zeitraum:
// das Datum genuegt, dann aber hoechstens ein Teilstrich je Tag.
MinStep = shortSpan ? 0d : TimeSpan.FromDays(1).Ticks,
LabelsRotation = shortSpan ? 45 : 0
};
// Ohne Trades hat die Achse keine Werte. LiveCharts nahm dann einen Standardbereich um
// 0 an - und weil Tick 0 dem 01.01.0001 entspricht, stand an der Achse zwoelfmal
// "01.01." (Befund B-8 des Linux-Sichttests). Jetzt spannt sie im Leerzustand den
// tatsaechlich gewaehlten Zeitraum auf.
if (curve.Count == 0)
if (emptyFrom.HasValue)
{
DateTime end = DateTime.UtcNow.Date.AddDays(1);
DateTime start = (since ?? end.AddDays(-30)).Date;
if (start >= end) start = end.AddDays(-1);
axis.MinLimit = start.Ticks;
axis.MaxLimit = end.Ticks;
axis.MinLimit = emptyFrom.Value.Ticks;
axis.MaxLimit = emptyTo!.Value.Ticks;
}
EquityXAxes = new[] { axis };
@@ -104,15 +121,7 @@ namespace PolyTrader.App.Avalonia.ViewModels
var byMod = TradeAnalytics.PnlByKey(scoped,
t => string.IsNullOrEmpty(t.ModuleName) ? "—" : t.ModuleName);
ModuleSeries = new ISeries[]
{
new ColumnSeries<double>
{
Name = "PnL",
Values = byMod.Select(x => (double)x.Pnl).ToArray(),
Fill = new SolidColorPaint(Accent)
}
};
ModuleSeries = SignedColumns(byMod.Select(x => x.Pnl).ToArray());
ModuleXAxes = new[] { new Axis { Labels = byMod.Select(x => x.Key).ToArray(), LabelsRotation = 0 } };
}
@@ -120,16 +129,7 @@ namespace PolyTrader.App.Avalonia.ViewModels
{
var byDay = TradeAnalytics.PnlByDay(scoped);
DaySeries = new ISeries[]
{
new ColumnSeries<double>
{
Name = "PnL",
Values = byDay.Select(x => (double)x.Pnl).ToArray(),
// Verlusttage rot, Gewinntage gruen – dieselbe Leseweise wie in den Trade-Listen.
Fill = new SolidColorPaint(byDay.Sum(x => x.Pnl) >= 0m ? Positive : Negative)
}
};
DaySeries = SignedColumns(byDay.Select(x => x.Pnl).ToArray());
DayXAxes = new[]
{
new Axis
@@ -140,6 +140,45 @@ namespace PolyTrader.App.Avalonia.ViewModels
};
}
/// <summary>
/// Balken nach ihrem Vorzeichen faerben: Gewinn gruen, Verlust rot – dieselbe Leseweise wie
/// in den Trade-Listen.
/// </summary>
/// <remarks>
/// Eine <c>ColumnSeries</c> hat genau ein <c>Fill</c> fuer alle ihre Balken; je Balken faerben
/// geht damit nicht. Frueher stand hier deshalb ein Ausdruck ueber die SUMME aller Werte –
/// der faerbte im Verlustfall auch die Gewinntage rot und sagte beim Ueberfliegen das
/// Gegenteil der Zahl darunter (Befund #385).
///
/// Stattdessen zwei Reihen an denselben Positionen: die eine traegt die Gewinne, die andere
/// die Verluste, die jeweils andere laesst dort eine Luecke (<c>null</c>, wird nicht
/// gezeichnet). <c>IgnoresBarPosition</c> haelt sie uebereinander statt nebeneinander.
/// </remarks>
private static ISeries[] SignedColumns(IReadOnlyList<decimal> values)
{
double?[] OnlyWhere(bool positive) => values
.Select(v => positive == (v >= 0m) ? (double?)v : null)
.ToArray();
return new ISeries[]
{
new ColumnSeries<double?>
{
Name = "Gewinn",
Values = OnlyWhere(true),
Fill = new SolidColorPaint(Positive),
IgnoresBarPosition = true
},
new ColumnSeries<double?>
{
Name = "Verlust",
Values = OnlyWhere(false),
Fill = new SolidColorPaint(Negative),
IgnoresBarPosition = true
}
};
}
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
field = value;
@@ -186,6 +186,9 @@ namespace PolyTrader.App.Avalonia.ViewModels
/// <summary>Nur-Lese-Anzeige (Eigenschaft ohne Setter, oder nicht editierbarer Typ).</summary>
public sealed class ReadOnlySettingsField : SettingsField
{
/// <summary>Steht für „hier steht nichts" – dieselbe Schreibweise wie in der Account-Übersicht.</summary>
public const string EmptyPlaceholder = "—";
public ReadOnlySettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
@@ -198,6 +201,25 @@ namespace PolyTrader.App.Avalonia.ViewModels
return raw?.ToString() ?? string.Empty;
}
}
/// <summary>
/// Der Wert für die Oberfläche: leer wird zum Platzhalter.
/// </summary>
/// <remarks>
/// „Deaktivierte Module" ist im Auslieferungszustand eine leere Liste. Ein leeres Nur-Lese-Feld
/// ist von einem fehlenden Bedienelement nicht zu unterscheiden – in der Zeile stand links die
/// Beschriftung, rechts der Hilfetext und dazwischen nichts, während jede andere Zeile des
/// Dialogs dort ein Feld hat (Befund #386). Der Platzhalter beantwortet die Frage „ist hier
/// etwas kaputt?" ohne Blick in den Quelltext.
/// </remarks>
public string DisplayValue
{
get
{
string v = Value;
return string.IsNullOrWhiteSpace(v) ? EmptyPlaceholder : v;
}
}
}
/// <summary>Eine Kategorie mit ihren Feldern – entspricht einer Überschrift im Dialog.</summary>
@@ -121,7 +121,7 @@
<DataGrid.Columns>
<DataGridTextColumn Header="Modul" Width="Auto" Binding="{Binding Module}" />
<DataGridTextColumn Header="Konto" Width="Auto" Binding="{Binding Account}" />
<DataGridTextColumn Header="Markt" Width="*" MinWidth="180" Binding="{Binding Market}" />
<DataGridTextColumn Header="Markt" Width="Auto" MinWidth="180" MaxWidth="360" Binding="{Binding Market}" />
<DataGridTextColumn Header="Outcome" Width="Auto" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="Auto" Binding="{Binding Side}" />
<DataGridTextColumn Header="Entry" Width="Auto" Binding="{Binding EntryPrice, StringFormat=\{0:F3\}}" />
@@ -40,7 +40,7 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Beschreibung" Width="*" MinWidth="180"
<DataGridTextColumn Header="Beschreibung" Width="Auto" MinWidth="180" MaxWidth="360"
Binding="{Binding Description}" />
</DataGrid.Columns>
</DataGrid>
@@ -116,7 +116,7 @@
<DataGrid.Columns>
<DataGridTextColumn Header="Zeitpunkt" Width="Auto" Binding="{Binding Timestamp, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
<DataGridTextColumn Header="Typ" Width="Auto" Binding="{Binding EventType}" />
<DataGridTextColumn Header="Markt" Width="*" MinWidth="180" Binding="{Binding MarketSlug}" />
<DataGridTextColumn Header="Markt" Width="Auto" MinWidth="180" MaxWidth="360" Binding="{Binding MarketSlug}" />
<DataGridTextColumn Header="Outcome" Width="Auto" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="Auto" Binding="{Binding Side}" />
<DataGridTextColumn Header="Size" Width="Auto" Binding="{Binding Size, StringFormat=\{0:F2\}}" />
@@ -103,7 +103,7 @@
<DataGrid.Columns>
<DataGridTextColumn Header="Account" Width="Auto" Binding="{Binding Account}" />
<DataGridTextColumn Header="Master-Trader" Width="Auto" Binding="{Binding Trader}" />
<DataGridTextColumn Header="Markt" Width="*" MinWidth="180" Binding="{Binding Market}" />
<DataGridTextColumn Header="Markt" Width="Auto" MinWidth="180" MaxWidth="360" Binding="{Binding Market}" />
<DataGridTextColumn Header="Outcome" Width="Auto" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="Auto" Binding="{Binding Side}" />
<DataGridTextColumn Header="Entry" Width="Auto" Binding="{Binding Entry, StringFormat=\{0:F3\}}" />
@@ -146,7 +146,7 @@
<DataGridTextColumn Header="#" Width="Auto" Binding="{Binding TradeId}" />
<DataGridTextColumn Header="Account" Width="Auto" Binding="{Binding Account}" />
<DataGridTextColumn Header="Master-Trader" Width="Auto" Binding="{Binding Trader}" />
<DataGridTextColumn Header="Markt" Width="*" MinWidth="180" Binding="{Binding Market}" />
<DataGridTextColumn Header="Markt" Width="Auto" MinWidth="180" MaxWidth="360" Binding="{Binding Market}" />
<DataGridTextColumn Header="Outcome" Width="Auto" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="Auto" Binding="{Binding Side}" />
<DataGridTextColumn Header="Entry" Width="Auto" Binding="{Binding Entry, StringFormat=\{0:F3\}}" />
@@ -22,7 +22,7 @@
<DataGrid Name="gridCandidates" x:DataType="rf:RfCandidate">
<DataGrid.Columns>
<DataGridTextColumn Header="Gescannt" Width="Auto" Binding="{Binding ScannedAt, StringFormat=\{0:dd.MM. HH:mm\}}" />
<DataGridTextColumn Header="Markt" Width="*" MinWidth="180" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Markt" Width="Auto" MinWidth="180" MaxWidth="360" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Outcome" Width="Auto" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Kategorie" Width="Auto" Binding="{Binding Category}" />
<DataGridTextColumn Header="Ask" Width="Auto" Binding="{Binding Ask, StringFormat=\{0:F3\}}" />
@@ -41,7 +41,7 @@
<DataGrid Name="gridPositions" x:DataType="rf:RfPosition">
<DataGrid.Columns>
<DataGridTextColumn Header="Eröffnet" Width="Auto" Binding="{Binding OpenedAt, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
<DataGridTextColumn Header="Markt" Width="*" MinWidth="180" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Markt" Width="Auto" MinWidth="180" MaxWidth="360" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Outcome" Width="Auto" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Kategorie" Width="Auto" Binding="{Binding Category}" />
<DataGridTextColumn Header="Entry" Width="Auto" Binding="{Binding EntryPrice, StringFormat=\{0:F3\}}" />
@@ -61,7 +61,7 @@
</Border>
<DataGrid Name="gridHistory" x:DataType="rf:RfClosedTrade">
<DataGrid.Columns>
<DataGridTextColumn Header="Markt" Width="*" MinWidth="180" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Markt" Width="Auto" MinWidth="180" MaxWidth="360" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Outcome" Width="Auto" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Kategorie" Width="Auto" Binding="{Binding Category}" />
<DataGridTextColumn Header="Entry" Width="Auto" Binding="{Binding EntryPrice, StringFormat=\{0:F3\}}" />