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
@@ -123,7 +123,12 @@
var isBrowsable = IsBrowsable(connId);
<tr>
<td class="small">@attr.Name</td>
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
@* Tag paths are OPC UA node ids (nsu=…;s=…) — unbounded
text that would otherwise set the table's width.
.cell-clip is display:block, hence the span. *@
<td class="small text-muted font-monospace">
<span class="cell-clip" title="@attr.DataSourceReference">@attr.DataSourceReference</span>
</td>
<td>
<select class="form-select form-select-sm"
value="@connId"
@@ -156,19 +161,21 @@
}
</tbody>
</table>
<div class="p-2 d-flex gap-2">
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path. Disabled when no testable rows. Protocol-agnostic —
any connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary btn-sm"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings
</button>
<div class="p-2">
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path. Disabled when no testable rows. Protocol-agnostic —
any connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings
</button>
</div>
</div>
}
</div>
@@ -252,7 +259,11 @@
</span>
}
</td>
<td class="small text-muted">@(attr.Value ?? "—")</td>
@* Template values are unbounded — canonical JSON for a
List attribute. Clip, full value on the title. *@
<td class="small text-muted">
<span class="cell-clip" title="@attr.Value">@(attr.Value ?? "—")</span>
</td>
<td>
@if (attr.DataType == DataType.List)
{
@@ -265,7 +276,10 @@
ShowElementType="false" />
@if (_overrideErrors.TryGetValue(attr.Name, out var listErr))
{
<div class="alert alert-danger small mt-2 mb-0"
@* Bounded: this alert lives inside a table cell,
so an unbounded message would widen the column. *@
<div class="alert alert-danger small mt-2 mb-0 text-break"
style="max-width: 20rem;"
data-test="override-list-error">@listErr</div>
}
@if (HasOverrideRow(attr.Name))
@@ -298,10 +312,6 @@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Alarm Overrides</strong>
<small class="text-muted ms-2">
Click <em>Edit</em> to override an alarm's trigger configuration or priority.
HiLo overrides merge into the inherited setpoints; other trigger types replace the whole config.
</small>
</div>
<div class="card-body p-0">
@if (_overridableAlarms.Count == 0)
@@ -323,6 +333,9 @@
<tbody>
@foreach (var alarm in _overridableAlarms)
{
// Comma-joined list of overridden JSON keys — unbounded key
// count, so it is clipped with the full text on the title.
var overrideSummary = HasOverride(alarm.Name) ? OverrideSummary(alarm.Name) : null;
<tr data-test="alarm-override-row-@alarm.Name">
<td class="small">@alarm.Name</td>
<td>
@@ -335,8 +348,12 @@
<td class="small">
@if (HasOverride(alarm.Name))
{
<span class="badge bg-warning text-dark me-1" data-test="alarm-override-badge" title="Override is set">●</span>
<span class="text-muted">@OverrideSummary(alarm.Name)</span>
@* .cell-clip is display:block, so the badge and the
clipped summary share a flex line. *@
<span class="d-flex align-items-center">
<span class="badge bg-warning text-dark me-1" data-test="alarm-override-badge" title="Override is set">●</span>
<span class="text-muted cell-clip cell-clip-sm" title="@overrideSummary">@overrideSummary</span>
</span>
}
else
{
@@ -344,17 +361,19 @@
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm me-1"
data-test="alarm-edit-btn"
@onclick="() => BeginEditOverride(alarm)"
disabled="@_saving">Edit</button>
@if (HasOverride(alarm.Name))
{
<button class="btn btn-outline-danger btn-sm"
data-test="alarm-clear-btn"
@onclick="() => ClearAlarmOverride(alarm.Name)"
disabled="@_saving">Clear</button>
}
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Override actions for {alarm.Name}")">
<button class="btn btn-outline-primary"
data-test="alarm-edit-btn"
@onclick="() => BeginEditOverride(alarm)"
disabled="@_saving">Edit</button>
@if (HasOverride(alarm.Name))
{
<button class="btn btn-outline-danger"
data-test="alarm-clear-btn"
@onclick="() => ClearAlarmOverride(alarm.Name)"
disabled="@_saving">Clear</button>
}
</div>
</td>
</tr>
}
@@ -371,10 +390,12 @@
<div class="modal-dialog modal-dialog-scrollable modal-lg">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">
Edit override: @_editingAlarm.Name
<span class="badge bg-secondary-subtle text-secondary-emphasis border ms-1">@_editingAlarm.TriggerType</span>
</h6>
@* Name and trigger-type badge separated so the heading is a
single identifier rather than an identifier run. *@
<div>
<h6 class="modal-title mb-1">Edit override: @_editingAlarm.Name</h6>
<span class="badge bg-secondary-subtle text-secondary-emphasis border">@_editingAlarm.TriggerType</span>
</div>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelEditOverride"></button>
</div>
<div class="modal-body">
@@ -419,9 +440,9 @@
disabled="@_saving">Clear Override</button>
}
</div>
<div>
<button class="btn btn-outline-secondary btn-sm" data-test="alarm-cancel-override" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-success btn-sm" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
<div class="btn-group btn-group-sm" role="group" aria-label="Override edit actions">
<button class="btn btn-outline-secondary" data-test="alarm-cancel-override" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-success" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
</div>
</div>
</div>
@@ -434,8 +455,9 @@
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<div>
<strong>Native Alarm Source Overrides</strong>
@* Kept: the blank-means-inherited rule is the only in-context
decoder for the three override inputs below. *@
<small class="text-muted ms-2">
Retarget an inherited native alarm source binding for this instance.
Leave a field blank to keep the inherited value.
</small>
</div>
@@ -512,9 +534,12 @@
<span class="badge bg-warning text-dark ms-1" title="Override is set">●</span>
}
</td>
<td class="small text-muted font-monospace text-truncate" style="max-width: 200px;"
@* Two identifiers — connection then source reference —
stacked rather than slammed together with a slash. *@
<td class="small" style="max-width: 200px;"
title="@($"{src.ConnectionName} / {src.SourceReference}")">
@src.ConnectionName / @src.SourceReference
<div class="text-truncate">@src.ConnectionName</div>
<div class="text-muted font-monospace text-truncate">@src.SourceReference</div>
</td>
<td>
<select class="form-select form-select-sm"
@@ -540,13 +565,15 @@
@onchange="e => _nasFilterEdit[src.Name] = string.IsNullOrWhiteSpace((string?)e.Value) ? null : ((string?)e.Value)!.Trim()" />
</td>
<td>
<button class="btn btn-success btn-sm me-1"
@onclick="() => SaveNativeOverride(src.Name)" disabled="@_saving">Save</button>
@if (HasNativeOverride(src.Name))
{
<button class="btn btn-outline-danger btn-sm"
@onclick="() => ClearNativeOverride(src.Name)" disabled="@_saving">Clear</button>
}
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Override actions for {src.Name}")">
<button class="btn btn-success"
@onclick="() => SaveNativeOverride(src.Name)" disabled="@_saving">Save</button>
@if (HasNativeOverride(src.Name))
{
<button class="btn btn-outline-danger"
@onclick="() => ClearNativeOverride(src.Name)" disabled="@_saving">Clear</button>
}
</div>
</td>
</tr>
}