diff --git a/src/RolemasterDb.App/Components/Layout/NavMenu.razor b/src/RolemasterDb.App/Components/Layout/NavMenu.razor index e55752b..0cb63c4 100644 --- a/src/RolemasterDb.App/Components/Layout/NavMenu.razor +++ b/src/RolemasterDb.App/Components/Layout/NavMenu.razor @@ -20,5 +20,11 @@ API Surface + + diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor new file mode 100644 index 0000000..a2ba454 --- /dev/null +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -0,0 +1,272 @@ +@page "/tables" +@using System +@using System.Collections.Generic +@using System.Diagnostics.CodeAnalysis +@inject LookupService LookupService + +Critical Tables + +
+ Critical Tables +

Browse every imported table

+

The authority on this page is the SQLite data that the importer maintains. Switch tables to see the full roll matrix, grouped columns, and affix legend for each import.

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

Loading reference data...

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

No critical tables have been imported yet.

+ } + else if (isDetailLoading) + { +

Loading the selected table...

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

@detailError

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

The selected table could not be loaded.

+ } + else if (tableDetail is { } detail) + { +
+
+ @detail.SourceDocument +

@detail.DisplayName

+ @if (!string.IsNullOrWhiteSpace(detail.Notes)) + { +

@detail.Notes

+ } +
+ +
+ + + + + @if (detail.Groups.Count > 0) + { + foreach (var group in detail.Groups) + { + + } + } + else + { + + } + + + @if (detail.Groups.Count > 0) + { + foreach (var group in detail.Groups) + { + foreach (var column in detail.Columns) + { + + } + } + } + else + { + foreach (var column in detail.Columns) + { + + } + } + + + + @foreach (var rollBand in detail.RollBands) + { + + + @if (detail.Groups.Count > 0) + { + foreach (var group in detail.Groups) + { + foreach (var column in detail.Columns) + { + + } + } + } + else + { + foreach (var column in detail.Columns) + { + + } + } + + } + +
Roll band@group.LabelColumns
+ @column.Label + @column.Role + + @column.Label + @column.Role +
@rollBand.Label + @RenderCell(rollBand.Label, group.Key, column.Key) + + @RenderCell(rollBand.Label, null, column.Key) +
+
+ + @{ + var legendEntries = detail.Legend ?? Array.Empty(); + } + + @if (legendEntries.Count > 0) + { +
+

Affix legend

+
+ @foreach (var entry in legendEntries) + { +
+ @entry.Symbol +
+ @entry.Label + @entry.Description +
+
+ } +
+
+ } +
+ } +
+ +@code { + private LookupReferenceData? referenceData; + private CriticalTableDetail? tableDetail; + private string selectedTableSlug = string.Empty; + private bool isDetailLoading; + private bool isReferenceDataLoading = true; + private string? detailError; + private Dictionary<(string RollBand, string? GroupKey, string ColumnKey), CriticalTableCellDetail>? cellIndex; + + protected override async Task OnInitializedAsync() + { + referenceData = await LookupService.GetReferenceDataAsync(); + isReferenceDataLoading = false; + + 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 LoadTableDetailAsync() + { + if (string.IsNullOrWhiteSpace(selectedTableSlug)) + { + tableDetail = null; + cellIndex = null; + return; + } + + isDetailLoading = true; + detailError = null; + tableDetail = null; + cellIndex = null; + + try + { + tableDetail = await LookupService.GetCriticalTableAsync(selectedTableSlug); + if (tableDetail is null) + { + detailError = "The selected table could not be loaded."; + } + } + catch (Exception exception) + { + detailError = exception.Message; + } + finally + { + isDetailLoading = false; + BuildCellIndex(); + } + } + + private void BuildCellIndex() + { + if (tableDetail?.Cells is null) + { + cellIndex = null; + return; + } + + cellIndex = new Dictionary<(string, string?, string), CriticalTableCellDetail>(); + foreach (var cell in tableDetail.Cells) + { + cellIndex[(cell.RollBand, cell.GroupKey, cell.ColumnKey)] = cell; + } + } + + private RenderFragment RenderCell(string rollBand, string? groupKey, string columnKey) => builder => + { + if (TryGetCell(rollBand, groupKey, columnKey, out var cell)) + { + builder.OpenComponent(0, typeof(CompactCriticalCell)); + builder.AddAttribute(1, "Description", cell.Description ?? string.Empty); + builder.AddAttribute(2, "Effects", cell.Effects ?? Array.Empty()); + builder.AddAttribute(3, "Branches", cell.Branches ?? Array.Empty()); + builder.CloseComponent(); + } + else + { + builder.OpenElement(4, "span"); + builder.AddAttribute(5, "class", "empty-cell"); + builder.AddContent(6, "—"); + builder.CloseElement(); + } + }; + + private bool TryGetCell(string rollBand, string? groupKey, string columnKey, [NotNullWhen(true)] out CriticalTableCellDetail? cell) + { + if (cellIndex is null) + { + cell = null; + return false; + } + + return cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell); + } +} diff --git a/src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor b/src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor new file mode 100644 index 0000000..0f7e687 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor @@ -0,0 +1,74 @@ +@using System +@using System.Collections.Generic +@using System.Diagnostics.CodeAnalysis +@using RolemasterDb.App.Domain +@using RolemasterDb.App.Features + +@if (EffectiveEffects.Count > 0) +{ +
+ @foreach (var effect in EffectiveEffects) + { + if (TryRenderEffect(effect, out var info, out var valueText)) + { + + @info.Symbol + @if (!string.IsNullOrWhiteSpace(valueText)) + { + @valueText + } + + } + else + { + + @FormatFallback(effect) + + } + } +
+} + +@code { + [Parameter] + public IReadOnlyList? Effects { get; set; } + + private IReadOnlyList EffectiveEffects => + Effects ?? Array.Empty(); + + private static bool TryRenderEffect( + CriticalEffectLookupResponse effect, + [NotNullWhen(true)] out AffixDisplayInfo? info, + out string valueText) + { + if (!AffixDisplayMap.TryGet(effect.EffectCode, out info)) + { + valueText = string.Empty; + return false; + } + + valueText = FormatAffixValue(effect); + return true; + } + + private static string FormatAffixValue(CriticalEffectLookupResponse effect) => + effect.EffectCode switch + { + CriticalEffectCodes.StunnedRounds + or CriticalEffectCodes.MustParryRounds + or CriticalEffectCodes.NoParryRounds => effect.DurationRounds?.ToString() ?? string.Empty, + CriticalEffectCodes.BleedPerRound => effect.PerRound?.ToString() ?? string.Empty, + CriticalEffectCodes.DirectHits => effect.ValueInteger?.ToString() ?? string.Empty, + CriticalEffectCodes.FoePenalty + or CriticalEffectCodes.AttackerBonusNextRound => effect.Modifier?.ToString() ?? string.Empty, + CriticalEffectCodes.PowerPointModifier => effect.ValueExpression ?? string.Empty, + _ => effect.ValueInteger?.ToString() + ?? effect.Modifier?.ToString() + ?? effect.ValueExpression + ?? effect.SourceText + ?? string.Empty + }; + + private static string FormatFallback(CriticalEffectLookupResponse effect) => + effect.SourceText ?? effect.EffectCode; +} diff --git a/src/RolemasterDb.App/Components/Shared/CompactCriticalCell.razor b/src/RolemasterDb.App/Components/Shared/CompactCriticalCell.razor new file mode 100644 index 0000000..fbc1b0c --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CompactCriticalCell.razor @@ -0,0 +1,41 @@ +@using System.Collections.Generic +@using RolemasterDb.App.Features + +
+ @if (!string.IsNullOrWhiteSpace(Description)) + { +

@Description

+ } + + + + @if (Branches?.Count > 0) + { +
+ @foreach (var branch in Branches) + { +
+ + @branch.ConditionText + + @if (!string.IsNullOrWhiteSpace(branch.Description)) + { +

@branch.Description

+ } + +
+ } +
+ } +
+ +@code { + [Parameter, EditorRequired] + public string Description { get; set; } = string.Empty; + + [Parameter] + public IReadOnlyList? Effects { get; set; } + + [Parameter] + public IReadOnlyList? Branches { get; set; } +} diff --git a/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor b/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor index ab5ce00..0f62416 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor +++ b/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor @@ -42,93 +42,13 @@

