fix(ui): invariant decimals + LocaleState test isolation + drawer offset + bundled Plotly

Cross-cutting polish that surfaced while Phase 8 was being implemented:

* Invariant-culture formatting on every decimal ToString("0.00" / "0.##")
  (OddsCell, OddsTimeline, SeverityBadge, AnomalyEvidence,
  Pages/Events/Detail) — previously the comma/dot decimal separator
  switched with the locale and broke tabular alignment + tests.

* LocaleState constructor no longer mutates process-wide ambient culture;
  apply only happens through Set(...). Stops parallel bUnit test runs
  leaking ru-RU into each other's threads.

* MainLayout: drawer-open state now offsets main content / footer / appbar
  by the drawer width (248px) so the sidebar no longer overlaps content.
  Mobile breakpoint (≤720px) keeps the original full-width layout.

* wwwroot/index.html (Marathon.UI RCL): switched from Plotly CDN to the
  bundled "_content/Plotly.Blazor/plotly-2.35.3.min.js" — works offline
  and matches the Plotly.Blazor 5.4.1 version pin.

* Marathon.Hosts.WpfBlazor/wwwroot/index.html: host-level page that
  BlazorWebView's HostPage attribute resolves to. Same Plotly bundle,
  no autostart="false" (BlazorWebView auto-starts).
