From 47b72419adb114919d78a69eef95a0c61feecf5c Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 15 Mar 2026 15:20:34 +0100 Subject: [PATCH] Separate UX diagnostics from curation --- docs/player_gm_ux_redesign_plan.md | 10 + .../Components/Layout/NavMenu.razor | 6 + .../Components/Pages/Diagnostics.razor | 332 ++++++++++++++++++ .../Shared/CriticalCellEditorDialog.razor | 152 +------- .../CriticalCellEngineeringDiagnostics.razor | 206 +++++++++++ src/RolemasterDb.App/wwwroot/app.css | 40 +++ 6 files changed, 601 insertions(+), 145 deletions(-) create mode 100644 src/RolemasterDb.App/Components/Pages/Diagnostics.razor create mode 100644 src/RolemasterDb.App/Components/Shared/CriticalCellEngineeringDiagnostics.razor diff --git a/docs/player_gm_ux_redesign_plan.md b/docs/player_gm_ux_redesign_plan.md index 7e4fbf1..18647e7 100644 --- a/docs/player_gm_ux_redesign_plan.md +++ b/docs/player_gm_ux_redesign_plan.md @@ -667,6 +667,16 @@ Acceptance criteria: ### Phase 5: Final tooling boundary cleanup +Status: + +- implemented in the web app on March 15, 2026 + +Implemented model: + +- the primary critical-cell editor now keeps only compact correction tools plus advanced review for generated-versus-current comparison +- engineering-only parser metadata, source text, and payload inspection moved to a dedicated Diagnostics page in the main navigation +- normal curation no longer mixes raw JSON and storage-facing diagnostics into the default correction flow + Scope: - verify that normal curation mode contains only user-facing correction tools diff --git a/src/RolemasterDb.App/Components/Layout/NavMenu.razor b/src/RolemasterDb.App/Components/Layout/NavMenu.razor index a2bfe8d..b5e1b0d 100644 --- a/src/RolemasterDb.App/Components/Layout/NavMenu.razor +++ b/src/RolemasterDb.App/Components/Layout/NavMenu.razor @@ -20,6 +20,12 @@ Critical Tables + + diff --git a/src/RolemasterDb.App/Components/Pages/Diagnostics.razor b/src/RolemasterDb.App/Components/Pages/Diagnostics.razor new file mode 100644 index 0000000..cea92a9 --- /dev/null +++ b/src/RolemasterDb.App/Components/Pages/Diagnostics.razor @@ -0,0 +1,332 @@ +@page "/diagnostics" +@rendermode InteractiveServer +@using System +@using System.Collections.Generic +@using System.Linq +@inject LookupService LookupService + +Diagnostics + +
+
+
+

Critical Cell Diagnostics

+

Engineering-only parser metadata, provenance, and payload inspection live here instead of inside the normal curation modal.