@Result.TableNotes

} -

@Result.Description

- - @if (!string.IsNullOrWhiteSpace(Result.AffixText)) - { -
-

Affix Text

-

@Result.AffixText

- - @if (Result.Effects.Count > 0) - { -
-
Parsed Affixes
-
    - @foreach (var effect in Result.Effects) - { -
  • - @if (!string.IsNullOrWhiteSpace(effect.SourceText)) - { - @effect.SourceText - } - @FormatEffect(effect) -
  • - } -
-
- } -
- } - else if (Result.Effects.Count > 0) - { -
-

Parsed Affixes

-
    - @foreach (var effect in Result.Effects) - { -
  • - @if (!string.IsNullOrWhiteSpace(effect.SourceText)) - { - @effect.SourceText - } - @FormatEffect(effect) -
  • - } -
-
- } - - @if (Result.Branches.Count > 0) - { -
-

Conditional Branches

-
- @foreach (var branch in Result.Branches) - { -
-
@branch.ConditionText
- - @if (!string.IsNullOrWhiteSpace(branch.Description)) - { -

@branch.Description

- } - - @if (!string.IsNullOrWhiteSpace(branch.AffixText)) - { -

@branch.AffixText

- } - - @if (branch.Effects.Count > 0) - { -
    - @foreach (var effect in branch.Effects) - { -
  • - @if (!string.IsNullOrWhiteSpace(effect.SourceText)) - { - @effect.SourceText - } - @FormatEffect(effect) -
  • - } -
- } -
- } -
-
- } +
+