This commit is contained in:
2026-05-09 15:11:13 +03:00
parent 9f090cec1f
commit a627c360c3
9 changed files with 106 additions and 14 deletions
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Marathon — Odds Lab</title>
<base href="/" />
<!-- Prevent flash of unthemed content -->
<style>
html, body { background: #f5f4ef; margin: 0; }
@media (prefers-color-scheme: dark) {
html, body { background: #0c0a09; }
}
</style>
<!-- Fonts: IBM Plex Sans / Serif / Mono + JetBrains Mono. Full Cyrillic coverage. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link href="_content/Marathon.UI/app.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<div style="padding: 64px; font-family: 'IBM Plex Serif', Georgia, serif; color: #475569;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d97706;">Booting</span>
<div style="font-size: 32px; font-weight: 300; margin-top: 8px;">Marathon Odds Lab</div>
</div>
</div>
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #dc2626; color: #fafaf7; font-family: 'IBM Plex Sans', sans-serif;">
<span>An unhandled error has occurred.</span>
<a href="" class="reload" style="color: #fff; text-decoration: underline; margin-left: 12px;">Reload</a>
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
</div>
<!-- Plotly.js for OddsTimeline charts (Phase 6). Bundled by Plotly.Blazor 5.4.1. -->
<script src="_content/Plotly.Blazor/plotly-2.35.3.min.js" charset="utf-8"></script>
<!-- Blazor must auto-start in BlazorWebView; no autostart="false". -->
<script src="_framework/blazor.webview.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@@ -217,7 +217,7 @@
builder.OpenElement(12, "span");
builder.AddAttribute(13, "class", "m-evidence__rate");
builder.AddContent(14, rate is { } r ? r.ToString("0.00") : "—");
builder.AddContent(14, rate is { } r ? r.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) : "—");
builder.CloseElement();
builder.CloseElement();
+3 -1
View File
@@ -87,7 +87,9 @@
[Parameter] public string AriaPrefix { get; set; } = "Odds";
[Parameter] public string EmptyPlaceholder { get; set; } = "—";
private string Formatted => Rate is { } r ? r.ToString("0.00") : EmptyPlaceholder;
private string Formatted => Rate is { } r
? r.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
: EmptyPlaceholder;
private Trend Direction
{
@@ -238,5 +238,6 @@
},
};
private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—";
private static string FormatRate(decimal? r) =>
r is { } v ? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) : "—";
}
@@ -19,7 +19,7 @@
<span class="m-severity__label">@Label</span>
@if (ShowScore && Score is { } s)
{
<span class="m-severity__score m-mono" aria-hidden="true">@s.ToString("0.00")</span>
<span class="m-severity__score m-mono" aria-hidden="true">@s.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)</span>
}
</span>
+19
View File
@@ -83,6 +83,25 @@
min-height: 0;
}
/* MudDrawer is positioned fixed/absolute by Mud's CSS — push main content
right by the drawer's width (248px) so the sidebar doesn't overlap.
Same shift on the appbar tools row so the brand isn't covered. */
.m-app-frame.is-drawer-open .m-main,
.m-app-frame.is-drawer-open .m-footer {
padding-left: 248px;
transition: padding-left 200ms ease;
}
.m-app-frame.is-drawer-open .m-appbar {
padding-left: calc(248px + clamp(var(--m-space-3), 2vw, var(--m-space-5)));
transition: padding-left 200ms ease;
}
@@media (max-width: 720px) {
/* On narrow viewports the drawer becomes a temporary overlay anyway. */
.m-app-frame.is-drawer-open .m-main,
.m-app-frame.is-drawer-open .m-footer,
.m-app-frame.is-drawer-open .m-appbar { padding-left: 0; }
}
.m-footer {
display: flex;
align-items: center;
+5 -3
View File
@@ -105,7 +105,7 @@
<td>@BetTypeLabel(row.Type)</td>
<td>@SideLabel(row.Side)</td>
<td class="m-mono" style="text-align: right;">@FormatThreshold(row.Threshold)</td>
<td class="m-mono" style="text-align: right; font-weight: 500;">@row.Rate.ToString("0.00")</td>
<td class="m-mono" style="text-align: right; font-weight: 500;">@row.Rate.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)</td>
</tr>
}
</tbody>
@@ -334,6 +334,8 @@
_ => s.ToString(),
};
private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—";
private static string FormatThreshold(decimal? v) => v is { } x ? x.ToString("0.##") : "—";
private static string FormatRate(decimal? r) =>
r is { } v ? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) : "—";
private static string FormatThreshold(decimal? v) =>
v is { } x ? x.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) : "—";
}
+25 -5
View File
@@ -15,23 +15,35 @@ public sealed class LocaleState
public static readonly IReadOnlyList<string> Supported = new[] { Russian, English };
private CultureInfo _culture = CultureInfo.GetCultureInfo(Russian);
private CultureInfo _culture;
public LocaleState()
{
// Initialise the field only — do NOT mutate process-wide ambient culture
// here. Production startup (App.xaml.cs) always calls Set(...) right after
// resolving this service, which applies ambient culture explicitly. Doing
// it here as well leaks ru-RU across parallel test runs whenever any
// bUnit test class instantiates MarathonTestContext.
_culture = CultureInfo.GetCultureInfo(Russian);
}
public CultureInfo Culture
{
get => _culture;
private set
{
// Always re-apply ambient cultures even when the value matches —
// App.xaml.cs's startup call uses the same default the constructor
// initialized to, so an equality short-circuit here would leave the
// actual thread CurrentCulture stuck on the system locale.
ApplyAmbientCulture(value);
if (string.Equals(_culture.Name, value.Name, StringComparison.OrdinalIgnoreCase))
{
return;
}
_culture = value;
CultureInfo.DefaultThreadCurrentCulture = value;
CultureInfo.DefaultThreadCurrentUICulture = value;
CultureInfo.CurrentCulture = value;
CultureInfo.CurrentUICulture = value;
OnChange?.Invoke();
}
}
@@ -47,4 +59,12 @@ public sealed class LocaleState
Culture = CultureInfo.GetCultureInfo(cultureName);
}
private static void ApplyAmbientCulture(CultureInfo c)
{
CultureInfo.DefaultThreadCurrentCulture = c;
CultureInfo.DefaultThreadCurrentUICulture = c;
CultureInfo.CurrentCulture = c;
CultureInfo.CurrentUICulture = c;
}
}
+2 -2
View File
@@ -38,8 +38,8 @@
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
</div>
<!-- Plotly.js for OddsTimeline charts (Phase 6). Pinned major version 2.x. -->
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>
<!-- Plotly.js for OddsTimeline charts (Phase 6). Bundled by Plotly.Blazor 5.4.1. -->
<script src="_content/Plotly.Blazor/plotly-2.35.3.min.js" charset="utf-8"></script>
<script src="_framework/blazor.webview.js" autostart="false"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>