ModelingEvolution.Chat 1.0.0-preview.11

This is a prerelease version of ModelingEvolution.Chat.
dotnet add package ModelingEvolution.Chat --version 1.0.0-preview.11
                    
NuGet\Install-Package ModelingEvolution.Chat -Version 1.0.0-preview.11
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ModelingEvolution.Chat" Version="1.0.0-preview.11" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ModelingEvolution.Chat" Version="1.0.0-preview.11" />
                    
Directory.Packages.props
<PackageReference Include="ModelingEvolution.Chat" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ModelingEvolution.Chat --version 1.0.0-preview.11
                    
#r "nuget: ModelingEvolution.Chat, 1.0.0-preview.11"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ModelingEvolution.Chat@1.0.0-preview.11
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ModelingEvolution.Chat&version=1.0.0-preview.11&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=ModelingEvolution.Chat&version=1.0.0-preview.11&prerelease
                    
Install as a Cake Tool

ModelingEvolution.Chat

Event-sourced chat — workspaces → channels → participants — with MudBlazor 9 Blazor components: ConversationList, ConversationView, ChatWidget, ChatInput, ChatMessageView (Markdown messages). Server-side command handlers and read models over MicroPlumberd 1.2.x / KurrentDB.

The published language (identifiers, commands, events) is ModelingEvolution.Chat.Types, a dependency of this package.

What a host needs — nothing else

This is the whole composition. A fresh Blazor Server host with only these lines renders a working multi-channel conversation (that claim is tested: testing/FreshChatHost in the source repository is exactly this and nothing more).

<PackageReference Include="ModelingEvolution.Chat" Version="X.Y.Z" />

Program.cs

using KurrentDB.Client;
using MicroPlumberd.Services;
using ModelingEvolution.Chat;
using MudBlazor.Services;

builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddMudServices();            // MudBlazor
builder.Services.AddMudMarkdownServices();    // MudBlazor.Markdown — message bodies render as Markdown
builder.Services.AddPlumberd(KurrentDBClientSettings.Create(builder.Configuration["KurrentDB"]!));
builder.Services.AddChatServer();             // command handlers + read models (AddChatClient() = read models only)
builder.Services.AddHealthChecks().AddPlumberdHealthChecks();   // readiness — see "Before you send" below

…and map it: app.MapHealthChecks("/health", …) with a response writer that names each check (the default prints only the status word) — see testing/FreshChatHost/Program.cs.

App.razor (or your layout's <head> / <body>)

<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link href="_content/MudBlazor.Markdown/MudBlazor.Markdown.min.css" rel="stylesheet" />
…
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="_content/MudBlazor.Markdown/MudBlazor.Markdown.min.js"></script>
<script src="_framework/blazor.web.js"></script>

Layout — MudBlazor's providers, once:

<MudThemeProvider />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />

_Imports.razor

@using MudBlazor
@using ModelingEvolution.Chat
@using ModelingEvolution.Chat.Components
@using ModelingEvolution.Chat.ReadModels

A page (interactive — the components bind input events and take EventCallbacks; static SSR will not run them):

@page "/chat"
@rendermode InteractiveServer

<ConversationList WorkspaceId="@Workspace" @bind-SelectedChannelId="_channel" />
<ConversationView ChannelId="@_channel" SenderId="@Me" />

Seeding a conversation

Everything goes through ICommandBus (MicroPlumberd) with the commands from ModelingEvolution.Chat.Types:

await bus.SendAsync(workspaceId,   new DefineWorkspace     { Name = "Support" });
await bus.SendAsync(channelId,     new DefineChannel       { WorkspaceId = workspaceId, Name = "general" });
await bus.SendAsync(participantId, new RegisterParticipant { Email = "alice@example.com", Name = "Alice" });
await bus.SendAsync(channelId,     new SendMessage         { ChannelId = channelId, SenderId = participantId, Content = "**hello**" });

Before you send. MicroPlumberd's command handlers subscribe from the end of the app command stream once the host is running; a command sent before they are ready is never answered (a 2-minute timeout, not an error). Wait for /health to report Healthy (or for AddPlumberdHealthChecks's check) before the first SendAsync — on startup, and in any test that boots the host and seeds it.