Cell details

+ +
Raw Imported Cell @@ -170,20 +90,4 @@ } } - private static string FormatEffect(CriticalEffectLookupResponse effect) => - effect.EffectCode switch - { - "direct_hits" when effect.ValueInteger is not null => $"{effect.ValueInteger} direct hits", - "must_parry_rounds" when effect.DurationRounds is not null => $"Must parry for {FormatRounds(effect.DurationRounds.Value)}", - "no_parry_rounds" when effect.DurationRounds is not null => $"No parry for {FormatRounds(effect.DurationRounds.Value)}", - "stunned_rounds" when effect.DurationRounds is not null => $"Stunned for {FormatRounds(effect.DurationRounds.Value)}", - "bleed_per_round" when effect.PerRound is not null => $"Bleeds {effect.PerRound} hits per round", - "foe_penalty" when effect.Modifier is not null => $"Foe penalty {effect.Modifier:+#;-#;0}", - "attacker_bonus_next_round" when effect.Modifier is not null => $"Attacker bonus next round {effect.Modifier:+#;-#;0}", - "power_point_modifier" when !string.IsNullOrWhiteSpace(effect.ValueExpression) => $"Foe power-point modifier {effect.ValueExpression}", - _ => effect.EffectCode.Replace('_', ' ') - }; - - private static string FormatRounds(int value) => - value == 1 ? "1 round" : $"{value} rounds"; } diff --git a/src/RolemasterDb.App/Domain/AffixDisplayInfo.cs b/src/RolemasterDb.App/Domain/AffixDisplayInfo.cs new file mode 100644 index 0000000..db195fb --- /dev/null +++ b/src/RolemasterDb.App/Domain/AffixDisplayInfo.cs @@ -0,0 +1,7 @@ +namespace RolemasterDb.App.Domain; + +public sealed record AffixDisplayInfo( + string Label, + string Symbol, + string Description, + string Tooltip); diff --git a/src/RolemasterDb.App/Domain/AffixDisplayMap.cs b/src/RolemasterDb.App/Domain/AffixDisplayMap.cs new file mode 100644 index 0000000..6d4073a --- /dev/null +++ b/src/RolemasterDb.App/Domain/AffixDisplayMap.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace RolemasterDb.App.Domain; + +public static class AffixDisplayMap +{ + private static readonly IReadOnlyDictionary Map = new Dictionary(StringComparer.Ordinal) + { + [CriticalEffectCodes.StunnedRounds] = new( + "Stunned", + "💫", + "Cannot act for the indicated rounds.", + "Stunned for the displayed duration."), + [CriticalEffectCodes.MustParryRounds] = new( + "Must Parry", + "🛡️", + "Forced parry for the indicated rounds.", + "Must parry until the duration ends."), + [CriticalEffectCodes.NoParryRounds] = new( + "No Parry", + "🚫🛡️", + "Cannot parry for the indicated rounds.", + "No parry for the displayed duration."), + [CriticalEffectCodes.BleedPerRound] = new( + "Bleed", + "🩸", + "Bleeds the displayed hits each round.", + "Bleeds the shown hits per round."), + [CriticalEffectCodes.DirectHits] = new( + "Direct Hits", + "🗡️", + "Additional direct hits.", + "Direct hits equal to the listed value."), + [CriticalEffectCodes.FoePenalty] = new( + "Foe Penalty", + "🔻", + "Penalty applied to the foe.", + "Foe penalty of the displayed value."), + [CriticalEffectCodes.AttackerBonusNextRound] = new( + "Attacker Bonus", + "✨", + "Attacker bonus next round.", + "Adds the shown bonus next round."), + [CriticalEffectCodes.PowerPointModifier] = new( + "Power Point Mod", + "⚡", + "Adjusts the foe's power points.", + "Power-point modifier per the expression.") + }; + + public static bool TryGet(string effectCode, [NotNullWhen(true)] out AffixDisplayInfo? info) => + Map.TryGetValue(effectCode, out info); + + public static IReadOnlyDictionary Entries => Map; +} diff --git a/src/RolemasterDb.App/Features/LookupContracts.cs b/src/RolemasterDb.App/Features/LookupContracts.cs index bfd153f..3ff3b24 100644 --- a/src/RolemasterDb.App/Features/LookupContracts.cs +++ b/src/RolemasterDb.App/Features/LookupContracts.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace RolemasterDb.App.Features; public sealed record LookupOption(string Key, string Label); @@ -92,3 +94,33 @@ public sealed record AttackLookupResponse( string RawNotation, string? Notes, CriticalLookupResponse? AutoCritical); + +public sealed record CriticalTableCellDetail( + string RollBand, + string ColumnKey, + string ColumnLabel, + string ColumnRole, + string? GroupKey, + string? GroupLabel, + string? Description, + IReadOnlyList Effects, + IReadOnlyList Branches); + +public sealed record CriticalTableLegendEntry( + string EffectCode, + string Symbol, + string Label, + string Description, + string Tooltip); + +public sealed record CriticalTableDetail( + string Slug, + string DisplayName, + string Family, + string SourceDocument, + string? Notes, + IReadOnlyList Columns, + IReadOnlyList Groups, + IReadOnlyList RollBands, + IReadOnlyList Cells, + IReadOnlyList Legend); diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs index 52b05f7..f6accef 100644 --- a/src/RolemasterDb.App/Features/LookupService.cs +++ b/src/RolemasterDb.App/Features/LookupService.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Linq; using Microsoft.EntityFrameworkCore; using RolemasterDb.App.Data; +using RolemasterDb.App.Domain; namespace RolemasterDb.App.Features; @@ -178,6 +182,158 @@ public sealed class LookupService(IDbContextFactory dbConte .SingleOrDefaultAsync(cancellationToken); } + public async Task GetCriticalTableAsync(string slug, CancellationToken cancellationToken = default) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var table = await dbContext.CriticalTables + .AsNoTracking() + .AsSplitQuery() + .Include(item => item.Columns) + .Include(item => item.Groups) + .Include(item => item.RollBands) + .Include(item => item.Results) + .ThenInclude(result => result.CriticalColumn) + .Include(item => item.Results) + .ThenInclude(result => result.CriticalGroup) + .Include(item => item.Results) + .ThenInclude(result => result.CriticalRollBand) + .Include(item => item.Results) + .ThenInclude(result => result.Effects) + .Include(item => item.Results) + .ThenInclude(result => result.Branches) + .ThenInclude(branch => branch.Effects) + .Where(item => item.Slug == slug) + .SingleOrDefaultAsync(cancellationToken); + + if (table is null) + { + return null; + } + + var columns = table.Columns + .OrderBy(column => column.SortOrder) + .Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder)) + .ToList(); + + var groups = table.Groups + .OrderBy(group => group.SortOrder) + .Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder)) + .ToList(); + + var rollBands = table.RollBands + .OrderBy(rollBand => rollBand.SortOrder) + .Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder)) + .ToList(); + + var cells = table.Results + .OrderBy(result => result.CriticalRollBand.SortOrder) + .ThenBy(result => result.CriticalGroup?.SortOrder ?? 0) + .ThenBy(result => result.CriticalColumn.SortOrder) + .Select(result => new CriticalTableCellDetail( + result.CriticalRollBand.Label, + result.CriticalColumn.ColumnKey, + result.CriticalColumn.Label, + result.CriticalColumn.Role, + result.CriticalGroup?.GroupKey, + result.CriticalGroup?.Label, + result.DescriptionText, + result.Effects + .OrderBy(effect => effect.Id) + .Select(effect => CreateEffectLookupResponse(effect)) + .ToList(), + result.Branches + .OrderBy(branch => branch.SortOrder) + .Select(branch => CreateBranchLookupResponse(branch)) + .ToList())) + .ToList(); + + var legend = BuildLegend(cells); + + return new CriticalTableDetail( + table.Slug, + table.DisplayName, + table.Family, + table.SourceDocument, + table.Notes, + columns, + groups, + rollBands, + cells, + legend); + } + private static IReadOnlyList BuildLegend(IReadOnlyList cells) + { + var seenCodes = new HashSet(StringComparer.Ordinal); + var legend = new List(); + + foreach (var cell in cells) + { + var baseEffects = cell.Effects ?? Array.Empty(); + foreach (var effect in baseEffects) + { + TryAddLegendEntry(effect.EffectCode); + } + + var branches = cell.Branches ?? Array.Empty(); + foreach (var branch in branches) + { + var branchEffects = branch.Effects ?? Array.Empty(); + foreach (var effect in branchEffects) + { + TryAddLegendEntry(effect.EffectCode); + } + } + } + + return legend + .OrderBy(item => item.Label, StringComparer.Ordinal) + .ToList(); + + void TryAddLegendEntry(string effectCode) + { + if (!seenCodes.Add(effectCode)) + { + return; + } + + if (!AffixDisplayMap.TryGet(effectCode, out var info)) + { + return; + } + + legend.Add(new CriticalTableLegendEntry(effectCode, info.Symbol, info.Label, info.Description, info.Tooltip)); + } + } + + private static CriticalEffectLookupResponse CreateEffectLookupResponse(CriticalEffect effect) => + new( + effect.EffectCode, + effect.Target, + effect.ValueInteger, + effect.ValueExpression, + effect.DurationRounds, + effect.PerRound, + effect.Modifier, + effect.BodyPart, + effect.IsPermanent, + effect.SourceType, + effect.SourceText); + + private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) => + new( + branch.BranchKind, + branch.ConditionKey, + branch.ConditionText, + branch.DescriptionText, + branch.RawAffixText, + (branch.Effects ?? Enumerable.Empty()) + .OrderBy(effect => effect.Id) + .Select(effect => CreateEffectLookupResponse(effect)) + .ToList(), + branch.RawText, + branch.SortOrder); + private static string NormalizeSlug(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant(); } diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index ffc3f0a..82d6226 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -255,6 +255,47 @@ textarea { margin-bottom: 0; } +.critical-cell { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.critical-cell-description { + margin: 0; + font-weight: 600; + color: #2c1a10; +} + +.critical-branch-stack { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.65rem; +} + +.critical-branch-card { + padding: 0.55rem 0.75rem; + border-radius: 12px; + border: 1px solid rgba(127, 96, 55, 0.12); + background: rgba(255, 255, 255, 0.85); +} + +.critical-branch-condition { + display: inline-block; + font-size: 0.75rem; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + color: #6b4c29; +} + +.critical-branch-description { + margin: 0.3rem 0 0.45rem; + font-size: 0.85rem; + color: #3b2a21; +} + .effect-stack { margin-top: 0.85rem; } @@ -298,6 +339,39 @@ textarea { margin-top: 0.75rem; } +.affix-badge-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.5rem; +} + +.affix-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.35rem 0.5rem; + border-radius: 999px; + border: 1px solid rgba(127, 96, 55, 0.18); + background: rgba(255, 250, 242, 0.9); + font-size: 0.78rem; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #5b4327; +} + +.affix-badge-symbol { + font-size: 1rem; +} + +.affix-badge-value { + font-weight: 700; +} + +.affix-badge-fallback { + text-transform: none; +} + .error-text { color: #8d2b1e; } @@ -376,6 +450,108 @@ textarea { color: #fff7ee; } +.tables-page { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.table-selector { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.table-shell { + border-radius: 20px; + padding: 1.2rem; + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(127, 96, 55, 0.2); + box-shadow: 0 18px 30px rgba(41, 22, 11, 0.08); +} + +.table-shell header { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.85rem; +} + +.table-shell .table-scroll { + overflow-x: auto; +} + +.critical-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.critical-table th, +.critical-table td { + border: 1px solid rgba(127, 96, 55, 0.2); + padding: 0.45rem; + vertical-align: top; +} + +.critical-table th { + background: rgba(238, 223, 193, 0.45); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.critical-table td { + background: rgba(255, 255, 255, 0.85); + min-width: 180px; + max-width: 260px; +} + +.critical-table td .critical-cell { + gap: 0.25rem; +} + +.critical-table .roll-band-header { + width: 120px; + background: rgba(255, 247, 230, 0.52); +} + +.empty-cell { + color: #a08464; + font-style: italic; +} + +.critical-legend { + margin-top: 1rem; + padding: 0.9rem 1rem; + border-radius: 16px; + background: rgba(235, 226, 209, 0.6); + border: 1px solid rgba(127, 96, 55, 0.2); +} + +.legend-grid { + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin-top: 0.75rem; +} + +.legend-item { + display: flex; + align-items: flex-start; + gap: 0.4rem; +} + +.legend-symbol { + font-size: 1.3rem; + line-height: 1; +} + +.legend-item strong { + display: block; + font-size: 0.95rem; +} + @media (max-width: 640.98px) { .content-shell { padding: 1rem;