@aparte/core 0.16.7 → 0.16.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/client/aparte-client.d.ts +57 -2
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/lifecycle-events.d.ts +8 -1
  4. package/dist/client/lifecycle-events.d.ts.map +1 -1
  5. package/dist/client/stream-adapter.d.ts +8 -0
  6. package/dist/client/stream-adapter.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +39 -18
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/chat/aparte-chat.d.ts +32 -25
  10. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  11. package/dist/components/composer/aparte-composer-action.d.ts +1 -1
  12. package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
  13. package/dist/components/composer/aparte-composer-attachments.d.ts +1 -1
  14. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  15. package/dist/components/composer/aparte-composer-cancel.d.ts +2 -2
  16. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-input.d.ts +6 -6
  18. package/dist/components/composer/aparte-composer-send.d.ts +3 -3
  19. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer.d.ts +9 -2
  21. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  22. package/dist/components/context/aparte-context.d.ts +0 -1
  23. package/dist/components/context/aparte-context.d.ts.map +1 -1
  24. package/dist/components/conversation-list/aparte-conversation-list.d.ts +11 -6
  25. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  26. package/dist/components/elicitation/aparte-elicitation.d.ts +20 -18
  27. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  28. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +11 -3
  29. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  30. package/dist/components/sidebar/aparte-sidebar.d.ts +16 -2
  31. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  32. package/dist/components/split/aparte-split.d.ts +9 -4
  33. package/dist/components/split/aparte-split.d.ts.map +1 -1
  34. package/dist/components/status/aparte-chat-status.d.ts +44 -5
  35. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  36. package/dist/components/viewport/aparte-chat-viewport.d.ts +27 -4
  37. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  38. package/dist/config/icon-provider.d.ts +6 -0
  39. package/dist/config/icon-provider.d.ts.map +1 -1
  40. package/dist/config/locale.d.ts +3 -1
  41. package/dist/config/locale.d.ts.map +1 -1
  42. package/dist/custom-elements.json +13010 -12737
  43. package/dist/{dialog-triggers-Dw_DIlQ7.js → dialog-triggers-CApC7K1I.js} +119 -44
  44. package/dist/dialog-triggers-CApC7K1I.js.map +1 -0
  45. package/dist/elicitation/panel.d.ts.map +1 -1
  46. package/dist/generated/element-attributes.d.ts +13 -3
  47. package/dist/generated/element-attributes.d.ts.map +1 -1
  48. package/dist/{glyphs-DQTeOj4w.js → glyphs-C8z2oJUg.js} +36 -27
  49. package/dist/{glyphs-DQTeOj4w.js.map → glyphs-C8z2oJUg.js.map} +1 -1
  50. package/dist/icons/extended.d.ts +0 -6
  51. package/dist/icons/extended.d.ts.map +1 -1
  52. package/dist/icons/glyphs.d.ts +13 -4
  53. package/dist/icons/glyphs.d.ts.map +1 -1
  54. package/dist/icons.js +28 -31
  55. package/dist/icons.js.map +1 -1
  56. package/dist/index.css +946 -386
  57. package/dist/index.js +321 -89
  58. package/dist/index.js.map +1 -1
  59. package/dist/index.node.js +1 -1
  60. package/dist/interop/element-props.d.ts.map +1 -1
  61. package/dist/primitives/icon/aparte-icon.d.ts +1 -1
  62. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts +2 -3
  63. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts.map +1 -1
  64. package/dist/primitives/select/aparte-optgroup.d.ts +1 -1
  65. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  66. package/dist/primitives/select/aparte-option.d.ts +4 -4
  67. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  68. package/dist/primitives/select/aparte-select.d.ts +43 -12
  69. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  70. package/dist/renderers/segments/code.d.ts.map +1 -1
  71. package/dist/renderers/segments/error.d.ts.map +1 -1
  72. package/dist/renderers/segments/thinking.d.ts.map +1 -1
  73. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  74. package/dist/types/events.d.ts +4 -2
  75. package/dist/types/events.d.ts.map +1 -1
  76. package/package.json +2 -2
  77. package/dist/dialog-triggers-Dw_DIlQ7.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
