Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.** `<DrylAlert Dismissible>` 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
Expand Down
106 changes: 73 additions & 33 deletions code/DRYL.Components/Components/Feedback/DrylAlert.razor
Original file line number Diff line number Diff line change
Expand Up @@ -22,47 +22,70 @@
• Dismissible shows a dismiss button; OnDismiss delivers the callback.
───────────────────────────────────────────────────────── *@

<div class="@CssClass"
role="@AriaRole"
aria-live="@AriaLive"
@attributes="AdditionalAttributes">
@* 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)
{
<DrylPresence Visible="!_selfDismissed" Transition="PresenceTransition.Fade">
@Surface
</DrylPresence>
}
else
{
@Surface
}

@* AI aura overlays — mounted while the aura is present (live or fading out) *@
<DrylAuraElements Aura="_aura" GenTick="_genTick" />
@code {
private RenderFragment Surface => @<div class="@CssClass"
role="@AriaRole"
aria-live="@AriaLive"
@attributes="AdditionalAttributes">

@* Icon-Chip *@
@if (!string.IsNullOrEmpty(ResolvedIcon))
{
<div class="ico" aria-hidden="true">
<DrylIcon Name="@ResolvedIcon" Size="14" />
</div>
}
@* AI aura overlays — mounted while the aura is present (live or fading out) *@
<DrylAuraElements Aura="_aura" GenTick="_genTick" />

@* Text area: title + body *@
<div class="alert-content">
@if (!string.IsNullOrEmpty(Title))
@* Icon-Chip *@
@if (!string.IsNullOrEmpty(ResolvedIcon))
{
<div class="title">@Title</div>
<div class="ico" aria-hidden="true">
<DrylIcon Name="@ResolvedIcon" Size="14" />
</div>
}
@if (ChildContent is not null)

@* Text area: title + body *@
<div class="alert-content">
@if (!string.IsNullOrEmpty(Title))
{
<div class="title">@Title</div>
}
@if (ChildContent is not null)
{
<div class="body">@ChildContent</div>
}
</div>

@* Optional dismiss button *@
@if (Dismissible)
{
<div class="body">@ChildContent</div>
<button class="alert-dismiss"
type="button"
aria-label="Dismiss notification"
@onclick="HandleDismiss">
<DrylIcon Name="X" Size="14" />
</button>
}
</div>
</div>;

@* Optional dismiss button *@
@if (Dismissible)
{
<button class="alert-dismiss"
type="button"
aria-label="Dismiss notification"
@onclick="HandleDismiss">
<DrylIcon Name="X" Size="14" />
</button>
}
</div>

@code {
/// <summary>Semantic variant — drives the icon and the accent colour.</summary>
[Parameter] public AlertKind Kind { get; set; } = AlertKind.Info;

Expand Down Expand Up @@ -102,6 +125,13 @@
private AiState _prevAi = AiState.None;
private int _genTick;
private readonly AuraLifecycle _aura = new();
private bool _selfDismissed;

/// <summary>
/// True when the alert offers a dismiss button that nobody is listening to, and
/// therefore has to answer it itself.
/// </summary>
private bool SelfDismisses => Dismissible && !OnDismiss.HasDelegate;

protected override void OnParametersSet()
{
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

/// <summary>Semantic variant of the alert.</summary>
Expand Down
11 changes: 9 additions & 2 deletions code/DRYL.Components/Components/Feedback/DrylNotifications.razor
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@
</span>
@if (!item.Read)
{
<span class="notif-item-dot" aria-label="Unread"></span>
@* 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. *@
<span class="notif-item-dot" aria-hidden="true"></span>
<span class="visually-hidden">Unread</span>
}
</button>
<button type="button"
Expand Down Expand Up @@ -214,8 +218,11 @@
{
if (!n.Read)
{
// Service-driven: the service *is* the state, so mark it there. Controlled: the
// caller owns the list, and a controlled component that writes to its own input
// is a surprise — raise the callback and let them decide.
if (_service is not null) _service.MarkRead(n.Id);
else { n.Read = true; await OnMarkRead.InvokeAsync(n); }
else await OnMarkRead.InvokeAsync(n);
}
await OnItemClick.InvokeAsync(n);
}
Expand Down
9 changes: 7 additions & 2 deletions code/DRYL.Components/Components/Feedback/DrylProgress.razor
Original file line number Diff line number Diff line change
Expand Up @@ -109,19 +109,24 @@

public void Dispose() => _aura.Dispose();

// The value the bar actually draws. Everything the component reports — the fill
// width, the percentage and aria-valuenow — is derived from this one number, so a
// Value outside 0..Max cannot make the drawn bar and the announced value disagree.
private double ClampedValue => Max <= 0 ? 0 : Math.Clamp(Value, 0, Max);

private double Percent
{
get
{
if (Max <= 0) return 0;
return Math.Clamp(Value / Max * 100, 0, 100);
return ClampedValue / Max * 100;
}
}

private string PctText => FormattableString.Invariant($"{Percent:0}%");

private string? AriaValueNow =>
Indeterminate ? null : Value.ToString(CultureInfo.InvariantCulture);
Indeterminate ? null : ClampedValue.ToString(CultureInfo.InvariantCulture);

private string? BarStyle =>
Indeterminate ? null : FormattableString.Invariant($"width: {Percent:0.##}%");
Expand Down
18 changes: 13 additions & 5 deletions code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,19 @@
to { opacity: 0; }
}

/* ─── Reduced motion ────────────────────────────────────────── */
/* ─── Reduced motion ────────────────────────────────────────────
The sliding strip is switched off for every .skel by the primitive in
dryl.css, so nothing here needs to slow it down — the rules below only
have to make sure the states that spoke *through* the strip still speak.
Streaming is the one that does: its whole signal was the violet-cyan
sweep, and with the sweep gone the block itself carries the colour. */
@media (prefers-reduced-motion: reduce) {
.skel-wrap.ai-aura.ai-thinking .skel::before { animation-duration: 1.4s; }
.skel-wrap.ai-aura.ai-streaming .skel::before { animation-duration: 1.4s; }
.skel-wrap.ai-aura.ai-streaming .skel {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--ai-a) 10%, transparent) 0%,
color-mix(in srgb, var(--ai-b) 16%, transparent) 100%
);
}
.skel-wrap.ai-aura.ai-generated .skel { animation: none; opacity: 0.25; }
.skel-text-block .skel::before,
.skel-card-header-text .skel::before { animation-delay: 0s; }
}
2 changes: 1 addition & 1 deletion code/DRYL.Components/DRYL.Components.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<!-- ===== NuGet package identity ===== -->
<PackageId>DRYL.Components</PackageId>
<Version>2.24.2</Version>
<Version>2.24.3</Version>
<Title>DRYL — Blazor Component Library</Title>
<Description>DRYL is a dark, glassy, AI-native UI component library for Blazor Server and Blazor WebAssembly. Token-driven, accessible by default, with a shared AI-state visual vocabulary (Active / Thinking / Streaming / Generated) across every surface — and zero JavaScript framework dependencies.</Description>
<PackageTags>blazor;blazor-components;ui;components;razor;dark;glassmorphism;ai;design-system;blazor-server;blazor-webassembly</PackageTags>
Expand Down
15 changes: 15 additions & 0 deletions code/DRYL.Components/wwwroot/dryl.css
Original file line number Diff line number Diff line change
Expand Up @@ -3550,6 +3550,21 @@ td.tbl-td-editing select {
animation: skel 1.4s var(--ease-in-out) infinite;
}
@keyframes skel { 0% { translate: -80% 0; } 100% { translate: 0 0; } }
/* A placeholder is usually most of the screen while a page loads, so the one
thing on it that never stops moving is the one that matters most here. With
motion reduced the sliding strip is not painted at all and the block rests as
a flat token surface; it still reads as "content is coming", without the
sweep (UX-06). Components that recolor the strip restore their signal as a
static tint instead — see DrylSkeleton's streaming state. */
@media (prefers-reduced-motion: reduce) {
.skel::before { display: none; }
/* Resting on --glass-3 rather than --glass-1: that is the bright midpoint the
sweep passes through, so a still block is as legible as a moving one was at
its clearest, instead of sitting at the dim end of its own range. It is the
difference between reading as a placeholder and reading as nothing in light
mode, where --glass-1 is near-white on a near-white page. */
.skel { background: var(--glass-3); }
}

/* Spinner */
.spinner {
Expand Down
Loading
Loading