ui: Central UI density/consistency sweep + Theme 0.4.1

Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
This commit is contained in:
Joseph Doherty
2026-08-11 05:50:12 -04:00
parent b6f383a225
commit 9e243493fb
77 changed files with 2197 additions and 1292 deletions
@@ -63,14 +63,16 @@
To="_filterTo" ToChanged="(v => _filterTo = v)"
IdPrefix="audit-filter" />
</div>
<div class="col-md-2 d-flex gap-1">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
<div class="col-md-2">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-primary" @onclick="Search" disabled="@_searching">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
<button class="btn btn-outline-secondary" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
</div>
</div>
</div>
@@ -107,7 +109,7 @@
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
<tr>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.User</td>
<td class="small"><span class="cell-clip cell-clip-sm" title="@entry.User">@entry.User</span></td>
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
<td class="small">@entry.EntityType</td>
<td class="small">
@@ -124,13 +126,24 @@
<span class="text-muted">—</span>
}
</td>
<td class="small">@entry.EntityName</td>
<td class="small">
@* Entity names are operator/bundle supplied — guarded and
bounded exactly like the Entity ID neighbour above. *@
@if (!string.IsNullOrEmpty(entry.EntityName))
{
<span class="cell-clip" title="@entry.EntityName">@entry.EntityName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (hasState)
{
if (isLarge)
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
<button class="btn btn-outline-info btn-sm"
@onclick="() => ShowStateModal(entry)"
aria-label="Open state details in modal for audit entry @entry.Id">
View in modal
@@ -138,7 +151,7 @@
}
else
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
<button class="btn btn-outline-info btn-sm"
@onclick="() => ToggleStateView(entry.Id)"
aria-label="Toggle state details for audit entry @entry.Id">
@(_expandedEntryId == entry.Id ? "Hide" : "View")
@@ -166,7 +179,12 @@
<OffsetPager Page="_page" PageChanged="OnPageChanged" HasNextPage="HasMore" TotalCount="_totalCount" PageSize="_pageSize" />
}
@if (_modalEntry != null)
@* The state modal holds only the entry's id and re-resolves the row from the
page currently on screen on every render (ModalEntry()). A refetch that
replaces the entry list therefore never leaves this surface rendering a
stale record, and an entry that has left the page closes the modal instead
of stranding it. *@
@if (ModalEntry() is { } modalEntry)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog">
@@ -174,12 +192,12 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
Audit entry @modalEntry.Id — @modalEntry.EntityType state
</h5>
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(modalEntry.AfterStateJson!)</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
@@ -217,12 +235,29 @@
private bool _searching;
private string? _errorMessage;
private int? _expandedEntryId;
private AuditLogEntry? _modalEntry;
// State modal. Only the entry's IDENTIFIER is held — never the AuditLogEntry
// record itself — so the modal cannot outlive the row it was opened for.
// ModalEntry() re-resolves it from the page currently on screen on every
// render; an entry that has left the page resolves to null and the modal
// renders nothing (it self-closes) rather than showing a stale snapshot.
private int? _modalEntryId;
private ToastNotification _toast = default!;
private bool HasMore => _page * _pageSize < _totalCount;
/// <summary>
/// Resolves the entry the state modal is open for from the page currently on
/// screen. Returns null when no entry is selected, when the list has not
/// loaded, or when the selected entry is no longer in the page — the modal's
/// visibility gate.
/// </summary>
private AuditLogEntry? ModalEntry() =>
_modalEntryId is { } id
? _entries?.FirstOrDefault(e => e.Id == id)
: null;
// Tracks the BundleImportId we last fetched against so a re-render with the
// same query param doesn't re-run the query on every parameter set.
private Guid? _lastFetchedBundleImportId;
@@ -327,12 +362,12 @@
private void ShowStateModal(AuditLogEntry entry)
{
_modalEntry = entry;
_modalEntryId = entry.Id;
}
private void CloseStateModal()
{
_modalEntry = null;
_modalEntryId = null;
}
private async Task CopyAsync(string text)