Stream naming: Workspace-{WorkspaceId}, Channel-{ChannelId}, Participant-{ParticipantId}; the read models subscribe by event type and need KurrentDB's standard projections running ($by_event_type).

What is in the contract (frozen at 1.0.0)

  • The components' [Parameter]s and EventCallbacks — see each .razor — not their pixels.
  • Workspaces / channels / participants; the multi-channel surface; send + live update; Markdown; participant name resolution.
  • Not in the contract: typing indicators (do not exist), notification hooks (deleted), read receipts (half-built — not asserted).

Latest is a standing constraint

The components are built on MudBlazor 9's supported primitives (MudPaper, MudStack, MudText, MudMarkdown) — never on a MudBlazor "chat" component. In the source repository Directory.Build.props makes a Razor element that resolves to no component (RZ10012) a build error, so a future MudBlazor deletion cannot ship as inert markup.

Rendering user-authored Markdown — the one policy (preview.3 / preview.4)

Every message goes through ChatMarkdownMudMarkdown with MarkdownPipeline="@ChatMarkdown.Pipeline" (raw HTML disabled: tags are literal text) and Props="@ChatMarkdown.Props" (every link/image URL through ChatMarkdown.SafeUrl). MudMarkdown on its own passes raw HTML through and puts any URL scheme into href (P0, 2026-08-17: stored XSS across participants); a host that renders chat content itself must use the same two knobs.

  • Links (ChatMarkdown.IsAllowedUrl): http/https/mailto or a same-origin relative path. Anything else — javascript:, data:, vbscript:, file:, and any spelling of protocol-relative (//evil, /\evil, \\evil, /%2F%2Fevil, /%5Cevil\, %2F, %5C are read as / and control/whitespace dropped before the test) — renders with an EMPTY href.
  • Images (ChatMarkdown.IsAllowedImageUrl) — hazard 5: a link is a choice, an image is a FETCH on render. A remote image from a stranger is a tracking pixel (viewer IP/UA) on a page that may carry a buyer capability, so image sources are allowed ONLY when same-origin relative (/media/a.png); https://… and everything with a scheme or an authority renders <img src=""> (a browser fetches nothing for it).
  • External links carry rel="noopener noreferrer" + target=_blank (asserted per link in the rendered DOM).

The payload sweep (ChatMarkdownXssTests, structural DOM assertions) is the guard; a host's own renderer of chat content belongs behind the same class, and the source repository pins that no raw-markup sink takes user text any other way.

preview.5 — additive, for a host whose buyer has no address and whose command server is elsewhere (design §4.6c)

  • RegisterParticipant.Email is optional: a participant may be registered WITHOUT an address (a portal buyer keyed by its enquiry, whose address lives sealed elsewhere). Address-less participants are never indexed by address (two of them do not collide); an address that IS given must be valid.
  • ConversationView/ChatMessageView OwnLabel (optional): what the viewer's OWN bubbles are labelled — "You" in the viewer's language — instead of the stored name/address; others keep their stored name; nothing stored ⇒ "Unknown".
  • ConversationView.FireAndForget (optional, default false): dispatch SendMessage without awaiting the handler, so an out-of-process command server that is down never stalls the sender's UI until the bus timeout. The command is on the app command stream and is handled when the server is up (see item 10 above: handlers subscribe from the END — a command sent while the server is DOWN is not replayed to it; a host that needs delivery-while-down keeps the default and shows the timeout, or queues on its own side).

preview.6 — channel tags (design §4.6d) and the estate's one tag type

DefineChannel.Tags / ChannelDefined.Tags carry ExternalReference tags from the ModelingEvolution.Tags package — the SAME type a booking's tags are — bounded at 16, duplicates collapsed, '@' refused by the type; ChannelReadModel.ByTag(tag) / ByTagKind(kind) answer "the chat for this offer" exactly as BookingLookupModel.ByTag answers "the meeting for this offer". Channels defined before tags existed read as having none. Add using ModelingEvolution.Tags; (Chat.Types depends on the package).

preview.6 — a sent message is local until the store acks it (design §4.6c, lead's ruling)

ConversationView AWAITS the send with a bounded SendTimeout (default 5 s) and shows the message the viewer typed as a LOCAL row — data-chat-delivery="sending" — until the store folds it (the command's id IS the message id: the handler writes it onto MessageSent, the read model folds once per id, and the local bubble is dropped when its stored twin appears). A fault or the timeout ⇒ data-chat-delivery="failed" with NotDeliveredLabel + a RetryLabel button (data-chat-retry) that re-sends the SAME message (SendMessage.MessageId, stable) as a NEW command (SendMessage.Id, fresh) — never a second message. Why a new command (preview.7, measured on TEST): a command appended while the server is DOWN is NOT replayed to it — MicroPlumberd's handlers subscribe from the END of the app command stream (item 10) — and a retry that re-used the command id was refused by the bus as in-flight / de-duplicated by the store, so it did nothing: "retry" that never leaves "failed". A retry must therefore be a command nobody has seen; the stable MessageId is what keeps a retry after a landed-but-unacked attempt from showing twice (the read model folds once per MessageSent.Id). Labels (SendingLabel, NotDeliveredLabel, RetryLabel) are the host's, per language; OnSendFailed (EventCallback<Exception>) fires as well, so a host can keep the draft or toast — in addition to the visible state, never instead of it. FireAndForget still exists as a flag but the buyer page does not use it: a possibly-lost message is worse than a visible "not delivered".

preview.8 — a command is a request, not a fact (owner ruling)

A SendMessage sent while no chat server is subscribed is NOT executed — the caller's send times out and the CALLER retries (the bounded send + retry above is exactly that path). This is BY DESIGN, not a gap: AddChatServer deliberately registers no server-side reconciler that reprocesses old commands. Such a reconciler was tried (preview.7, §6.124) and removed here — it would resurrect a message the buyer was told had NOT been delivered, possibly after they retyped it, i.e. two messages from one intent. Handlers subscribe from the END of the app command stream (item 10); a command appended during downtime is the caller's to resend, not the server's to replay.

preview.10 — a message may only be sent by a PARTICIPANT of the channel (§6.153)

SendMessage used to copy SenderId onto the event and never ask whether that sender belonged to the channel. The invariant was held by PROVENANCE — the caller derived the sender server-side — which is a rule the next caller inherits without knowing it exists. Now the handler asks, and refuses with 403 SenderNotAParticipant.

The fact this needed did not exist before preview.10. Participants are registered globally (Participant-{id}); channels had no member list. So a channel now carries one, on its own short stream ChannelMembership-{ChannelId} — never on Channel-{id}, which is the message log (an aggregate that replayed every message to answer "who may write" would cost more with every message ever sent).

// Provision at creation — BOTH sides go in the same list; the buyer's derived id and staff
// ParticipantId.FromEmail ids are the same kind of value, so one set admits both.
await bus.SendAsync(channelId, new DefineChannel
{
    WorkspaceId = workspaceId, Name = "offer chat",
    Participants = [buyerId, salesId],
});

// …or later
await bus.SendAsync(channelId, new AddChannelParticipant { ChannelId = channelId, ParticipantId = salesId });
  • A channel defined with no participants refuses everyone. Empty is an answer, not a default — and it fails at development time rather than silently in production. (Your first send will be a FaultException<SenderNotAParticipant>; that is this rule, not a bug.)
  • Admission is idempotent — re-provisioning the same set on every boot appends nothing.
  • The refusal reaches the buyer through the delivery contract the panel already draws: a failed send with a retry (data-chat-delivery="failed"), never a silently dropped message.

Upgrading an existing deployment — READ THIS

Every channel created before preview.10 has no membership record at all. That is deliberately distinguishable from "a membership that admits nobody" (an event, ChannelMembershipProvisioned, exists precisely so an empty membership is a FACT rather than an absence), and the two are treated as opposites:

The channel Behaviour
Has a membership record The rule applies. Not admitted ⇒ 403. An empty record refuses everyone.
Has none (defined before preview.10) Keeps working by default — exposure is exactly what it was before this release.
builder.Services.AddChatServer();                                           // default: legacy channels keep working
builder.Services.AddChatServer(o => o.RefuseSendsOnChannelsWithoutMembership = true);   // after you have backfilled

The migration is the host's obligation, and STRICT MODE IS THE INTENDED END STATE. The default is compatible, not final: it exists to avoid breaking conversations that already exist, and it is meant to be turned off. Two steps:

  1. Backfill every existing channelAddChannelParticipant for everyone who may write. Admission is idempotent, so the backfill is safe to re-run and safe to leave in place.
  2. Set RefuseSendsOnChannelsWithoutMembership = true once the count below reaches zero.

Who decides, and who records — the separation this rests on. The package does not decide who is staff, who is a buyer, or who is allowed to open a conversation: the host's authorization decides who may open the pane; this package records the resulting membership. That is why "the staff surface admits itself on open" is not a hole in the check — the surface has already been through the host's auth, already resolved the identity, and already knows the channel; the AddChannelParticipant it sends is writing down a decision the host just made, not making one.

The staff hazard — read this before you write the backfill. A backfill that admits only the buyer will break the staff side of every existing channel, and it will do so silently, because nobody is watching that pane. Buyer ids are usually derivable (a host that provisions channels deterministically knows them); staff ids often are not — if your staff participant is derived at runtime from the signed-in identity (ParticipantId.FromEmail(email claim)) and registered lazily on first use, no list written at channel-creation time can contain them. The shape that works is the staff surface admitting itself when it opens a channel: it already knows both the channel and the identity it just resolved, and AddChannelParticipant is idempotent, so "join on open" costs one append per staff member per channel and nothing thereafter.

What this check is NOT — a stated non-goal

This is a participant model made explicit and enforced. It is not an authentication boundary.

AddChannelParticipant is a command like any other: a caller that can send SendMessage can also send an admission for itself first. The check therefore raises the bar from "any sender id on any channel" to "any caller who can also write a membership fact" — real, and worth having, because it makes an invariant that used to live in the callers' habits into one the model states and the handler enforces. But it is meaningful only because the command bus is internal.

Authorization at the bus edge is a separate concern and this package does not provide it. If your command bus is reachable by anything you do not trust, that is the control you need, and this check is not a substitute for it. Said plainly because a guard that is quietly weaker than it looks is worse than no guard: the next person builds on the strength they assumed.

Seeing how much is left

AddChatServer registers ChannelMembershipCoverageModel. It folds channel definitions against membership provisionings — no other read model injected — and answers the only question that ends the transitional state:

var coverage = app.Services.GetRequiredService<ChannelMembershipCoverageModel>();
logger.LogInformation("{Coverage}", coverage.Describe());
//  chat: 12 of 91 channel(s) have NO membership record — sends on them are not participant-checked. …
//  chat: all 91 channel(s) have a membership record.        ← the end state
// coverage.ChannelsWithoutMembership drives the backfill; coverage.WithoutMembership is what you alert on.

A send accepted on a channel with no membership record also logs a warning, once per channel — the count tells you how much is left, the warning tells you it is still happening. A grandfather clause nobody can see is indistinguishable from a bug.

Revocation — ruled, deliberately not built

There is no "remove a participant" event, and its absence is a decision, not an omission. A revocation removes the ability to write; it does not unwrite. Messages already sent are facts that happened: they stay in the stream and they keep rendering. Deleting or hiding them would falsify the transcript and leave the two sides of one conversation looking at different histories — worse than either. When removal is built it will affect admission on subsequent sends only, with its own pins.

Health checks this package registers

None. AddChatServer and AddChatClient register NO health checks of their own (the preview.7 stranded-command check was removed in preview.8). A host that maps /health gets only what IT adds — typically AddPlumberdHealthChecks(), whose "Plumberd Startup Health Check" is UNTAGGED and so lands on /health, and which is Unhealthy until the handlers have subscribed (a real readiness state — item 10 — not a "hasn't run yet" placeholder). If your container gates on curl /health, that check is the one that holds it Unhealthy until ready; give it a start_period.

Hosting the panel — the one CSS contract (preview.9)

ConversationView is a full-height panel: the HOST owns the outer frame, the PANEL owns the inner scroll region (it must, because jump-to-latest, "don't yank the view when the buyer has scrolled up", and scroll-to-first-unread all read and set scrollTop on the message list — a host-owned scroller can't be driven from inside the panel). The contract, and WHY it is easy to get silently wrong:

  • The host must give the panel a definite, bounded height — place it in a flex or grid track sized 1fr (or a fixed height), and set min-height: 0 on that track. A flex/grid child defaults to min-height: auto, which refuses to shrink below its content, so WITHOUT min-height: 0 the message list grows the page instead of scrolling and the panel's own overflow-y:auto never engages — an unscrollable chat that looks fine until there are enough messages to overflow.
  • The panel is height: 100% of that cell and manages the rest (a scrolling message region above, the always-visible input below). Do NOT wrap it in an auto-height container.

Example host cell: display:grid; grid-template-rows: 1fr auto; min-height:0; height:100dvh; with the panel in the 1fr row. The panel ships its scroll behaviour as a same-origin RCL module (_content/ModelingEvolution.Chat/...) — no external subresource.

preview.9 — THE PANEL: rows, not bubbles (screen-chat-panel.md §3, normative)

ConversationView is the Slack-shaped panel, not a message box. One walk over ChatTranscript.Build's items renders it, so grouping, day separators and the unread divider stay decided in the pure fold (and pinned there).

  • Rows. No right-alignment and no fill for own messages: same gutter, same left edge, a 2 px accent rule in the gutter and the word You (OwnLabel). A tinted surface behind every own message is a large accent area, which brand guidance forbids — and a long thread is a large area.
  • A 32 px chip in a fixed 44 px gutter on BOTH sides — staff initials derived from the display name (ChatInitials: an address is not a name, so a@b.com gives A, never AB), the viewer a neutral role glyph. Never an <img>, from any source — a remote avatar is a third-party subresource on a page that may carry a capability secret, and a Gravatar-style hash of an address is the same violation with a privacy leak on top.
  • Grouping: one predicate — same sender AND gap ≤ GroupWindow (5 min) AND same calendar day in BusinessTimeZone AND neither row in an isolating delivery state. A continuation row draws no chip, no name, no time.
  • Day separator sticky, unread divider inline — deliberately different: the day names the region in view; the divider marks a POSITION, and sticking it would claim "everything above this is read" at any scroll position.
  • Scroll (the panel owns it): it lands on the divider when there is one, else at the bottom; a message arriving while the buyer is pinned (ConversationView.PinnedThresholdPx = 64 px of the bottom) scrolls, and one arriving while they are scrolled up does not move the list — the jump-to-unread pill rises instead and jumps to the divider.
  • The draft survives a reload (F-M24): sessionStorage, keyed by ChannelId, cleared on a successful send. Not localStorage — buyer-typed text on a page carrying a capability secret must not outlive the tab.
  • The composer: grows 1→6 rows then scrolls inside; the Send button is always visible and is the guaranteed send path. Enter sends only where (pointer: fine) matches AND the viewport is ≥ 1024 px — a declared heuristic, and the hint line states whichever behaviour is actually active (a keyboard contract that lies is worse than none).

Ships one same-origin RCL module, _content/ModelingEvolution.Chat/chat-panel.js, imported by the components themselves — no <script> tag for the host to add, and no external subresource; script-src 'self' is enough.

Every user-visible word is the host's

The package carries no language of its own — a hard-coded English string on a Polish page is a defect, and a test sweeps for it (each label set to a sentinel; no word may remain in the panel's visible text). Supply them:

<ConversationView ChannelId="@_channel" SenderId="@Me"
                  BusinessTimeZone="@_businessZone"        @* BookingConfigModel.TimeZone — see below *@
                  UnreadAfter="@_frozenReadBoundary"
                  OwnLabel="Ty" NoChannelLabel="…" NewMessagesLabel="Nowe wiadomości" UnknownSenderLabel="Nieznany"
                  Placeholder="Napisz wiadomość…" SendLabel="Wyślij"
                  DayLabel="@(d => _days.Label(d))" JumpToUnreadLabel="@(n => $"{n} nowe")"
                  EnterSendsLabel="Enter wysyła · Shift+Enter to nowa linia" EnterNewLineLabel="Użyj przycisku Wyślij"
                  ComposerFooterLabel="Nie wysyłaj tu haseł ani danych karty."
                  ComposerDisabled="@(!_connected)" ComposerDisabledLabel="Połączenie zerwane…"
                  SendingLabel="Wysyłanie…" NotDeliveredLabel="Nie dostarczono" RetryLabel="Ponów" />

BusinessTimeZone is the CALENDAR's zone, not the browser's

Day separators are computed in the zone the booking calendar renders in (BookingConfigModel.TimeZone, Europe/Warsaw today) — not the buyer's browser zone and not UTC. A London buyer reading "Your meeting · Tuesday 2 September" must not see a chat day separator saying "Monday 1 September" two lines above it: two clocks on one page is a defect, and a day separator is exactly where it reappears. The default is UTC so a host that has not decided still gets ONE consistent clock.

Delivery state is a closed type

ModelingEvolution.Chat.Rendering.DeliveryStateSending / Acked / Failed, private constructor. It used to be a string compared to "failed", which failed open: a caller passing "Failed", "error" or "send-failed" got a row that grouped normally, so the failure marker rendered under the header of the message that was delivered. Isolation is decided on not-known-good (ChatTranscript.Isolates), so a state added later isolates by default.

There is no delivered tick. data-chat-delivery renders only while the message is pending; on ack it leaves the pending list and re-renders with no delivery state, so the row disappearing IS the success signal. A delivery line that persists after the ack is the regression (pinned in that form).

The DOM contract the host's tests can hold on to

data-chat-list, data-chat-message (+ data-chat-message-id, data-chat-own, data-chat-group-start), data-chat-sender, data-chat-time, data-chat-body, data-chat-chip (initials | role), data-chat-day="yyyy-MM-dd", data-chat-unread-divider (+ data-chat-unread-below), data-chat-unread-pill, data-chat-composer, data-chat-send, data-chat-keyboard-hint (enter-sends | enter-newline), data-chat-delivery, data-chat-retry, data-chat-composer-inert.

The "new messages" divider — the host resolves the boundary (preview.9)

ChatTranscript.Build places the divider by TIME: unreadAfter is the SentAt of the last message the buyer had read when the panel opened, frozen for the session. The HOST resolves it from the store's read state and MUST distinguish two cases that look alike but read differently to the buyer:

  • read position is OLDER than the loaded window (they were away) ⇒ pass that older timestamp ⇒ divider at the TOP, "everything here is new";
  • no read position at all (never opened this channel, or the boundary message was deleted) ⇒ pass null ⇒ NO divider. The divider is frozen: the panel does not advance unreadAfter while the buyer reads, so the line stays on the same message as new ones arrive; the host advances it (and commits MarkMessageAsRead) on its own when-to-commit policy — on focus/visibility, not on render. Durable read state lives in the store (MessageRead); the frozen boundary is the panel's.
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-preview.11 0 8/19/2026
1.0.0-preview.10 0 8/19/2026
1.0.0-preview.9 0 8/19/2026
1.0.0-preview.8 58 8/17/2026
1.0.0-preview.7 47 8/17/2026
1.0.0-preview.6 43 8/17/2026
1.0.0-preview.5 43 8/17/2026
1.0.0-preview.4 49 8/17/2026
1.0.0-preview.3 52 8/17/2026
1.0.0-preview.2 56 8/17/2026
1.0.0-preview.1 52 8/17/2026