# Phase 5: Blazor Hybrid Host + Theme + Localization **Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend **Implementer:** Opus + frontend-design skill ## Objective Create the WPF + BlazorWebView host that loads `Marathon.UI` (Razor Class Library), establish the design system / theme using MudBlazor, set up bilingual (RU/EN) localization end-to-end, and wire up DI to compose Application + Infrastructure layers. ## Tasks - [x] In `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj`: - Set `true`, `false` - SDK: `Microsoft.NET.Sdk.Razor` (so Razor + WPF interop works) - Add packages: - `Microsoft.AspNetCore.Components.WebView.Wpf` - `MudBlazor` - `Microsoft.Extensions.Hosting` - `Serilog.Extensions.Hosting` - `Serilog.Sinks.File` - `Serilog.Sinks.Console` - [x] In `src/Marathon.UI/Marathon.UI.csproj`: - SDK: `Microsoft.NET.Sdk.Razor` - `net8.0` with WebView for Razor Components - Add `MudBlazor` (so components in this RCL can use MudBlazor) - [x] Create `Marathon.UI/_Imports.razor` with namespace and component imports (Microsoft.AspNetCore.Components.*, MudBlazor, project namespaces). - [x] Create `Marathon.UI/wwwroot/index.html` (Blazor host HTML for the WebView). - [x] Create `Marathon.UI/MainLayout.razor` with MudBlazor `MudLayout` + `MudAppBar` + `MudDrawer` navigation. Include locale switcher (RU/EN) in the AppBar. - [x] Create `Marathon.UI/Pages/Home.razor` placeholder dashboard. - [x] Create `Marathon.UI/Pages/Settings.razor` — bound to all `appsettings.json` options (ScrapingOptions, WorkerOptions, StorageOptions, AnomalyOptions, LocalizationOptions). Live save via `IOptionsMonitor` + writing back to `appsettings.Local.json`. - [x] Establish theme tokens in `Marathon.UI/Theme/MarathonTheme.cs` — distinctive palette per frontend-design guidance, NOT generic AI-default. Include: - Primary, secondary, accent - Surface tones for light + dark mode - Typography stack (RU-friendly font for Cyrillic — IBM Plex Sans / Serif + JetBrains Mono) - Spacing scale, radius scale, shadow scale as CSS variables in a `app.css` - [x] Wire MudBlazor theme via `MudThemeProvider` in `MainLayout.razor`. - [x] Localization: - Add `Microsoft.Extensions.Localization` to `Marathon.UI` - Create `Marathon.UI/Resources/SharedResource.cs` (marker class for `IStringLocalizer`) - Add `Marathon.UI/Resources/SharedResource.ru.resx` and `SharedResource.en.resx` with all UI strings used in this phase + placeholders for later phases - Configure supported cultures in host: `ru-RU`, `en-US` - Locale switcher persists choice to `appsettings.Local.json` and reloads UI - [x] In `src/Marathon.Hosts.WpfBlazor/MainWindow.xaml`: - Single `BlazorWebView` filling the window - `HostPage="wwwroot/index.html"` - `RootComponents` add `` (uses `App.razor` Router instead of MainLayout directly so navigation works) - [x] In `src/Marathon.Hosts.WpfBlazor/App.xaml.cs`: - Build `IHost` via `Host.CreateApplicationBuilder()` - Call `services.AddMarathonInfrastructure(config)` (best-effort via reflection — Phase 4 lands the formal entry point) - Call `services.AddMarathonApplication(config)` (best-effort, same) - Call `services.AddWpfBlazorWebView()` - Add MudBlazor: `services.AddMudServices()` - Configure Serilog (rolling file at `./logs/marathon-.log`, console) - Start the host on `OnStartup`, stop on `OnExit` - [x] Add `appsettings.json` to `Marathon.Hosts.WpfBlazor/` with all sections. Add `appsettings.Development.json` template. - [x] Tests in `Marathon.UI.Tests` (using bUnit): - Test: `MainLayout` renders brand + navigation; toggles theme via state - Test: locale switcher changes culture and persists to settings - Test: theme toggle flips state and notifies subscribers only on real change - Test (bonus): `JsonSettingsWriter` round-trip + section reset ## Files to Modify/Create - `src/Marathon.UI/_Imports.razor` - `src/Marathon.UI/App.razor` - `src/Marathon.UI/MainLayout.razor` - `src/Marathon.UI/Pages/Home.razor`, `Pages/Settings.razor`, `Pages/PreMatch.razor`, `Pages/Live.razor`, `Pages/Anomalies.razor`, `Pages/Results.razor`, `Pages/Placeholders.razor` - `src/Marathon.UI/Theme/MarathonTheme.cs`, `Theme/Tokens.cs` - `src/Marathon.UI/wwwroot/index.html`, `wwwroot/app.css` - `src/Marathon.UI/Resources/SharedResource.{cs,ru.resx,en.resx}` - `src/Marathon.UI/Components/LocaleSwitcher.razor`, `ThemeToggle.razor`, `AppBrand.razor`, `NavBody.razor`, `StatCard.razor`, `PipelineStep.razor`, `Field.razor`, `SectionFooter.razor` - `src/Marathon.UI/Services/UiServicesExtensions.cs`, `ThemeState.cs`, `LocaleState.cs`, `LocalizationOptions.cs`, `WorkerOptions.cs`, `AnomalyOptions.cs`, `ScrapingSettingsForm.cs`, `ISettingsWriter.cs`, `JsonSettingsWriter.cs` - `src/Marathon.Hosts.WpfBlazor/App.xaml`, `App.xaml.cs` - `src/Marathon.Hosts.WpfBlazor/MainWindow.xaml`, `MainWindow.xaml.cs` - `src/Marathon.Hosts.WpfBlazor/appsettings.json`, `appsettings.Development.json` - `tests/Marathon.UI.Tests/MainLayoutTests.cs`, `LocaleSwitcherTests.cs`, `ThemeToggleTests.cs`, `JsonSettingsWriterTests.cs`, `Support/MarathonTestContext.cs`, `Support/TestSettingsWriter.cs`, `Support/TestLocalizer.cs` ## Acceptance Criteria - [x] Host project compiles (Big Bang smoke check). All Phase-5-owned projects build clean. - [x] `Marathon.UI` is a clean RCL — references only Domain + Application, no WPF/BlazorWebView. Verified by `dotnet build src/Marathon.UI/Marathon.UI.csproj`. - [x] Theme is distinct: editorial-quant aesthetic. IBM Plex Serif + Sans + JetBrains Mono, deep navy / parchment / amber palette, signal-red anomaly accent. No Inter, no purple gradients. - [x] Locale switcher works (segmented RU/EN control wired through `LocaleState`, flips `CultureInfo.CurrentUICulture`, persists to `appsettings.Local.json`). - [x] Settings page surfaces every configurable parameter from `appsettings.json` across five sections (Scraping, Workers, Storage, Anomaly, Localization). ## Notes - This phase ran parallel with Phases 2 and 3 per the plan. - The frontend-design skill informed every visual decision; the aesthetic direction is documented in `MarathonTheme.cs` header and the Handoff section below. - Cyrillic-friendly fonts: IBM Plex Serif/Sans + JetBrains Mono are loaded from Google Fonts in `wwwroot/index.html` with `display=swap`. - For BlazorWebView in WPF, the project SDK is `Microsoft.NET.Sdk.Razor` and OutputType is `WinExe` with WPF enabled. ## Review Checklist - [x] Compiles (Marathon.UI, Marathon.UI.Tests, Marathon.Hosts.WpfBlazor all green) - [x] `Marathon.UI` references no host-specific code (BlazorWebView, WPF) - [x] Theme not generic — distinctive palette + serif display + mono numerals - [x] All `appsettings.json` keys reachable via the Settings page - [x] RU + EN both renderable (full key parity) - [x] Accessibility: keyboard nav, visible amber focus rings, ARIA labels on icon buttons and segmented controls ## Handoff to Next Phase ### Aesthetic direction — "Editorial-Quant" Inspired by long-form data journalism (FT, Quartz) and trading terminals (Bloomberg). Confident, dense, serif-led on display surfaces. Sharp corners (2 px radius), tabular mono numerals everywhere odds appear, asymmetric content grid, paper-grain background, single amber accent + signal-red anomaly tone. The aesthetic earns authority through restraint — there are NO gradient meshes, NO drop shadows on content cards, NO generic Material card-with-icon clusters. ### Typography | Role | Stack | |---|---| | Display (H1–H3) | `"IBM Plex Serif", "PT Serif", Georgia, serif` | | Body (H4–H6, Body, Subtitle, Button) | `"IBM Plex Sans", "PT Sans", system-ui, sans-serif` | | Numerals / Caption / Overline / kicker | `"JetBrains Mono", "IBM Plex Mono", "Fira Code", Consolas, monospace` | All three families have full Cyrillic coverage. Numbers use `font-variant-numeric: tabular-nums lining-nums` and OpenType `tnum`/`lnum`/`ss01` features (`--m-num-feature` token, applied via `.m-num`, `.m-mono`, all Mud table cells, and any element with `data-numeric`). ### Theme tokens (CSS variables in `app.css`, mirrored in `Theme/Tokens.cs`) | Token | Light | Dark | Purpose | |---|---|---|---| | `--m-c-ink` | `#0f172a` | `#f5f5f4` | Primary text / ink | | `--m-c-paper` | `#fafaf7` | `#1c1917` | Surface | | `--m-c-paper-2` | `#f5f4ef` | `#0c0a09` | Background | | `--m-c-rule` | `#e7e5e4` | `#292524` | Dividers, borders | | `--m-c-accent` | `#d97706` | `#fbbf24` | Amber accent (kickers, focus rings, hover) | | `--m-c-anomaly` | `#dc2626` | `#f87171` | Load-bearing for Phase 7 anomaly UI | | `--m-c-positive` | `#15803d` | `#4ade80` | Confirmations, OK status | | `--m-c-info` | `#0369a1` | `#38bdf8` | Informational accents | Spacing scale: `--m-space-1` … `--m-space-9` (4 → 96 px). Radius scale: `--m-radius-sharp` (0) → `--m-radius-lg` (10 px) — defaults to `--m-radius-xs` (2 px). Shadow scale: defined inline in `MarathonTheme.cs::MarathonShadows`. Use sparingly; the language is borders, not shadows. The MudBlazor `MudTheme` is built in `Marathon.UI.Theme.MarathonTheme.Build()`. Phase 6 should consume the Mud palette via `Color.Primary`, `Color.Tertiary` (= amber accent), `Color.Error` (= anomaly signal). Do NOT hard-code hexes outside `MarathonTheme.cs` and `app.css`. ### Component primitives available to Phase 6+ | Component | Path | Purpose | |---|---|---| | `` | `Components/AppBrand.razor` | Wordmark + dateline lockup for the AppBar | | `` | `Components/NavBody.razor` | Drawer navigation (dark surface, amber active state) | | `` | `Components/LocaleSwitcher.razor` | RU/EN segmented control | | `` | `Components/ThemeToggle.razor` | Light/dark icon button | | `` | `Components/StatCard.razor` | Editorial stat block (kicker + mono value + delta) | | `` | `Components/PipelineStep.razor` | Numbered status row (`ok`/`warn`/`error`/`idle`) | | `...` | `Components/Field.razor` | 240 px label column + control column with hint text | | `` | `Components/SectionFooter.razor` | Right-aligned save bar inside `.m-section` | CSS primitives (raw classes in `app.css`): `m-shell`, `m-grid--asym`, `m-grid--three`, `m-card`, `m-card--accented`, `m-card--anomaly`, `m-section`, `m-section__head`, `m-section__body`, `m-field-row`, `m-stat`, `m-anomaly` (with `m-anomaly__pulse`), `m-kicker`, `m-display`, `m-rule` / `m-rule--double`, `m-rise` (+`m-rise-1`…`m-rise-5` for staggered reveals), `m-num`, `m-mono`. ### Localization key naming convention Dot-segmented `.` (sub-segmented as needed): - `App.*` — application chrome (`App.Title`, `App.BrandMark`, `App.Dateline`, `App.Tagline`) - `Nav.*` — primary navigation labels and section headings (`Nav.Section.Analysis`, `Nav.Dashboard`, `Nav.PreMatch`, `Nav.Live`, `Nav.Anomalies`, `Nav.Results`, `Nav.Settings`, `Nav.Section.System`) - `Home.*` — dashboard surfaces (`Home.Kicker`, `Home.Title`, `Home.Lede`, `Home.Stat.*`, `Home.Section.*`, `Home.Pipeline.Step1..4`, `Home.Empty`) - `Settings.*` — settings page; further nested by section (`Settings.Section.Scraping`, `Settings.Scraping.`, `Settings.Scraping..Hint`, etc.) - `Locale.*` — locale switcher labels (`Locale.Russian`, `Locale.English`, `Locale.Tooltip.Switch`) - `Theme.*` — theme toggle (`Theme.Toggle.Light`, `Theme.Toggle.Dark`) - `Common.*` — shared verbs/nouns (`Common.Save`, `Common.Cancel`, `Common.Reset`, `Common.Loading`, `Common.Empty`, `Common.Yes`, `Common.No`) - `Anomaly.*` — anomaly feed placeholders (`Anomaly.Live`, `Anomaly.Kind.SuspensionFlip`, `Anomaly.Score`) Add new keys to BOTH `SharedResource.ru.resx` AND `SharedResource.en.resx`. Phase 6 should follow the same scheme; e.g. event browsing keys go under `PreMatch.*`, `Live.*` matching the route names in PLAN. ### Settings reload mechanism 1. Host registers `appsettings.json` + `appsettings.{Env}.json` + `appsettings.Local.json` (gitignored, optional, `reloadOnChange: true`) + `MARATHON_*` env vars in `App.xaml.cs::OnStartup`. 2. `Marathon.UI.Services.UiServicesExtensions.AddMarathonUi(IConfiguration, settingsLocalPath)` binds: - `LocalizationOptions` (`Localization:*`) - `WorkerOptions` (`Workers:*`) — drives Phase 4 pollers - `AnomalyOptions` (`Anomaly:*`) — drives Phase 7 detector - `StorageOptions` (`Storage:*`) — Phase 2's options class, lives in Marathon.Application.Storage - `ScrapingSettingsForm` (`Scraping:*`) — UI-side mirror of `Marathon.Infrastructure.Configuration.ScrapingOptions` so the RCL stays host-agnostic. Phase 4 may bind the same JSON section to both forms. 3. `JsonSettingsWriter` writes user edits as a single section into `appsettings.Local.json` via atomic temp-file rename. Other sections in that file are preserved (round-trip tested). 4. Components inject `IOptionsMonitor` and re-read on demand. The Settings page snapshots a clone of `CurrentValue` into local edit state, then writes the whole section. 5. `LocaleState` and `ThemeState` are singletons with `Action OnChange` events; `MainLayout.razor`, `LocaleSwitcher.razor`, and `ThemeToggle.razor` subscribe and call `StateHasChanged`. Setting the locale also flips `CultureInfo.DefaultThreadCurrent{,UI}Culture` so newly created `IStringLocalizer` instances pick up the new culture. ### `Marathon.UI` portability invariant — verified `Marathon.UI.csproj` references **only** Domain + Application + framework packages (`Microsoft.AspNetCore.Components.Web`, `MudBlazor`, `Microsoft.Extensions.Localization`, `Microsoft.Extensions.Options*`, `Microsoft.Extensions.Configuration*`, `Microsoft.Extensions.Logging.Abstractions`). It does NOT reference Infrastructure or any WPF/WebView assembly. A future ASP.NET Core Blazor Server host can register `AddMarathonUi(...)` and mount `` at `#app` with no UI changes. The `ScrapingSettingsForm` mirror in `Marathon.UI.Services` is intentional — keeping `Infrastructure.Configuration.ScrapingOptions` out of the RCL means Phase 6 can ship the Settings UI to the future ASP.NET Core host without dragging in EF Core, AngleSharp, or Polly. ### What Phase 4 needs to know - **`UiServicesExtensions.AddMarathonUi(IConfiguration, settingsLocalPath)`** is the single registration entry point. The host already calls it. - **Host wiring of Application/Infrastructure** is best-effort via reflection in `App.xaml.cs::TryAddApplicationAndInfrastructure`. When Phase 4 lands `AddMarathonInfrastructure(IServiceCollection, IConfiguration)` (or per-module variants), the existing call patterns will pick them up automatically — no host edit required. Replace the reflection with a direct call when Phase 4 commits. - **`WorkerOptions` lives in `Marathon.UI.Services`** (`WorkerOptions.SectionName == "Workers"`). Phase 4 may read it directly from configuration, or rebind into its own type — both work since they share JSON shape. The Settings page already exposes its three keys (`UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`). - **`AnomalyOptions`** likewise (`Anomaly:*`). - **`appsettings.Local.json` is the "user-facing" override file**. Phase 4 services should depend on `IOptionsMonitor` so they react to user edits within seconds (file watcher is enabled on all three JSON sources). ### What Phase 6 needs to know - **Use the existing primitives.** ``, ``, ``, the `m-card` / `m-section` / `m-grid--asym` / `m-grid--three` / `m-shell` classes form the layout language. Resist creating new card types until you have three concrete designs that the existing primitives can't express. - **Tabular numerals are mandatory** for any display of odds, scores, or counts. Add `class="m-num"` (or use a Mud table) — the OpenType features are wired globally. - **Anomaly visual language** must hang off `--m-c-anomaly` / `Color.Error` / `.m-anomaly` / `.m-anomaly__pulse`. Phase 7 inherits these. - **Page-load motion** is a single staggered reveal: add `m-rise m-rise-1`…`m-rise-5` to header/grid/aside in source order. Respects `prefers-reduced-motion`. - **Routes and nav labels** are pre-wired: `/`, `/prematch`, `/live`, `/anomalies`, `/results`, `/settings`. Phase 6/7/8 just replace the `Placeholders` body with real content — the nav drawer, breadcrumbs, AppBar, and locale switcher are already in `MainLayout`. ### Deviations / known gaps 1. **Settings persistence reload.** `IOptionsMonitor` triggers when the JSON file changes. The Settings page snapshots a copy of `CurrentValue` into local state on initialisation, so a save-then-rebind cycle requires the user to navigate away and back (or for Phase 6 to hook `OnChange` and refresh local state). Acceptable for Phase 5; Phase 6 may add the listener. 2. **`AddMarathonApplication` / `AddMarathonInfrastructure` reflection probe.** Until Phase 4 lands the canonical entry points, the host invokes whatever matching extension methods it can find via reflection. This degrades gracefully (logs a warning if absent) but Phase 4 should replace the reflection block with direct calls. 3. **bUnit version** auto-resolved from 1.35.6 → 1.36.0 (NU1603). Updated `Directory.Packages.props` accordingly. 4. **Settings dialog confirmation** uses `Dialogs.ShowMessageBox(...)`. The `DialogParameters` block is currently dead code — left in place because future dialogs may want to use a custom layout instead of the message box. 5. **Pre-existing build failures outside Phase 5 scope:** `tests/Marathon.Infrastructure.Tests` references `internal` repository classes (Phase 2 scope). Marathon.UI / Marathon.UI.Tests / Marathon.Hosts.WpfBlazor build clean. All 11 bUnit tests pass.