ModelingEvolution.Chat
1.0.0-preview.11
dotnet add package ModelingEvolution.Chat --version 1.0.0-preview.11
NuGet\Install-Package ModelingEvolution.Chat -Version 1.0.0-preview.11
<PackageReference Include="ModelingEvolution.Chat" Version="1.0.0-preview.11" />
<PackageVersion Include="ModelingEvolution.Chat" Version="1.0.0-preview.11" />
<PackageReference Include="ModelingEvolution.Chat" />
paket add ModelingEvolution.Chat --version 1.0.0-preview.11
#r "nuget: ModelingEvolution.Chat, 1.0.0-preview.11"
#:package ModelingEvolution.Chat@1.0.0-preview.11
#addin nuget:?package=ModelingEvolution.Chat&version=1.0.0-preview.11&prerelease
#tool nuget:?package=ModelingEvolution.Chat&version=1.0.0-preview.11&prerelease
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 andEventCallbacks — 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 ChatMarkdown — MudMarkdown 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/mailtoor a same-origin relative path. Anything else —javascript:,data:,vbscript:,file:, and any spelling of protocol-relative (//evil,/\evil,\\evil,/%2F%2Fevil,/%5Cevil—\,%2F,%5Care 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.Emailis 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/ChatMessageViewOwnLabel(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): dispatchSendMessagewithout 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:
- Backfill every existing channel —
AddChannelParticipantfor everyone who may write. Admission is idempotent, so the backfill is safe to re-run and safe to leave in place. - Set
RefuseSendsOnChannelsWithoutMembership = trueonce 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, andAddChannelParticipantis 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 setmin-height: 0on that track. A flex/grid child defaults tomin-height: auto, which refuses to shrink below its content, so WITHOUTmin-height: 0the message list grows the page instead of scrolling and the panel's ownoverflow-y:autonever 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, soa@b.comgivesA, neverAB), 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 inBusinessTimeZoneAND 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 byChannelId, cleared on a successful send. NotlocalStorage— 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.DeliveryState — Sending / 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 advanceunreadAfterwhile the buyer reads, so the line stays on the same message as new ones arrive; the host advances it (and commitsMarkMessageAsRead) 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 | Versions 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. |
-
net10.0
- Markdig (>= 0.45.0)
- MicroPlumberd (>= 1.2.2)
- MicroPlumberd.Services (>= 1.2.2)
- MicroPlumberd.SourceGenerators (>= 1.2.2)
- ModelingEvolution.Chat.Types (>= 1.0.0-preview.11)
- ModelingEvolution.Observable.Blazor (>= 0.0.12)
- MudBlazor (>= 9.7.0)
- MudBlazor.Markdown (>= 9.0.0)
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 |