feat(centralui): row 75 — Approve is caution amber, not destructive red

The Secured Writes page rendered Approve and Reject as IDENTICAL red buttons.
Row 28 fixed the labels but left the styling, and the styling was the worse
half: at the moment of a two-person-control decision the colour carried no
discriminating information at all, and the affirmative half of the control wore
the delete affordance.

Root cause is that `danger` was a two-state flag for a three-state concept, so a
consequential-but-not-destructive action had to choose between looking like a
delete and looking like the safe default. Adds the missing middle rung:

    DialogSeverity { Normal -> btn-primary, Caution -> btn-warning,
                     Destructive -> btn-danger }

DECISION. Approve -> Caution (amber): approving executes a real write to live
plant equipment, so it is not a benign primary action, but it is not destroying
anything either. Reject KEEPS Destructive (red): it is terminal and irreversible
for the request — the Operator must resubmit — and holding it red is what keeps
the two opposite decisions visually distinct now that Approve is amber. Dropping
Reject to neutral was considered and rejected: it would invert the bias, making
declining look like the safe default. Colour is never the only signal — both
buttons carry their verb.

Only Destructive inherits the row-28 "Delete" label fallback; an unlabelled
Caution reads "Confirm", since a caution action is by definition not a delete —
otherwise row 28 would come back through the new door.

ADDITIVE OVERLOAD, deliberately. The boolean form stays the signature for all
~34 existing call sites, which are genuinely two-state, so nothing in src/
outside SecuredWrites.razor changed. Adding a fifth optional parameter to the
boolean form instead would have silently un-matched every existing four-argument
NSubstitute stub — the exact row-28 gotcha (null Task, caller NREs, no compile
error). The overload cannot: the only breakage was six hand-written IDialogService
fakes failing to compile, which is the loud failure mode. That gotcha did land
once, in SecuredWritesTests' own stub, and is now stubbed on both overloads with
a comment saying why.

DialogState's `bool Danger` became `DialogSeverity Severity` rather than gaining
a second field — it is read only by DialogHost, and one field cannot disagree
with itself.

Fail-before, both halves:
  - Approve call site reverted to `danger: true` -> both new Approve pins fail.
  - Caution remapped to btn-danger in DialogHost -> the amber styling pin fails.

