feat(phase-7-frontend): anomaly feed UI + nav badge + Settings toggle (+31 bUnit tests)

Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.

New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
  Phase 5 placeholder with a severity-coded card stream, filter chips
  (severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
  lockup + AnomalyEvidence panel + back link to /events/{eventCode}.

New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
  amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
  pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
  handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
  bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
  signal-red 3px left border on the post column. Handles 2-way (no draw).

State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
  / AnomalyEvidenceSnapshot record. Severity computed in the view-model
  from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
  parses Anomaly.EvidenceJson into typed view-models, applies filters
  client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
  GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
  sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.

Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
  pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
  user clicks 'Mark all read' on the feed toolbar.

Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
  added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
  (default true).

Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.

Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.

Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.

Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).

Phase 7 status:  Done (backend + frontend both complete, awaiting review).

Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
This commit is contained in:
2026-05-05 13:39:39 +03:00
parent a6ff368015
commit 12208a4762
27 changed files with 2273 additions and 32 deletions
@@ -0,0 +1,249 @@
@*
AnomalyCard — single row in the anomaly feed.
Asymmetric layout: severity badge at top-right, sport icon + event title
at top-left, a compact pre→post odds strip in the middle, the detected-at
timestamp at the bottom. Whole card is clickable / Enter/Space-keyable to
navigate to /anomalies/{id}.
Visual tone shifts with severity:
- High: signal-red left border + paper-2 background.
- Medium: amber left border.
- Low: muted neutral border.
*@
@using Marathon.UI.Components
@inject IStringLocalizer<SharedResource> L
<article class="m-anomaly-card m-anomaly-card--@_severityClass m-rise"
role="link"
tabindex="0"
data-test="anomaly-card"
data-anomaly-id="@Item.Id"
@onclick="HandleClick"
@onkeydown="HandleKey">
<header class="m-anomaly-card__head">
<div class="m-anomaly-card__lockup">
<SportIcon Code="@Item.Sport.Value" Label="@SportLabel(Item.Sport.Value)" ClassName="m-anomaly-card__sport" />
<div class="m-anomaly-card__title-block">
<span class="m-kicker">@KindLabel(Item.Kind) · @Item.CountryCode · @Item.LeagueId</span>
<h3 class="m-anomaly-card__title">@Item.EventTitle</h3>
</div>
</div>
<SeverityBadge Severity="Item.Severity" Score="Item.Score" />
</header>
<div class="m-anomaly-card__strip" aria-hidden="true">
@RenderRateCell(L["Detail.Chart.Win1"], Item.PreWin1Rate, Item.PostWin1Rate)
@if (!Item.IsTwoWay)
{
@RenderRateCell(L["Detail.Chart.Draw"], Item.PreDrawRate, Item.PostDrawRate)
}
@RenderRateCell(L["Detail.Chart.Win2"], Item.PreWin2Rate, Item.PostWin2Rate)
</div>
<footer class="m-anomaly-card__foot">
<span class="m-mono m-anomaly-card__time">
<span class="m-anomaly-card__time-label">@L["Anomaly.Card.DetectedAt"]</span>
<time datetime="@Item.DetectedAt.ToString("o")" title="@Item.DetectedAt.ToString("dd MMM yyyy HH:mm:ss")">
@FormatRelative(Item.DetectedAt)
</time>
</span>
<span class="m-mono m-anomaly-card__gap">
@L["Anomaly.Card.GapSeconds"] · @FormatGap(Item.SuspensionGapSeconds)
</span>
</footer>
</article>
<style>
.m-anomaly-card {
display: grid;
gap: var(--m-space-3);
padding: var(--m-space-4) var(--m-space-5);
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-left: 3px solid var(--m-c-rule);
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
text-decoration: none;
color: inherit;
}
.m-anomaly-card:hover {
background: var(--m-c-paper-2);
transform: translateX(2px);
}
.m-anomaly-card:focus-visible {
outline: 2px solid var(--m-c-accent);
outline-offset: 2px;
}
.m-anomaly-card--high { border-left-color: var(--m-c-anomaly); }
.m-anomaly-card--medium { border-left-color: var(--m-c-accent); }
.m-anomaly-card--low { border-left-color: var(--m-c-ink-soft); }
.m-anomaly-card__head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--m-space-3);
align-items: start;
}
.m-anomaly-card__lockup {
display: flex;
gap: var(--m-space-3);
align-items: flex-start;
}
.m-anomaly-card__sport {
--m-sport-size: 22px;
margin-top: 4px;
}
.m-anomaly-card__title-block { display: grid; gap: 4px; min-width: 0; }
.m-anomaly-card__title {
margin: 0;
font-family: var(--m-font-display);
font-weight: 400;
font-size: 1.125rem;
line-height: 1.25;
color: var(--m-c-ink);
overflow: hidden;
text-overflow: ellipsis;
}
.m-anomaly-card__strip {
display: flex;
gap: var(--m-space-3);
flex-wrap: wrap;
padding: var(--m-space-3);
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
}
.m-anomaly-card__rate {
display: grid;
grid-template-columns: auto auto auto auto;
gap: 8px;
align-items: baseline;
font-family: var(--m-font-mono);
}
.m-anomaly-card__rate-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
}
.m-anomaly-card__rate-pre {
color: var(--m-c-ink-soft);
font-size: 0.875rem;
}
.m-anomaly-card__rate-arrow { color: var(--m-c-accent); font-size: 0.875rem; }
.m-anomaly-card__rate-post {
color: var(--m-c-ink);
font-weight: 600;
font-size: 0.9375rem;
}
.m-anomaly-card--high .m-anomaly-card__rate-post {
color: var(--m-c-anomaly);
}
.m-anomaly-card__foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--m-space-3);
flex-wrap: wrap;
font-size: 0.6875rem;
color: var(--m-c-ink-soft);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-anomaly-card__time-label { margin-right: 6px; opacity: 0.7; }
.m-anomaly-card__time time { color: var(--m-c-ink); font-weight: 500; }
</style>
@code {
[Parameter, EditorRequired] public AnomalyListItem Item { get; set; } = default!;
[Parameter] public EventCallback<AnomalyListItem> OnClick { get; set; }
private string _severityClass => Item.Severity switch
{
AnomalySeverity.High => "high",
AnomalySeverity.Medium => "medium",
_ => "low",
};
private RenderFragment RenderRateCell(string label, decimal? pre, decimal? post) => builder =>
{
builder.OpenElement(0, "span");
builder.AddAttribute(1, "class", "m-anomaly-card__rate");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", "m-anomaly-card__rate-label");
builder.AddContent(4, label);
builder.CloseElement();
builder.OpenElement(5, "span");
builder.AddAttribute(6, "class", "m-anomaly-card__rate-pre");
builder.AddContent(7, FormatRate(pre));
builder.CloseElement();
builder.OpenElement(8, "span");
builder.AddAttribute(9, "class", "m-anomaly-card__rate-arrow");
builder.AddContent(10, "→");
builder.CloseElement();
builder.OpenElement(11, "span");
builder.AddAttribute(12, "class", "m-anomaly-card__rate-post");
builder.AddContent(13, FormatRate(post));
builder.CloseElement();
builder.CloseElement();
};
private async Task HandleClick()
{
if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item);
}
private async Task HandleKey(KeyboardEventArgs e)
{
if (e.Key == "Enter" || e.Key == " ")
{
if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item);
}
}
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
_ => kind.ToString(),
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code),
};
private static string FormatRate(decimal? r) => r is { } v
? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
: "—";
private static string FormatGap(int seconds)
{
if (seconds <= 0) return "—";
var ts = TimeSpan.FromSeconds(seconds);
if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds);
if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m{1:00}s", (int)ts.TotalMinutes, ts.Seconds);
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h{1:00}m", (int)ts.TotalHours, ts.Minutes);
}
private static string FormatRelative(DateTimeOffset value)
{
var delta = DateTimeOffset.UtcNow - value;
if (delta < TimeSpan.Zero) delta = TimeSpan.Zero;
if (delta.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s ago", (int)delta.TotalSeconds);
if (delta.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m ago", (int)delta.TotalMinutes);
if (delta.TotalHours < 24) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h ago", (int)delta.TotalHours);
return value.ToString("dd MMM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,244 @@
@*
AnomalyEvidence — two-column "before / after" presentation of the parsed
EvidenceJson from an anomaly. Each column shows the snapshot timestamp,
the implied probability per side (with a horizontal bar), and the raw
rate (mono numerals).
The favourite-swap is called out via a single-line statement above the
columns. Tennis (2-way) markets render with no Draw row — handled by
nullable PDraw / RateDraw on the snapshot.
Pure presentation — no data fetching. Callers shape an `AnomalyDetailVm`
and pass `Pre` + `Post` snapshots in.
*@
@inject IStringLocalizer<SharedResource> L
<div class="m-evidence" data-test="anomaly-evidence">
<div class="m-evidence__summary">
<div class="m-evidence__gap">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Anomaly.Evidence.SuspensionDuration"]
</span>
<span class="m-evidence__gap-value m-mono">@FormatGap(SuspensionGapSeconds)</span>
</div>
@if (Pre.Favourite != Post.Favourite && Pre.Favourite != AnomalyFavourite.None && Post.Favourite != AnomalyFavourite.None)
{
<div class="m-evidence__swap" data-test="favourite-swap">
<span class="m-anomaly__pulse" style="background: var(--m-c-anomaly);"></span>
<span class="m-evidence__swap-text">
@L["Anomaly.Evidence.FavouriteSwap"] ·
<strong>@FavLabel(Pre.Favourite) → @FavLabel(Post.Favourite)</strong>
</span>
</div>
}
</div>
<div class="m-evidence__columns">
<article class="m-evidence__col">
<header class="m-evidence__col-head">
<span class="m-kicker">@L["Anomaly.Evidence.Pre"]</span>
<time class="m-mono" datetime="@Pre.CapturedAt.ToString("o")">
@Pre.CapturedAt.ToString("dd MMM HH:mm:ss")
</time>
</header>
@RenderRow(L["Detail.Chart.Win1"], Pre.P1, Pre.Rate1, IsFavourite(Pre, AnomalyFavourite.Side1))
@if (!IsTwoWay)
{
@RenderRow(L["Detail.Chart.Draw"], Pre.PDraw, Pre.RateDraw, IsFavourite(Pre, AnomalyFavourite.Draw))
}
@RenderRow(L["Detail.Chart.Win2"], Pre.P2, Pre.Rate2, IsFavourite(Pre, AnomalyFavourite.Side2))
</article>
<div class="m-evidence__arrow" aria-hidden="true">→</div>
<article class="m-evidence__col m-evidence__col--post">
<header class="m-evidence__col-head">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Anomaly.Evidence.Post"]
</span>
<time class="m-mono" datetime="@Post.CapturedAt.ToString("o")">
@Post.CapturedAt.ToString("dd MMM HH:mm:ss")
</time>
</header>
@RenderRow(L["Detail.Chart.Win1"], Post.P1, Post.Rate1, IsFavourite(Post, AnomalyFavourite.Side1))
@if (!IsTwoWay)
{
@RenderRow(L["Detail.Chart.Draw"], Post.PDraw, Post.RateDraw, IsFavourite(Post, AnomalyFavourite.Draw))
}
@RenderRow(L["Detail.Chart.Win2"], Post.P2, Post.Rate2, IsFavourite(Post, AnomalyFavourite.Side2))
</article>
</div>
</div>
<style>
.m-evidence {
display: grid;
gap: var(--m-space-4);
}
.m-evidence__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--m-space-4);
flex-wrap: wrap;
}
.m-evidence__gap {
display: flex;
flex-direction: column;
gap: 6px;
}
.m-evidence__gap-value {
font-size: 1.5rem;
font-weight: 500;
color: var(--m-c-ink);
}
.m-evidence__swap {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: color-mix(in srgb, var(--m-c-anomaly) 8%, transparent);
border: 1px solid var(--m-c-anomaly);
color: var(--m-c-anomaly);
font-family: var(--m-font-mono);
font-size: 0.75rem;
}
.m-evidence__swap-text strong { font-weight: 600; }
.m-evidence__columns {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--m-space-4);
align-items: stretch;
}
@@media (max-width: 720px) {
.m-evidence__columns { grid-template-columns: 1fr; }
.m-evidence__arrow { display: none; }
}
.m-evidence__col {
display: grid;
gap: var(--m-space-3);
padding: var(--m-space-4);
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
}
.m-evidence__col--post {
border-left: 3px solid var(--m-c-anomaly);
}
.m-evidence__col-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--m-space-3);
flex-wrap: wrap;
}
.m-evidence__col-head time {
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-evidence__arrow {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--m-font-display);
font-size: 1.75rem;
color: var(--m-c-accent);
}
.m-evidence__row {
display: grid;
grid-template-columns: 60px minmax(0, 1fr) 56px;
gap: var(--m-space-3);
align-items: center;
}
.m-evidence__row-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--m-c-ink-soft);
}
.m-evidence__row.is-favourite .m-evidence__row-label {
color: var(--m-c-ink);
font-weight: 600;
}
.m-evidence__bar {
height: 8px;
background: var(--m-c-rule);
position: relative;
overflow: hidden;
border-radius: 1px;
}
.m-evidence__bar-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--m-c-ink-soft);
transition: width 240ms ease;
}
.m-evidence__col--post .m-evidence__bar-fill { background: var(--m-c-anomaly); }
.m-evidence__row.is-favourite .m-evidence__bar-fill { background: var(--m-c-accent); }
.m-evidence__col--post .m-evidence__row.is-favourite .m-evidence__bar-fill {
background: var(--m-c-anomaly);
}
.m-evidence__rate {
text-align: right;
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-weight: 500;
}
</style>
@code {
[Parameter, EditorRequired] public AnomalyEvidenceSnapshot Pre { get; set; } = default!;
[Parameter, EditorRequired] public AnomalyEvidenceSnapshot Post { get; set; } = default!;
[Parameter] public int SuspensionGapSeconds { get; set; }
[Parameter] public bool IsTwoWay { get; set; }
private RenderFragment RenderRow(string label, decimal? probability, decimal? rate, bool isFavourite) => builder =>
{
var pct = (probability ?? 0m) * 100m;
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", isFavourite ? "m-evidence__row is-favourite" : "m-evidence__row");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", "m-evidence__row-label");
builder.AddContent(4, label);
builder.CloseElement();
builder.OpenElement(5, "div");
builder.AddAttribute(6, "class", "m-evidence__bar");
builder.AddAttribute(7, "role", "img");
builder.AddAttribute(8, "aria-label", string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:0}%", pct));
builder.OpenElement(9, "span");
builder.AddAttribute(10, "class", "m-evidence__bar-fill");
builder.AddAttribute(11, "style", string.Format(System.Globalization.CultureInfo.InvariantCulture, "width: {0:0.0}%;", Math.Clamp(pct, 0m, 100m)));
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(12, "span");
builder.AddAttribute(13, "class", "m-evidence__rate");
builder.AddContent(14, rate is { } r ? r.ToString("0.00") : "—");
builder.CloseElement();
builder.CloseElement();
};
private static bool IsFavourite(AnomalyEvidenceSnapshot s, AnomalyFavourite side) => s.Favourite == side;
private string FavLabel(AnomalyFavourite f) => f switch
{
AnomalyFavourite.Side1 => L["Detail.Chart.Win1"],
AnomalyFavourite.Draw => L["Detail.Chart.Draw"],
AnomalyFavourite.Side2 => L["Detail.Chart.Win2"],
_ => "—",
};
private static string FormatGap(int seconds)
{
if (seconds <= 0) return "—";
var ts = TimeSpan.FromSeconds(seconds);
if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds);
if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m {1:00}s", (int)ts.TotalMinutes, ts.Seconds);
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h {1:00}m", (int)ts.TotalHours, ts.Minutes);
}
}
+44
View File
@@ -1,4 +1,6 @@
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject AnomalyBrowsingState AnomalyState
<nav class="m-nav" aria-label="primary">
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
@@ -26,6 +28,12 @@
<NavLink class="m-nav__link" href="anomalies">
<MudIcon Icon="@Icons.Material.Outlined.Warning" Size="Size.Small" />
<span>@L["Nav.Anomalies"]</span>
@if (AnomalyState.UnreadCount > 0)
{
<span class="m-nav__badge" data-test="anomaly-badge" aria-label="@L["Anomaly.Nav.UnreadAria"]">
@AnomalyState.UnreadCount
</span>
}
</NavLink>
<NavLink class="m-nav__link" href="results">
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
@@ -38,3 +46,39 @@
<span>@L["Nav.Settings"]</span>
</NavLink>
</nav>
<style>
.m-nav__badge {
margin-left: auto;
min-width: 18px;
padding: 0 6px;
background: var(--m-c-anomaly);
color: #ffffff;
font-family: var(--m-font-mono);
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.05em;
line-height: 18px;
height: 18px;
text-align: center;
border-radius: var(--m-radius-xs);
animation: m-pulse 1.6s ease-in-out infinite;
}
@@media (prefers-reduced-motion: reduce) {
.m-nav__badge { animation: none; }
}
</style>
@code {
protected override void OnInitialized()
{
AnomalyState.OnChange += OnAnomalyStateChanged;
}
private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose()
{
AnomalyState.OnChange -= OnAnomalyStateChanged;
}
}
@@ -0,0 +1,94 @@
@*
SeverityBadge — small uppercase pill encoding an anomaly's severity bucket.
The High variant is signal-red (`--m-c-anomaly`) and pulses to draw the eye
on the feed page. Medium uses the editorial amber accent. Low is a muted
neutral so it does not compete with higher severities.
The component is presentational only — callers compute the severity (via
`AnomalySeverityRules.FromScore`) and pass it in.
*@
@inject IStringLocalizer<SharedResource> L
<span class="m-severity m-severity--@_classKey @AdditionalClass" data-severity="@Severity" data-test="severity-badge">
@if (ShowDot)
{
<span class="m-severity__dot" aria-hidden="true"></span>
}
<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>
<style>
.m-severity {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.16em;
border-radius: var(--m-radius-xs);
border: 1px solid currentColor;
white-space: nowrap;
}
.m-severity__dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
flex: 0 0 auto;
}
.m-severity__score {
font-feature-settings: var(--m-num-feature);
opacity: 0.78;
font-size: 0.625rem;
letter-spacing: 0.08em;
}
.m-severity--low {
color: var(--m-c-ink-soft);
background: color-mix(in srgb, var(--m-c-ink-soft) 8%, transparent);
}
.m-severity--medium {
color: var(--m-c-accent);
background: color-mix(in srgb, var(--m-c-accent) 12%, transparent);
}
.m-severity--high {
color: var(--m-c-anomaly);
background: color-mix(in srgb, var(--m-c-anomaly) 12%, transparent);
}
.m-severity--high .m-severity__dot {
animation: m-pulse 1.6s ease-in-out infinite;
}
@@media (prefers-reduced-motion: reduce) {
.m-severity--high .m-severity__dot { animation: none; opacity: 1; }
}
</style>
@code {
[Parameter, EditorRequired] public AnomalySeverity Severity { get; set; }
[Parameter] public decimal? Score { get; set; }
[Parameter] public bool ShowScore { get; set; } = true;
[Parameter] public bool ShowDot { get; set; } = true;
[Parameter] public string? AdditionalClass { get; set; }
private string _classKey => Severity switch
{
AnomalySeverity.High => "high",
AnomalySeverity.Medium => "medium",
_ => "low",
};
private string Label => Severity switch
{
AnomalySeverity.High => L["Anomaly.Severity.High"],
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
_ => L["Anomaly.Severity.Low"],
};
}