All versions since 0.16.11
0.16.11
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
6d3272e: The README carries the webcomponents.org badge; nothing changes in the code you import.
The listing at webcomponents.org/element/@aparte/core exists as of 2026-09-05 and the badge links to it from npm and GitHub. Only core is listed, by decision: the plugins stay off the catalogue for now.
@aparte/core -
21dd3bc: The four plugins that ship a custom element now carry the
web-componentsnpm keyword; nothing changes in the code you import.Each already pointed
customElementsat its manifest, which is what the webcomponents.org catalogue reads, but only core carried the keyword the catalogue and npm search filter on. The five plugins that expose no element (compaction, marked, shiki, streaming-markdown, titler) are untouched: they have nothing to list there.@aparte/plugin-approval,@aparte/plugin-artifacts,@aparte/plugin-ask-user,@aparte/plugin-model-selector
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.17.0 Latest
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
7d009cf: A binary attachment now warns instead of vanishing;
AparteFilePartis removed — aparté inlines images and text files only. If you referencedAparteFilePart(or engine’sStreamFilePart) in your own types, drop it:AparteContentPartis text or image.Nothing ever produced a file part. The client inlines images and recognized text files and returned
nullfor everything else, and both wire mappers answered the type with an empty text part — a branch no message could reach. What the type did do was promise a consumer that attaching a PDF sent it, while the chip stayed on screen and the model answered as if nothing had come with the message. That promise is now a warning at the one place the file is really dropped, once per file, naming the file and its type; the chip is untouched, so your own upload or RAG layer still sees it on theaparte-sendevent.The two-arm union also empties the third mapper’s fallback: the vision runner counted “content parts this runner cannot carry” and warned about them, over a branch no message could reach. The count and its warning are gone, the same way they went from the two wire mappers.
@aparte/core,@aparte/engine -
ba9baa1: Add
manageto<aparte-conversation-list>: with a conversation manager registered, the⋯menu now performs delete/archive/pin/rename itself. Nothing changes without the attribute.Registering the manager is the one line you already write for the rest of the persistence (
setConversationManager(manager));manageon the element is the second. The six listeners that forwarded rename, pin, unpin, archive, unarchive and delete to that same manager can go: each of our six example apps deleted its whole forwarding block for one attribute.The events still fire, first, and they are now cancelable:
preventDefault()on one takes that single gesture back and leaves the rest to the list. So a host that owns its persistence leavesmanageoff and keeps exactly the behaviour it has today, and a host that owns only one of the six can setmanageand cancel that one.managewith no manager registered warns on the first gesture, naming the call to make.Selecting a conversation is untouched: it is a load, not a write, and stays the host’s — the
conversationIdbinding, or the window-levelaparte-conversation-selecta bound controller hears.@aparte/core -
edd2f06: A tool turn is now an
assistantmessage carryingtoolCallsand atoolmessage carryingtoolCallId; the'tool_call'and'tool_result'roles are gone.Who has to change something. Only code that BUILDS an
AparteChatMessage[]by hand: ahistoryfunction, arequestInterceptorthat inspects roles, or a host mirroringonHistoryAppendinto a log of its own. Nothing that usesAparteClientnormally, and no provider you did not write yourself.The mapping, as two lines:
{ role: 'tool_call', content: '', precedingText: T, toolCalls: C }{ role: 'assistant', content: T, toolCalls: C }{ role: 'tool_result', content: R, toolCallId: I }{ role: 'tool', content: R, toolCallId: I, toolName: N }precedingTextis removed, not deprecated — no alias, no compatibility shim: before 1.0 a rename is a rename, and the type error is the migration. What it carried is the assistant message’s owncontent; an assistant that said nothing before its calls carries''.toolNameis new and optional, on atoolmessage. Set it and the AI SDK bridge stops guessing'unknown'for a result whose call it cannot find, and@aparte/provider-scenarioroutes on the name without scanning back. Both still fall back to the declaring assistant message when it is absent.Your stored conversations need nothing. Persistence holds
AparteMessage, whose role is alwaysuserorassistant; no tool turn was ever written to IndexedDB. The conversation schema version is unchanged.If you hold the envelope by reference — the rule
onHistoryAppendalready stated fortoolCalls— note thatcontentis now the field that is finalised after you are notified: a provider that streams text after its first call re-reads it at the turn’s end. Hold the reference, not a snapshot, or serialise only once the turn’s lasttoolmessage has arrived. A snapshot used to lose a trailing clause; it now loses the whole sentence.Why the shape changed: every message API in use — OpenAI-compatible, Anthropic, the AI SDK — expresses a tool round-trip as an assistant turn that declares its calls plus one message per result. Two roles of our own meant every provider translated on the way out, every consumer learned a vocabulary that matched no documentation they had read, and the assistant’s own sentence lived in a field (
precedingText) that only this library had a name for. This is the last known structural breaking change to the message type before the beta.@aparte/core,@aparte/engine,@aparte/provider-ai-sdk,@aparte/provider-openai-compat,@aparte/provider-scenario,@aparte/provider-transformers -
d86c28a: A conversation can be on its way:
<aparte-chat-viewport loading>and<aparte-conversation-list loading>draw skeletons, the manager fetches a conversation’s messages on demand through your adapter’sloadFull(), and the controller setsloadingwhile it waits and drops a reply for a conversation the user has already left.Nothing to change unless your adapter implements
loadMeta()andloadFull(): it is now used —init()reads the list throughloadMeta()andsetConversationId(id)fetches the messages throughloadFull(id)the first time.manager.isLoaded(id)andmanager.ensureFull(id)are public. An adapter withloadAll()alone behaves exactly as before.What was wrong:
loadMeta()andloadFull()were in the storage contract from the start and nothing called them, so between a click on a conversation and its messages there was no moment at all — and a page wired to a real store had to invent one, with a skeleton of its own over the components. Worse,aparte-chat[center-empty]decided it was empty by looking at the DOM: a transcript whose messages had not arrived yet has no bubble, so the welcome screen and the centred composer showed while a conversation loaded. Now:<aparte-chat-viewport loading>(reflected;viewport.loading = trueorsetLoading(true)) draws two skeleton turns with the kit’s recipe, marks the scroll surfacearia-busyand names the wait for a screen reader (locale keyloadingConversation).data-busyis still a reply streaming; this is the transcript itself arriving.aparte-chat[center-empty]counts a loading viewport as not empty.<aparte-chat>disables its composer while its viewport is loading, and gives it back as it found it (adisabledyou set stays). A send in that window raced the fetch: the message went into a history the arriving one overwrote.<aparte-conversation-list loading>draws six skeleton rows,aria-busy, with its own status line (locale keyloadingConversations). Set it while your store answers, clear it when you assignconversations.AparteChatBinding.setLoading?(on)is the controller’s way to say it; the default DOM binding sets the viewport’s attribute.- Two quick clicks: the controller re-reads the active id after the fetch and drops the stale reply, so A never paints over B.
@aparte/core,@aparte/locale-fr
-
d67148b: Composer buttons are
<aparte-composer-action>elements, not registry entries:zones: ['composer'],composer.positionandsetActionHiddenare removed. If you registered an action withzones: ['composer'], write the element in your composer markup instead and listen foraparte-action-click;zones: ['bubble']is untouched. Theaparte-actionevent detail’szoneis typedAparteActionZonenow, so a listener that narrows on'composer'no longer compiles.The registry never placed a composer button.
getActionswas only ever called with'bubble', by the message bubble; nothing in core asked it for the composer zone, so an action registered there rendered nowhere,composer.positiondecided nothing andsetActionHiddentoggled a flag no element read.AparteActionZoneis now'bubble', which is what the code always meant.composer.position: 'left' | 'right'was also the one place left that named a side. Placement in the composer is the order you write the elements in, plusmargin-inline-start: auto— a name a right-to-left locale contradicts is a name that will lie.The customization guide now shows the composer button it used to mention in passing: the element, where it goes in the row, and the
action-idthat tells two of them apart.@aparte/core -
23044a5: The conversation list’s seven events are renamed subject-first, with no alias:
aparte-conversation-select,-delete,-archive,-unarchive,-rename,-pin,-unpin(wereaparte-select-conversation,aparte-delete-conversation, …). Wrapper bindings follow:onConversationSelectin React,@conversation-selectin Vue,on:aparte-conversation-selectin Svelte,(conversationSelect)in Angular. Theno-groupsattribute isflat([flat]in Angular).Every other event of the library reads subject then verb —
aparte-select-change,aparte-message-start,aparte-split-resize— and the conversation list’s detail types were already subject-first (AparteConversationSelectDetail), so oneaddEventListenerline carried both orders.no-groupswas the only negative boolean attribute;flatsays what the rows are, and covers the “pinned first” order the same flag also removes. Pre-1.0, a rename is a rename: this lands before the beta freezes the surface, and the old names are gone rather than kept as aliases.@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
bd91b89:
AparteConversationandAparteConversationMetadropfolderId,lastMessagePreview,messageCountandtotalTokens— nothing in the library read them.Who has to change something. Only a storage adapter that WRITES one of the four onto the object it hands back, and only if it types that object as
AparteConversation/AparteConversationMeta. Records already in your store are untouched: the schema version is unchanged, nothing migrates, and an extra key on a stored row is still an extra key that reads back fine.If your store computes any of them, keep them — on your own row type:
interface MyRow extends AparteConversationMeta {folderId?: string;messageCount?: number;}Why they leave.
folderIdwas “optional folder/tag id for organisation”, a column for a folders feature this library does not have;lastMessagePreviewwas a cached preview the sidebar never asked for (it renders the conversation it already holds);messageCountwas “for the sidebar badge”, and there is no badge;totalTokenswas a per-conversation sum nothing added up. Four fields an adapter author had to decide about, on a contract that never read one of them. The token totals that DO exist stay where they are earned, inAparteUsageon a message.@aparte/core -
937b062:
AparteChatRequestdropsprefill,systemOverrideandfastStream— no provider read them; pass provider-specific options in_meta. If you set one, move it under a key of your own:_meta: { llamacpp: { prefill: 'x' } }, which reaches your provider untouched.All three were documented “providers MAY ignore it”, and every provider did: they were one model family’s raw-completion vocabulary flattened onto every request, so a third-party provider implementing the type inherited three fields it could not honour.
_metais the channel that already works — an open, namespaced bag core neither filters nor rewrites. The trigger to reconsider is a raw-completion provider in this repo that would actually read one; the JSDoc on the interface now says so, and a test drives an option through the client andAparteDirectTransportto prove the path stays open.One correction comes with it:
_meta’s own documentation claimed it was “never sent to the AI provider — stripped before the network call”. Nothing strips it, andAparteBackendTransportserialises it to your endpoint inside the default{ providerId, request }body. Both halves were false, and the field now says what it does — including that it is not a place to hide a secret.@aparte/core -
333b5a3: A conversation with no text yet has an empty
title(it used to be the English stringNew Chat), and editing the first user message re-titles the conversation unless you named it yourself.Two things for a consumer that reads
AparteConversation.title:createNew()with no argument, and a first message with no text, now leavetitleempty.<aparte-conversation-list>already showed its locale’snewChatword for an empty title, so a French UI no longer displays “New Chat” from the store. Rendertitle || yourLocale.newChatwhere you show a title of your own.- A new optional field,
autoTitle, istruewhile the title is the manager’s decision (the first message as typed, or the title provider’s answer). Editing the first user message then re-titles through the same path.updateTitle()sets it tofalse, and a title you typed is kept whatever happens to the messages. Records written before this field existed have no flag and read as typed: an old title is never overwritten.@aparte/core
-
33bf7bd: Import
showcasefrom@aparte/provider-scenario/showcase; the root export is deprecated and removed in this release. One line changes:import { createScenarioProvider, showcase } from '@aparte/provider-scenario'becomes an import ofcreateScenarioProviderfrom the root and one ofshowcasefrom@aparte/provider-scenario/showcase. Nothing else moves — the scenario format,createScenarioProvider,defaultMatchandplayTurnare untouched.showcaseis a demo corpus, not a capability: twelve scripted turns with their markdown, their code block, their reasoning and their artifact, about 4 kB of the package. It sat on the root barrel, so a consumer who wrote their own scenarios shipped it anyway — runtime laziness is not distribution weight, and the lever for weight is a separate entry point, not a flag.The provider and the corpus now build as two entries, and the scenario page, the README and the docs’ live frames all import it from the subpath.
@aparte/provider-scenario -
595ec0b: The four wrappers show a conversation on its way: a
loadingprop (an input in Angular) draws two skeleton turns in the transcript, keeps the empty state off and disables the composer until the messages land — and the conversation controller sets it by itself while it fetches through your adapter’sloadFull(). Nothing to change unless you want a wait of your own: passloading.Core’s host binding gained
onLoadingChange?(on): the controller’s wait, forwarded beside the viewport’sloadingattribute, so a wrapper that renders its own DOM can draw it. Underframework-managedthe viewport itself draws no skeleton any more (a<div>prepended into a host React reconciles is one React did not render); it still sets the attribute andaria-busy. And the viewport’s status line for a screen reader is now a sibling of the skeleton, not a child of it — inside anaria-hiddenbox it was hidden too.Svelte sets the viewport’s
loadingattribute imperatively too: under Svelte 5 a template attribute on an element that has a property of that name is set as the property, and''through the setter read as false — the attribute never appeared, which the Svelte 5 example caught (Svelte 4 set the attribute).@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue
Patch Changes
-
56cc153: The application header’s sidebar toggle shows at every width while the sidebar is collapsed, so a sidebar closed on a desktop can be opened again. Nothing to change on your side if your header carries
.aparte-app-header__togglewithdata-aparte-sidebar-toggle, the shape the recipe documents.On the chat site at 1440px, collapsing the sidebar with its own toggle took it to 0px,
inertandaria-hidden, its toggle with it, while the header’s toggle wasdisplay: noneoutside the phone media query: nothing on the page reopened it. The sidebar keepsaria-expandedon every[data-aparte-sidebar-toggle], so the recipe shows the header’s control exactly when the sidebar is closed (.aparte-app-header__toggle[aria-expanded="false"]), on top of the drawer case it already had.@aparte/core -
f474a95: A new conversation is titled from the message that opened it even when two sends land together, a rename keeps the conversation’s place in the list, a confirmed delete hands the keyboard to the row that took its place, and the composer comes back when a fetch is superseded by an id the store does not know. Nothing to change in your code, unless you relied on a rename floating a conversation to the top.
Two coalesced sends make one first message.
addMessage()suspends twice before it writes — for the messages, when they are not in memory, then for the title provider — so a suggestion and a quick type, released into it within a microtask of each other, both read a conversation with no user message in it yet: both counted as the first, and the second one’s text became the title, in memory and in the store, permanently (the persist that follows the reply re-titles only an edited first message). Appends are now serialised per conversation: the next one reads what the previous one wrote. Message order was already right; only the title was wrong.A rename leaves
updatedAtalone. Like pin, unpin, archive and unarchive, a rename is metadata, and the list sorts on the last message rather than the last edit. It also could not be otherwise: an adapter’srename()hook carries the title alone by contract, so the bump lived in memory only — the row re-grouped under “Today” and jumped back to its old date on the next reload. Memory and storage now agree in both branches, hook orsave().A superseded fetch always ends its wait.
setConversationIdclears the wait it superseded on every exit now, including the two that returned without it: an id the manager does not know (a deep link to a conversation another tab deleted) and the hydration retry. The in-flight fetch returns silently once the active id has moved, so nothing else was going to clear it — the viewport kept its skeleton andaria-busy, and the composer stayed disabled until a reload.A second
init()keeps the conversation on screen persisting. It re-reads the list from the store, which for a split adapter is metadata alone; what the firstinit()had fetched was forgotten with the rows it lived on, so writes to the open conversation were refused — silently, since the refusal is the controller’s own. A conversation whose messages are already in memory now keeps them and stays loaded; a row the store no longer lists is dropped with its messages.init()’s docblock says so.The store gets the messages you sent.
updateMessages()saved thesegmentsa viewport derived from a message’s markdown on display — they were already excluded from the change comparison, but not from the record, on either half of it: the flat messages and the branch tree. Stored, they filled the store with core-generated ids and froze the parse as it stood the day it was saved, so a block grammar registered later never applied to that message. The tree is the half that mattered most, since that is the half a viewport reads back.A confirmed delete keeps the keyboard in the list. Delete is confirmed from the row’s own menu, so the row holding the focus is the row that leaves: the focus landed on
<body>and the next Tab restarted at the top of the page, right after the one action here that cannot be undone. It goes to the row that took its place (the next one, else the one above), and to the list itself when the last conversation goes.@aparte/core -
c60a806: With
@aparte/plugin-streaming-markdowninstalled as your only Markdown renderer, a reply now stays rendered when the turn ends instead of collapsing to raw Markdown source. Nothing to change on your side; a one-shot provider is now genuinely optional.The settling update flushes the incremental parser and then re-renders the message once through the one-shot provider, for full fidelity. That trade only pays when a one-shot provider exists: with the streaming plugin alone, the one-shot side is core’s zero-dependency default — escape plus
<br>— so the re-render replaced<strong>,<pre>and<ul>with the Markdown that produced them, at the exact moment the reader stopped waiting. The message read richly for the whole stream and turned back into source on the last token.writeStreamedMarkdownnow asks the seam which renderer it is about to use. WhenrenderMarkdownis core’s built-in default, the flushed DOM is kept and passed throughsanitizeHtmlin place; when a provider is registered — including one that hands prose back untouched, since that is still the caller’s renderer and not ours — the settle re-renders through it exactly as before. Either way a settled message is what the sanitizer produced, which is what allows an incremental provider to write DOM directly in the first place. The flush also feeds the parser any content the last streaming update did not carry, so what is kept is the whole message.@aparte/core -
913969b: A keyboard-focused conversation row now draws a focus ring; a chat in a shadow root follows the dark theme; the English locale no longer writes
dir="ltr"on the chat, so a page that setsdir="rtl"is honoured. Removed: ten CSS classes and seven CSS variables nothing read (.aparte-input-wrapper,.aparte-input-container,.aparte-editor,.aparte-has-content,.aparte-input-upper,.aparte-input-footer,.aparte-provider-select,.aparte-model-select,.aparte-actions-left,.aparte-actions-right, and--aparte-input-gap,--aparte-input-container-min-height,--aparte-input-editor-max-height,--aparte-input-editor-font-size,--aparte-input-text,--aparte-model-select-chevron-room,--aparte-model-select-min-width). If your markup or theme uses one of those names, it was already doing nothing to the live composer — the live editor is.aparte-ci-editorand reads--aparte-input-font-size/--aparte-composer-control-size.The row’s ring. It was removed on 2026-09-05 for good reasons — it drew the title BUTTON’s box, so the ⋯ sat outside it with the ring’s end hidden underneath, and a rectangle inside a rounded row read as a mistake — and the whole indication was left to the hover lift. Measured with core’s own tokens: the lift is
--aparte-surface-3, a 6% tint, so a focused row read 1.12:1 against its neighbour in light and 1.18:1 in dark, where SC 1.4.11 asks 3:1 of the state that says which row Enter opens. It is the primary navigation of every chat product this library is meant to build, thirteen tab stops into the page. The ring is back keeping every one of those reasons: drawn on the ROW so the ⋯ is inside it and it follows the row’s 9px radius, INSET by its own width so the list’s horizontal clip cannot cut it, and the kit’s own ring — same token, same colour, same 1px. Measured after: 4.22:1 against the focused row’s fill, 4.72:1 against the sidebar in light, 5.79:1 in dark. The button keepsoutline: none: dropping it gives two rings, the inner one Chromium’s blackauto 1pxon a box 34px narrower than the row.A dark host. The light palette is declared on
:root, :host, so a stylesheet adopted into a shadow root put the light hexes ON THE HOST, where a local declaration beats the dark value the host would otherwise inherit — and neither dark block could reach it, because:rootdoes not match inside a shadow tree and a bare[data-aparte-theme="dark"]does not match the host element. Measured before: a chat in a shadow root under a dark page read--aparte-bg: #f6f2ea, a light card on a dark page, silently — which is exactly the arrangementguides/theming.mdpromises works. Each of the three palette blocks now names its:host(...)twin. One honest limit, worth stating: the media query is fully fixed, but the ATTRIBUTE path only seesdata-aparte-themeset on the host element itself. An attribute on an ancestor outside the shadow root stays invisible from inside — only:host-context()could see it, and its support is partial.The reading direction.
APARTE_DEFAULT_LOCALEdeclareddirection: 'ltr'as a literal, and both writers stamped it, so core OVERWROTE the host’s direction: with<html dir="rtl">the app shell mirrored and the chat inside it did not — bubbles, branch picker, composer order and every line of text stayed left-to-right. The field is now undefined by default, the waytagnext to it already was, andundefinedmeans “follow the host”. A locale that wants the direction pinned still declares it, which is the case the field exists for;setLocalereplaces the object wholesale, so no default merges back in. Six physical declarations that only mattered once RTL was reachable went logical with it: the prose list indent, the blockquote rail and gutter, the reasoning rail, the pending attachment’s ✕ and the status dot’s gap.The dead block. 201 lines of
components/composer.cssunder its own “Legacy composer input utility classes” banner styled markup no source in the repo emits — verified against the running page, where all nine classes count zero. Dead CSS here is not inert:reference/classes.mdxis generated from these sheets, so the whole set shipped as public API; the block was where the abandoned Tailwind ramp survived, two data-URI chevrons on#6b7280/#94a3b8against a palette that had moved to#a89bb6; and it held the last threeslot[name="footer-left|center|right"]rules, the positional names decision #4 retired. The seven variables had that block as their ONE reader —check:derived-varsasks whether a stylesheet reads a token, which an ORPHANED reader still satisfies — so they were published as knobs that moved nothing..aparte-send-buttonand theaparte-model-selector/.aparte-model-selectorpair sat inside the range and stay.Two smaller ones. The scroll rail’s focus ring moved onto the
::beforethat already draws the tick’s 24×24 pressable zone:outlinepaints on the element’s border box, and the tick’s box is the 14×2 line it draws, so the indicator came out 16×4 floating in the middle of the target a keyboard user follows down the whole transcript. And the composer toolbar’s empty-row rule was split in two, so an engine without:has()still hides an empty row: an unsupported selector anywhere in a comma list voids the whole rule, and the first-paint reservation was taking the two plain selectors down with it.Measured and deliberately not changed. A
position: fixedpopover inside a bubble is clipped at the bubble’s edges.contain: layout style paintonaparte-chat-bubblelooked like the cause and is not:content-visibility: autoturns on layout, style and paint containment on its own, solayout style paint,stylealone andnoneall paint the same 2 of 4 sampled rows of an<aparte-select>opened inside a bubble, and only removingcontent-visibilityreaches 4 of 4 — which is the streaming-performance win, not a style cleanup. It would not be enough either:aparte-chat-viewportis a container-query root withcontain: layout style, so a fixed popover anywhere in the transcript resolves against the viewport rather than the window. Core puts no popover in a bubble; a tool renderer that needs one should mount it beside the viewport. The numbers are inbubble.cssso the next reader does not re-derive them.@aparte/core -
acc33dc: Nothing changes in your code.
AparteLocalenow documents the eleven keys a plugin renders and core does not — the artifact card’s, the compaction summary’s, the approval mode picker’s and the model selector’s — as a closed exception, the localization guide shows a plugin of your own the route that needs no room in that list, andcheck:locale-keysholds the eleven inside this repo.AparteLocaleis the closed list of the strings core itself draws, andt()is typed against it — which leaves a plugin author exactly one place to put a translatable key. Eleven arrived that way: the artifact card’s labels and its two sandbox lines (@aparte/plugin-artifacts), the summary title (@aparte/plugin-compaction), the mode picker’s label (@aparte/plugin-approval) and the empty picker’s placeholder (@aparte/plugin-model-selector). They stay, and they are right where they are: a locale package translates one flat object rather than one per plugin, and a key that ships in every bundle costs a release notice to withdraw. What was missing is that nothing told them apart from core’s own words, so a twelfth would have followed by reflex.The guard reads core and the plugins as two corpora now, and holds the eleven in
PLUGIN_OWNED. A declared key that only a plugin renders and is absent from that list fails, naming the plugin and the three ways out; a listed key that core has started rendering fails the other way, asking for the line back — a list that outlives its reason is how a guard goes quietly decorative. Both directions run inscripts/__tests__/check-locale-keys.test.tsover a doctored locale and a doctored corpus (--locale,--sources), instead of being described.It shipped first as nine, because the matcher knew
cfg.t('x')andgetLocale().xand notgetLocale()['x']— the shape@aparte/plugin-approvaland@aparte/plugin-model-selectorboth use, so their two keys read as nobody’s and the guard called the tree clean. The bracket shape counts now, and the suite carries a fixture written that way in both directions. The counts are unchanged: 91 keys, 82t('…')reads.For a plugin of your own nothing moves: read your string off
getLocale()and default it at the call site. Your key is yours, and it needs no room in core’s list.@aparte/core -
9e44756: A conversation row’s
⋯menu can be used with a mouse in Safari and every other WebKit browser: a press inside the menu no longer closes it before the click reaches the item. Nothing to change on your side.WebKit does not move the focus to a button on mousedown. It blurs the element that held it instead — a
focusoutwith norelatedTarget— before the click is delivered. The menu read that as the focus leaving and closed on the press itself, so with a mouse the confirmation never appeared and no item could be chosen; the keyboard path was unaffected. The menu now holds a press inside it frompointerdownto its release and ignores that one blur.@aparte/core -
4a73996: Three sanitising gaps close, none of which asks you to touch your code: a message that finishes streaming is re-rendered through the sanitizer, an
<input>in model markup keeps onlytype="checkbox", and an attachment thumbnail’s URL is checked before it becomes an<img src>.The settle pass now runs.
writeStreamedMarkdowndocuments two states — while streaming, an incremental provider writes DOM directly and bypassessanitizeHtml; on the settling update it flushes that provider and re-renders once through the one-shot provider, whose output is sanitised. The second half is what makes the first acceptable, and on the simple-content path (appendMessage/appendToken/completeMessage, the first thing the getting-started guide teaches) it never happened:_updateStreaming(false)toggleddata-streaming,aria-busyand a class, and never re-rendered the content. The live end-of-turn call carries no content of its own (completeMessagesends{ status: 'completed' }alone), so whatever the streaming provider wrote stayed on the page for good. The segment path was never affected.<input>is what its allowlist entry always said it was. The tag is allowed for one thing, a GFM task-list checkbox, andtypewas copied through unexamined — so<input type="password" title="API key">rendered a credential prompt inside a reply. Nothing could read what was typed (form,buttonandnameare refused,on*is never copied, and the inline-style allowlist has noposition, so the control could not be lifted out of the bubble either), but the ask is the attack. Everytypeother thancheckboxis now dropped.An attachment tile applies the same URL policy as everything else core writes.
AparteAttachment.urlis documented as “URL or data URI” and a storage adapter re-mints it when a conversation is restored, so it is the app’s value; it was escaped and written, where every other URL core emits is first asked whether its scheme is allowed at all. It now goes throughisSafeUrl, and an image whose URL is refused falls back to the file chip — which still names the file — rather than rendering a broken picture.blob:is kept, because that is the shape core itself mints for a file the user just picked.@aparte/core -
c73636b:
client.stop()now takes that client’skeyResolverback off its config, and when a config holds several key sources the most recently registered one answers first. Remounting a chat with new options changes the key again. Two things may need a look on your side: code that relied on a stopped client still signing requests, and any annotation you copied from the config reference —config.getKey(providerId)resolves tostring | Record<string, string> | undefined, notstring | undefined, so widen yours if you typed it. The runtime type is unchanged; the page was wrong.new AparteClient({ keyResolver })registers the resolver on its config so the model list signs its request with the same credentials as the chat. That registration outlived the client that made it: nothing released it, andgetKeyanswered in registration order, so the first resolver a page ever registered kept answering for every later one. Three consequences. A stopped client kept signing model-list requests. The remount the wrappers document as the way to swap options (“remount the component that owns the hook”) added a source behind the one it meant to replace, so the new key never reachedrefreshProviderModels. And each mount left a retained closure on the config for the life of the page.The client now holds the teardown
registerKeyProviderreturns and calls it instop()— before the early return, since the source is added in the constructor and a client that never started still holds one — andstart()registers again, so a stop/start pair is symmetric.getKeywalks the registered sources newest first, over a snapshot, so a source torn down while an earlier one is being awaited cannot shift the iteration underneath it. EachregisterKeyProvidercall is its own source too — the registry used to be keyed by the function itself, so two panes handed the same module-level resolver were one entry and the first to unmount took the second one’s key with it.AparteConfig.registerKeyProvideris on the config reference page now, with an example and the resolution order, andgetKey’s signature there says what it has actually returned since it learned to carry{ apiKey, endpoint }.@aparte/core -
dbd64b2:
<aparte-composer placeholder="…">,<aparte-composer disabled>and<aparte-context window={8000}>no longer throw under React 19 and Svelte, and<aparte-suggestions suggestions='[…]'>renders its chips.AparteUiHandleis now exported from@aparte/core— the contract each wrapper’s<AparteUi>handle re-exports.React 19 and Svelte assign the PROPERTY whenever the element has one of that name, and only fall back to the attribute when it does not.
placeholder,disabledandwindowwere getter-only, so the assignment threwCannot set property … which has only a getterand took the render down — on a spelling both wrappers’ typed template surfaces declare valid. They have setters now,disabledthroughpresenceOnso the documented''means ON (#62).<aparte-suggestions>’s setter took an array only, while the attribute it mirrors carries the JSON string form and its own docblock calls the two channels equivalent. A React or Svelte template handing it that string failedArray.isArray, the list became[], the attribute was never written andattributeChangedCallbacknever ran: no chips, no warning. The setter takesAparteSuggestion[] | stringand routes a string through the same parse the attribute uses.AparteUiHandlemoves here for the reasonAparteChatImperativeApidid: it was four hand-written copies, each docblock promising “the same contract on all four wrappers”, and it had already drifted once.@aparte/core -
43297ea:
<aparte-optgroup collapsible>no longer throws under React 19 and Svelte 5: the property has a setter, and assigning it toggles the attribute.React 19 and Svelte 5 write the PROPERTY whenever the element has one of that name, and fall back to the attribute only when it does not.
collapsiblewas a getter with no setter besidecollapsed, which has one — so the assignment threwCannot set property collapsible of #<AparteOptgroup> which has only a getterand took the whole render down, on a spelling the typed JSX/Svelte surfaces declare valid and the element’s own@exampleshows. The in-repo emitter was safe by accident:@aparte/plugin-model-selectorbuilds its groups as an HTML string.The same shape is now asserted for the select family as a whole rather than for the one case: an attribute those elements declare is either absent as a property or writable as one — never getter-only.
@aparte/core -
af3edf5:
aparte-btnon an<a>no longer shows the browser’s underline; nothing to change unless you relied on it.The recipe is documented as classes for your own elements, and a settings page reached by a link is the first thing a site puts it on. The UA stylesheet underlines an anchor and no rule of the recipe said otherwise, so the link rendered as underlined text beside an icon button. The reset now lives in the recipe.
@aparte/core -
5805be2: The focus ring, the selected-row mark and the composer’s focus border are neutral now, not brass; a theme that wants the accent back sets
--aparte-border-focus,--aparte-mark-barand--aparte-select-border-hover.What changed on screen, measured on the chat-site example:
--aparte-border-focusisvar(--aparte-neutral): every focused control drew a 2px accent ring, which turned a page of controls into a frame gallery. The ring is still 2px, outside the box, on:focus-visibleonly.--aparte-mark-baris0px: the accent bar on the start edge of a chosen row (the active conversation, the selected option, a checked choice) is gone; the tint and the text weight carry the mark.- A checked
.aparte-field-choicelifts a step (--aparte-surface-2) instead of taking an accent border and tint: the radio or the box shows the choice. - The composer shell’s
:focus-withinborder is--aparte-text-muted, a contrast step on the hairline rather than the accent. - The select trigger’s hover border is
--aparte-text-muted; a keyboard-active option wears the focus ring’s colour rather than the accent. - Conversation rows read in
--aparte-text, the group headings stay muted: the two no longer looked alike.@aparte/core
-
71a535d: An assistant message handed over as a markdown string (
appendMessage,setMessages,importTree) now renders like the same words streamed: a fence becomes the code card with its filename, language and copy button, a<think>block becomes a reasoning block. You no longer callparseMarkdownToSegmentsyourself before appending.The stream went through the parser and a string went through the prose renderer, so the same reply looked different once it came back from a store or a non-streaming call: a bare
<pre>where the live turn had the card.appendMessagenow runs the same parser, with the same registered stream blocks, on an assistant’scontentwhen it carries nosegmentsand there is something to split. A user’s message is not parsed, plain prose keeps the content path, and a message that already has segments is untouched.@aparte/core -
5581be9:
fallbackis declared on every segment, not only ontype: 'custom'— the renderer already read it that way.Nothing to change. The field moves up from
AparteCustomSegmenttoAparteSegmentBase, so the six built-in segment types declare it, and so does any type of your own written asAparteSegmentBase & { type: '…' }. Anything that already set it on a custom segment still compiles.What it buys you: a segment whose renderer is not registered draws its
fallbacksentence instead of[Unknown segment type: …], and that has always been true for EVERY type, because the bubble reads the field structurally. Only the declaration was narrow — a built-in segment could not type the sentence it was about to be rendered from:import type { AparteSegmentBase, AparteTextSegment } from "@aparte/core";// A built-in type, carrying what a client with no markdown renderer will draw.const text: AparteTextSegment = {id: "s1",type: "text",content: "**A transport** is the object that talks to the model.",fallback: "A transport is the object that talks to the model.",};// Your own type is the same base plus a `type` of yours — no cast, and `fallback`// is declared for you.type CitationSegment = AparteSegmentBase & { type: "citation"; url: string };const citation: CitationSegment = {id: "s2",type: "citation",url: "https://example.org/weather",fallback: "Weather report, 6 September.",};One clause of the field’s documentation was wrong and is corrected. In the HISTORY sent back to the model,
fallbackstands in for the segment only where core does not serialise the type itself —custom, and any type core does not know (a registered block grammar’s, a plugin’s). Atextor acodesegment contributes itscontentand nothing else, andthinking/tool_call/errorare kept out of the history on purpose. The docblock used to promise the substitution for every type; the behaviour is unchanged, the sentence is.Supplying a fallback also silences the “no renderer for segment” developer warning: an author who wrote one has already said this can happen.
@aparte/core -
7e7200c:
dist/index.cssis minified (138 kB instead of 368, 20 kB gzip instead of 98), and a page that ships the chat as static HTML no longer jumps when the elements upgrade.Measured with Lighthouse on the vanilla example built for production: the cumulative layout shift went from 0.226 to 0.011 and the performance score from 88 to 100. Four rules apply before an element is defined, so the first paint already has the final shape:
aparte-chat[center-empty]with no bubble in its markup centres its composer from the first frame; the viewport releases its height the way it does oncedata-emptyis written.aparte-composer-inputreserves the editor’s height (--aparte-composer-control-size).aparte-suggestionsreserves one row of chips while the chat is empty.aparte-composer-toolbarholding only whitespace draws nothing, as it will once it reflectsdata-empty.
The minified stylesheet is what a page that links
dist/index.cssdirectly (a CDN, no bundler) downloads; a bundler minified it already and sees no change.@aparte/core -
141e0a3: A fenced code block rendered from markdown no longer paints an inline-code background on every line; nothing to change on your side.
The prose rule for inline
codematched the<code>inside a<pre>too, and withwhite-space: pre-wrapits background and padding paint per line box: every line of a markdown block wore its own stripe, with a notch at its start. The rule is scoped to:not(pre) > codenow, so inline code keeps its chip and a block keeps one surface.@aparte/core -
db15e77:
<aparte-conversation-list>takesmanageas a property, so bindingfalsefrom a framework turns the writes off; and its delete confirmation reports as a dialog instead of two plain buttons inside a menu. Nothing to change on your side, unless a selector of yours pins that confirm step to[role="menu"]: while it asks, the popover is[role="dialog"], so anchor on[data-menu-action="confirm-delete"]instead.manage={false}used to mean on.manageis the attribute that authorises the list to write — rename, pin, archive, delete, through the registered conversation manager — and it was attribute-only. Vue and both Sveltes stringify a bound boolean onto an element with no property of that name, somanage={false}wrotemanage="false", whichhasAttributereads as set: a gesture the host meant to handle itself was also finished by the list. The property is the fix,presenceOnlike every other boolean in core (''is on,falseandundefinedare off) — the same protectionloadingalready had. React 19 and Angular were never affected; they remove the attribute forfalsethemselves.The confirm step is a dialog. A
role="menu"may hold menu items and nothing else, and the delete question’s two answers are ordinary buttons — axe reads that as a criticalaria-required-childrenviolation, in a state one click away, on the element the accessibility guide offers as the pattern to copy. While it asks, the popover now carriesrole="dialog"and the question as its accessible name; it is a menu again the next time it opens.role="menuitem"on a destructive confirm would have been the cheap answer, and a question with two answers is not a menu. The keyboard is unchanged: the arrows,EscapeandTabbehave exactly as before, and Cancel still takes the focus.@aparte/core -
ba9baa1: Sending, retrying and editing, the menus and their keyboard, the sidebar toggle, the split panes and the selects now work when you mount a chat inside your own shadow root. Nothing to change on your side.
The send.
AparteClientresolves the chat a send belongs to by lookup — the id the composer names, then a walk up from the event, then a scan of the page — and all three read the document, which cannot see into a shadow tree. A chat mounted in one therefore had its send dropped with a warning; and on a page that also holds a chat in the light DOM, the scan answered with that one, so the person’s message AND the model’s reply appeared in a transcript they never typed into. The lookup now searches the tree the gesture came from first (composedPath()), then the document, and the walk crosses the boundary instead of stopping at the shadow host.aparte-retryandaparte-editshare that resolver, so they went the same way and are fixed with it. If you worked around this withtargetResolver, it also takes the<aparte-chat>shell now — it used to require the element itself to exposeappendMessage, sotargetResolver: () => root.querySelector('aparte-chat'), the obvious call, was rejected.Four handlers listen on
document— the conversation row menu’s outside-press,[data-aparte-sidebar-toggle],[data-aparte-split-pane]and the select’s close-on-outside-click. An event that leaves a shadow tree is retargeted, soevent.targetread as the shadow HOST, and each of the four asked “was this inside me?” of the wrong element and got no. The row menu closed on the press, so no item could be chosen; the sidebar toggle and the pane buttons did nothing; a select shut its own dropdown on the click that opened it. All four now readcomposedPath(), which names the node that was actually hit.Two lookups went the same way. A toggle or a pane button that names no element resolves the one beside it, and it did that through
document.querySelector, which cannot see into a shadow tree: it found nothing, or found the sidebar you also have in the light DOM. Both now look in the control’s own tree first.document.activeElementretargets for the same reason, so “which item holds the focus?” answered nothing for every element in the tree. The drawer’s Tab trap and the select’s “is the caret in the filter field?” already read the focus from the tree they are in; the conversation row menu’s arrow keys, the focus a re-render of the rows puts back, and the scroll rail’s arrow keys now do too —pinwas unreachable by keyboard, the keyboard landed on<body>after a rename or a delete, and the rail’s arrows were a silent no-op.Only the behaviour crossed badly. The stylesheet already declared its tokens on
:hostfor this arrangement, and that half worked.@aparte/core -
dbd64b2: A retry no longer writes the superseded reply’s tail into the new bubble, a stored conversation continued under React/Vue/Svelte/Angular comes back in the order it was written, and
⋯ → Deleteon a conversation row can be confirmed again. Two things to know:loadingset as a PROPERTY now follows core’s presence convention (el.loading = ''turns the wait ON, asloading=""always did in markup — it used to turn it off), and a binding that providesonLoadingChangenow owns the viewport’sloadingattribute outright, because the host writing it too made a wrapper draw a skeleton over a transcript the attribute said had arrived.What was wrong, all reproduced by the audit of 2026-09-05.
Turns crossing. The viewport resolved the 1-argument streaming convention (
addSegment(segment)) by scanning the ACTIVE PATH for the streaming message, and a retry on an earlier bubble takes the reply being written off that path — so the rest of it was appended to the message that had just replaced it. The scan reads the whole tree now (resetHeadalso stopped moving the head out of a branch it did not touch). The same trigger from the other side:aparte-message-abortedfor the superseded turn carries ITS message id, and neither the chat host nor the composer compared it — one turn’s unwind turned Stop back into Send mid-stream, evicted the open approval panel, and put retry, edit and the branch arrows back on every bubble while a reply was still streaming. Both compare the id now.Conversations under a framework. The controller handed a stored list to the framework’s own message list and nothing else, so the viewport’s tree — what
exportTree()saves — never held the history: the follow-up became the root of a fresh tree and the history was appended after it, so the saved conversation read [new turn, old history] and the next open restored exactly that. And a segment streamed before the framework had painted its bubble went into the PREVIOUS reply’s bubble (the host wrote to “the last bubble on the page”), which then showed the samedata-segment-idtwice; it waits for its own bubble now, andsyncBubblespaints it.A conversation restored from a tree now gets the same markdown grammar as one restored from a flat list:
importTreeskipped the parser, so on every conversation that had ever persisted a tree, a fence came back as bare prose instead of a code card. The segments core derives that way are marked (APARTE_DERIVED_SEGMENTS, a non-enumerable symbol) so merely opening a conversation does not count as changing it.The conversation row.
⋯ → Deletewas unreachable: replacing the menu with the confirmation removed the item holding the focus, the browser blurred it, and the list read that as “focus left the menu” and closed it — so nothing could be deleted, by mouse or keyboard. The question now takes the focus before the items it replaces are removed. Enter on a row also left the focus on<body>(the re-render replaces every row); it lands back on the row.Smaller.
<aparte-select>opens on one click after a framework has moved it — each re-parent used to add another trigger listener, so a click toggled twice and the control looked dead, and a mutation observer leaked per connect. Two chats sharing one config no longer share one elicitation queue, where an unanswered approval in the first held the second’s question for ever.<aparte-chat>installs its loading gate when the viewport ARRIVES, not only when it was there at connect (a framework renders its children after the host).clearAll()releases the attachment URLs on every branch, not just the active path. And the tworole="status"waits are inserted empty and named a frame later, so a screen reader hears a change rather than a new node.@aparte/core -
54bac2c: Split storage (
loadMeta()+loadFull()) no longer loses messages, and the controller’s wait always ends. Two things to know:manager.ensureFull(id)now resolves a boolean (falsewhen the store has no such record, and the row then does NOT count as loaded), andupdateMessages()on a conversation whose messages are not in memory is refused with a warning instead of writing an empty transcript over the stored one.What was wrong, all reproduced by the audit of 2026-09-05: a rename, pin or archive of a conversation the user had not opened wrote the row back with the
messages: []the list came with — the stored messages gone (the adapter’srename()hook was never called; it is now, and without a hook the messages are fetched first); a message sent to such a row did the same; switching conversations while a reply streamed persisted the emptied binding into the conversation being fetched;addMessage()andupdateMessages()read a snapshot, awaited the title provider, and wrote the snapshot back, destroying a reply that had landed meanwhile (with@aparte/plugin-titlerthe await is a model load, exactly while the first reply streams); “New chat” during a fetch, or a switch to an already-loaded conversation, left the viewportloadingand the composer disabled for good; deleting the conversation being fetched did the same; a failing or emptyloadFull()left an emptied binding active, which the next send wrote back;loadFull()answering with another conversation’s id showed its messages under the wrong title; a secondinit()remembered what the first had fetched and opened it empty; a conversation created while the list was on its way vanished from it; a list repeating an id made every write hit twins; asave()that threw on the first send left the session with no active conversation; a title auto-derived after retention trimmed the history came from the wrong message; and a conversation merely opened and left was re-saved and floated to the top of the list, because the segments the viewport derives from a reply’s markdown counted as a change — they are marked now (APARTE_DERIVED_SEGMENTS, a non-enumerable symbol) and ignored by the comparison.@aparte/core -
5581be9: The bubble-shell contract lists the five hooks it was missing, and a custom shell now gets
role="article"and its accessible name from the bubble itself.Nothing to change. A shell you already wrote gains the role and the name with no edit. If you wrote one from the old list you are probably missing something, and the new list says what — including the three spans the waiting region needs to draw anything.
AparteBubbleShellRendererenumerated eight class hooks; the bubble looks for more than that. A shell copied from it dropped.aparte-message-content(the painted content box, which the bubble hides when the turn has nothing to show),.aparte-waitingwith its.aparte-sr-onlylabel,.aparte-footer, and.aparte-branch-status.Two of those fail with nothing on screen to say so, and the contract now names them: without
.aparte-waitingthere is no “thinking” state at all between the send and the first token, and without.aparte-branch-statusa screen-reader user gets no word that the branch moved — the arrows deliberately do not take focus, so nothing else announces it. The list is also in render order now, and a test builds a shell out of it, so a hook that leaves the documentation goes red..aparte-waitingneeded a second clause, because listing it was not enough: the bubble only SHOWS and HIDES that region — the markup that paints is the shell’s. A.aparte-dotsspan holding three.aparte-dotspans is what the stylesheet animates, so a shell built from the old bullet had a thinking state only a screen reader could perceive, and the region was literally not visible. The contract says so, this repo’s own documented shell renders the spans, and the test counts them.ARIA is the one thing the list does NOT ask of you. The bubble writes
role="article"and the accessible name onto your root after your shell renders — so every shell that exists gains an article and a name with no author action — unless the root already declares arole, which is how you override it. A markup contract that also demanded arolewould lose it the first time somebody forgot one, silently, which is exactly what had happened.One containment is load-bearing and is now stated:
.aparte-message-contentHOLDS.aparte-segments,.aparte-contentand.aparte-waiting. The background, radius and padding of a user message are declared on that box, so a shell that renders the three as its siblings keeps every behaviour and loses the bubble. The contract also names the two classes that carry the default layout and nothing else —.aparte-bodyand.aparte-header— which the bubble never queries, so a shell laid out differently can drop both.@aparte/core -
a9784fa: The centred composer glides to the bottom when a conversation opens, the way it already glided back up when a new one started. Nothing to change on your side; if you styled
aparte-chat[center-empty][data-empty]or.aparte-chat-container--auto-center[data-aparte-empty]for theirjustify-content, that declaration is gone — the centring is a spacer now.Measured on the chat site, the top of the composer sampled every 40 ms: a new chat glided 816 → 494 px over about 300 ms, opening a conversation snapped 494 → 816 in one frame.
center-emptyanimated the viewport’sflex-grow, but the viewport also carriesheight: 100%for the scroll chain, and the moment the empty state dropped that height applied at once, leavingflex-grownothing to animate. The other direction removed the height, soflex-growdid the sliding.Under both centring modes (
center-emptyon<aparte-chat>,centerWhenEmptyon the wrappers) the viewport now keeps one size rule,flex: 1 1 0%and no height, and the centring is a::afterspacer after the composer whoseflex-growis 1 while the chat is empty. Only the spacer animates, in both directions. The empty group sits between two equal halves, so the 32 px the empty viewport used to add no longer shifts it, and the first-paint reservation (:not(:defined)) grows the same spacer. Underoverlay-composerthe spacer does not animate: the first message takes the viewport out of the flow, and a spacer still shrinking would drop the composer from the top of the column — the overlay’s composer snaps to the bottom, as it always did.@aparte/core -
3f01fb4: The composer toolbar wraps, so a second control is no longer pushed off the composer on a phone.
Nothing to change. A row that already fits looks the same; one that does not now puts the overflowing control on a second line instead of past the edge.
aparte-selectsets amin-widthon its host — a hard floor, so a select in a flex row cannot shrink — and the arrangement we document puts two of them in this row: the approval switch (@aparte/plugin-approval’s own example says “beside the model selector, in the composer’s toolbar”) and the model selector. That is 400px of controls in a row the toolbar did not wrap.Measured in Chromium at 390px, the width our responsive suite calls a phone: the model selector’s right edge sat at 449 against a 390px viewport, and the page did not scroll to it — an ancestor clipped it. The model picker was unreachable, not merely ugly, and RTL mirrored it exactly at x=-59. The row is the fix rather than the example markup: a documented arrangement that needs an inline
styleto work is a missing knob.An end-aligned control keeps its
margin-inline-start: autoand lands at the end of the line it is on.@aparte/core -
5581be9: The custom-segment example now shows what actually happens: one renderer for
type: 'custom'that switches onsubType.Nothing to change — this is the documentation catching up with the code.
The old
@exampleonAparteCustomSegmentsat under the sentence “with no renderer registered forsubType, core drawsfallback”, which reads as if the registry lookedsubTypeup. It does not:registerSegmentRendererkeys ontypeand only ontype. An author who registered a renderer for'weather-widget'saw nothing render, with no error to explain it.So the example is now the whole shape — one renderer for
custom, aswitchonsubType, andfallbackas the default branch — and it says the other half out loud: an independent view takes its owntype(register achartrenderer for a chart), andcustomis for a family of small views an app would rather keep behind one key. The example is executed by a test, so the two cannot drift.@aparte/core -
dbd64b2: The model picker now works with
new AparteClient({ keyResolver })alone, and a key provider may return{ apiKey, endpoint }so the model list follows your endpoint. Nothing to change on your side;aparteGlobalConfig.setKeyProvider()behaves as before, andAparteConfig.registerKeyProvider(provider)is new if you want to add a key source of your own.Two channels carry a provider’s credentials, and
refreshProviderModels— the model selector’s only data path — could see neither properly.keyResolveris what the providers guide teaches (“you hand it to each request viakeyResolveronAparteClient”), withsetKeyProvidercalled an alternative, and nothing told you to call both. But the resolver reached the chat only:refreshProviderModelsreadconfig.getKeyalone, sofetchModelswas called withundefinedand returns[]for every cloud provider. An empty picker, a working chat, and no warning — it read as “the vendor returned nothing”. The client now registers its resolver ON the config, so the capability is not hostage to the client (a page that constructs none still lists models), and one resolution order answers both questions: a registered resolver first, thensetKeyProvider— the precedence the client already documented.AparteKeyProviderwas typed as returning a string, so the{ apiKey, endpoint }record the transport reads andfetchModelsaccepts could not travel on it. A consumer who points a preset at their own host — a corporate proxy, a self-hosted vLLM, an LM Studio box on another port — configures it asendpoint, and the chat honoured it while the model refresh sentGET {vendor default}/modelscarrying the key that host had been given. It travels now, on both channels.@aparte/core -
d873d93:
<aparte-scroll-rail>no longer rewrites itsaria-labeland the current tick’saria-currenton a reconcile when their values did not change. Nothing to change on your side.A reconcile runs on every mutation of the transcript, and an attribute written to the value it already has is still a DOM mutation: a mutation observer on the rail sees it, and so does an assistive technology watching the tree. CI’s WebKit measured it — a late host mutation after the last reply settled ran a reconcile that rewrote both attributes, unchanged, inside the window where the rail is meant to hold still. Both writes are now conditional, the way the ticks’ labels already were.
@aparte/core -
e9c910c: A focused scroll-rail tick draws its keyboard ring on all four sides, inset by the ring’s own width. Nothing to change on your side; if you had re-declared
outline-offseton.aparte-scroll-rail__tick:focus-visible::beforeto work around the clipping, drop it.The ring is drawn on the
::beforethat computes the tick’s 24x24 pressable zone, and that zone is not merely near the rail’s clip box — it is the clip box: the rail is exactly--aparte-scroll-rail-widthwide with no inline padding, the zone is anchored to both of its inline edges, and the rail’s block padding puts the clip line on the first and last zone’s edge.outlinepaints outside the border box, so at the shared0offset both verticals fell into whatoverflow-x: hiddencuts, and the end ticks lost a third side. Measured on a page with the ring colour forced: 44 painted pixels on a middle tick, two detached 16px hairlines 24px apart with nothing marking the tick itself, and 26 on the last one. Offsetting inward by--aparte-focus-outline-width— the same idiom the conversation row and the image thumbnail already use, each for a clip of its own — paints 64, both verticals included.@aparte/core -
5581be9: The thinking block reads
--aparte-thinking-bgand--aparte-thinking-content-bg; both default totransparent, so nothing changes on screen.Nothing to change. If you set either token, it now does what its name says.
Both were declared and read by nothing: the reasoning block hard-coded
background: transparenton the rail and on the panel, so a theme that set either one was setting a decoration. Wiring them without moving a pixel is the whole point — the default is exactly the value the two rules used to hard-code, which is also why the two declarations move from the derived layer to the literal palette (transparentis a literal, not something derived from a master).aparte-chat {--aparte-thinking-bg: var(--aparte-surface-2);--aparte-thinking-content-bg: var(--aparte-surface-1);}That turns the understated left rail into a filled card, which is a look, not a default.
The panel’s token is read on ONE class, and that matters if you style the panel yourself. It first shipped as a two-class rule (
.aparte-segment-thinking .aparte-thinking-content) so it would beatprose.css, which is imported last — and it beat the one-class.aparte-thinking-content { background: … }a consumer may already have written just as well, with nothing on screen to explain it.prose.cssreads the token itself now: same pixels, one specificity, your own rule wins again.@aparte/core -
1200563: In Safari and every other WebKit browser, a framework-rendered transcript stays anchored at the bottom when a streamed reply finishes; it used to end up 80px short with the follow switched off. Nothing to change on your side, unless you had set
overflow-anchoron the transcript yourself.The viewport pins the reader to the bottom itself and reads a
scrollTopdecrease it did not write as the reader walking away. Whether a decrease was the reader’s used to be decided by its size alone — no larger than the height change seen at that scroll event — and WebKit’s settle refutes that: between two layouts of a finishing reply it movesscrollTopby more than the regrown height explains (82px against 30, once 27px against nothing), one frame after the viewport’s own pin. Three signals decide now. The reader’s hand: a scroll gesture leaves a trace (wheel, touch, a press in the scrollbar gutter, a scroll key), and a press on the text that drags a selection past the edge holds the pointer while the container scrolls. The churn, as before: a decrease no larger than the height change is the layout’s. And the settle: a decrease of at most 100px within 100ms of a transcript mutation the viewport observed is the engine finishing that layout, and the follow stays armed and re-anchors. A host’s ownscrollTo, or a tool’s scroll-into-view before a click, comes with no mutation or moves by far more, and still disarms the way a reader does. The browser’s own scroll anchoring is also off on both scroll surfaces: a chat appends below the reader, so it protected nothing there and only fought the pin.@aparte/core -
e9c910c: Transcript text aligns to the reading direction instead of the left edge, so an RTL page gets right-aligned messages. Nothing to change on your side unless you had overridden
aparte-chat-viewport { text-align }to get this.aparte-chat-viewportdeclaredtext-align: left, andtext-aligninherits: that one declaration reached every message, segment and paragraph below it. It could not lie while core wrotedir="ltr"onto its own DOM; now that the direction follows the host, an Arabic transcript came out mirrored with every line still hugging the left edge — lists indented on the right, the blockquote rail on the right, the prose left-aligned between them. It istext-align: startnow. The suite that swept the sheets for physical edges readstext-align: left|rightas physical too, across the whole corpus rather than the four sheets it had named.@aparte/core -
595ec0b: The four wrappers show a conversation on its way: a
loadingprop (an input in Angular) draws two skeleton turns in the transcript, keeps the empty state off and disables the composer until the messages land — and the conversation controller sets it by itself while it fetches through your adapter’sloadFull(). Nothing to change unless you want a wait of your own: passloading.Core’s host binding gained
onLoadingChange?(on): the controller’s wait, forwarded beside the viewport’sloadingattribute, so a wrapper that renders its own DOM can draw it. Underframework-managedthe viewport itself draws no skeleton any more (a<div>prepended into a host React reconciles is one React did not render); it still sets the attribute andaria-busy. And the viewport’s status line for a screen reader is now a sibling of the skeleton, not a child of it — inside anaria-hiddenbox it was hidden too.Svelte sets the viewport’s
loadingattribute imperatively too: under Svelte 5 a template attribute on an element that has a property of that name is set as the property, and''through the setter read as false — the attribute never appeared, which the Svelte 5 example caught (Svelte 4 set the attribute).@aparte/core -
6e57533: A forced
toolChoiceis now sent on the first turn only; the turns after it go out as'auto', so the run ends with an answer instead of the turn limit. Nothing to change on your side.The request is rebuilt from
baseRequestevery turn, and only the synthetic{ name, input }shape stripped itself. A plain{ name }— the shape a consumer sets to force a tool — therefore travelled again after the tool had already answered: the model was made to call it once per turn untilmaxTurns, so a forced tool ran ten times and the run reportedturn-limit-exceededrather than replying.Forcing a tool is a turn-1 instruction and the loop is the only thing that can lift it, so the loop lifts it: any object
toolChoiceis dropped from the base request after the first transport call.@aparte/engine -
6e57533: A Stop stays a Stop when the transport’s iterator throws on the way out, however it throws. Nothing to change on your side.
Settling the iterator was guarded with
.catch(), which only ever sees a rejected promise. A hand-written iterator — the likely shape when a host drivesrunStreamAgentwith a transport of its own — throws synchronously, before there is a promise to reject, and one whosereturn()gives a plain object has no.catchat all. Either way the deliberate stop came back to the caller as a thrown run error, and core paints an error card over the reply the user had just stopped.Both settle sites (the abort bail and the per-turn
finally) now usetry/catch, which covers all three shapes.@aparte/engine -
6e57533:
onHistoryAppend’s documentation now says which fields of the assistant turn carrying a tool call are filled in after you are notified, and what a host that writes bytes at receipt should do about it. Documentation only.The option exists for a host that owns its own transcript — a prefix cache, an append-only log — and the docblock said to hold the reference rather than a snapshot, justifying it with the turn’s later
toolCalls. Since the loop re-reads that turn’s text at the turn’s end (a provider that emits a tool call before the rest of its sentence), an append-only log, which by definition serialises at receipt and cannot hold a reference, was told to do the one thing it cannot.The paragraph now names both late fields and says to treat that turn as provisional until the last
toolmessage of the turn.@aparte/engine -
7802512: Two tool calls that arrive with the same id in one turn get one row and one history slot each; text streamed after a tool call reaches the history; and a Stop stays a Stop when the transport’s iterator throws on the way out. Nothing to change on your side.
A call id is an identity downstream — the transcript keys a segment on
tool-${id}andupdateSegmenttakes the first match, and the history files atoolmessage undertoolCallId. A provider that repeats one (a vendor omittingidproduced''for every call) therefore wrote the second call’s result onto the first call’s row, and left a pair no OpenAI-shaped endpoint can match on the next turn. A repeat is renamed rather than refused — the model asked for both — and the rename happens once, before the first emit, so the row, the handler, the approval and the history slot all name the same call.The assistant turn that carries the calls snapshotted its own text when the first call was declared, and a turn can go on streaming afterwards. A provider that emits
[tool, text]in that order (@aparte/provider-scenariodoes;openai-compatflushes its calls at the end of the stream and so cannot) showed the user a sentence the model never saw again. The envelope is re-read at the turn’s end, the way itstoolCallsarray already was.The abort path settled the transport’s iterator without the
.catch(() => {})thefinallyuses, so a host driving the loop with an iterator of its own turned a deliberate Stop into a thrown run error — and core paints an error card over the reply that was just stopped.@aparte/engine -
7802512: The AI SDK stream is told to stop on every exit, not only when the consumer cancels. Nothing to change on your side.
cancel()callsiterator.return()precisely so the vendor call stops instead of draining to its natural end after the consumer has walked away. The two in-band terminals,finishanderror, returned straight out of the loop and skipped it — so after an error part the call was left running exactly where the bridge was written to end it.@aparte/provider-ai-sdk -
b5b103d: A message still carrying the old
'tool_call'or'tool_result'role is skipped with one console warning that names the new shape; the warning goes away in 0.18. Only code that builds anAparteChatMessage[]by hand — ahistory:function, arequestInterceptor, a mirror ofonHistoryAppend— can be affected, and the warning tells it exactly what to write.AparteChatMessage.roleno longer allows either value, so this is code compiled against an older aparté reaching a newer provider — the one case a type cannot catch. The two mappers used to fail differently and both badly: the OpenAI-compatible adapter put the unknown role on the wire, where it is a 400 for the whole request, and the AI SDK bridge let it fall through to auserturn, so the model read a tool result as something the person had typed. Each mapper now warns once per role and skips the message. A warning is not an alias: it does not make the old shape work, it names the new one.@aparte/provider-ai-sdk,@aparte/provider-openai-compat -
6e57533: A tool call a server addresses by
indexin one chunk and byidin the next is one call again, and a minted id can no longer collide with the server’s own. Nothing to change on your side.The parser picked ONE key per delta —
indexwhen it was there,idotherwise — so a vendor that used both, one at a time, produced two calls: the first with the name and no arguments, the second with the arguments and an empty name. The turn ran the tool on{}and then held a call it could not answer, so the reply came back blank. Both are addresses, not identities: a delta’s keys are now aliases that resolve to the same accumulated call, and whichever key is already known names it. When a delta carries only addresses the parser has not seen yet, its function name decides — a name appears only on a call’s first delta, so a nameless delta continues the call before it and a named one opens its own. That covers the vendor whose chunks never carry both addresses at once, in either order.The id minted for a call a vendor sends without one reads
aparte-call-1rather thancall_1, which is what an OpenAI-shaped server calls its own first call: a turn mixing an id-less call with a realcall_1used to mint a duplicate of it.@aparte/provider-openai-compat -
7802512: Parallel tool calls survive a server that omits
tool_calls[].index, a call with noidgets one, and a forcedtoolChoice: { name }reaches the endpoint instead of being downgraded toauto. Nothing to change on your side.indexwas the accumulation map’s only key, and it is a streaming convenience rather than part of the function-call payload — plenty of compat servers leave it out. Every call of such a turn landed on slot 0: the secondidandnameoverwrote the first, the two argument strings concatenated into non-JSON, and the turn ran ONE tool on{}while the model was told it had two answers. The only trace was a console warning naming “malformed arguments JSON”, which reads as the model’s fault. The key is nowindex, elseid, else the function name — which in this format appears only on a call’s first delta, so a nameless chunk still continues the call before it.An id is minted (
call_1,call_2, …) when the vendor sends none; a real id arriving in a later chunk still wins.id: ''used to travel, and downstream it is an identity: the transcript keys a row ontool-${id}and the history files a result undertoolCallId, so two id-less calls in one turn shared one row — the second call’s result was written onto the first call’s line — and one history slot an OpenAI-shaped endpoint rejects on the next turn.tool_choicewas written as'auto'unconditionally whenever tools were present.{ name }is a shape the agent loop passes straight through (it intercepts only{ name, input }, the synthetic call it runs itself), and the ai-sdk bridge has always honoured it, so the same request forced a tool on one provider and not on this one.@aparte/provider-openai-compat -
e573758:
createScenarioProvidernow warns about a tool with noafterroute even when you pass amatchof your own. Nothing to change in your code — when amatchis present the warning ends with this line is the one to ignore, because amatchthat routes the tool result by value (the documented branching shape) does not loop.The warning exists because a
whenscenario that calls a tool, with nothing answering the result, loops: the default rule sends the tool result back through the samewhen, round after identical round, until the client’smaxTurnsstops it.Passing
matchused to switch that warning off, on the premise that a custom rule replaces the default one. It does not replace it, it precedes it — the pick readsmatch(request, scenarios) ?? defaultMatch(request, scenarios)— so amatchthat returnsundefinedfor a tool result, which is exactly what the documented and in-repo examples write, lands right back on the rule the warning protects. The exemption was silencing the shape most likely to need it.@aparte/provider-scenario -
7802512:
turnsdocuments that its cursor belongs to the provider, not to a chat. No behaviour change.Two chats registered against one provider take turns from the same script and interleave it — chat A gets
turns[0], chat Bturns[1]— because a request carries no conversation identity to key a cursor on. The JSDoc now says so and names the two ways out: a provider per chat (createScenarioProvideris cheap and takes anid), orscenarios, which answers from the request itself and has no cursor at all.@aparte/provider-scenario -
029c44b: The showcase scenario answers “ship it” with a four-step tool chain — search, read, write, run — so a multi-tool turn can be seen. Register
search_docs,read_file,write_fileandrun_commandif you want the chain to resolve; unregistered tool names abort the call, exactly asget_weatherandask_useralready did.Every other scenario calls ONE tool and answers its result, so the turn a real agent produces — several calls in a row, each feeding the next — was the one shape nothing in this repository could show. It is what a transcript has to survive: five rows stacked in one bubble, an approval decision landing in the middle of them, a refusal cutting the rest of the turn.
Each tool in the chain carries its own
after:route (releaseRead,releaseWrite,releaseRun,releaseDone), because a tool whose result is not routed falls back through thewhenthat started the chain and the conversation eats its own tail — the warningcreateScenarioProviderprints at creation.@aparte/provider-scenario -
7d009cf: A binary attachment now warns instead of vanishing;
AparteFilePartis removed — aparté inlines images and text files only. If you referencedAparteFilePart(or engine’sStreamFilePart) in your own types, drop it:AparteContentPartis text or image.Nothing ever produced a file part. The client inlines images and recognized text files and returned
nullfor everything else, and both wire mappers answered the type with an empty text part — a branch no message could reach. What the type did do was promise a consumer that attaching a PDF sent it, while the chip stayed on screen and the model answered as if nothing had come with the message. That promise is now a warning at the one place the file is really dropped, once per file, naming the file and its type; the chip is untouched, so your own upload or RAG layer still sees it on theaparte-sendevent.The two-arm union also empties the third mapper’s fallback: the vision runner counted “content parts this runner cannot carry” and warned about them, over a branch no message could reach. The count and its warning are gone, the same way they went from the two wire mappers.
@aparte/provider-transformers -
c0f362b: A local runner now says when it dropped a tool call the assistant made, not only the tool result that answered it, and the vision runner counts a content part it cannot carry instead of failing at generate time. Nothing to change on your side; you get one warning where you used to get none.
Neither built-in runner can carry a tool turn — the wire syntax for one is model-specific — and both have always said so. Now that a call rides on an
assistantmessage rather than a role of its own, the call passes the runner’s role test: a turn where the model said nothing before calling was then dropped by the emptiness check below it, in silence, which is the exact defect the warning exists to prevent. Both runners count such a turn, and the shared message names both halves — the call the assistant made, and the result that answered it.The message says one thing more precisely than it did. What is dropped is the CALL and its result; what the assistant said before calling is still in the prompt, because that sentence rides on the same
assistantmessage and passes the role test. “Dropped tool turn(s) from the prompt” read as if the whole turn left, which had stopped being true.On the parts axis the vision runner had no guard at all. Its content loop was
textor else an image, so afilepart — removed fromAparteContentPart, and still reachable from an app built against an older aparté — was pushed into the image list asundefinedand reachedload_image(undefined), failing the turn at generate time. It is counted and named now, the way the wire mappers already guard the removed tool ROLES: same class, same treatment.@aparte/provider-transformers -
7802512: Preparing a second model no longer disposes the pipeline a reply is streaming from, two
prepareModelcalls in the same tick no longer leave two models resident, and the cache budget will not evict weights a generation is reading. Nothing to change on your side — aprepareissued during a stream now waits for it instead of interrupting it.prepare,generateandcommandall go throughensureRunner, which disposes the previous runner when the model or runner key changes, and the worker ran them concurrently. Picking another model while a reply streamed — the documentedTransformersProvider.prepareModel, which was on no chain at all whererunnerCommandis explicitly queued — therefore calleddispose()on the ONNX session the runningpipe(...)was executing. And two prepares issued before the first resolved both observed no resident runner across the three awaits, so the second orphaned the first: two multi-GB models in one tab, the older one unreachable for cleanup, which is the exact failure the “one pipeline per tab” rule exists to prevent. All three now share one promise chain in the worker, the way the main thread already chains its generates;cancelstays immediate, since its whole job is to reach the generate running now.The cache budget fires on
pipeline-ready, i.e. when ANOTHER model finishes loading, and kept only that model — so it deleted the files of the model that was answering, anddeleteCachedModelterminates the worker when that model is the loaded one. A model with a generate queued or running counts as in use.@aparte/provider-transformers -
6e57533: Stop now reaches a reply that is still queued behind a model load, and a token the worker emits after a Stop no longer lands in the stream. Nothing to change on your side.
The worker created a generate’s
AbortControllerwhen the queue reached that generate. Acancelarriving earlier — whileprepareModelwas still downloading another model, which can take minutes — found nothing to abort, so once the load finished the stopped reply started anyway and streamed to the end. The controller is now registered when the message arrives, and a generate cancelled before it starts never loads a model; it answersgen-done, which is what releases the queue slot.The main thread kept the stream open after posting the
cancel, waiting for the worker to answer, so a token already in flight was enqueued into a reply the user had ended. The stream is closed at the Stop instead — withdone, since a stop is not a failure — and the worker’s owngen-donestill releases the slot. On the non-streaming path (chat({ stream: false })) that means a Stop after the request has reached the worker resolves with the text produced so far, where a Stop before it still rejects withGeneration cancelled before it started.@aparte/provider-transformers -
4a73996: A CSS artifact can no longer close its own
<style>and run a script, the binary preview is sanitised by the config of the chat it is mounted in, and a card’s tab ids are unique per card. Nothing to change in your code.The
csspreview only styles.<style>is raw text to the HTML parser exactly like<script>— it ends on</stylefollowed by whitespace,/,>or end-of-input, whatever the CSS tokenizer thinks — and thecsskind interpolated the model’s body into it unescaped while the siblingjskind ran its body throughescapeClosingScriptTag. So a stylesheet artifact could break out of its wrapper and open a<script>, on the one previewable kind whose whole promise is that it only styles. The containment always held (opaque origin, noallow-same-origin, andPREVIEW_CSPapplied both as thecspattribute and as a<meta http-equiv>), so this was the gap between what “preview this stylesheet” promises and what it does. A closing-</style>escaper now mirrors the script one.The binary preview reads the element’s config.
previewMarkupresolved the ambient config, which from the promise callback that swaps the preview in is the global one long after the render — so a chat with its ownAparteConfigthat registered DOMPurify throughsetHtmlSanitizerhad that policy silently skipped at the one sink that puts app-supplied HTML on the page, and its locale skipped at the sentence beside it. Both callers already held the right config; it is passed in now. It failed safe before (the global default is core’s own allowlist), which is why nothing showed it.A card’s tab ids come from a counter, not from the model. They were built from
segment.id, and for a tool-call segment that istool-${toolCallId}— the id the model chose. Two calls answering to the same id put two cards on the page wearing the sameidand the samearia-controls, sogetElementByIdreturned whichever parsed first: exactly the collision the scoping was introduced to prevent, and the DOM-clobbering shape core’s sanitizer already refuses on model markup.@aparte/plugin-artifacts -
913969b: The empty-preview line is readable on the artifact card’s paper in the dark theme, the error panel’s dark wash now also reaches a system-dark reader who sets no attribute, and the error heading takes the error ink again. Nothing to change on your side. New knob if you re-skin the paper:
--aparte-art-paper-text-muted, declared beside--aparte-art-paper-bg/-textand mixed from them.The preview pane forces the light paper on purpose — an artifact preview is a DOCUMENT shown inside the chat, the way a PDF viewer shows a white page in a dark editor — and the empty-state line inside it reached back into the theme for
--aparte-text-muted. In the dark theme that is#a89bb6, on#fff: 2.62:1, under 4.5 and under 3. It takes the paper’s own muted ink now, so a consumer who re-declares the paper moves it too. (.aparte-art-file__error-hintwas checked and is not the same case: it sits on--aparte-error-bg, where the same colour measures 6.14:1.)The sheet’s one dark rule was
[data-aparte-theme="dark"] .aparte-segment-artifact-filewith noprefers-color-schemesibling, so a system-dark reader with no attribute kept the LIGHT wash (rgba(0,0,0,0.04)) on the already-dark--aparte-error-bg. It is duplicated now, the way core’stheme.cssduplicates its dark block for the same reason. And.aparte-art-file__error-titlewas still reading--aparte-error-title, a token core removed with the error renderer’s private classes: invalid at computed-value time, so the heading quietly took the panel’s body ink. It reads--aparte-error-text.Why all three survived: this is the only plugin stylesheet in the repo and it is read by no guard.
check:derived-varsreadscoreStylesheets(), which is the import block ofpackages/core/src/index.ts, so the prefix rule, the single-owner rule, the dead-keyframe rule and “a documented@cssprophas a reader” all stop at core’s edge. A unit suite reading this sheet stands in for now; widening the guard’s corpus is a change to the guard, not to its input — its “a token is read only under its declarer” rule reports 18 false positives here, because the tokens are declared on.aparte-segment-artifact-*and read on descendants named.aparte-art-*, a relationship its BEM heuristic cannot see.@aparte/plugin-artifacts -
f91584f: A light/dark theme pair now follows the OS colour scheme when the page sets no
data-aparte-theme, the way core’s own theme does; nothing to change on your side.The pair’s stylesheet keyed its dark half on
[data-aparte-theme="dark"]alone, so a page in system dark — every page that sets no attribute — rendered the light theme’s white block on a dark bubble. The dark half now also applies underprefers-color-scheme: darkunless the root sayslight, the same three states core answers.@aparte/plugin-shiki -
4a73996: A streamed code fence now gets
class="language-<name>", the same class a one-shot renderer writes — so model text can no longer wear core’s ownaparte-*names while a message streams. Nothing to change in your code.streaming-markdownmaps itsLANGattribute toclassand passes the fence’s info string through verbatim, and the wrapper this package installs filtered onlyhrefandsrc. So three backticks followed byaparte-approval-option aparte-btn aparte-btn--solidpainted a pixel-perfect approval button inside the transcript — the exact forgery core’s sanitizer drops on the one-shot path — and a fence naming aposition: fixedrecipe (.aparte-select-dropdown,.aparte-sidebar__scrim) repainted the page around the chat. It stayed until the message settled, and on the simple-content path it did not go away even then.The class is prefixed rather than filtered, which fixes three things at once: the streamed DOM and the settled DOM now agree (both say
language-…),language-*is the one prefix core’s class policy deliberately exempts, andhighlightMarkdownFencescan finally read a streamed fence’s language. The token is the first word of the info string, truncated at the first character a language name cannot hold —c++andf#survive,py<script>becomespy, and an info string with nothing nameable in it writes no class at all.@aparte/plugin-streaming-markdown -
7802512: A failed model load is retried on the next title instead of disabling auto-titling for the life of the page. Nothing to change on your side.
The model is resolved once and cached, and the cache kept a REJECTED promise: one transient failure — the model fetch, a CSP hiccup on the dynamic import — and every conversation from then on quietly kept its default title.
@aparte/plugin-shikiclears its cached promise on failure for the same reason. The error still reaches the caller; only the caching of it is gone.@aparte/plugin-titler -
19d708d:
clearMessages({ revokeAttachments: false })now reaches the host on all four wrappers, so the attachments in the transcript keep their object URLs. The helpers (useAparteChat,createAparteChat) forward the option too, and React’s exportedUseAparteChattype declares it.The bridge was
clearMessages: () => host?.clearMessages()at seven sites, so an explicit “don’t revoke” arrived asundefinedand the viewport revoked by default — the exact inversion of what the caller asked for, and every image and file chip still on screen came back broken. TypeScript could not see it: a zero-parameter function is assignable to a one-optional-parameter signature, sosatisfies,implementsand Svelte’s_assertImperativeParityall passed. Each wrapper’s suite now asserts it against a spiedURL.revokeObjectURL— the option kept, and the default (revoke) kept too.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
0e0814e:
provideAparte’s documentation now shows the calls React, Vue and Svelte make instead of it, so its absence in the other three wrappers is not read as a missing feature. Nothing to change in your code: the docblock ships in the published types.The docblock said only that you “can equally call
aparteGlobalConfig.*yourself” — a capability named in passing, with no example, which is the shape a reader skips. It now carries the four calls that replace the provider (register a provider, set the model config, set the locale, setdata-aparte-theme) and says what Angular alone needs it for: an initializer that runs before the first component, and aDestroyRefto release the theme listener it registers. The one option that is not a plain call is named as such:theme: 'auto'is aprefers-color-schemelistener you write and dispose of yourself.The same block, and the trigger that would end the asymmetry — a second wrapper needing that listener with a disposer of its own, at which point
applyThemeModemoves into@aparte/coreand all four call it — are on the Wrapper surface reference page.@aparte/angular -
19d708d: The ergonomics helpers gain the four members they were missing —
getMessages,scrollToBottom,focusInput,getViewport— souseAparteChat(React, Vue) andcreateAparteChat(Svelte) expose the whole imperative surface. On Angular:AparteAiServiceexposesclient(theAparteClientthe other three return as{ client, abort }), two new inputscontainerClass/containerStyleland on the inner.aparte-chat-container, andAparteUiPropsis exported. All four now re-exportAparteUiHandlefrom@aparte/corerather than declaring their own copy.The helper is the documented entry point, and it exposed 17 of the 20 members — silently narrowing the contract at exactly the reads and view acts a consumer reaches for after a send.
getMessages()is not themessagesstate: the host’s list is the authority and, mid-stream, a frame ahead of what the framework has rendered.Angular’s
containerClass/containerStyleare the parity the other three get for free. Their root IS the container, so a consumer’sclassName/classlands on the div carrying.aparte-chat-container,[overlay-composer]and[data-aparte-empty]— the selectors core’s shell recipe keys on. Angular’s own<aparte-chat>host sits one level above them, so overriding the shell needed a descendant selector there and nowhere else.Also on Angular: the bubble-reconcile effect no longer skips an empty list (React, Vue and Svelte all sync unconditionally). With the default bubbles the
#bubblequery hid it by re-syncing on its own; under a custom[bubbleTemplate]that query never fires and the effect was the only path left.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
7ccfa5c:
AparteChatandAparteUinow ship real, compiler-checked prop, event and slot types — a wrong prop on either component is atsc/svelte-checkerror in your own project, not just a mismatch you’d only catch at runtime.The package always re-exported
./AparteChat.svelte, but noAparteChat.svelte.d.tswas ever emitted next to it, so every import resolved to an untyped component and the props were documentation only. The cause:svelte-package’s own.d.tsemission (svelte2tsx’semitDts) silently emits nothing under this package’s realtsconfig.json, which inheritsnoEmitOnError: truefrom the repo base — that setting blocks emission on diagnostics that are expected noise in svelte2tsx’sdtstransform mode (svelte2tsx’s own diagnostic filter already treats those exact codes as non-fatal). A dedicatedtsconfig.dts.json, passed via--tsconfigand used only for this one step, relaxes it; the package’s realtsc -bbuild is untouched by it. Verified with a standalone consumer project: importing the built package and assigning a wrong-typed prop toAparteChatnow failstsc --noEmit.@aparte/svelte -
43297ea: The emitted
AparteChat.svelte.d.tsimports./types.jswith its extension, so the package’s types compile for a consumer onmoduleResolution: nodenextwithskipLibCheck: false.svelte-packagecopies the component’s<script>imports into the declaration it emits, and that one import was written without an extension while every sibling.tsfile in the package already carried.js. UndernodenextTypeScript reportedTS2835on the shipped file. The realistic Svelte consumer (bundlerresolution,skipLibCheck: true) never saw it; the fix is the import in the component, not a post-process ondist/.@aparte/svelte
Version-only bumps (no changes of their own): @aparte/plugin-approval, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/docs-mcp.