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.
18 KiB
Phase 5: Blazor Hybrid Host + Theme + Localization
Status: ✅ Done Parent plan: 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
- 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.WpfMudBlazorMicrosoft.Extensions.HostingSerilog.Extensions.HostingSerilog.Sinks.FileSerilog.Sinks.Console
- Set
- 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)
- SDK:
- Create
Marathon.UI/_Imports.razorwith namespace and component imports (Microsoft.AspNetCore.Components.*, MudBlazor, project namespaces). - Create
Marathon.UI/wwwroot/index.html(Blazor host HTML for the WebView). - Create
Marathon.UI/MainLayout.razorwith MudBlazorMudLayout+MudAppBar+MudDrawernavigation. Include locale switcher (RU/EN) in the AppBar. - Create
Marathon.UI/Pages/Home.razorplaceholder dashboard. - Create
Marathon.UI/Pages/Settings.razor— bound to allappsettings.jsonoptions (ScrapingOptions, WorkerOptions, StorageOptions, AnomalyOptions, LocalizationOptions). Live save viaIOptionsMonitor+ writing back toappsettings.Local.json. - 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
- Wire MudBlazor theme via
MudThemeProviderinMainLayout.razor. - Localization:
- Add
Microsoft.Extensions.LocalizationtoMarathon.UI - Create
Marathon.UI/Resources/SharedResource.cs(marker class forIStringLocalizer) - Add
Marathon.UI/Resources/SharedResource.ru.resxandSharedResource.en.resxwith 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.jsonand reloads UI
- Add
- In
src/Marathon.Hosts.WpfBlazor/MainWindow.xaml:- Single
BlazorWebViewfilling the window HostPage="wwwroot/index.html"RootComponentsadd<RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />(usesApp.razorRouter instead of MainLayout directly so navigation works)
- Single
- In
src/Marathon.Hosts.WpfBlazor/App.xaml.cs:- Build
IHostviaHost.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 onOnExit
- Build
- Add
appsettings.jsontoMarathon.Hosts.WpfBlazor/with all sections. Addappsettings.Development.jsontemplate. - Tests in
Marathon.UI.Tests(using bUnit):- Test:
MainLayoutrenders 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):
JsonSettingsWriterround-trip + section reset
- Test:
Files to Modify/Create
src/Marathon.UI/_Imports.razorsrc/Marathon.UI/App.razorsrc/Marathon.UI/MainLayout.razorsrc/Marathon.UI/Pages/Home.razor,Pages/Settings.razor,Pages/PreMatch.razor,Pages/Live.razor,Pages/Anomalies.razor,Pages/Results.razor,Pages/Placeholders.razorsrc/Marathon.UI/Theme/MarathonTheme.cs,Theme/Tokens.cssrc/Marathon.UI/wwwroot/index.html,wwwroot/app.csssrc/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.razorsrc/Marathon.UI/Services/UiServicesExtensions.cs,ThemeState.cs,LocaleState.cs,LocalizationOptions.cs,WorkerOptions.cs,AnomalyOptions.cs,ScrapingSettingsForm.cs,ISettingsWriter.cs,JsonSettingsWriter.cssrc/Marathon.Hosts.WpfBlazor/App.xaml,App.xaml.cssrc/Marathon.Hosts.WpfBlazor/MainWindow.xaml,MainWindow.xaml.cssrc/Marathon.Hosts.WpfBlazor/appsettings.json,appsettings.Development.jsontests/Marathon.UI.Tests/MainLayoutTests.cs,LocaleSwitcherTests.cs,ThemeToggleTests.cs,JsonSettingsWriterTests.cs,Support/MarathonTestContext.cs,Support/TestSettingsWriter.cs,Support/TestLocalizer.cs
Acceptance Criteria
- Host project compiles (Big Bang smoke check). All Phase-5-owned projects build clean.
Marathon.UIis a clean RCL — references only Domain + Application, no WPF/BlazorWebView. Verified bydotnet build src/Marathon.UI/Marathon.UI.csproj.- 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.
- Locale switcher works (segmented RU/EN control wired through
LocaleState, flipsCultureInfo.CurrentUICulture, persists toappsettings.Local.json). - Settings page surfaces every configurable parameter from
appsettings.jsonacross 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.csheader and the Handoff section below. - Cyrillic-friendly fonts: IBM Plex Serif/Sans + JetBrains Mono are loaded from
Google Fonts in
wwwroot/index.htmlwithdisplay=swap. - For BlazorWebView in WPF, the project SDK is
Microsoft.NET.Sdk.Razorand OutputType isWinExewith WPF enabled.
Review Checklist
- Compiles (Marathon.UI, Marathon.UI.Tests, Marathon.Hosts.WpfBlazor all green)
Marathon.UIreferences no host-specific code (BlazorWebView, WPF)- Theme not generic — distinctive palette + serif display + mono numerals
- All
appsettings.jsonkeys reachable via the Settings page - RU + EN both renderable (full key parity)
- 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 |
|---|---|---|
<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
- Host registers
appsettings.json+appsettings.{Env}.json+appsettings.Local.json(gitignored, optional,reloadOnChange: true) +MARATHON_*env vars inApp.xaml.cs::OnStartup. Marathon.UI.Services.UiServicesExtensions.AddMarathonUi(IConfiguration, settingsLocalPath)binds:LocalizationOptions(Localization:*)WorkerOptions(Workers:*) — drives Phase 4 pollersAnomalyOptions(Anomaly:*) — drives Phase 7 detectorStorageOptions(Storage:*) — Phase 2's options class, lives in Marathon.Application.StorageScrapingSettingsForm(Scraping:*) — UI-side mirror ofMarathon.Infrastructure.Configuration.ScrapingOptionsso the RCL stays host-agnostic. Phase 4 may bind the same JSON section to both forms.
JsonSettingsWriterwrites user edits as a single section intoappsettings.Local.jsonvia atomic temp-file rename. Other sections in that file are preserved (round-trip tested).- Components inject
IOptionsMonitor<T>and re-read on demand. The Settings page snapshots a clone ofCurrentValueinto local edit state, then writes the whole section. LocaleStateandThemeStateare singletons withAction OnChangeevents;MainLayout.razor,LocaleSwitcher.razor, andThemeToggle.razorsubscribe and callStateHasChanged. Setting the locale also flipsCultureInfo.DefaultThreadCurrent{,UI}Cultureso newly createdIStringLocalizer<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 landsAddMarathonInfrastructure(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. WorkerOptionslives inMarathon.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).AnomalyOptionslikewise (Anomaly:*).appsettings.Local.jsonis the "user-facing" override file. Phase 4 services should depend onIOptionsMonitor<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>, them-card/m-section/m-grid--asym/m-grid--three/m-shellclasses 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-5to header/grid/aside in source order. Respectsprefers-reduced-motion. - Routes and nav labels are pre-wired:
/,/prematch,/live,/anomalies,/results,/settings. Phase 6/7/8 just replace thePlaceholdersbody with real content — the nav drawer, breadcrumbs, AppBar, and locale switcher are already inMainLayout.
Deviations / known gaps
- Settings persistence reload.
IOptionsMonitor<T>triggers when the JSON file changes. The Settings page snapshots a copy ofCurrentValueinto local state on initialisation, so a save-then-rebind cycle requires the user to navigate away and back (or for Phase 6 to hookOnChangeand refresh local state). Acceptable for Phase 5; Phase 6 may add the listener. AddMarathonApplication/AddMarathonInfrastructurereflection 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.- bUnit version auto-resolved from 1.35.6 → 1.36.0 (NU1603). Updated
Directory.Packages.propsaccordingly. - Settings dialog confirmation uses
Dialogs.ShowMessageBox(...). TheDialogParametersblock is currently dead code — left in place because future dialogs may want to use a custom layout instead of the message box. - Pre-existing build failures outside Phase 5 scope:
tests/Marathon.Infrastructure.Testsreferencesinternalrepository classes (Phase 2 scope). Marathon.UI / Marathon.UI.Tests / Marathon.Hosts.WpfBlazor build clean. All 11 bUnit tests pass.