diff --git a/CHANGELOG.md b/CHANGELOG.md index 73df1a6..c804a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,17 @@ Version bump guide: ## [Unreleased] +## [2.24.3] — 2026-08-20 + +### Changed +- `DrylAlert` — **a dismiss button with no `OnDismiss` handler now actually dismisses.** `` on its own rendered a button that was focusable, announced to screen readers, and completely inert: the alert never removed itself and nobody was listening, so pressing it did nothing at all. Which of the two parameters is set now decides who owns the alert's lifetime. With a handler, nothing changes — dismissing is a request and your host answers it by unmounting, exactly as before. With no handler, the alert answers the press itself and fades out through `DrylPresence`, so the control is never a lie. Setting `Dismissible` back to `false` restores a self-dismissed alert without remounting it. The wrapper element that self-dismissal needs is rendered **only** in that configuration, so an alert with a handler and a non-dismissible alert produce exactly the markup they did before. +- `DrylNotifications` — **a controlled inbox no longer writes to the list you gave it.** Clicking an unread row set `Read` on your own `DrylNotification` and *then* raised `OnMarkRead`, which is the opposite of what controlled mode promises: you own the state, the component raises callbacks. A caller holding a snapshot found it changed underneath them, and the callback arrived describing something that had already happened. The component now raises `OnMarkRead` and writes nothing — **if your handler was empty because the component did the work, the row will no longer turn read; set `Read` in the handler.** Service-driven mode is untouched, because there the service genuinely is the state. + +### Fixed +- `DrylNotifications` — **the unread state is now announced, not only coloured.** The accent dot carried an `aria-label` on a bare `span`, and a generic element with no role is not reliably named by assistive technology, so a screen-reader user heard a row's title and its time and nothing about it being unread — the one thing the bell exists to convey. The word now sits as visually-hidden text inside the row's own button, so it is part of the row's accessible name, and the dot is marked decorative so the state is announced once rather than twice. +- `DrylSkeleton`, `DrylImage` — **the shimmer now stops when the user has asked for less motion.** The skeleton's own reduced-motion rules calmed its AI states and dropped the stagger between its bars, which read as the component honouring the preference; it did not. The sweep itself lives on the shared `.skel` primitive in `dryl.css` and no reduced-motion rule had ever touched it, so someone who set the preference got a placeholder that was entirely, permanently in motion — on a loading screen, that is most of what is on the page. The sliding strip is no longer painted at all under `prefers-reduced-motion: reduce`, and the blocks rest on `--glass-3`, the bright midpoint the sweep used to pass through, so a still placeholder is as legible as a moving one was at its clearest rather than sitting at the dim end of its own range. `AiState.Streaming` keeps its violet-cyan colour as a static tint on the blocks, so the one thing the moving shimmer was saying — model output is arriving here — survives the loss of the motion that said it. The fix is in the primitive, so `DrylImage`'s loading state is covered by it too, as is any consumer using the `skel` classes through `SkeletonVariant.Custom`. Nothing changes with motion on. +- `DrylProgress` — **the value announced to a screen reader is now the value the bar actually draws.** The fill was clamped into the track and `aria-valuenow` was not, so the two halves of the same component disagreed whenever `Value` fell outside `0..Max`: `Value="120" Max="100"` drew a full bar and reported "120 of 100", and a negative value drew an empty bar and reported the negative number. The half a sighted user cannot check was the wrong one. The fill width, the percentage label and the reported value are now all derived from one clamped number, so a bar cannot show one thing and say another. No API change; a bar whose `Value` was always in range renders and reports exactly as before. + ## [2.24.2] — 2026-08-20 ### Changed diff --git a/code/DRYL.Components/Components/Feedback/DrylAlert.razor b/code/DRYL.Components/Components/Feedback/DrylAlert.razor index 276156d..6934f3e 100644 --- a/code/DRYL.Components/Components/Feedback/DrylAlert.razor +++ b/code/DRYL.Components/Components/Feedback/DrylAlert.razor @@ -22,47 +22,70 @@ • Dismissible shows a dismiss button; OnDismiss delivers the callback. ───────────────────────────────────────────────────────── *@ -
+@* Who owns the alert's lifetime decides how it is rendered. + + With an OnDismiss handler attached, the host owns it: the alert is rendered + plainly, exactly as it always has been, and dismissing is a request the host + answers by unmounting (and animating that itself). + + With no handler, nobody is listening and the button would otherwise be inert, + so the alert dismisses itself — and something that unmounts conditionally + animates out rather than blinking away (DESIGN-12). The DrylPresence wrapper + exists only in that configuration, so no alert that worked before gains an + element around it. +*@ +@if (SelfDismisses) +{ + + @Surface + +} +else +{ + @Surface +} - @* AI aura overlays — mounted while the aura is present (live or fading out) *@ - +@code { + private RenderFragment Surface => @
- @* Icon-Chip *@ - @if (!string.IsNullOrEmpty(ResolvedIcon)) - { - - } + @* AI aura overlays — mounted while the aura is present (live or fading out) *@ + - @* Text area: title + body *@ -
- @if (!string.IsNullOrEmpty(Title)) + @* Icon-Chip *@ + @if (!string.IsNullOrEmpty(ResolvedIcon)) { -
@Title
+ } - @if (ChildContent is not null) + + @* Text area: title + body *@ +
+ @if (!string.IsNullOrEmpty(Title)) + { +
@Title
+ } + @if (ChildContent is not null) + { +
@ChildContent
+ } +
+ + @* Optional dismiss button *@ + @if (Dismissible) { -
@ChildContent
+ } -
+
; - @* Optional dismiss button *@ - @if (Dismissible) - { - - } -
- -@code { /// Semantic variant — drives the icon and the accent colour. [Parameter] public AlertKind Kind { get; set; } = AlertKind.Info; @@ -102,6 +125,13 @@ private AiState _prevAi = AiState.None; private int _genTick; private readonly AuraLifecycle _aura = new(); + private bool _selfDismissed; + + /// + /// True when the alert offers a dismiss button that nobody is listening to, and + /// therefore has to answer it itself. + /// + private bool SelfDismisses => Dismissible && !OnDismiss.HasDelegate; protected override void OnParametersSet() { @@ -110,6 +140,10 @@ _genTick++; _prevAi = Ai; _aura.Sync(Ai, () => InvokeAsync(StateHasChanged)); + + // Turning Dismissible off and on again brings a self-dismissed alert back, so a + // host that never had a handler still has a way to show it without remounting. + if (!Dismissible) _selfDismissed = false; } public void Dispose() => _aura.Dispose(); @@ -162,7 +196,13 @@ private async Task HandleDismiss() { if (OnDismiss.HasDelegate) + { await OnDismiss.InvokeAsync(); + return; + } + + // Nobody is listening. The button still has to do the obvious thing. + _selfDismissed = true; } /// Semantic variant of the alert. diff --git a/code/DRYL.Components/Components/Feedback/DrylNotifications.razor b/code/DRYL.Components/Components/Feedback/DrylNotifications.razor index 059dea6..b08f060 100644 --- a/code/DRYL.Components/Components/Feedback/DrylNotifications.razor +++ b/code/DRYL.Components/Components/Feedback/DrylNotifications.razor @@ -97,7 +97,11 @@ @if (!item.Read) { - + @* The dot is decorative; the state belongs in the row's own + accessible name. aria-label on a role-less span is not + reliably announced, so the word is real text instead. *@ + + Unread }