+
+
+ + @if (referenceData is null) + { +

Loading table list...

+ } + else if (!referenceData.CriticalTables.Any()) + { +

No critical tables are available yet.

+ } + else + { +
+
+ + +
+ +
+ + +
+ + @if (tableDetail is { Groups.Count: > 0 }) + { +
+ + +
+ } + +
+ + +
+
+ + @if (!string.IsNullOrWhiteSpace(detailError)) + { +

@detailError

+ } + else if (tableDetail is null) + { +

The selected table could not be loaded.

+ } + else if (!tableDetail.Cells.Any()) + { +

The selected table has no filled cells to inspect.

+ } + else if (selectedCell is null) + { +
+
+
+ No Filled Cell At This Position +

Pick another roll band, variant, or severity to inspect a stored result.

+
+
+
+ } + else + { +
+ Inspecting + @tableDetail.DisplayName + · Roll band @selectedCell.RollBand + · Severity @selectedCell.ColumnLabel + @if (!string.IsNullOrWhiteSpace(selectedCell.GroupLabel)) + { + · Variant @selectedCell.GroupLabel + } + · Result ID @selectedCell.ResultId +
+ + @if (isDiagnosticsLoading) + { +

Loading diagnostics...

+ } + else if (!string.IsNullOrWhiteSpace(diagnosticsError)) + { +

@diagnosticsError

+ } + else if (diagnosticsModel is not null) + { + + } + } + } +
+ +@code { + private LookupReferenceData? referenceData; + private CriticalTableDetail? tableDetail; + private CriticalTableCellDetail? selectedCell; + private CriticalCellEditorModel? diagnosticsModel; + private string selectedTableSlug = string.Empty; + private string selectedRollBand = string.Empty; + private string selectedColumnKey = string.Empty; + private string? selectedGroupKey; + private bool isDetailLoading; + private bool isDiagnosticsLoading; + private string? detailError; + private string? diagnosticsError; + + private bool isBusy => isDetailLoading || isDiagnosticsLoading; + + protected override async Task OnInitializedAsync() + { + referenceData = await LookupService.GetReferenceDataAsync(); + selectedTableSlug = referenceData.CriticalTables.FirstOrDefault()?.Key ?? string.Empty; + await LoadTableDetailAsync(); + } + + private async Task HandleTableChanged(ChangeEventArgs args) + { + selectedTableSlug = args.Value?.ToString() ?? string.Empty; + await LoadTableDetailAsync(); + } + + private async Task HandleRollBandChanged(ChangeEventArgs args) + { + selectedRollBand = args.Value?.ToString() ?? string.Empty; + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + + private async Task HandleGroupChanged(ChangeEventArgs args) + { + selectedGroupKey = NormalizeOptionalText(args.Value?.ToString()); + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + + private async Task HandleColumnChanged(ChangeEventArgs args) + { + selectedColumnKey = args.Value?.ToString() ?? string.Empty; + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + + private async Task LoadTableDetailAsync() + { + if (string.IsNullOrWhiteSpace(selectedTableSlug)) + { + tableDetail = null; + selectedCell = null; + diagnosticsModel = null; + return; + } + + isDetailLoading = true; + detailError = null; + diagnosticsError = null; + diagnosticsModel = null; + selectedCell = null; + + try + { + tableDetail = await LookupService.GetCriticalTableAsync(selectedTableSlug); + if (tableDetail is null) + { + detailError = "The selected table could not be loaded."; + return; + } + + SetDefaultSelection(tableDetail); + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + catch (Exception exception) + { + detailError = exception.Message; + tableDetail = null; + selectedCell = null; + diagnosticsModel = null; + } + finally + { + isDetailLoading = false; + } + } + + private void SetDefaultSelection(CriticalTableDetail detail) + { + if (!detail.Cells.Any()) + { + selectedRollBand = detail.RollBands.FirstOrDefault()?.Label ?? string.Empty; + selectedColumnKey = detail.Columns.FirstOrDefault()?.Key ?? string.Empty; + selectedGroupKey = detail.Groups.FirstOrDefault()?.Key; + return; + } + + var rollOrder = detail.RollBands + .Select((rollBand, index) => new { rollBand.Label, index }) + .ToDictionary(item => item.Label, item => item.index, StringComparer.Ordinal); + var columnOrder = detail.Columns + .Select((column, index) => new { column.Key, index }) + .ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); + var groupOrder = detail.Groups + .Select((group, index) => new { group.Key, index }) + .ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); + + var firstCell = detail.Cells + .OrderBy(cell => rollOrder.GetValueOrDefault(cell.RollBand, int.MaxValue)) + .ThenBy(cell => + { + if (cell.GroupKey is null) + { + return -1; + } + + return groupOrder.GetValueOrDefault(cell.GroupKey, int.MaxValue); + }) + .ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)) + .First(); + + selectedRollBand = firstCell.RollBand; + selectedColumnKey = firstCell.ColumnKey; + selectedGroupKey = firstCell.GroupKey; + } + + private void ResolveSelectedCell() + { + if (tableDetail is null) + { + selectedCell = null; + return; + } + + selectedCell = tableDetail.Cells.FirstOrDefault(cell => + string.Equals(cell.RollBand, selectedRollBand, StringComparison.Ordinal) && + string.Equals(cell.ColumnKey, selectedColumnKey, StringComparison.Ordinal) && + string.Equals(cell.GroupKey ?? string.Empty, selectedGroupKey ?? string.Empty, StringComparison.Ordinal)); + } + + private async Task LoadSelectedCellDiagnosticsAsync() + { + diagnosticsError = null; + diagnosticsModel = null; + + if (selectedCell is null || string.IsNullOrWhiteSpace(selectedTableSlug)) + { + return; + } + + isDiagnosticsLoading = true; + + try + { + var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, selectedCell.ResultId); + if (response is null) + { + diagnosticsError = "The selected cell could not be loaded."; + return; + } + + diagnosticsModel = CriticalCellEditorModel.FromResponse(response); + } + catch (Exception exception) + { + diagnosticsError = exception.Message; + } + finally + { + isDiagnosticsLoading = false; + } + } + + private static string? NormalizeOptionalText(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor index e2c3133..e379e9e 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor @@ -1,7 +1,6 @@ @using System @using System.Collections.Generic @using System.Linq -@using System.Text.Json @using Microsoft.JSInterop @using RolemasterDb.App.Domain @using RolemasterDb.App.Features @@ -206,11 +205,11 @@ } -
+
- Advanced Review & Diagnostics - @GetAdvancedSummary(Model, ComparisonBaseline) + Advanced Review + @GetReviewSummary(Model, ComparisonBaseline)
@@ -257,87 +256,6 @@
} - -
-
-
- Parser Metadata -