- import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, K as escapeHtml, a8 as writeStreamedMarkdown, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, a9 as stampSegmentActivity, aa as stampSegmentOnInsert, ab as stampSegmentOnUpdate, ac as renumberSegments, ad as openSegmentIds, ae as isTerminalStatus, af as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, e as APARTE_DEFAULT_LOCALE, P as installDialogTriggersOnce } from "./dialog-triggers-Dw_DIlQ7.js";
2
- import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-Dw_DIlQ7.js";
1
+ import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, K as escapeHtml, a8 as writeStreamedMarkdown, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, a9 as stampSegmentActivity, aa as stampSegmentOnInsert, ab as stampSegmentOnUpdate, ac as renumberSegments, ad as openSegmentIds, ae as isTerminalStatus, af as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, e as APARTE_DEFAULT_LOCALE, P as installDialogTriggersOnce } from "./dialog-triggers-CApC7K1I.js";
2
+ import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-CApC7K1I.js";
3
+ function presenceOn(value) {
4
+ return value === "" ? true : Boolean(value);
5
+ }
3
6
  class AparteOption extends HTMLElement {
4
7
  static get observedAttributes() {
5
8
  return ["value", "disabled", "selected", "data-status"];
@@ -34,21 +37,13 @@ class AparteOption extends HTMLElement {
34
37
  return this.hasAttribute("disabled");
35
38
  }
36
39
  set disabled(val) {
37
- if (val) {
38
- this.setAttribute("disabled", "");
39
- } else {
40
- this.removeAttribute("disabled");
41
- }
40
+ this.toggleAttribute("disabled", presenceOn(val));
42
41
  }
43
42
  get selected() {
44
43
  return this.hasAttribute("selected");
45
44
  }
46
45
  set selected(val) {
47
- if (val) {
48
- this.setAttribute("selected", "");
49
- } else {
50
- this.removeAttribute("selected");
51
- }
46
+ this.toggleAttribute("selected", presenceOn(val));
52
47
  }
53
48
  _updateAriaSelected() {
54
49
  this.setAttribute("aria-selected", this.selected ? "true" : "false");
@@ -104,18 +99,13 @@ class AparteOptgroup extends HTMLElement {
104
99
  return this.hasAttribute("collapsed");
105
100
  }
106
101
  set collapsed(val) {
107
- if (val) {
108
- this.setAttribute("collapsed", "");
109
- } else {
110
- this.removeAttribute("collapsed");
111
- }
102
+ this.toggleAttribute("collapsed", presenceOn(val));
112
103
  }
113
104
  get loading() {
114
105
  return this.hasAttribute("loading");
115
106
  }
116
107
  set loading(val) {
117
- if (val) this.setAttribute("loading", "");
118
- else this.removeAttribute("loading");
108
+ this.toggleAttribute("loading", presenceOn(val));
119
109
  }
120
110
  _render() {
121
111
  if (this.label) {
@@ -152,7 +142,9 @@ class AparteOptgroup extends HTMLElement {
152
142
  if (!loader) {
153
143
  loader = document.createElement("div");
154
144
  loader.className = "aparte-optgroup-loader";
155
- loader.innerHTML = '<span class="aparte-spinner-small"></span> Fetching models...';
145
+ const spinner = document.createElement("span");
146
+ spinner.className = "aparte-spinner-small";
147
+ loader.replaceChildren(spinner, ` ${resolveConfig(this).t("loading") || "Loading…"}`);
156
148
  this.appendChild(loader);
157
149
  }
158
150
  } else if (loader) {
@@ -211,14 +203,16 @@ class AparteSelect extends HTMLElement {
211
203
  _boundHandleDocumentClick = this._handleDocumentClick.bind(this);
212
204
  _boundHandleKeydown = this._handleKeydown.bind(this);
213
205
  static get observedAttributes() {
214
- return ["value", "placeholder", "disabled", "grouped", "searchable", "open"];
206
+ return ["value", "placeholder", "disabled", "searchable", "open"];
215
207
  }
216
208
  connectedCallback() {
217
209
  this._value = this.getAttribute("value") || "";
218
- this._isOpen = this.hasAttribute("open");
210
+ const wantsOpen = this.hasAttribute("open") && !this._isOpen;
219
211
  this._render();
212
+ this._applyDisabled();
220
213
  this._setupEventListeners();
221
214
  this._setupMutationObserver();
215
+ if (wantsOpen) this._openDropdown();
222
216
  }
223
217
  disconnectedCallback() {
224
218
  this.removeEventListener("click", this._boundHandleOptionClick);
@@ -233,14 +227,19 @@ class AparteSelect extends HTMLElement {
233
227
  this._updateTriggerLabel();
234
228
  }
235
229
  if (name === "open") {
236
- this._isOpen = this.hasAttribute("open");
237
- if (this._isOpen) {
238
- this._dropdown?.removeAttribute("hidden");
239
- this._searchInput?.focus();
240
- } else {
241
- this._dropdown?.setAttribute("hidden", "");
230
+ const wanted = this.hasAttribute("open");
231
+ if (wanted !== this._isOpen) {
232
+ if (wanted) this._openDropdown();
233
+ else this._closeDropdown();
242
234
  }
243
235
  }
236
+ if (name === "placeholder") {
237
+ this._updateTriggerLabel();
238
+ }
239
+ if (name === "disabled") {
240
+ this._applyDisabled();
241
+ if (!this.hasAttribute("disabled") && this.hasAttribute("open")) this._openDropdown();
242
+ }
244
243
  }
245
244
  // ─────────────────────────────────────────────────────────────────────────
246
245
  // Public API
@@ -260,11 +259,7 @@ class AparteSelect extends HTMLElement {
260
259
  return this._isOpen;
261
260
  }
262
261
  set open(val) {
263
- if (val) {
264
- this.setAttribute("open", "");
265
- } else {
266
- this.removeAttribute("open");
267
- }
262
+ this.toggleAttribute("open", presenceOn(val));
268
263
  }
269
264
  // ─────────────────────────────────────────────────────────────────────────
270
265
  // Rendering
@@ -281,7 +276,7 @@ class AparteSelect extends HTMLElement {
281
276
  const trigger = document.createElement("div");
282
277
  trigger.className = "aparte-select-trigger";
283
278
  trigger.setAttribute("tabindex", "0");
284
- trigger.setAttribute("role", "combobox");
279
+ trigger.setAttribute("role", searchable ? "button" : "combobox");
285
280
  trigger.setAttribute("aria-haspopup", "listbox");
286
281
  trigger.setAttribute("aria-expanded", "false");
287
282
  trigger.setAttribute("aria-label", this.getAttribute("aria-label") || placeholder);
@@ -301,12 +296,17 @@ class AparteSelect extends HTMLElement {
301
296
  const dropdown = document.createElement("div");
302
297
  dropdown.className = "aparte-select-dropdown";
303
298
  dropdown.hidden = !this._isOpen;
299
+ let searchInput = null;
304
300
  if (searchable) {
305
- const searchInput = document.createElement("input");
301
+ searchInput = document.createElement("input");
306
302
  searchInput.type = "text";
307
303
  searchInput.className = "aparte-select-search";
308
304
  searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
309
305
  searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
306
+ searchInput.setAttribute("role", "combobox");
307
+ searchInput.setAttribute("aria-haspopup", "listbox");
308
+ searchInput.setAttribute("aria-autocomplete", "list");
309
+ searchInput.setAttribute("aria-expanded", String(this._isOpen));
310
310
  dropdown.appendChild(searchInput);
311
311
  }
312
312
  const optionsContainer = document.createElement("div");
@@ -315,6 +315,7 @@ class AparteSelect extends HTMLElement {
315
315
  optionsContainer.setAttribute("aria-label", trigger.getAttribute("aria-label") ?? placeholder);
316
316
  optionsContainer.id = this.id ? `${this.id}-listbox` : `aparte-listbox-${++AparteSelect._listboxSeq}`;
317
317
  trigger.setAttribute("aria-controls", optionsContainer.id);
318
+ searchInput?.setAttribute("aria-controls", optionsContainer.id);
318
319
  slottedChildren.forEach((child) => {
319
320
  if (child.tagName === "APARTE-OPTION" || child.tagName === "APARTE-OPTGROUP") {
320
321
  optionsContainer.appendChild(child);
@@ -463,6 +464,20 @@ class AparteSelect extends HTMLElement {
463
464
  const base = this._activeIndex < 0 ? delta > 0 ? -1 : 0 : this._activeIndex;
464
465
  this._setActive(base + delta);
465
466
  }
467
+ /**
468
+ * The element that holds DOM focus while the list is being driven, and therefore
469
+ * the one that must carry the combobox state a reader follows: the filter field
470
+ * when the select is `searchable` (it is focused on open), the trigger otherwise.
471
+ */
472
+ _activeHost() {
473
+ return this._searchInput ?? this._trigger;
474
+ }
475
+ /** `aria-expanded` belongs to the combobox — write it wherever the combobox is. */
476
+ _setExpanded(open) {
477
+ const flag = String(open);
478
+ this._trigger?.setAttribute("aria-expanded", flag);
479
+ this._searchInput?.setAttribute("aria-expanded", flag);
480
+ }
466
481
  /** Highlight the option at `index` (clamped) and point aria-activedescendant at it. */
467
482
  _setActive(index) {
468
483
  const all = this.querySelectorAll("aparte-option");
@@ -470,7 +485,7 @@ class AparteSelect extends HTMLElement {
470
485
  const opts = this._visibleOptions();
471
486
  if (opts.length === 0) {
472
487
  this._activeIndex = -1;
473
- this._trigger?.removeAttribute("aria-activedescendant");
488
+ this._activeHost()?.removeAttribute("aria-activedescendant");
474
489
  return;
475
490
  }
476
491
  const clamped = Math.max(0, Math.min(index, opts.length - 1));
@@ -478,13 +493,13 @@ class AparteSelect extends HTMLElement {
478
493
  const active = opts[clamped];
479
494
  if (!active.id) active.id = `aparte-option-${++AparteSelect._optIdSeq}`;
480
495
  active.setAttribute("data-active", "");
481
- this._trigger?.setAttribute("aria-activedescendant", active.id);
496
+ this._activeHost()?.setAttribute("aria-activedescendant", active.id);
482
497
  active.scrollIntoView?.({ block: "nearest" });
483
498
  }
484
499
  /** Clear the keyboard highlight (on close). */
485
500
  _clearActive() {
486
501
  this._activeIndex = -1;
487
- this._trigger?.removeAttribute("aria-activedescendant");
502
+ this._activeHost()?.removeAttribute("aria-activedescendant");
488
503
  this.querySelectorAll("aparte-option").forEach((o) => o.removeAttribute("data-active"));
489
504
  }
490
505
  // ─────────────────────────────────────────────────────────────────────────
@@ -498,10 +513,11 @@ class AparteSelect extends HTMLElement {
498
513
  }
499
514
  }
500
515
  _openDropdown() {
516
+ if (this._isOpen) return;
501
517
  if (this.hasAttribute("disabled")) return;
502
518
  this._isOpen = true;
503
519
  this._dropdown?.removeAttribute("hidden");
504
- this._trigger?.setAttribute("aria-expanded", "true");
520
+ this._setExpanded(true);
505
521
  this.setAttribute("open", "");
506
522
  this._updatePosition();
507
523
  this._searchInput?.focus();
@@ -514,7 +530,7 @@ class AparteSelect extends HTMLElement {
514
530
  this._isOpen = false;
515
531
  this._clearActive();
516
532
  this._dropdown?.setAttribute("hidden", "");
517
- this._trigger?.setAttribute("aria-expanded", "false");
533
+ this._setExpanded(false);
518
534
  this.removeAttribute("open");
519
535
  this.removeAttribute("position");
520
536
  if (this._dropdown) {
@@ -558,6 +574,47 @@ class AparteSelect extends HTMLElement {
558
574
  this._emitChange(previousValue);
559
575
  this._trigger?.focus();
560
576
  }
577
+ /**
578
+ * The combobox and its listbox are named by the host's `aria-label`, else by the
579
+ * placeholder — the same rule the first render applies, re-applied whenever the
580
+ * placeholder or the selection changes. Written only when different: the mutation
581
+ * observer watches this subtree.
582
+ *
583
+ * A `combobox` is NAMED by that attribute and VALUED by its visible label span, so
584
+ * the name is the control and nothing else. A `button` — the shape a `searchable`
585
+ * trigger takes, since the filter field is then the combobox — is named by its
586
+ * CONTENT, which this very attribute overrides: the searchable trigger announced
587
+ * "Pick a model, button" and never said which model was selected. So the button's
588
+ * name carries both halves, and drops the second when it would only repeat the
589
+ * first (nothing selected: the value IS the placeholder).
590
+ */
591
+ _updateNames() {
592
+ const placeholder = this.getAttribute("placeholder") || "Select...";
593
+ const name = this.getAttribute("aria-label") || placeholder;
594
+ const list = this.querySelector(".aparte-select-options");
595
+ if (list && list.getAttribute("aria-label") !== name) list.setAttribute("aria-label", name);
596
+ const trigger = this._trigger;
597
+ if (!trigger) return;
598
+ const value = this._getSelectedLabel() || placeholder;
599
+ const wanted = trigger.getAttribute("role") === "button" && value !== name ? `${name}: ${value}` : name;
600
+ if (trigger.getAttribute("aria-label") !== wanted) trigger.setAttribute("aria-label", wanted);
601
+ }
602
+ /**
603
+ * `disabled` takes the trigger out of the tab order and says so — opacity alone
604
+ * left it focusable and announced as operable. An open dropdown closes.
605
+ */
606
+ _applyDisabled() {
607
+ const trigger = this._trigger;
608
+ if (!trigger) return;
609
+ const disabled = this.hasAttribute("disabled");
610
+ trigger.setAttribute("tabindex", disabled ? "-1" : "0");
611
+ if (disabled) {
612
+ trigger.setAttribute("aria-disabled", "true");
613
+ if (this._isOpen) this._closeDropdown();
614
+ } else {
615
+ trigger.removeAttribute("aria-disabled");
616
+ }
617
+ }
561
618
  _updateTriggerLabel() {
562
619
  const placeholder = this.getAttribute("placeholder") || "Select...";
563
620
  const labelText = this._trigger?.querySelector(".aparte-select-label-text");
@@ -577,6 +634,7 @@ class AparteSelect extends HTMLElement {
577
634
  }));
578
635
  }
579
636
  }
637
+ this._updateNames();
580
638
  const options = this.querySelectorAll("aparte-option");
581
639
  options.forEach((opt) => {
582
640
  const isSelected = opt.getAttribute("value") === this._value;
@@ -895,7 +953,7 @@ if (typeof customElements !== "undefined" && !customElements.get("aparte-elicita
895
953
  }
896
954
  class AparteChat extends HTMLElement {
897
955
  static get observedAttributes() {
898
- return ["placeholder", "disabled", "center-empty", "attachments"];
956
+ return ["placeholder", "disabled", "submit-on-enter", "center-empty", "attachments"];
899
957
  }
900
958
  _observer = null;
901
959
  /**
@@ -908,6 +966,7 @@ class AparteChat extends HTMLElement {
908
966
  this._render();
909
967
  this._forwardAttr("placeholder");
910
968
  this._forwardAttr("disabled");
969
+ this._forwardAttr("submit-on-enter");
911
970
  this._syncEmptyWatch();
912
971
  }
913
972
  disconnectedCallback() {
@@ -1064,6 +1123,12 @@ class AparteChatBubble extends HTMLElement {
1064
1123
  _editing = false;
1065
1124
  /** The live inline editor (the composer's contenteditable primitive), present only while `_editing`. */
1066
1125
  _editInput = null;
1126
+ /**
1127
+ * The `data-action` of the button the editor was opened from, so leaving it can hand
1128
+ * the keyboard back. The STRING, not the node: entering and leaving edit mode rewrite
1129
+ * the action bar's `innerHTML`, so the opener's identity is destroyed twice.
1130
+ */
1131
+ _editReturnAction = null;
1067
1132
  static get observedAttributes() {
1068
1133
  return ["data-role", "content", "timestamp", "message-id", "streaming", "name"];
1069
1134
  }
@@ -1187,6 +1252,7 @@ class AparteChatBubble extends HTMLElement {
1187
1252
  appendToken(chunk) {
1188
1253
  this._content += chunk;
1189
1254
  this._updateContent();
1255
+ this._syncCopyAction();
1190
1256
  }
1191
1257
  /** Set content directly */
1192
1258
  setContent(content) {
@@ -1194,6 +1260,7 @@ class AparteChatBubble extends HTMLElement {
1194
1260
  this.setAttribute("content", content);
1195
1261
  this._resetMarkdownStream();
1196
1262
  this._updateContent();
1263
+ this._syncCopyAction();
1197
1264
  }
1198
1265
  /**
1199
1266
  * Drop the incremental Markdown parser's state.
@@ -1229,6 +1296,7 @@ class AparteChatBubble extends HTMLElement {
1229
1296
  const updated = mergeSegmentUpdate(this._segments[index], updates);
1230
1297
  this._segments[index] = updated;
1231
1298
  this._applySegmentUpdate(segmentId, updated, updates);
1299
+ this._syncCopyAction();
1232
1300
  }
1233
1301
  }
1234
1302
  /** Append content to a segment */
@@ -1237,6 +1305,7 @@ class AparteChatBubble extends HTMLElement {
1237
1305
  if (segment && "content" in segment) {
1238
1306
  segment.content += content;
1239
1307
  this._applySegmentUpdate(segmentId, segment, { content: segment.content });
1308
+ this._syncCopyAction();
1240
1309
  }
1241
1310
  }
1242
1311
  /** Get all segments */
@@ -1266,6 +1335,7 @@ class AparteChatBubble extends HTMLElement {
1266
1335
  const el2 = this._segmentsEl?.querySelector(`:scope > [data-segment-id="${cssEscape(segmentId)}"]`);
1267
1336
  el2?.remove();
1268
1337
  this._updateWaiting();
1338
+ this._syncCopyAction();
1269
1339
  }
1270
1340
  /** Set attachments (chips shown above message content, user role only) */
1271
1341
  setAttachments(attachments) {
@@ -1420,7 +1490,7 @@ class AparteChatBubble extends HTMLElement {
1420
1490
  </div>
1421
1491
  <div class="aparte-footer">
1422
1492
  <div class="aparte-branch-picker" hidden>
1423
- <button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-prev" aria-label="${escapeAttr(this._cfg.getLocale().previousResponse ?? "Previous response")}">&#8249;</button>
1493
+ <button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-prev" aria-label="${escapeAttr(this._cfg.getLocale().previousResponse ?? "Previous response")}">${this._cfg.getIcon("prevBranch")}</button>
1424
1494
  <span class="aparte-branch-label">1 / 1</span>
1425
1495
  <!-- The move has to be ANNOUNCED. Pressing the arrows deliberately does not
1426
1496
  take focus, so without a live region a screen-reader user gets the new
@@ -1429,7 +1499,7 @@ class AparteChatBubble extends HTMLElement {
1429
1499
  dots, which reads as nothing. No new locale key — the position is
1430
1500
  digits, and the buttons beside it already carry translated labels. -->
1431
1501
  <span class="aparte-sr-only aparte-branch-status" aria-live="polite"></span>
1432
- <button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-next" aria-label="${escapeAttr(this._cfg.getLocale().nextResponse ?? "Next response")}">&#8250;</button>
1502
+ <button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-next" aria-label="${escapeAttr(this._cfg.getLocale().nextResponse ?? "Next response")}">${this._cfg.getIcon("nextBranch")}</button>
1433
1503
  </div>
1434
1504
  <div class="aparte-action-bar" role="toolbar" aria-label="${escapeAttr(this._cfg.getLocale().messageActions ?? "Message actions")}"></div>
1435
1505
  </div>
@@ -1589,6 +1659,7 @@ class AparteChatBubble extends HTMLElement {
1589
1659
  this._contentEl.style.display = this._segments.length > 0 ? "none" : "";
1590
1660
  }
1591
1661
  this._reflectError();
1662
+ this._syncCopyAction();
1592
1663
  }
1593
1664
  /**
1594
1665
  * Reflect the error state on the bubble: `data-error` on `.aparte-message` while
@@ -1644,16 +1715,16 @@ class AparteChatBubble extends HTMLElement {
1644
1715
  this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
1645
1716
  const name = escapeHtml(a4.name);
1646
1717
  if (a4.type.startsWith("image/")) {
1647
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1718
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1648
1719
  }
1649
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1720
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1650
1721
  }).join("");
1651
1722
  if (!this._cfg.getHostHandlers().attachmentPreview) return;
1652
1723
  this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
1653
1724
  tile.setAttribute("role", "button");
1654
1725
  tile.setAttribute("tabindex", "0");
1655
1726
  const open = () => {
1656
- const img = tile.querySelector(".aparte-thumb__img");
1727
+ const img = tile.querySelector(".aparte-thumbnail__image");
1657
1728
  if (!img) return;
1658
1729
  this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
1659
1730
  bubbles: true,
@@ -1790,6 +1861,27 @@ class AparteChatBubble extends HTMLElement {
1790
1861
  // ─────────────────────────────────────────────────────────────────────────
1791
1862
  // Action Bar
1792
1863
  // ─────────────────────────────────────────────────────────────────────────
1864
+ /**
1865
+ * What the copy action copies: the reply, not the reasoning. A `thinking` segment is
1866
+ * the model's scratchpad, and the client already keeps it out of the history it sends
1867
+ * back (`_segmentsToText`) for the same reason — copying it along with the answer
1868
+ * pasted a paragraph of deliberation above every reply, which no assistant on the
1869
+ * market does. Segments with no `content` (a tool call) contribute nothing, so a
1870
+ * turn made only of those copies as `''` — and the action bar reads this to decide
1871
+ * whether to offer the button at all.
1872
+ */
1873
+ _copyableText() {
1874
+ return (this._content || this._segments.filter((s2) => s2.type !== "thinking").map((s2) => s2.content ?? "").join("\n")).trim();
1875
+ }
1876
+ /**
1877
+ * The copy button is for text. `hidden` rather than removed: the bar is rebuilt
1878
+ * rarely and the text changes on every streamed token, so the button is toggled in
1879
+ * place from each path that changes what a copy would paste.
1880
+ */
1881
+ _syncCopyAction() {
1882
+ const btn = this._actionBarEl?.querySelector(".aparte-action-copy");
1883
+ if (btn) btn.hidden = this._copyableText() === "";
1884
+ }
1793
1885
  _updateActionBar() {
1794
1886
  if (!this._actionBarEl) return;
1795
1887
  if (this._editing) {
@@ -1821,6 +1913,7 @@ class AparteChatBubble extends HTMLElement {
1821
1913
  }
1822
1914
  }
1823
1915
  this._actionBarEl.innerHTML = buttons.join("");
1916
+ this._syncCopyAction();
1824
1917
  this._appendCustomActions(icons);
1825
1918
  this._applyTranscriptBusy();
1826
1919
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
@@ -1912,7 +2005,8 @@ class AparteChatBubble extends HTMLElement {
1912
2005
  const roles = a4.bubble?.roles ?? ["user", "assistant"];
1913
2006
  if (!roles.includes(this._role)) continue;
1914
2007
  const btn = document.createElement("button");
1915
- btn.className = "aparte-btn aparte-btn--icon aparte-action-btn aparte-action-custom";
2008
+ btn.type = "button";
2009
+ btn.className = "aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-custom";
1916
2010
  btn.dataset["action"] = `custom:${a4.id}`;
1917
2011
  btn.setAttribute("aria-label", a4.label);
1918
2012
  btn.setAttribute("title", a4.label);
@@ -1926,28 +2020,28 @@ class AparteChatBubble extends HTMLElement {
1926
2020
  switch (action) {
1927
2021
  case "copy": {
1928
2022
  const l2 = locale.copy ?? "Copy";
1929
- return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.copy()}</button>`;
2023
+ return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.copy()}</button>`;
1930
2024
  }
1931
2025
  case "edit": {
1932
2026
  const l2 = locale.edit ?? "Edit message";
1933
- return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.edit()}</button>`;
2027
+ return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.edit()}</button>`;
1934
2028
  }
1935
2029
  case "retry": {
1936
2030
  const l2 = locale.retry ?? "Retry";
1937
- return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.retry()}</button>`;
2031
+ return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.retry()}</button>`;
1938
2032
  }
1939
2033
  case "thumbUp": {
1940
2034
  const l2 = locale.feedbackPositive ?? "Good response";
1941
- return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbUp()}</button>`;
2035
+ return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbUp()}</button>`;
1942
2036
  }
1943
2037
  case "thumbDown": {
1944
2038
  const l2 = locale.feedbackNegative ?? "Bad response";
1945
- return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbDown()}</button>`;
2039
+ return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbDown()}</button>`;
1946
2040
  }
1947
2041
  case "info": {
1948
2042
  if (!this._usage) return "";
1949
2043
  const l2 = locale.messageInfo ?? "Details";
1950
- return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-info" data-action="info" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${this._cfg.getIcon("info")}</button>`;
2044
+ return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-info" data-action="info" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${this._cfg.getIcon("info")}</button>`;
1951
2045
  }
1952
2046
  default:
1953
2047
  return "";
@@ -1959,7 +2053,7 @@ class AparteChatBubble extends HTMLElement {
1959
2053
  const locale = this._cfg.getLocale();
1960
2054
  const saveLabel = locale.editConfirm ?? "Save";
1961
2055
  const cancelLabel = locale.editCancel ?? "Cancel";
1962
- this._actionBarEl.innerHTML = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--success aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${escapeAttr(saveLabel)}" title="${escapeAttr(saveLabel)}">${this._cfg.getIcon("check")}</button><button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--danger aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${escapeAttr(cancelLabel)}" title="${escapeAttr(cancelLabel)}">${this._cfg.getIcon("close")}</button>`;
2056
+ this._actionBarEl.innerHTML = `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--success aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${escapeAttr(saveLabel)}" title="${escapeAttr(saveLabel)}">${this._cfg.getIcon("check")}</button><button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--danger aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${escapeAttr(cancelLabel)}" title="${escapeAttr(cancelLabel)}">${this._cfg.getIcon("close")}</button>`;
1963
2057
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1964
2058
  btn.addEventListener("click", (e) => this._handleActionClick(e));
1965
2059
  });
@@ -1988,7 +2082,7 @@ class AparteChatBubble extends HTMLElement {
1988
2082
  }
1989
2083
  switch (action) {
1990
2084
  case "copy": {
1991
- const text = this._content || this._segments.filter((s2) => s2.type !== "thinking").map((s2) => s2.content ?? "").join("\n");
2085
+ const text = this._copyableText();
1992
2086
  const icons = this._cfg.getIconProvider();
1993
2087
  const locale = this._cfg.getLocale();
1994
2088
  copyText(text).then(() => {
@@ -2072,6 +2166,8 @@ class AparteChatBubble extends HTMLElement {
2072
2166
  */
2073
2167
  _enterEditMode() {
2074
2168
  if (this._editing || !this._contentEl) return;
2169
+ const opener = document.activeElement;
2170
+ this._editReturnAction = opener instanceof HTMLElement && this.contains(opener) ? opener.getAttribute("data-action") ?? "edit" : null;
2075
2171
  this._editing = true;
2076
2172
  this.querySelector(".aparte-message")?.setAttribute("data-editing", "");
2077
2173
  const input = document.createElement("aparte-composer-input");
@@ -2105,6 +2201,16 @@ class AparteChatBubble extends HTMLElement {
2105
2201
  this.querySelector(".aparte-message")?.removeAttribute("data-editing");
2106
2202
  this._editing = false;
2107
2203
  this._updateActionBar();
2204
+ const returnTo = this._editReturnAction;
2205
+ this._editReturnAction = null;
2206
+ if (returnTo && this._actionBarEl) {
2207
+ const back = Array.from(this._actionBarEl.querySelectorAll("[data-action]")).find((btn) => btn.getAttribute("data-action") === returnTo);
2208
+ const target = back && !back.disabled ? back : this._actionBarEl.querySelector("button:not([disabled])");
2209
+ if (target) {
2210
+ for (const button of this._rovingButtons()) button.tabIndex = button === target ? 0 : -1;
2211
+ target.focus();
2212
+ }
2213
+ }
2108
2214
  if (save && newContent && newContent !== original) {
2109
2215
  const messageId = this.getAttribute("message-id");
2110
2216
  if (messageId) {
@@ -2252,27 +2358,63 @@ class AparteChatStatus extends HTMLElement {
2252
2358
  <span class="aparte-dot"></span>
2253
2359
  </div>
2254
2360
  <span class="aparte-status-text"></span>
2361
+ <span class="aparte-sr-only aparte-status-sr"></span>
2255
2362
  </div>
2256
2363
  </div>
2257
2364
  </div>
2258
2365
  `;
2259
2366
  this.querySelector(".aparte-status-container")?.setAttribute("aria-label", text);
2260
- if (this.hasAttribute("text")) {
2261
- const textEl = this.querySelector(".aparte-status-text");
2262
- if (textEl) textEl.textContent = text;
2263
- }
2367
+ this._writeText(this.getAttribute("text"));
2368
+ }
2369
+ /**
2370
+ * The one writer for both text nodes, so exactly ONE of them is ever populated.
2371
+ *
2372
+ * The container is a polite live region, and a live region announces its CONTENT.
2373
+ * The dots-only default had none — an `aria-hidden` dot and an empty span — so the
2374
+ * whole state rode on `aria-label`, which names the region rather than reporting it,
2375
+ * and nothing was announced. The screen-reader span carries the fallback word in that
2376
+ * case; when `text` is set the visible span already carries the same string, and
2377
+ * populating both would have the region read it twice.
2378
+ */
2379
+ _writeText(text) {
2380
+ const line = text || null;
2381
+ const textEl = this.querySelector(".aparte-status-text");
2382
+ if (textEl) textEl.textContent = line ?? "";
2383
+ this._syncLiveWord();
2384
+ }
2385
+ /**
2386
+ * The fallback word enters and leaves the live region WITH the element's visibility.
2387
+ *
2388
+ * Writing it at render time put it in the region while the host was still
2389
+ * `display: none` — every wrapper mounts `<aparte-chat-status>` once and flips
2390
+ * `visible` — so by the time the region was exposed its content had not changed, and
2391
+ * on the second and third turn there was not even a reveal to notice: the text was
2392
+ * byte-identical to what was already sitting there. Revealing a region that already
2393
+ * holds its text is the path assistive tech is documented not to announce reliably;
2394
+ * mutating one that is already exposed is the path that works. So the word is written
2395
+ * when `visible` arrives and cleared when it leaves, which makes every turn a real
2396
+ * change.
2397
+ *
2398
+ * With `text` set the visible span is the region's content and the screen-reader span
2399
+ * stays empty — one copy, read once.
2400
+ */
2401
+ _syncLiveWord() {
2402
+ const srEl = this.querySelector(".aparte-status-sr");
2403
+ if (!srEl) return;
2404
+ const dotsOnly = !this.getAttribute("text");
2405
+ srEl.textContent = dotsOnly && this.hasAttribute("visible") ? "Typing" : "";
2264
2406
  }
2265
2407
  _updateVisibility(visible) {
2266
2408
  const container = this.querySelector(".aparte-status-container");
2267
2409
  if (container) {
2268
2410
  container.setAttribute("data-visible", String(visible));
2269
2411
  }
2412
+ this._syncLiveWord();
2270
2413
  }
2271
2414
  _updateText(text) {
2272
- const textEl = this.querySelector(".aparte-status-text");
2273
2415
  const container = this.querySelector(".aparte-status-container");
2274
2416
  if (!container) return;
2275
- if (textEl) textEl.textContent = text ?? "";
2417
+ this._writeText(text);
2276
2418
  container.setAttribute("aria-label", text || "Typing");
2277
2419
  }
2278
2420
  }
@@ -2352,6 +2494,8 @@ class AparteChatViewport extends HTMLElement {
2352
2494
  _overlayObserver = null;
2353
2495
  /** Last `--aparte-bottom-inset` written, in px — style writes only when it changes. */
2354
2496
  _overlayInset = -1;
2497
+ /** Last `--aparte-transcript-inset` written on the host, in px — same write-on-change rule. */
2498
+ _transcriptInset = -1;
2355
2499
  _boundResetHandler = null;
2356
2500
  /**
2357
2501
  * When true, _reRenderActivePath() only dispatches aparte-path-changed without
@@ -2372,6 +2516,7 @@ class AparteChatViewport extends HTMLElement {
2372
2516
  this._render();
2373
2517
  this._setupEventListeners();
2374
2518
  this._setupObservers();
2519
+ this._updateTranscriptInset();
2375
2520
  this._boundResetHandler = () => this.clearAll();
2376
2521
  window.addEventListener("aparte-reset", this._boundResetHandler);
2377
2522
  window.addEventListener("aparte-config-change", this._onConfigChange);
@@ -3275,6 +3420,7 @@ class AparteChatViewport extends HTMLElement {
3275
3420
  _setupObservers() {
3276
3421
  this._resizeObserver = new ResizeObserver(() => {
3277
3422
  this._updateOverlayInset();
3423
+ this._updateTranscriptInset();
3278
3424
  if (this._isAutoScrollEnabled) {
3279
3425
  this._scrollToBottom();
3280
3426
  }
@@ -3355,6 +3501,32 @@ class AparteChatViewport extends HTMLElement {
3355
3501
  this._overlayInset = inset;
3356
3502
  this.style.setProperty("--aparte-bottom-inset", `${inset}px`);
3357
3503
  }
3504
+ /**
3505
+ * Publish, on the chat host, how far from the host's inline edge the rows start —
3506
+ * the transcript's padding plus the scrollbar gutter the scroller reserves on both
3507
+ * edges, at whatever step its container query has taken — as
3508
+ * `--aparte-transcript-inset`. The composer pads by it (composer.css), so its box
3509
+ * lands on the row's box at every width. The two used to be independent stacks: the
3510
+ * composer cannot know the gutter and a container query cannot reach it, so they sat
3511
+ * 10px apart at 768 and the gutter's half apart at 1280, and the reading-column demo
3512
+ * showed three left edges 12px apart. Written only when the value changes, for the
3513
+ * same reason `_updateOverlayInset` is. On the HOST, not on this element: the
3514
+ * composer is a sibling, and a custom property only travels down.
3515
+ */
3516
+ _updateTranscriptInset() {
3517
+ const host = this.parentElement;
3518
+ if (!host) return;
3519
+ const ref = this.querySelector(".aparte-messages-wrapper") ?? this._container ?? this;
3520
+ const pad = parseFloat(getComputedStyle(ref).paddingInlineStart) || 0;
3521
+ const rtl = getComputedStyle(host).direction === "rtl";
3522
+ const refRect = ref.getBoundingClientRect();
3523
+ const hostRect = host.getBoundingClientRect();
3524
+ const raw = rtl ? hostRect.right - refRect.right : refRect.left - hostRect.left;
3525
+ const inset = Math.max(0, Math.round((raw + pad) * 10) / 10);
3526
+ if (inset === this._transcriptInset) return;
3527
+ this._transcriptInset = inset;
3528
+ host.style.setProperty("--aparte-transcript-inset", `${inset}px`);
3529
+ }
3358
3530
  /** Is the scroll surface within `_scrollThreshold` of its bottom, right now? */
3359
3531
  _isAtBottom() {
3360
3532
  if (!this._container) return true;
@@ -3617,6 +3789,7 @@ class AparteChatViewport extends HTMLElement {
3617
3789
  this._overlayObserver = null;
3618
3790
  this._overlayRoot = null;
3619
3791
  this._overlayInset = -1;
3792
+ this._transcriptInset = -1;
3620
3793
  this._resizeObserver = null;
3621
3794
  this._mutationObserver = null;
3622
3795
  if (this._spacerRafId !== null) {
@@ -3927,6 +4100,8 @@ class AparteComposer extends HTMLElement {
3927
4100
  onEvict?.();
3928
4101
  }
3929
4102
  _teardownPanel() {
4103
+ const active = document.activeElement;
4104
+ const hadFocus = active instanceof Node && this.contains(active);
3930
4105
  const existing = this.querySelector("[data-aparte-panel]");
3931
4106
  if (existing) existing.remove();
3932
4107
  this.removeAttribute("data-panel-active");
@@ -3938,7 +4113,7 @@ class AparteComposer extends HTMLElement {
3938
4113
  this._panelOnEvict = null;
3939
4114
  this._panelToken = null;
3940
4115
  this._emit("panel-change", { active: false, submitEnabled: false, mode: "submit" });
3941
- this.focus();
4116
+ if (hadFocus) this.focus();
3942
4117
  }
3943
4118
  /**
3944
4119
  * Update the send button's state while a panel is active.
@@ -4000,7 +4175,14 @@ class AparteComposer extends HTMLElement {
4000
4175
  const detail = {
4001
4176
  content: value,
4002
4177
  timestamp: Date.now(),
4003
- targetId: this.targetId ?? void 0,
4178
+ /*
4179
+ * `_ownTargetId()`, not the bare attribute — the same resolution `cancel()`
4180
+ * uses, and for the same reason. The wrappers all set `target`, so this is
4181
+ * byte-identical there; in RAW core nothing writes it, so every send carried
4182
+ * `targetId: undefined` and a page with two hand-written chats delivered the
4183
+ * reply to whichever one the host resolved first.
4184
+ */
4185
+ targetId: this._ownTargetId(),
4004
4186
  files: this._attachments.length > 0 ? [...this._attachments] : void 0
4005
4187
  };
4006
4188
  this.dispatchEvent(new CustomEvent("aparte-send", {
@@ -4400,6 +4582,7 @@ class AparteComposerSend extends HTMLElement {
4400
4582
  const root = this._getRoot();
4401
4583
  const disabled = !root || root.disabled || root.value.trim() === "";
4402
4584
  this.innerHTML = `<button
4585
+ type="button"
4403
4586
  class="aparte-btn aparte-btn--primary aparte-btn--solid aparte-btn--icon aparte-cs-button aparte-send-button"
4404
4587
  aria-label="${escapeAttr(label)}"
4405
4588
  title="${escapeAttr(label)}"
@@ -4551,6 +4734,7 @@ class AparteComposerCancel extends HTMLElement {
4551
4734
  const label = resolveConfig(this).t("stopButton") || "Stop";
4552
4735
  const icon = this._getStopIcon();
4553
4736
  this.innerHTML = `<button
4737
+ type="button"
4554
4738
  class="aparte-btn aparte-btn--icon aparte-cc-button"
4555
4739
  aria-label="${escapeAttr(label)}"
4556
4740
  title="${escapeAttr(label)}"
@@ -4633,9 +4817,9 @@ class AparteComposerAttachments extends HTMLElement {
4633
4817
  if (file.type.startsWith("image/")) {
4634
4818
  const url = URL.createObjectURL(file);
4635
4819
  this._objectUrls.push(url);
4636
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeAttr(url)}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4820
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeAttr(url)}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4637
4821
  }
4638
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4822
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4639
4823
  }).join("");
4640
4824
  this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
4641
4825
  btn.addEventListener("click", (e) => {
@@ -4645,20 +4829,22 @@ class AparteComposerAttachments extends HTMLElement {
4645
4829
  });
4646
4830
  });
4647
4831
  if (!resolveConfig(this).getHostHandlers().attachmentPreview) return;
4648
- this.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
4649
- tile.setAttribute("role", "button");
4650
- tile.setAttribute("tabindex", "0");
4832
+ const images = files.filter((f2) => f2.type.startsWith("image/"));
4833
+ this.querySelectorAll(".aparte-thumb--image").forEach((tile, i2) => {
4834
+ const img = tile.querySelector(".aparte-thumbnail__image");
4835
+ if (!img) return;
4836
+ img.setAttribute("role", "button");
4837
+ img.setAttribute("tabindex", "0");
4838
+ img.setAttribute("aria-label", images[i2]?.name ?? "");
4651
4839
  const open = () => {
4652
- const img = tile.querySelector(".aparte-thumb__img");
4653
- if (!img) return;
4654
4840
  this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
4655
4841
  bubbles: true,
4656
4842
  composed: true,
4657
- detail: { url: img.src, name: tile.getAttribute("title") ?? "" }
4843
+ detail: { url: img.src, name: images[i2]?.name ?? "" }
4658
4844
  }));
4659
4845
  };
4660
- tile.addEventListener("click", open);
4661
- tile.addEventListener("keydown", (e) => {
4846
+ img.addEventListener("click", open);
4847
+ img.addEventListener("keydown", (e) => {
4662
4848
  const key = e.key;
4663
4849
  if (key !== "Enter" && key !== " ") return;
4664
4850
  e.preventDefault();
@@ -5288,7 +5474,10 @@ class AparteConversationList extends HTMLElement {
5288
5474
  input.dataset["renameId"] = conv.id;
5289
5475
  select.replaceWith(input);
5290
5476
  this._renaming = { id: conv.id, input, done: false };
5291
- input.addEventListener("blur", () => this._finishRename(true, false));
5477
+ input.addEventListener("blur", (e) => {
5478
+ const next = e.relatedTarget;
5479
+ this._finishRename(true, !next || this.contains(next));
5480
+ });
5292
5481
  input.focus();
5293
5482
  input.select();
5294
5483
  }
@@ -5297,23 +5486,28 @@ class AparteConversationList extends HTMLElement {
5297
5486
  * the blur a removal causes cannot commit a second time. The row is re-rendered
5298
5487
  * from the array either way: the list shows data, and the host's re-assignment is
5299
5488
  * what makes the new title appear.
5489
+ *
5490
+ * `restoreFocus` puts the keyboard back on the row: every exit destroys the field
5491
+ * the reader was in, so without it the focus lands on `<body>` and the next Tab
5492
+ * restarts at the top of the page. The caller decides — the blur path says no when
5493
+ * the focus is already heading somewhere live outside this list.
5300
5494
  */
5301
- _finishRename(commit, viaKeyboard) {
5495
+ _finishRename(commit, restoreFocus) {
5302
5496
  const renaming = this._renaming;
5303
5497
  if (!renaming || renaming.done) return;
5304
5498
  renaming.done = true;
5305
5499
  const conv = this._conversations.find((c2) => c2.id === renaming.id);
5306
5500
  const title = renaming.input.value.trim();
5307
5501
  this._render();
5308
- if (viaKeyboard) {
5309
- this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
5310
- }
5311
5502
  if (commit && conv && title && title !== conv.title) {
5312
5503
  this.dispatchEvent(new CustomEvent(
5313
5504
  "aparte-rename-conversation",
5314
5505
  { detail: { id: conv.id, title }, bubbles: true, composed: true }
5315
5506
  ));
5316
5507
  }
5508
+ if (restoreFocus) {
5509
+ this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
5510
+ }
5317
5511
  }
5318
5512
  // ─── Events ───────────────────────────────────────────────────────────
5319
5513
  _onClick = (e) => {
@@ -5914,9 +6108,6 @@ class AparteScrollRail extends HTMLElement {
5914
6108
  if (!customElements.get("aparte-scroll-rail")) {
5915
6109
  customElements.define("aparte-scroll-rail", AparteScrollRail);
5916
6110
  }
5917
- function presenceOn(value) {
5918
- return value === "" ? true : Boolean(value);
5919
- }
5920
6111
  const DRAWER_QUERY = "(max-width: 48rem)";
5921
6112
  class AparteSidebar extends HTMLElement {
5922
6113
  static get observedAttributes() {
@@ -5987,6 +6178,8 @@ class AparteSidebar extends HTMLElement {
5987
6178
  this._watchBreakpoint();
5988
6179
  this._syncHidden();
5989
6180
  this._lastCollapsed = this.collapsed;
6181
+ if (!this.id) this.id = `aparte-sidebar-${++sidebarIdSeq}`;
6182
+ this._syncToggles();
5990
6183
  this._ready = true;
5991
6184
  }
5992
6185
  /**
@@ -6030,6 +6223,7 @@ class AparteSidebar extends HTMLElement {
6030
6223
  if (!this._auto && !this.drawer) this._closedByHost = collapsed;
6031
6224
  this._syncScrim();
6032
6225
  this._syncHidden();
6226
+ this._syncToggles();
6033
6227
  if (collapsed && this.drawer && this._opener?.isConnected) {
6034
6228
  this._opener.focus();
6035
6229
  this._opener = null;
@@ -6074,6 +6268,26 @@ class AparteSidebar extends HTMLElement {
6074
6268
  }
6075
6269
  this._syncScrim();
6076
6270
  this._syncHidden();
6271
+ this._syncToggles();
6272
+ }
6273
+ /**
6274
+ * Every `[data-aparte-sidebar-toggle]` that drives THIS sidebar says what it does:
6275
+ * `aria-expanded` follows the state, `aria-controls` names the element. Synced from
6276
+ * the three places the state settles (connect, the attribute, the breakpoint), never
6277
+ * from `open()` alone — a close on Escape would otherwise leave an "expanded" that
6278
+ * lies. The row's own `⋯` button already did this; the sidebar's toggle did not.
6279
+ */
6280
+ _syncToggles() {
6281
+ for (const control of document.querySelectorAll("[data-aparte-sidebar-toggle]")) {
6282
+ if (!this._drives(control)) continue;
6283
+ control.setAttribute("aria-expanded", String(!this.collapsed));
6284
+ control.setAttribute("aria-controls", this.id);
6285
+ }
6286
+ }
6287
+ /** Whether a toggle control targets this sidebar: named by id, else the nearest one. */
6288
+ _drives(control) {
6289
+ const named = control.getAttribute("data-aparte-sidebar-toggle");
6290
+ return named ? named === this.id : nearestSidebar(control) === this;
6077
6291
  }
6078
6292
  /**
6079
6293
  * A collapsed sidebar is invisible — folded to nothing in the flow, slid off the
@@ -6110,7 +6324,7 @@ class AparteSidebar extends HTMLElement {
6110
6324
  * nothing focusable in it keeps the focus where it was, which is the honest outcome.
6111
6325
  */
6112
6326
  _focusFirst() {
6113
- this.querySelector(FOCUSABLE)?.focus();
6327
+ visibleFocusable(this)[0]?.focus();
6114
6328
  }
6115
6329
  _syncScrim() {
6116
6330
  const wanted = this.drawer && !this.collapsed;
@@ -6126,21 +6340,35 @@ class AparteSidebar extends HTMLElement {
6126
6340
  }
6127
6341
  }
6128
6342
  _onKeydown = (e) => {
6129
- if (e.key === "Escape" && this.drawer && !this.collapsed) {
6343
+ if (!this.drawer || this.collapsed) return;
6344
+ if (e.key === "Escape") {
6130
6345
  e.preventDefault();
6131
6346
  this.close();
6347
+ return;
6348
+ }
6349
+ if (e.key === "Tab") {
6350
+ const focusable = visibleFocusable(this);
6351
+ if (focusable.length === 0) return;
6352
+ const first = focusable[0];
6353
+ const last = focusable[focusable.length - 1];
6354
+ const active = document.activeElement;
6355
+ if (!this.contains(active)) {
6356
+ e.preventDefault();
6357
+ first.focus();
6358
+ } else if (e.shiftKey && active === first) {
6359
+ e.preventDefault();
6360
+ last.focus();
6361
+ } else if (!e.shiftKey && active === last) {
6362
+ e.preventDefault();
6363
+ first.focus();
6364
+ }
6132
6365
  }
6133
6366
  };
6134
6367
  /** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
6135
6368
  _onDocumentClick = (e) => {
6136
6369
  const control = e.target?.closest?.("[data-aparte-sidebar-toggle]");
6137
6370
  if (!control) return;
6138
- const named = control.getAttribute("data-aparte-sidebar-toggle");
6139
- if (named) {
6140
- if (named !== this.id) return;
6141
- } else if (nearestSidebar(control) !== this) {
6142
- return;
6143
- }
6371
+ if (!this._drives(control)) return;
6144
6372
  this.toggle(control);
6145
6373
  };
6146
6374
  // ─── Search ───────────────────────────────────────────────────────────
@@ -6174,6 +6402,7 @@ class AparteSidebar extends HTMLElement {
6174
6402
  this.dataset["ownLabel"] = "locale";
6175
6403
  }
6176
6404
  }
6405
+ let sidebarIdSeq = 0;
6177
6406
  const FOCUSABLE = [
6178
6407
  "a[href]",
6179
6408
  "button:not([disabled])",
@@ -6182,6 +6411,9 @@ const FOCUSABLE = [
6182
6411
  "textarea:not([disabled])",
6183
6412
  '[tabindex]:not([tabindex="-1"])'
6184
6413
  ].join(",");
6414
+ function visibleFocusable(root) {
6415
+ return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el2) => !el2.closest("[hidden]") && el2.checkVisibility?.() !== false);
6416
+ }
6185
6417
  function nearestSidebar(control) {
6186
6418
  return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
6187
6419
  }