Tests: DialogSeverityStylingTests renders the REAL DialogService behind a real
DialogHost (FailoverConfirmLabelTests' harness), so it pins what the user sees,
not what an argument was — all three severities, both label fallbacks, and the
boolean adapter. SecuredWritesTests pins that the page asks for Caution, with a
negative control that it did NOT also go through the boolean form (without which
the assertion would pass on a call site left at danger:true). Reject's red is
pinned too, so a later "make this page less alarming" pass cannot flatten both
to one colour.

No Playwright selectors needed updating — the suite has no Secured Writes
coverage, and no `.btn-danger` selector elsewhere targets this page.

Docs: Component-CentralUI.md gains the severity contract under Technology (next
to the row-28 label contract) plus the decision on the Secured Writes section.

CentralUI.Tests: 1055/1055 green.
This commit is contained in:
Joseph Doherty
2026-08-15 21:33:08 -04:00
parent 9d668861ad
commit 40944a0d04
13 changed files with 340 additions and 15 deletions
@@ -424,7 +424,13 @@
$"Value: {row.ValueJson} ({row.ValueType})\n" +
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
$"Operator: {row.OperatorUser}",
danger: true,
// Caution, NOT danger (deferred-work register row 75). Approving executes a
// real write to live plant equipment, so this is not a benign primary action
// — but it is not destroying anything either, and red made it look like a
// delete while rendering it identical to the Reject button right beside it.
// Amber keeps the beat of hesitation the write deserves and restores the
// colour contrast between the two opposite decisions.
DialogSeverity.Caution,
confirmLabel: "Approve");
if (!confirmed) return;
@@ -461,6 +467,12 @@
var confirmed = await Dialog.ConfirmAsync(
"Reject secured write",
$"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?",
// Reject KEEPS danger styling (register row 75). It is terminal and
// irreversible for the request — the Operator has to resubmit — and holding
// it red is what makes the pair readable: with Approve amber, the two
// opposite decisions no longer look alike. Dropping Reject to a neutral
// button would just invert the bias, making declining look like the safe
// default.
danger: true,
confirmLabel: "Reject");
if (!confirmed) return;
@@ -50,7 +50,7 @@
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button type="button"
class="btn @(state.Danger ? "btn-danger" : "btn-primary")"
class="btn @ConfirmButtonClass(state)"
@onclick="Confirm">
@ConfirmLabel(state)
</button>
@@ -185,17 +185,32 @@
svc.Resolve(resolveValue);
}
// An explicit label from the caller always wins. The kind/danger fallback below
// is a convenience for the common cases only: a bare danger confirm is assumed to
// be a delete, which is exactly the assumption that put "Delete" on the Trigger
// Bootstrap class for the confirm button, by severity (deferred-work register row 75).
// Caution is the middle rung: amber, for an action that is consequential but is not
// destroying anything — the Secured Writes "Approve" is the case it was added for,
// where red made the affirmative half of a two-person control look like a delete and
// made Approve and Reject visually indistinguishable. Colour is never the only signal:
// the button is always labelled with its verb.
private static string ConfirmButtonClass(DialogState state) => state.Severity switch
{
DialogSeverity.Destructive => "btn-danger",
DialogSeverity.Caution => "btn-warning",
_ => "btn-primary",
};
// An explicit label from the caller always wins. The kind/severity fallback below
// is a convenience for the common cases only: a bare destructive confirm is assumed
// to be a delete, which is exactly the assumption that put "Delete" on the Trigger
// failover button (deferred-work register row 28). Callers whose destructive verb
// is not "Delete" must pass ConfirmLabel — see IDialogService.ConfirmAsync.
// Note Caution deliberately does NOT inherit that fallback: an unlabelled caution
// dialog reads "Confirm", because a caution action is by definition not a delete.
private static string ConfirmLabel(DialogState state) =>
!string.IsNullOrWhiteSpace(state.ConfirmLabel)
? state.ConfirmLabel
: state.Kind switch
{
DialogKind.Prompt => "Save",
_ => state.Danger ? "Delete" : "Confirm",
_ => state.Severity == DialogSeverity.Destructive ? "Delete" : "Confirm",
};
}
@@ -35,7 +35,15 @@ public class DialogService : IDialogService
private TaskCompletionSource<object?>? _tcs;
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) =>
ConfirmAsync(
title,
message,
danger ? DialogSeverity.Destructive : DialogSeverity.Normal,
confirmLabel);
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -44,7 +52,7 @@ public class DialogService : IDialogService
title,
DialogKind.Confirm,
message,
danger,
severity,
PromptInitial: string.Empty,
Placeholder: null,
ConfirmLabel: confirmLabel);
@@ -58,7 +66,7 @@ public class DialogService : IDialogService
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs = tcs;
Current = new DialogState(title, DialogKind.Prompt, label, Danger: false, PromptInitial: initialValue, Placeholder: placeholder);
Current = new DialogState(title, DialogKind.Prompt, label, DialogSeverity.Normal, PromptInitial: initialValue, Placeholder: placeholder);
OnChange?.Invoke();
return Project(tcs.Task, static r => r as string);
}
@@ -85,7 +93,7 @@ public class DialogService : IDialogService
title,
DialogKind.Custom,
Body: string.Empty,
Danger: false,
DialogSeverity.Normal,
PromptInitial: string.Empty,
Placeholder: null,
Content: content,
@@ -146,7 +154,9 @@ public class DialogService : IDialogService
/// <param name="Title">Modal title text.</param>
/// <param name="Kind">Discriminates between confirm, prompt, and custom rendering.</param>
/// <param name="Body">For confirm: the message; for prompt: the input label; ignored for custom.</param>
/// <param name="Danger">When true, the confirm button uses danger styling.</param>
/// <param name="Severity">Drives the confirm button's styling, and the verb the host
/// falls back to when <paramref name="ConfirmLabel"/> is blank. Single source of truth
/// for both — there is deliberately no separate boolean, so the two cannot disagree.</param>
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</param>
/// <param name="Placeholder">Placeholder shown when the prompt input is empty.</param>
/// <param name="Content">For <see cref="DialogKind.Custom"/>: the body fragment to
@@ -160,7 +170,7 @@ public record DialogState(
string Title,
DialogKind Kind,
string Body,
bool Danger,
DialogSeverity Severity,
string PromptInitial,
string? Placeholder,
RenderFragment? Content = null,
@@ -173,3 +183,39 @@ public enum DialogKind
Prompt,
Custom
}
/// <summary>
/// How consequential the confirmed action is — drives the confirm button's colour.
/// </summary>
/// <remarks>
/// Deferred-work register row 75. The dialog surface used to carry a two-state
/// <c>bool danger</c>, which forced every consequential-but-not-destructive action to
/// choose between looking like a delete and looking like the safe default. The Secured
/// Writes page is where that bit them: <em>Approve</em> and <em>Reject</em> both rendered
/// as identical red buttons, so at the moment of a two-person-control decision the colour
/// carried no discriminating information at all — and the affirmative half of the control
/// was painted with the delete affordance.
/// <para>
/// <see cref="Caution"/> is the middle rung that was missing. It is for actions that are
/// consequential and worth a beat of hesitation, but are not destroying anything: the
/// canonical case is a write to live plant equipment.
/// </para>
/// </remarks>
public enum DialogSeverity
{
/// <summary>Ordinary confirmation — renders <c>btn-primary</c>.</summary>
Normal,
/// <summary>
/// Consequential but not destructive — renders <c>btn-warning</c>. Does NOT inherit
/// the "Delete" label fallback; a caution dialog with no explicit label reads
/// "Confirm".
/// </summary>
Caution,
/// <summary>
/// Destructive or irreversible — renders <c>btn-danger</c>, and is the only severity
/// that falls back to the "Delete" verb (see <c>DialogHost.ConfirmLabel</c>).
/// </summary>
Destructive
}
@@ -20,7 +20,10 @@ public interface IDialogService
/// <param name="message">Body text shown to the user.</param>
/// <param name="danger">When <c>true</c>, the confirm button renders in
/// <c>btn-danger</c> styling; otherwise a primary button is shown. This
/// controls <em>styling only</em> — it does not name the action.</param>
/// controls <em>styling only</em> — it does not name the action. For an action that
/// is consequential but NOT destructive, use the <see cref="DialogSeverity"/>
/// overload and pass <see cref="DialogSeverity.Caution"/> rather than reaching for
/// red (deferred-work register row 75).</param>
/// <param name="confirmLabel">Verb rendered on the confirm button. When
/// <c>null</c> or blank the button falls back to "Delete" for a danger
/// dialog and "Confirm" otherwise. <strong>Any destructive action that is
@@ -31,6 +34,35 @@ public interface IDialogService
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null);
/// <summary>
/// Shows a confirmation dialog with an explicit <see cref="DialogSeverity"/>, for
/// actions the <c>danger</c> boolean cannot describe.
/// </summary>
/// <remarks>
/// Deferred-work register row 75. This is an ADDITIVE overload, deliberately: the
/// boolean form stays the signature for the ~34 existing call sites, all of which are
/// genuinely two-state. Changing the boolean's type instead would have rippled into
/// pages this change has no business touching, and adding a fifth optional parameter
/// to the boolean form would have silently un-matched every existing four-argument
/// NSubstitute stub — the exact gotcha register row 28 recorded (a stub that stops
/// matching returns a null Task and the caller NREs, with no compile error). A
/// separate overload cannot do either.
/// <para>
/// Reach for this only when <see cref="DialogSeverity.Caution"/> is what you mean.
/// For Normal / Destructive the boolean form says the same thing more briefly.
/// </para>
/// </remarks>
/// <param name="title">Modal title text.</param>
/// <param name="message">Body text shown to the user.</param>
/// <param name="severity">How consequential the action is; selects the confirm
/// button's styling.</param>
/// <param name="confirmLabel">Verb rendered on the confirm button. Only
/// <see cref="DialogSeverity.Destructive"/> falls back to "Delete" when this is
/// blank; the other severities fall back to "Confirm". The row-28 rule still
/// applies — name the action.</param>
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null);
/// <summary>
/// Shows a single-line text prompt and resolves to the entered value, or
/// <c>null</c> if the user cancels.