Files
maraphon-app/plans/initial-implementation/phase-5-host-theme-i18n.md
T
alexei.dolgolyov e4d8476782 WIP(initial-implementation): parallel batch P2/P3/P5 — code complete, unreviewed
Snapshot of the parallel batch (Phases 2 + 3 + 5) at session pause. Solution does
NOT build cleanly yet — known cross-phase compile issues remain to be resolved
before review. See plans/initial-implementation/PLAN.md "Resume Notes" section
for the exact tomorrow-morning action list.

Phase 2 (Storage):
- Repository interfaces in Marathon.Application/Abstractions
- DateRange, ExportKind, StorageOptions in Marathon.Application/Storage
- EF Core 8 + SQLite (WAL) persistence: 7 entities + configurations + 4 repos
- Hand-written InitialCreate migration (dotnet ef blocked by parallel work)
- ClosedXML ExcelExporter with exact customer-spec wide columns
- PersistenceModule.AddMarathonPersistence DI extension
- Round-trip + export tests (cannot run yet — see cross-phase issues)

Phase 3 (Scraping):
- IOddsScraper, IBetPlacer in Marathon.Application/Abstractions
- ScrapingOptions in Marathon.Infrastructure/Configuration
- MarathonbetScraper with 4 parsers (Upcoming, Live, EventOdds, Results)
- Helpers: ServerTimeProvider, PeriodScopeMapper, OutcomeCodeMapper, MoscowDateParser
- UserAgentRotatorHandler + Polly v8 resilience pipeline
- ScrapingModule.AddMarathonScraping DI extension
- GlobalUsings.cs aliases for EventId / Configuration disambiguation
- Parser tests with trimmed HTML fixtures
- ScrapeResultsAsync interim no-op (Phase 8 will replace via watch-list polling)

Phase 5 (UI shell — killed mid-final-verify, assumed ~95%):
- Marathon.UI populated: MainLayout, App.razor, Pages (Home, Settings),
  Components, Theme (MarathonTheme.cs + Tokens.cs + app.css), Resources
  (SharedResource.{cs,ru.resx,en.resx}), Services (ISettingsWriter), wwwroot
- WPF host: App.xaml(.cs), MainWindow.xaml(.cs), Marathon.Hosts.WpfBlazor.csproj
  with Microsoft.AspNetCore.Components.WebView.Wpf + MudBlazor + Serilog
- appsettings.json + appsettings.Development.json with all sections wired
- bUnit tests: MainLayoutTests, LocaleSwitcherTests, ThemeToggleTests,
  JsonSettingsWriterTests + Support helpers

Cross-phase issues to resolve at next session:
1. Phase 2 repository classes are 'internal' — Phase 3's tests can't reference
   them. Fix: add InternalsVisibleTo to Marathon.Infrastructure.csproj.
2. Phase 5: LocalizationOptions namespace ambiguity (AspNetCore vs Extensions).
3. Phase 5: WpfBlazor Serilog API mismatch.

Reviewer has NOT run on this batch. Move to Phase 4 only after build is green
and a combined parallel-batch reviewer passes.
2026-05-05 01:56:53 +03:00

264 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<UseWPF>true</UseWPF>`, `<UseWindowsForms>false</UseWindowsForms>`
- 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`
- `<TargetFramework>net8.0</TargetFramework>` 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 `<RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />`
(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 (H1H3) | `"IBM Plex Serif", "PT Serif", Georgia, serif` |
| Body (H4H6, 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 |
|---|---|---|
| `<AppBrand />` | `Components/AppBrand.razor` | Wordmark + dateline lockup for the AppBar |
| `<NavBody />` | `Components/NavBody.razor` | Drawer navigation (dark surface, amber active state) |
| `<LocaleSwitcher />` | `Components/LocaleSwitcher.razor` | RU/EN segmented control |
| `<ThemeToggle />` | `Components/ThemeToggle.razor` | Light/dark icon button |
| `<StatCard Label Value Delta Anomaly />` | `Components/StatCard.razor` | Editorial stat block (kicker + mono value + delta) |
| `<PipelineStep Index Label Status />` | `Components/PipelineStep.razor` | Numbered status row (`ok`/`warn`/`error`/`idle`) |
| `<Field Label Hint>...` | `Components/Field.razor` | 240 px label column + control column with hint text |
| `<SectionFooter OnSave />` | `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 `<Surface>.<Element>` (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.<Field>`, `Settings.Scraping.<Field>.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<T>` 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<T>` 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 `<App />` 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<T>` 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.** `<StatCard>`, `<Field>`, `<PipelineStep>`, 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<T>` 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.