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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) : "—";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user