Last loaded or re-parsed parser result.

-
-
- -
-
-
OCR Source
-
@(string.IsNullOrWhiteSpace(Model.RawCellText) ? "—" : Model.RawCellText)
-
-
-
Parse Status
-
@Model.ParseStatus
-
-
-
Raw Affix Text
-
@(string.IsNullOrWhiteSpace(Model.RawAffixText) ? "—" : Model.RawAffixText)
-
-
- - @if (Model.ValidationMessages.Count > 0) - { -
- @foreach (var message in Model.ValidationMessages) - { -

@message

- } -
- } - -
- -
@FormatJson(Model.ParsedJson)
-
-
- - @{ - var effectMetadata = GetEffectMetadataRows(Model); - } - - @if (effectMetadata.Count > 0) - { -
-
-
- Effect Source Metadata -

Stored source markers and labels for the current effect list.

-
-
- -
- @foreach (var entry in effectMetadata) - { -
-
- @entry.Scope -

@entry.EffectLabel

-
-
- Type: @entry.SourceType - Text: @(string.IsNullOrWhiteSpace(entry.SourceText) ? "—" : entry.SourceText) -
-
- } -
-
- } - -
-
-
- Current Save Payload -

Request built from the visible editor state, including generated branch internals.

-
-
-
@BuildCurrentSavePayloadJson(Model)
-
@@ -388,10 +306,6 @@ [Parameter, EditorRequired] public EventCallback OnSave { get; set; } - private static readonly JsonSerializerOptions DiagnosticJsonOptions = new() - { - WriteIndented = true - }; private static readonly IReadOnlyList<(string Token, IReadOnlyList Effects)> QuickParseLegendEntries = [ ("+15", [CreateQuickLegendEffect(CriticalEffectCodes.DirectHits, valueInteger: 15)]), @@ -631,7 +545,7 @@ effect.IsOverridden = true; } - private static string GetAdvancedSummary(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline) + private static string GetReviewSummary(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline) { var differenceCount = GetComparisonDifferenceCount(model, comparisonBaseline); var noteCount = model.ValidationMessages.Count; @@ -647,13 +561,13 @@ segments.Add($"{noteCount} parser note{(noteCount == 1 ? string.Empty : "s")}"); } - return segments.Count == 0 ? "Generated compare and diagnostics" : string.Join(" · ", segments); + return segments.Count == 0 ? "Generated compare" : string.Join(" · ", segments); } private static string GetParserNoteSummary(int noteCount) => noteCount == 1 - ? "1 parser note is available under Advanced Review & Diagnostics." - : $"{noteCount} parser notes are available under Advanced Review & Diagnostics."; + ? "1 parser note is available under Advanced Review." + : $"{noteCount} parser notes are available under Advanced Review."; private static CriticalEffectLookupResponse CreateQuickLegendEffect( string effectCode, @@ -675,27 +589,6 @@ "legend", null); - private static string FormatJson(string json) - { - if (string.IsNullOrWhiteSpace(json)) - { - return "{}"; - } - - try - { - using var document = JsonDocument.Parse(json); - return JsonSerializer.Serialize(document.RootElement, DiagnosticJsonOptions); - } - catch (JsonException) - { - return json.Trim(); - } - } - - private static string BuildCurrentSavePayloadJson(CriticalCellEditorModel model) => - JsonSerializer.Serialize(model.ToRequest(), DiagnosticJsonOptions); - private static IReadOnlyList GetComparisonSummaryItems(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline) { if (model.GeneratedState is null) @@ -775,37 +668,6 @@ .Select(CreatePreviewBranch) .ToList(); - private static List<(string Scope, string EffectLabel, string SourceType, string? SourceText)> GetEffectMetadataRows(CriticalCellEditorModel model) - { - var rows = new List<(string Scope, string EffectLabel, string SourceType, string? SourceText)>(); - - foreach (var effect in model.Effects) - { - if (ShouldIncludeEffectMetadata(effect)) - { - rows.Add(("Base Result", GetEffectLabel(effect), effect.SourceType, effect.SourceText)); - } - } - - foreach (var branch in model.Branches.OrderBy(item => item.SortOrder)) - { - var scope = string.IsNullOrWhiteSpace(branch.ConditionText) ? "Condition" : branch.ConditionText.Trim(); - foreach (var effect in branch.Effects) - { - if (ShouldIncludeEffectMetadata(effect)) - { - rows.Add((scope, GetEffectLabel(effect), effect.SourceType, effect.SourceText)); - } - } - } - - return rows; - } - - private static bool ShouldIncludeEffectMetadata(CriticalEffectEditorModel effect) => - !string.IsNullOrWhiteSpace(effect.SourceText) || - !string.IsNullOrWhiteSpace(effect.SourceType); - private static IReadOnlyList BuildSingleBadgeEffect(CriticalEffectEditorModel effect) => [CreateBadgeEffect(effect)]; diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEngineeringDiagnostics.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellEngineeringDiagnostics.razor new file mode 100644 index 0000000..79d3d14 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEngineeringDiagnostics.razor @@ -0,0 +1,206 @@ +@using System +@using System.Collections.Generic +@using System.Linq +@using System.Text.Json +@using RolemasterDb.App.Domain +@using RolemasterDb.App.Features + +
+
+
+
+ Cell Context +

Engineering metadata for the selected critical-table result.

+
+
+ +
+
+
Result ID
+
@Model.ResultId
+
+
+
Table
+
@Model.TableName
+
+
+
Source Document
+
@Model.SourceDocument
+
+
+
Roll Band
+
@Model.RollBand
+
+
+
Severity
+
@Model.ColumnLabel
+
+
+
Variant
+
@(string.IsNullOrWhiteSpace(Model.GroupLabel) ? "—" : Model.GroupLabel)
+
+
+
+ +
+
+
+ Parser Metadata +

Last loaded or re-parsed parser result for this cell.

+
+
+ +
+
+
Parse Status
+
@Model.ParseStatus
+
+
+
Raw Affix Text
+
@(string.IsNullOrWhiteSpace(Model.RawAffixText) ? "—" : Model.RawAffixText)
+
+
+ +
+ +
@FormatTextBlock(Model.QuickParseInput)
+
+ +
+ +
@FormatTextBlock(Model.RawCellText)
+
+ + @if (Model.ValidationMessages.Count > 0) + { +
+ @foreach (var message in Model.ValidationMessages) + { +

@message

+ } +
+ } + +
+ +
@FormatJson(Model.ParsedJson)
+
+
+ + @{ + var effectMetadata = GetEffectMetadataRows(Model); + } + + @if (effectMetadata.Count > 0) + { +
+
+
+ Effect Source Metadata +

Stored source markers and labels for the current effect list.

+
+
+ +
+ @foreach (var entry in effectMetadata) + { +
+
+ @entry.Scope +

@entry.EffectLabel

+
+
+ Type: @entry.SourceType + Text: @(string.IsNullOrWhiteSpace(entry.SourceText) ? "—" : entry.SourceText) +
+
+ } +
+
+ } + +
+
+
+ Current Save Payload +

Request built from the current editor state, including generated branch internals.

+
+
+
@BuildCurrentSavePayloadJson(Model)
+
+
+ +@code { + [Parameter, EditorRequired] + public CriticalCellEditorModel Model { get; set; } = default!; + + private static readonly JsonSerializerOptions DiagnosticJsonOptions = new() + { + WriteIndented = true + }; + + private static string FormatTextBlock(string? value) => + string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); + + private static string FormatJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return "{}"; + } + + try + { + using var document = JsonDocument.Parse(json); + return JsonSerializer.Serialize(document.RootElement, DiagnosticJsonOptions); + } + catch (JsonException) + { + return json.Trim(); + } + } + + private static string BuildCurrentSavePayloadJson(CriticalCellEditorModel model) => + JsonSerializer.Serialize(model.ToRequest(), DiagnosticJsonOptions); + + private static List<(string Scope, string EffectLabel, string SourceType, string? SourceText)> GetEffectMetadataRows(CriticalCellEditorModel model) + { + var rows = new List<(string Scope, string EffectLabel, string SourceType, string? SourceText)>(); + + foreach (var effect in model.Effects) + { + if (ShouldIncludeEffectMetadata(effect)) + { + rows.Add(("Base Result", GetEffectLabel(effect), effect.SourceType, effect.SourceText)); + } + } + + foreach (var branch in model.Branches.OrderBy(item => item.SortOrder)) + { + var scope = string.IsNullOrWhiteSpace(branch.ConditionText) ? "Condition" : branch.ConditionText.Trim(); + foreach (var effect in branch.Effects) + { + if (ShouldIncludeEffectMetadata(effect)) + { + rows.Add((scope, GetEffectLabel(effect), effect.SourceType, effect.SourceText)); + } + } + } + + return rows; + } + + private static bool ShouldIncludeEffectMetadata(CriticalEffectEditorModel effect) => + !string.IsNullOrWhiteSpace(effect.SourceText) || + !string.IsNullOrWhiteSpace(effect.SourceType); + + private static string GetEffectLabel(CriticalEffectEditorModel effect) + { + if (AffixDisplayMap.TryGet(effect.EffectCode, out var info)) + { + return info.Label; + } + + return string.IsNullOrWhiteSpace(effect.EffectCode) ? "Custom Effect" : effect.EffectCode; + } +} diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index eaef300..2b2e235 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -1068,6 +1068,40 @@ textarea { text-align: right; } +.diagnostics-page { + display: grid; + gap: 1rem; +} + +.diagnostics-page-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; +} + +.diagnostics-selector-grid { + display: grid; + gap: 0.85rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.diagnostics-selection-summary { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + padding: 0.85rem 1rem; + border-radius: 16px; + background: rgba(247, 239, 225, 0.65); + border: 1px solid rgba(127, 96, 55, 0.12); + color: #5d4429; +} + +.critical-diagnostics-body { + display: grid; + gap: 0.85rem; +} + .critical-editor-inline-button { border: none; background: transparent; @@ -1138,4 +1172,10 @@ textarea { justify-items: start; text-align: left; } + + .diagnostics-page-header, + .diagnostics-selection-summary { + flex-direction: column; + align-items: stretch; + } }