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
@@ -107,11 +107,12 @@
{
@foreach (var r in _recipients)
{
var contact = _type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress;
<tr>
<td>@r.Name</td>
<td>@(_type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)</td>
<td><span class="cell-clip cell-clip-sm" title="@r.Name">@r.Name</span></td>
<td><span class="cell-clip" title="@contact">@contact</span></td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteRecipient(r)">Delete</button>
</td>
</tr>
}
@@ -57,7 +57,7 @@
var recipients = _recipients.GetValueOrDefault(list.Id)
?? (IReadOnlyList<NotificationRecipient>)Array.Empty<NotificationRecipient>();
<tr @key="list.Id">
<td>@list.Name</td>
<td><span class="cell-clip" title="@list.Name">@list.Name</span></td>
<td>@list.Type</td>
<td>
@if (recipients.Count == 0)
@@ -66,27 +66,42 @@
}
else
{
@foreach (var r in recipients)
@foreach (var r in recipients.Take(MaxVisibleRecipients))
{
@* Type-aware contact: SMS recipients carry a PhoneNumber and a null
EmailAddress, so an email-shaped badge would render "Name <>". Fall
back to whichever contact field is populated. *@
back to whichever contact field is populated. The two identifiers
are separated inside the chip — name, then contact in a lighter
weight — rather than fused into one "Name <contact>" run. *@
var contact = list.Type == NotificationType.Sms
? r.PhoneNumber
: r.EmailAddress;
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1">@r.Name &lt;@contact&gt;</span>
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1"
title="@r.Name — @contact">@r.Name<span class="fw-normal opacity-75 ms-1">@contact</span></span>
}
@if (recipients.Count > MaxVisibleRecipients)
{
@* Long lists roll up rather than filling the cell with chips.
The remainder stays readable on the tooltip, and the full set
is always available on the list's Edit page. *@
<span class="badge bg-body-secondary text-body-secondary border"
title="@RemainingRecipientsTitle(list, recipients)">
+@(recipients.Count - MaxVisibleRecipients) more
</span>
}
}
</td>
<td class="text-end">
<button class="btn btn-outline-primary btn-sm me-1"
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
Edit
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteList(list)">
Delete
</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary"
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
Edit
</button>
<button class="btn btn-outline-danger"
@onclick="() => DeleteList(list)">
Delete
</button>
</div>
</td>
</tr>
}
@@ -97,6 +112,13 @@
</div>
@code {
/// <summary>
/// Recipient chips rendered inline before the "+N more" roll-up. A list may
/// hold dozens of recipients and the cell is uncapped, so the grid would
/// otherwise render one chip per recipient into a single row.
/// </summary>
private const int MaxVisibleRecipients = 5;
private bool _loading = true;
private string? _errorMessage;
private List<NotificationList> _lists = new();
@@ -125,6 +147,16 @@
_loading = false;
}
/// <summary>
/// Tooltip for the "+N more" roll-up chip: the recipients that were not given
/// a chip of their own, using the same type-aware contact field as the chips.
/// </summary>
private static string RemainingRecipientsTitle(
NotificationList list, IReadOnlyList<NotificationRecipient> recipients) =>
string.Join(", ", recipients
.Skip(MaxVisibleRecipients)
.Select(r => $"{r.Name} — {(list.Type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)}"));
private async Task DeleteList(NotificationList list)
{
if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true))
@@ -153,7 +153,9 @@
<td>@n.Type</td>
<td>@n.ListName</td>
<td>
@n.Subject
@* Subject is script-authored free text, so it is bounded
the same way the Last error line below it is. *@
<span class="cell-clip" title="@n.Subject">@n.Subject</span>
@if (!string.IsNullOrEmpty(n.LastError))
{
<div class="small text-danger text-truncate" style="max-width: 320px;"
@@ -176,22 +178,24 @@
@* NotificationId is the audit
CorrelationId, so the link deep-links into the central Audit
Log pre-filtered to this notification's lifecycle events. *@
<a class="btn btn-outline-secondary btn-sm me-1"
href="/audit/log?correlationId=@n.NotificationId"
data-test="audit-link-@n.NotificationId">
View audit history
</a>
@if (n.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
Discard
</button>
}
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-outline-secondary"
href="/audit/log?correlationId=@n.NotificationId"
data-test="audit-link-@n.NotificationId">
View audit history
</a>
@if (n.Status == "Parked")
{
<button class="btn btn-outline-success"
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger"
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
Discard
</button>
}
</div>
</td>
</tr>
}
@@ -209,10 +213,14 @@
}
</div>
@* ── Row detail modal ── *@
@if (_detailNotification != null)
@* ── Row detail modal ──
The modal holds only the row's NotificationId and re-resolves the summary from
the page currently on screen on every render (DetailRow()). A refresh that
replaces the row list therefore never leaves this surface rendering a stale
record, and a row that has left the page closes the modal instead of stranding
it. *@
@if (DetailRow() is { } d)
{
var d = _detailNotification;
<div class="modal show d-block sb-modal-backdrop" tabindex="-1"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
@@ -334,14 +342,16 @@
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
</div>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
@@ -370,12 +380,28 @@
private string? _listError;
private bool _actionInProgress;
// Row detail modal
private NotificationSummary? _detailNotification;
// Row detail modal. Only the row's IDENTIFIER is held — never the
// NotificationSummary record itself — so the modal cannot outlive the row it
// was opened for. DetailRow() re-resolves it from the page currently on screen
// on every render; a row that has left the page resolves to null and the modal
// renders nothing (it self-closes) rather than showing a stale snapshot.
private string? _detailNotificationId;
private NotificationDetail? _detail;
private bool _detailLoading;
private string? _detailError;
/// <summary>
/// Resolves the row the detail modal is open for from the page currently on
/// screen. Returns null when no row is selected, when the list has not loaded,
/// or when the selected row is no longer in the page — the modal's visibility
/// gate, so a refresh that drops the row closes the surface instead of leaving
/// it stranded on a stale record.
/// </summary>
private NotificationSummary? DetailRow() =>
_detailNotificationId is { } id
? _notifications?.FirstOrDefault(n => n.NotificationId == id)
: null;
// Filters
private string _statusFilter = string.Empty;
private string _typeFilter = string.Empty;
@@ -544,7 +570,7 @@
{
// The summary fields render immediately; Body + recipients fill in once the
// full-detail fetch completes.
_detailNotification = n;
_detailNotificationId = n.NotificationId;
_detail = null;
_detailError = null;
_detailLoading = true;
@@ -572,7 +598,7 @@
private void CloseDetail()
{
_detailNotification = null;
_detailNotificationId = null;
_detail = null;
_detailError = null;
_detailLoading = false;
@@ -53,7 +53,11 @@
<div class="col-md-4 text-muted">Messaging Service SID</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.MessagingServiceSid) ? "(not set)" : sms.MessagingServiceSid)</div>
<div class="col-md-4 text-muted">API Base URL</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</div>
@* An override base URL is an operator-supplied URL with no
break opportunities — clip it so it cannot widen the card. *@
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@sms.ApiBaseUrl">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</span>
</div>
<div class="col-md-4 text-muted">Auth Token</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.AuthToken) ? "(not set)" : "(stored)")</div>
<div class="col-md-4 text-muted">Connection Timeout</div>
@@ -122,8 +126,10 @@
<div class="col-12"><div class="text-danger small">@_formError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
<div class="btn-group" role="group">
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
</div>
</div>
</div>
</div>
@@ -38,7 +38,10 @@
{
<div class="card mb-3" @key="smtp.Id">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@smtp.Host</strong>
@* Under the Ews transport Host is a full EWS endpoint URL, not a
host name — clip the card title so it cannot push the Edit
button off the header. *@
<strong class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</strong>
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
@@ -51,12 +54,16 @@
@if (IsEws(smtp.Transport))
{
<div class="col-md-4 text-muted">EWS URL</div>
<div class="col-md-8">@smtp.Host</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</span>
</div>
}
else
{
<div class="col-md-4 text-muted">Host</div>
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@($"{smtp.Host}:{smtp.Port}")">@smtp.Host:@smtp.Port</span>
</div>
}
<div class="col-md-4 text-muted">Auth Type</div>
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
@@ -66,15 +73,23 @@
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
}
<div class="col-md-4 text-muted">From Address</div>
<div class="col-md-8">@smtp.FromAddress</div>
<div class="col-md-8">
<span class="cell-clip" title="@smtp.FromAddress">@smtp.FromAddress</span>
</div>
<div class="col-md-4 text-muted">Credentials</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
@if (string.Equals(smtp.AuthType, "OAuth2", StringComparison.OrdinalIgnoreCase))
{
@* Authority/Scope are operator-supplied URLs/URIs with no
break opportunities — clipped for the same reason. *@
<div class="col-md-4 text-muted">OAuth2 Authority</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Authority">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</span>
</div>
<div class="col-md-4 text-muted">OAuth2 Scope</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Scope">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</span>
</div>
}
</div>
</div>
@@ -152,8 +167,10 @@
<div class="col-12"><div class="text-danger small">@_formError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
<div class="btn-group" role="group">
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
</div>
</div>
</div>
</div>