@aparte/core 0.15.0 → 0.16.0

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 (138) hide show
  1. package/dist/client/aparte-client.d.ts +53 -51
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/lifecycle-events.d.ts +0 -12
  4. package/dist/client/lifecycle-events.d.ts.map +1 -1
  5. package/dist/client/stream-adapter.d.ts +2 -12
  6. package/dist/client/stream-adapter.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +45 -1
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/composer/aparte-composer-attachments.d.ts +0 -1
  10. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  11. package/dist/components/composer/aparte-composer-send.d.ts +11 -11
  12. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  13. package/dist/components/composer/aparte-composer.d.ts +20 -11
  14. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  15. package/dist/components/context/aparte-context.d.ts +31 -8
  16. package/dist/components/context/aparte-context.d.ts.map +1 -1
  17. package/dist/components/conversation-list/aparte-conversation-list.d.ts +93 -36
  18. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  19. package/dist/components/conversation-list/index.d.ts +1 -1
  20. package/dist/components/conversation-list/index.d.ts.map +1 -1
  21. package/dist/components/elicitation/aparte-elicitation.d.ts +19 -1
  22. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  23. package/dist/components/index.d.ts +7 -1
  24. package/dist/components/index.d.ts.map +1 -1
  25. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +105 -0
  26. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -0
  27. package/dist/components/scroll-rail/index.d.ts +3 -0
  28. package/dist/components/scroll-rail/index.d.ts.map +1 -0
  29. package/dist/components/sidebar/aparte-sidebar.d.ts +186 -0
  30. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -0
  31. package/dist/components/sidebar/index.d.ts +3 -0
  32. package/dist/components/sidebar/index.d.ts.map +1 -0
  33. package/dist/components/split/aparte-split.d.ts +354 -0
  34. package/dist/components/split/aparte-split.d.ts.map +1 -0
  35. package/dist/components/split/geometry.d.ts +65 -0
  36. package/dist/components/split/geometry.d.ts.map +1 -0
  37. package/dist/components/split/index.d.ts +3 -0
  38. package/dist/components/split/index.d.ts.map +1 -0
  39. package/dist/components/suggestions/aparte-suggestions.d.ts +7 -1
  40. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  41. package/dist/components/viewport/aparte-chat-viewport.d.ts +52 -2
  42. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  43. package/dist/config/aparte-config.d.ts +95 -33
  44. package/dist/config/aparte-config.d.ts.map +1 -1
  45. package/dist/config/icon-provider.d.ts +6 -0
  46. package/dist/config/icon-provider.d.ts.map +1 -1
  47. package/dist/config/index.d.ts +2 -2
  48. package/dist/config/index.d.ts.map +1 -1
  49. package/dist/config/locale.d.ts +140 -27
  50. package/dist/config/locale.d.ts.map +1 -1
  51. package/dist/config/sanitize.d.ts +9 -0
  52. package/dist/config/sanitize.d.ts.map +1 -1
  53. package/dist/conversations/conversation-controller.d.ts +13 -1
  54. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  55. package/dist/conversations/conversation-manager.d.ts +9 -0
  56. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  57. package/dist/custom-elements.json +9838 -7333
  58. package/dist/{is-awaiting-reply-BZ_iJ3s3.js → dialog-triggers-CVCxqKg3.js} +718 -1182
  59. package/dist/dialog-triggers-CVCxqKg3.js.map +1 -0
  60. package/dist/elicitation/approval-panel.d.ts +1 -1
  61. package/dist/elicitation/approval-panel.d.ts.map +1 -1
  62. package/dist/elicitation/panel.d.ts +24 -16
  63. package/dist/elicitation/panel.d.ts.map +1 -1
  64. package/dist/elicitation/types.d.ts +31 -0
  65. package/dist/elicitation/types.d.ts.map +1 -1
  66. package/dist/generated/element-attributes.d.ts +47 -1
  67. package/dist/generated/element-attributes.d.ts.map +1 -1
  68. package/dist/{glyphs-B3BZ2TIb.js → glyphs-DQTeOj4w.js} +22 -13
  69. package/dist/{glyphs-B3BZ2TIb.js.map → glyphs-DQTeOj4w.js.map} +1 -1
  70. package/dist/host/aparte-chat-host.d.ts +10 -2
  71. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  72. package/dist/icons/extended.d.ts +4 -2
  73. package/dist/icons/extended.d.ts.map +1 -1
  74. package/dist/icons/glyphs.d.ts +9 -0
  75. package/dist/icons/glyphs.d.ts.map +1 -1
  76. package/dist/icons.js +16 -15
  77. package/dist/icons.js.map +1 -1
  78. package/dist/index.css +1217 -396
  79. package/dist/index.d.ts +26 -15
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +2097 -295
  82. package/dist/index.js.map +1 -1
  83. package/dist/index.node.d.ts +21 -5
  84. package/dist/index.node.d.ts.map +1 -1
  85. package/dist/index.node.js +39 -34
  86. package/dist/index.node.js.map +1 -1
  87. package/dist/interop/dialog-triggers.d.ts +27 -0
  88. package/dist/interop/dialog-triggers.d.ts.map +1 -0
  89. package/dist/interop/element-props.d.ts +16 -8
  90. package/dist/interop/element-props.d.ts.map +1 -1
  91. package/dist/parsers/aparte-stream-parser.d.ts +52 -22
  92. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  93. package/dist/parsers/index.d.ts +1 -1
  94. package/dist/parsers/index.d.ts.map +1 -1
  95. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  96. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  97. package/dist/renderers/segments/code.d.ts.map +1 -1
  98. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  99. package/dist/runtime/message-repository.d.ts.map +1 -1
  100. package/dist/transport/backend-handler.d.ts.map +1 -1
  101. package/dist/transport/backend-transport.d.ts +6 -1
  102. package/dist/transport/backend-transport.d.ts.map +1 -1
  103. package/dist/types/chat.d.ts +8 -14
  104. package/dist/types/chat.d.ts.map +1 -1
  105. package/dist/types/element-map.d.ts +54 -4
  106. package/dist/types/element-map.d.ts.map +1 -1
  107. package/dist/types/event-map.d.ts +16 -10
  108. package/dist/types/event-map.d.ts.map +1 -1
  109. package/dist/types/events.d.ts +59 -117
  110. package/dist/types/events.d.ts.map +1 -1
  111. package/dist/types/imperative-api.d.ts +3 -1
  112. package/dist/types/imperative-api.d.ts.map +1 -1
  113. package/dist/types/index.d.ts +5 -4
  114. package/dist/types/index.d.ts.map +1 -1
  115. package/dist/types/models.d.ts +11 -31
  116. package/dist/types/models.d.ts.map +1 -1
  117. package/dist/types/segments.d.ts +11 -47
  118. package/dist/types/segments.d.ts.map +1 -1
  119. package/dist/types/stream-blocks.d.ts +57 -0
  120. package/dist/types/stream-blocks.d.ts.map +1 -0
  121. package/dist/types/tools.d.ts +57 -0
  122. package/dist/types/tools.d.ts.map +1 -1
  123. package/dist/utils/copy-text.d.ts +24 -0
  124. package/dist/utils/copy-text.d.ts.map +1 -0
  125. package/dist/utils/css-escape.d.ts +5 -0
  126. package/dist/utils/css-escape.d.ts.map +1 -1
  127. package/dist/utils/tool-input.d.ts +20 -0
  128. package/dist/utils/tool-input.d.ts.map +1 -0
  129. package/package.json +10 -3
  130. package/dist/is-awaiting-reply-BZ_iJ3s3.js.map +0 -1
  131. package/dist/renderers/segments/artifact/binary-file.d.ts +0 -45
  132. package/dist/renderers/segments/artifact/binary-file.d.ts.map +0 -1
  133. package/dist/renderers/segments/artifact/card.d.ts +0 -3
  134. package/dist/renderers/segments/artifact/card.d.ts.map +0 -1
  135. package/dist/renderers/segments/artifact/preview-document.d.ts +0 -27
  136. package/dist/renderers/segments/artifact/preview-document.d.ts.map +0 -1
  137. package/dist/renderers/segments/artifact/shared.d.ts +0 -29
  138. package/dist/renderers/segments/artifact/shared.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
- import { Z as resolveConfig, a2 as subscribeConfigChange, $ as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, I as escapeAttr, E as cssEscape, a5 as mergeSegmentUpdate, J as escapeHtml, a6 as writeStreamedMarkdown, M as getSegmentRenderer, N as installDefaultRenderersOnce, r as AparteMessageRepository, a7 as stampSegmentActivity, a8 as stampSegmentOnInsert, a9 as stampSegmentOnUpdate, aa as renumberSegments, ab as openSegmentIds, ac as isTerminalStatus, ad as adoptMessageSegments, O as isAwaitingReply, U as populateBubbleFromMessage, a4 as uuid, _ as revokeAttachmentUrls } from "./is-awaiting-reply-BZ_iJ3s3.js";
2
- import { A, a, b, c, d, e, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, C, D, F, G, H, K, L, P, Q, R, S, T, V, W, X, Y, a0, a1, a3 } from "./is-awaiting-reply-BZ_iJ3s3.js";
3
- import { deriveArtifactKind } from "@aparte/engine";
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-CVCxqKg3.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-CVCxqKg3.js";
4
3
  class AparteOption extends HTMLElement {
5
4
  static get observedAttributes() {
6
5
  return ["value", "disabled", "selected", "data-status"];
@@ -136,8 +135,8 @@ class AparteOptgroup extends HTMLElement {
136
135
  chevron.innerHTML = resolveConfig(this).getIcon("expand");
137
136
  header.appendChild(chevron);
138
137
  header.style.cursor = "pointer";
139
- header.addEventListener("click", (e2) => {
140
- e2.stopPropagation();
138
+ header.addEventListener("click", (e) => {
139
+ e.stopPropagation();
141
140
  this._toggleCollapse();
142
141
  });
143
142
  }
@@ -278,6 +277,7 @@ class AparteSelect extends HTMLElement {
278
277
  return;
279
278
  }
280
279
  const slottedChildren = Array.from(this.children);
280
+ const cfg = resolveConfig(this);
281
281
  const trigger = document.createElement("div");
282
282
  trigger.className = "aparte-select-trigger";
283
283
  trigger.setAttribute("tabindex", "0");
@@ -296,7 +296,7 @@ class AparteSelect extends HTMLElement {
296
296
  labelSpan.append(labelText, sizer);
297
297
  const chevronSpan = document.createElement("span");
298
298
  chevronSpan.className = "aparte-select-chevron";
299
- chevronSpan.innerHTML = resolveConfig(this).getIcon("expand");
299
+ chevronSpan.innerHTML = cfg.getIcon("expand");
300
300
  trigger.append(labelSpan, chevronSpan);
301
301
  const dropdown = document.createElement("div");
302
302
  dropdown.className = "aparte-select-dropdown";
@@ -305,8 +305,8 @@ class AparteSelect extends HTMLElement {
305
305
  const searchInput = document.createElement("input");
306
306
  searchInput.type = "text";
307
307
  searchInput.className = "aparte-select-search";
308
- searchInput.placeholder = "Search...";
309
- searchInput.setAttribute("aria-label", "Search options");
308
+ searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search";
309
+ searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
310
310
  dropdown.appendChild(searchInput);
311
311
  }
312
312
  const optionsContainer = document.createElement("div");
@@ -382,68 +382,68 @@ class AparteSelect extends HTMLElement {
382
382
  }
383
383
  _setupEventListeners() {
384
384
  this._trigger?.addEventListener("click", () => this._toggle());
385
- this._trigger?.addEventListener("keydown", (e2) => {
386
- if (e2.key === "Enter" || e2.key === " ") {
385
+ this._trigger?.addEventListener("keydown", (e) => {
386
+ if (e.key === "Enter" || e.key === " ") {
387
387
  if (this._isOpen) return;
388
- e2.preventDefault();
388
+ e.preventDefault();
389
389
  this._toggle();
390
390
  }
391
- if (e2.key === "ArrowDown" && !this._isOpen) {
392
- e2.preventDefault();
391
+ if (e.key === "ArrowDown" && !this._isOpen) {
392
+ e.preventDefault();
393
393
  this._openDropdown();
394
394
  }
395
395
  });
396
396
  this.removeEventListener("click", this._boundHandleOptionClick);
397
397
  this.addEventListener("click", this._boundHandleOptionClick);
398
- this._searchInput?.addEventListener("input", (e2) => {
399
- const query = e2.target.value.toLowerCase();
398
+ this._searchInput?.addEventListener("input", (e) => {
399
+ const query = e.target.value.toLowerCase();
400
400
  this._filterOptions(query);
401
401
  });
402
402
  document.addEventListener("click", this._boundHandleDocumentClick);
403
403
  document.addEventListener("keydown", this._boundHandleKeydown);
404
404
  }
405
- _handleOptionClick(e2) {
406
- const option = e2.target.closest("aparte-option");
405
+ _handleOptionClick(e) {
406
+ const option = e.target.closest("aparte-option");
407
407
  if (option && !option.hasAttribute("disabled")) {
408
408
  this._selectOption(option);
409
409
  }
410
410
  }
411
- _handleDocumentClick(e2) {
412
- if (!this.contains(e2.target)) {
411
+ _handleDocumentClick(e) {
412
+ if (!this.contains(e.target)) {
413
413
  this._closeDropdown();
414
414
  }
415
415
  }
416
- _handleKeydown(e2) {
416
+ _handleKeydown(e) {
417
417
  if (!this._isOpen) return;
418
418
  const inSearch = document.activeElement === this._searchInput;
419
- switch (e2.key) {
419
+ switch (e.key) {
420
420
  case "Escape":
421
- e2.preventDefault();
421
+ e.preventDefault();
422
422
  this._closeDropdown();
423
423
  this._trigger?.focus();
424
424
  break;
425
425
  case "ArrowDown":
426
- e2.preventDefault();
426
+ e.preventDefault();
427
427
  this._moveActive(1);
428
428
  break;
429
429
  case "ArrowUp":
430
- e2.preventDefault();
430
+ e.preventDefault();
431
431
  this._moveActive(-1);
432
432
  break;
433
433
  case "Home":
434
434
  if (inSearch) break;
435
- e2.preventDefault();
435
+ e.preventDefault();
436
436
  this._setActive(0);
437
437
  break;
438
438
  case "End":
439
439
  if (inSearch) break;
440
- e2.preventDefault();
440
+ e.preventDefault();
441
441
  this._setActive(this._visibleOptions().length - 1);
442
442
  break;
443
443
  case "Enter": {
444
444
  const active = this._visibleOptions()[this._activeIndex];
445
445
  if (active) {
446
- e2.preventDefault();
446
+ e.preventDefault();
447
447
  this._selectOption(active);
448
448
  }
449
449
  break;
@@ -695,8 +695,8 @@ class AparteElicitation extends HTMLElement {
695
695
  * cannot identify accepts everything rather than becoming deaf. Only a
696
696
  * MISMATCH is ignored.
697
697
  */
698
- _onTurnEnd = (e2) => {
699
- const evtTargetId = e2.detail?.targetId;
698
+ _onTurnEnd = (e) => {
699
+ const evtTargetId = e.detail?.targetId;
700
700
  const own = this._pendingTargetId();
701
701
  if (evtTargetId && own && evtTargetId !== own) return;
702
702
  this._cancelPending();
@@ -783,7 +783,7 @@ class AparteElicitation extends HTMLElement {
783
783
  const approvalMode = () => panel2.isComplete() ? "submit" : "none";
784
784
  const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], () => {
785
785
  composer.setPanelSubmitEnabled(panel2.isComplete(), approvalMode());
786
- }));
786
+ }, request.details));
787
787
  panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
788
788
  this._pending = { abort: () => fail(), composer, relabel: () => panel2.relabel() };
789
789
  slot.token = composer.showPanel(panel2.el, {
@@ -829,11 +829,6 @@ class AparteElicitation extends HTMLElement {
829
829
  */
830
830
  onEvict: () => fail(),
831
831
  onSubmit: () => {
832
- if (panel.mode() === "advance") {
833
- panel.proceed();
834
- composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
835
- return;
836
- }
837
832
  if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
838
833
  }
839
834
  });
@@ -1034,11 +1029,17 @@ function resolveSegmentRenderer(type, config) {
1034
1029
  installDefaultRenderersOnce(config);
1035
1030
  return getSegmentRenderer(type, config);
1036
1031
  }
1037
- function segmentRenderResultToElement(result) {
1038
- if (result instanceof HTMLElement) return result;
1039
- const wrapper = document.createElement("div");
1040
- wrapper.innerHTML = result;
1041
- return wrapper.firstElementChild;
1032
+ function segmentRenderResultToElement(result, segment) {
1033
+ let el2;
1034
+ if (result instanceof HTMLElement) {
1035
+ el2 = result;
1036
+ } else {
1037
+ const wrapper = document.createElement("div");
1038
+ wrapper.innerHTML = result;
1039
+ el2 = wrapper.firstElementChild;
1040
+ }
1041
+ if (el2 && segment && !el2.hasAttribute("data-segment-id")) el2.setAttribute("data-segment-id", segment.id);
1042
+ return el2;
1042
1043
  }
1043
1044
  class AparteChatBubble extends HTMLElement {
1044
1045
  _contentEl = null;
@@ -1072,8 +1073,8 @@ class AparteChatBubble extends HTMLElement {
1072
1073
  // Rebuild the action bar when the global config changes (e.g. a live skin
1073
1074
  // switch calling setBubbleActions / setIconProvider) so already-rendered
1074
1075
  // bubbles pick up the new per-role actions + icons without being re-created.
1075
- _onConfigChange = (e2) => {
1076
- const detail = e2.detail;
1076
+ _onConfigChange = (e) => {
1077
+ const detail = e.detail;
1077
1078
  if (detail?.config && detail.config !== this._cfg) return;
1078
1079
  this._updateActionBar();
1079
1080
  this._updateName();
@@ -1140,10 +1141,12 @@ class AparteChatBubble extends HTMLElement {
1140
1141
  this._updateTimestamp(this.getAttribute("timestamp"));
1141
1142
  window.addEventListener("aparte-config-change", this._onConfigChange);
1142
1143
  this.addEventListener("click", this._onBranchPickerClick);
1144
+ this.addEventListener("click", this._onLinkClick);
1143
1145
  }
1144
1146
  disconnectedCallback() {
1145
1147
  window.removeEventListener("aparte-config-change", this._onConfigChange);
1146
1148
  this.removeEventListener("click", this._onBranchPickerClick);
1149
+ this.removeEventListener("click", this._onLinkClick);
1147
1150
  if (this._avatarCleanup) {
1148
1151
  try {
1149
1152
  this._avatarCleanup();
@@ -1307,7 +1310,7 @@ class AparteChatBubble extends HTMLElement {
1307
1310
  this._updateContent();
1308
1311
  }
1309
1312
  if ("segments" in updates) {
1310
- this._segments = updates.segments;
1313
+ this._segments = [...updates.segments];
1311
1314
  this._renderSegments();
1312
1315
  }
1313
1316
  if ("timestamp" in updates) {
@@ -1333,7 +1336,7 @@ class AparteChatBubble extends HTMLElement {
1333
1336
  }
1334
1337
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1335
1338
  if (renderer) {
1336
- const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1339
+ const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1337
1340
  if (el2) {
1338
1341
  this._segmentsEl.appendChild(el2);
1339
1342
  runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
@@ -1355,7 +1358,7 @@ class AparteChatBubble extends HTMLElement {
1355
1358
  if (renderer.update) {
1356
1359
  runWithConfig(this._cfg, () => renderer.update(el2, segment));
1357
1360
  } else {
1358
- const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1361
+ const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1359
1362
  if (newEl) {
1360
1363
  el2.replaceWith(newEl);
1361
1364
  runWithConfig(this._cfg, () => renderer.setup?.(newEl, segment));
@@ -1438,6 +1441,7 @@ class AparteChatBubble extends HTMLElement {
1438
1441
  this._segmentsEl = this.querySelector(".aparte-segments");
1439
1442
  this._attachmentsEl = this.querySelector(".aparte-attachments");
1440
1443
  this._actionBarEl = this.querySelector(".aparte-action-bar");
1444
+ this._actionBarEl?.addEventListener("keydown", this._onActionBarKeydown);
1441
1445
  this._branchPickerEl = this.querySelector(".aparte-branch-picker");
1442
1446
  this._footerEl = this.querySelector(".aparte-footer");
1443
1447
  this._transcriptBusy = !!this.closest("aparte-chat-viewport")?.hasAttribute("data-busy");
@@ -1572,7 +1576,7 @@ class AparteChatBubble extends HTMLElement {
1572
1576
  for (const segment of this._segments) {
1573
1577
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1574
1578
  if (renderer) {
1575
- const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1579
+ const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1576
1580
  if (el2) {
1577
1581
  this._segmentsEl.appendChild(el2);
1578
1582
  runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
@@ -1631,18 +1635,18 @@ class AparteChatBubble extends HTMLElement {
1631
1635
  const customAttachment = this._cfg.getAttachmentRenderer?.();
1632
1636
  if (customAttachment) {
1633
1637
  this._attachmentsEl.replaceChildren();
1634
- for (const a2 of this._attachments) {
1635
- const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a2)));
1638
+ for (const a4 of this._attachments) {
1639
+ const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a4)));
1636
1640
  if (el2) this._attachmentsEl.appendChild(el2);
1637
1641
  }
1638
1642
  return;
1639
1643
  }
1640
- this._attachmentsEl.innerHTML = this._attachments.map((a2) => {
1641
- const name = escapeHtml(a2.name);
1642
- if (a2.type.startsWith("image/")) {
1643
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1644
+ this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
1645
+ const name = escapeHtml(a4.name);
1646
+ 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>`;
1644
1648
  }
1645
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
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>`;
1646
1650
  }).join("");
1647
1651
  if (!this._cfg.getHostHandlers().attachmentPreview) return;
1648
1652
  this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
@@ -1658,10 +1662,10 @@ class AparteChatBubble extends HTMLElement {
1658
1662
  }));
1659
1663
  };
1660
1664
  tile.addEventListener("click", open);
1661
- tile.addEventListener("keydown", (e2) => {
1662
- const key = e2.key;
1665
+ tile.addEventListener("keydown", (e) => {
1666
+ const key = e.key;
1663
1667
  if (key !== "Enter" && key !== " ") return;
1664
- e2.preventDefault();
1668
+ e.preventDefault();
1665
1669
  open();
1666
1670
  });
1667
1671
  });
@@ -1707,6 +1711,33 @@ class AparteChatBubble extends HTMLElement {
1707
1711
  detail
1708
1712
  }));
1709
1713
  };
1714
+ /**
1715
+ * A link the MODEL wrote is about to be followed. Announced as a cancelable event
1716
+ * before the browser acts, so a host can route it — an external browser, a
1717
+ * confirmation, an embedded view — without intercepting the DOM (issue #38). With
1718
+ * no listener the browser follows it, which the sanitizer has already made open in
1719
+ * a new tab for an external URL; `preventDefault()` on the event cancels the click.
1720
+ *
1721
+ * Only anchors inside the message body: the action bar and the branch picker are
1722
+ * buttons, and an attachment tile is its own event.
1723
+ */
1724
+ _onLinkClick = (event) => {
1725
+ const target = event.target;
1726
+ const anchor = target?.closest?.("a[href]");
1727
+ if (!anchor || !this.contains(anchor)) return;
1728
+ const detail = {
1729
+ href: anchor.getAttribute("href") ?? "",
1730
+ anchor,
1731
+ messageId: this.getAttribute("message-id")
1732
+ };
1733
+ const announced = this.dispatchEvent(new CustomEvent("aparte-link-click", {
1734
+ bubbles: true,
1735
+ composed: true,
1736
+ cancelable: true,
1737
+ detail
1738
+ }));
1739
+ if (!announced) event.preventDefault();
1740
+ };
1710
1741
  /**
1711
1742
  * Whether a reply is streaming somewhere in this bubble's transcript. Set by the
1712
1743
  * viewport (`data-busy` on it, pushed here), read once on connect for a bubble
@@ -1771,14 +1802,14 @@ class AparteChatBubble extends HTMLElement {
1771
1802
  const buttons = [];
1772
1803
  if (this._role === "user") {
1773
1804
  if (config.user) {
1774
- for (const a2 of config.user) buttons.push(this._actionButtonHtml(a2, icons, locale));
1805
+ for (const a4 of config.user) buttons.push(this._actionButtonHtml(a4, icons, locale));
1775
1806
  } else {
1776
1807
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1777
1808
  if (config.edit) buttons.push(this._actionButtonHtml("edit", icons, locale));
1778
1809
  }
1779
1810
  } else if (this._role === "assistant") {
1780
1811
  if (config.assistant) {
1781
- for (const a2 of config.assistant) buttons.push(this._actionButtonHtml(a2, icons, locale));
1812
+ for (const a4 of config.assistant) buttons.push(this._actionButtonHtml(a4, icons, locale));
1782
1813
  } else {
1783
1814
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1784
1815
  if (config.retry) buttons.push(this._actionButtonHtml("retry", icons, locale));
@@ -1793,10 +1824,71 @@ class AparteChatBubble extends HTMLElement {
1793
1824
  this._appendCustomActions(icons);
1794
1825
  this._applyTranscriptBusy();
1795
1826
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1796
- btn.addEventListener("click", (e2) => this._handleActionClick(e2));
1827
+ btn.addEventListener("click", (e) => this._handleActionClick(e));
1797
1828
  });
1798
1829
  this._syncFooterVisibility();
1799
1830
  }
1831
+ /**
1832
+ * `role="toolbar"` promises a keyboard model. This is it.
1833
+ *
1834
+ * The bar has announced itself as a toolbar since it existed, and a toolbar is ONE
1835
+ * tab stop whose members are reached with the arrow keys — that is the whole of what
1836
+ * the role means to a screen-reader user. What shipped was five independent tab
1837
+ * stops per message, so the role described a behaviour that did not exist: a reader
1838
+ * told to press Right heard nothing move, and tabbing through a long transcript
1839
+ * walked every button of every message.
1840
+ *
1841
+ * The roving index is re-applied from `_syncFooterVisibility`, which is the single
1842
+ * funnel all three build paths already run through (the flag-driven bar, the edit
1843
+ * bar, and the branch picker's own visibility pass). Re-deriving it there rather
1844
+ * than in each builder is what keeps the invariant true after a `setBubbleActions`
1845
+ * rebuild — the drift this pattern usually dies of.
1846
+ *
1847
+ * DISABLED buttons are skipped, not merely un-tabbable: while the transcript is busy
1848
+ * retry and edit are disabled, and a toolbar whose arrows stop on a dead control
1849
+ * reads as broken. If every button is disabled the bar keeps one tab stop anyway, so
1850
+ * focus sitting there when the turn ends is not thrown to the top of the page.
1851
+ */
1852
+ _rovingButtons() {
1853
+ if (!this._actionBarEl) return [];
1854
+ return [...this._actionBarEl.querySelectorAll("button")];
1855
+ }
1856
+ _setRovingIndex() {
1857
+ const buttons = this._rovingButtons();
1858
+ if (!buttons.length) return;
1859
+ const usable = buttons.filter((b2) => !b2.disabled);
1860
+ const current = buttons.find((b2) => b2.tabIndex === 0 && !b2.disabled);
1861
+ const stop = current ?? usable[0] ?? buttons[0];
1862
+ for (const button of buttons) button.tabIndex = button === stop ? 0 : -1;
1863
+ }
1864
+ /**
1865
+ * Left/Right walk the bar, Home/End jump to its ends, and both wrap.
1866
+ *
1867
+ * Arrow keys follow the READING direction, per the ARIA practices: in an RTL
1868
+ * transcript Left is "next". `dir` is resolved from the nearest ancestor that sets
1869
+ * it — the viewport writes it from `locale.direction` — because a bubble has none of
1870
+ * its own and `getComputedStyle` is not something jsdom can answer.
1871
+ */
1872
+ _onActionBarKeydown = (e) => {
1873
+ const event = e;
1874
+ const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
1875
+ if (!keys.includes(event.key)) return;
1876
+ const buttons = this._rovingButtons().filter((b2) => !b2.disabled);
1877
+ if (buttons.length === 0) return;
1878
+ const active = document.activeElement;
1879
+ const from = buttons.findIndex((b2) => b2 === active);
1880
+ if (from === -1 && event.key !== "Home" && event.key !== "End") return;
1881
+ const rtl = (this.closest("[dir]")?.getAttribute("dir") ?? document.documentElement.dir) === "rtl";
1882
+ const forward = rtl ? "ArrowLeft" : "ArrowRight";
1883
+ let next;
1884
+ if (event.key === "Home") next = 0;
1885
+ else if (event.key === "End") next = buttons.length - 1;
1886
+ else if (event.key === forward) next = (from + 1) % buttons.length;
1887
+ else next = (from - 1 + buttons.length) % buttons.length;
1888
+ event.preventDefault();
1889
+ for (const button of this._rovingButtons()) button.tabIndex = button === buttons[next] ? 0 : -1;
1890
+ buttons[next]?.focus();
1891
+ };
1800
1892
  /**
1801
1893
  * An empty action bar is not a bar: with every action off it was still a
1802
1894
  * `role="toolbar"` with nothing in it (announced as such), and it still reserved
@@ -1806,6 +1898,7 @@ class AparteChatBubble extends HTMLElement {
1806
1898
  */
1807
1899
  _syncFooterVisibility() {
1808
1900
  if (this._actionBarEl) this._actionBarEl.hidden = this._actionBarEl.children.length === 0;
1901
+ this._setRovingIndex();
1809
1902
  if (!this._footerEl) return;
1810
1903
  const barEmpty = !this._actionBarEl || this._actionBarEl.hidden;
1811
1904
  const pickerHidden = !this._branchPickerEl || this._branchPickerEl.hidden;
@@ -1815,16 +1908,16 @@ class AparteChatBubble extends HTMLElement {
1815
1908
  /** Append the registered custom action buttons for this bubble's role. */
1816
1909
  _appendCustomActions(icons) {
1817
1910
  if (!this._actionBarEl) return;
1818
- for (const a2 of this._cfg.getActions("bubble")) {
1819
- const roles = a2.bubble?.roles ?? ["user", "assistant"];
1911
+ for (const a4 of this._cfg.getActions("bubble")) {
1912
+ const roles = a4.bubble?.roles ?? ["user", "assistant"];
1820
1913
  if (!roles.includes(this._role)) continue;
1821
1914
  const btn = document.createElement("button");
1822
1915
  btn.className = "aparte-btn aparte-btn--icon aparte-action-btn aparte-action-custom";
1823
- btn.dataset["action"] = `custom:${a2.id}`;
1824
- btn.setAttribute("aria-label", a2.label);
1825
- btn.setAttribute("title", a2.label);
1826
- const fromProvider = icons[a2.icon];
1827
- btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : typeof fromProvider === "function" ? fromProvider() : a2.iconFallback ?? "";
1916
+ btn.dataset["action"] = `custom:${a4.id}`;
1917
+ btn.setAttribute("aria-label", a4.label);
1918
+ btn.setAttribute("title", a4.label);
1919
+ const fromProvider = icons[a4.icon];
1920
+ btn.innerHTML = a4.icon.startsWith("<") ? a4.icon : typeof fromProvider === "function" ? fromProvider() : a4.iconFallback ?? "";
1828
1921
  this._actionBarEl.appendChild(btn);
1829
1922
  }
1830
1923
  }
@@ -1868,12 +1961,12 @@ class AparteChatBubble extends HTMLElement {
1868
1961
  const cancelLabel = locale.editCancel ?? "Cancel";
1869
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>`;
1870
1963
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1871
- btn.addEventListener("click", (e2) => this._handleActionClick(e2));
1964
+ btn.addEventListener("click", (e) => this._handleActionClick(e));
1872
1965
  });
1873
1966
  this._syncFooterVisibility();
1874
1967
  }
1875
- _handleActionClick(e2) {
1876
- const btn = e2.currentTarget;
1968
+ _handleActionClick(e) {
1969
+ const btn = e.currentTarget;
1877
1970
  const action = btn.dataset["action"];
1878
1971
  const messageId = this.getAttribute("message-id");
1879
1972
  if (action?.startsWith("custom:") && messageId) {
@@ -1890,7 +1983,7 @@ class AparteChatBubble extends HTMLElement {
1890
1983
  composed: true,
1891
1984
  detail
1892
1985
  }));
1893
- this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(e2);
1986
+ this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(e);
1894
1987
  return;
1895
1988
  }
1896
1989
  switch (action) {
@@ -1898,7 +1991,7 @@ class AparteChatBubble extends HTMLElement {
1898
1991
  const text = this._content || this._segments.filter((s2) => s2.type !== "thinking").map((s2) => s2.content ?? "").join("\n");
1899
1992
  const icons = this._cfg.getIconProvider();
1900
1993
  const locale = this._cfg.getLocale();
1901
- navigator.clipboard.writeText(text).then(() => {
1994
+ copyText(text).then(() => {
1902
1995
  btn.innerHTML = icons.check();
1903
1996
  btn.setAttribute("data-copied", "");
1904
1997
  const copiedLabel = locale.copied ?? locale.copy ?? "Copied";
@@ -1988,9 +2081,9 @@ class AparteChatBubble extends HTMLElement {
1988
2081
  this._contentEl.insertAdjacentElement("afterend", input);
1989
2082
  input.setValue(this._content);
1990
2083
  input.addEventListener("aparte-composer-submit", () => this._exitEditMode(true));
1991
- input.addEventListener("keydown", (e2) => {
1992
- if (e2.key === "Escape" && !e2.isComposing) {
1993
- e2.preventDefault();
2084
+ input.addEventListener("keydown", (e) => {
2085
+ if (e.key === "Escape" && !e.isComposing) {
2086
+ e.preventDefault();
1994
2087
  this._exitEditMode(false);
1995
2088
  }
1996
2089
  });
@@ -2086,8 +2179,8 @@ class AparteChatStatus extends HTMLElement {
2086
2179
  disconnectedCallback() {
2087
2180
  window.removeEventListener("aparte-config-change", this._onConfigChange);
2088
2181
  }
2089
- _onConfigChange = (e2) => {
2090
- const detail = e2.detail;
2182
+ _onConfigChange = (e) => {
2183
+ const detail = e.detail;
2091
2184
  if (detail?.config && detail.config !== this._cfg) return;
2092
2185
  this.innerHTML = "";
2093
2186
  this._render();
@@ -2215,6 +2308,12 @@ class AparteChatViewport extends HTMLElement {
2215
2308
  _readerInputAt = Number.NEGATIVE_INFINITY;
2216
2309
  /** A decrease inside this window after our own scroll, with no gesture, is the browser's. */
2217
2310
  _ownScrollWindowMs = 1e3;
2311
+ /** How long after a scroll of ours we keep re-anchoring while the layout churns.
2312
+ * Not `readonly`: a test shortens it to run past the window's end in a few frames. */
2313
+ _settleWindowMs = 400;
2314
+ /** Deadline of the settle in flight (0 = none). Pushed forward, never stacked. */
2315
+ _settleUntil = 0;
2316
+ _settleRafId = null;
2218
2317
  /** The keys that scroll a focused scroll container — the only keydowns that are a gesture. */
2219
2318
  _scrollKeys = /* @__PURE__ */ new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
2220
2319
  _scrollThreshold = 50;
@@ -2257,12 +2356,43 @@ class AparteChatViewport extends HTMLElement {
2257
2356
  * render — so a chat already on screen never flipped to RTL until a reload.
2258
2357
  * Only OUR config: an instance-scoped change elsewhere must not touch us.
2259
2358
  */
2260
- _onConfigChange = (e2) => {
2261
- const detail = e2.detail;
2359
+ _onConfigChange = (e) => {
2360
+ const detail = e.detail;
2262
2361
  if (detail?.config && detail.config !== resolveConfig(this)) return;
2263
2362
  this._applyDirection();
2264
2363
  this._scrollBtn?.setAttribute("aria-label", resolveConfig(this).t("scrollToBottom"));
2364
+ const surface = this._frameworkManagedDOM ? this : this.querySelector(".aparte-viewport-container");
2365
+ surface?.setAttribute("aria-label", resolveConfig(this).t("transcript"));
2265
2366
  };
2367
+ /**
2368
+ * The transcript is a TAB STOP, with a name.
2369
+ *
2370
+ * A scrollable region that no element can hold focus in cannot be scrolled from
2371
+ * the keyboard in WebKit: Chromium and Firefox hand an unfocusable overflow box a
2372
+ * caret or a "scroller" tab stop of their own, Safari does not. So a plain-text
2373
+ * transcript — no links, no code blocks, nothing focusable inside — was unreadable
2374
+ * past the first screen for a Safari keyboard user, with no error and nothing on
2375
+ * screen to say so.
2376
+ *
2377
+ * `tabindex="0"` on the surface itself, in BOTH DOM modes. The framework mode
2378
+ * appeared to work already, and only by accident: the scroll button is inside the
2379
+ * host and stays tabbable while it is visually hidden, so Tab happened to land
2380
+ * somewhere that scrolled. A tab stop that exists because a hidden button happens
2381
+ * to sit there is not an affordance, it is a coincidence.
2382
+ *
2383
+ * The name ships WITH the tab stop rather than after it: a focusable `role="log"`
2384
+ * with no accessible name is announced as an unnamed region, which is a worse
2385
+ * answer than no tab stop at all. And the ROLE ships with both: a name on a
2386
+ * generic element is the same defect mirrored — `aria-label` is prohibited on an
2387
+ * element whose role resolves to none, which is what a bare custom element is. The
2388
+ * default mode already writes `role="log"` on the container before this runs, so
2389
+ * the guard here is what gives the framework-mode host the same declaration.
2390
+ */
2391
+ _nameScrollSurface(el2) {
2392
+ el2.setAttribute("tabindex", "0");
2393
+ if (!el2.hasAttribute("role")) el2.setAttribute("role", "log");
2394
+ el2.setAttribute("aria-label", resolveConfig(this).t("transcript"));
2395
+ }
2266
2396
  /** Mirror `locale.direction` onto the scroll container. */
2267
2397
  _applyDirection() {
2268
2398
  const container = this.querySelector(".aparte-viewport-container");
@@ -2389,7 +2519,18 @@ class AparteChatViewport extends HTMLElement {
2389
2519
  * framework's DOM, reads the attribute itself when it connects.
2390
2520
  */
2391
2521
  _syncBusy() {
2392
- const busy = this._streamingMessageId() !== null;
2522
+ if (this._frameworkManagedDOM) return;
2523
+ this.setTranscriptBusy(this._streamingMessageId() !== null);
2524
+ }
2525
+ _busy = false;
2526
+ /**
2527
+ * The transcript's read-only-while-streaming flag — `data-busy` on this element and
2528
+ * fanned out to the bubbles it holds. The vanilla path derives it from the
2529
+ * repository (`_syncBusy`); a framework host, whose messages live outside the
2530
+ * repository during a turn, writes it directly from its own streaming id.
2531
+ */
2532
+ setTranscriptBusy(busy) {
2533
+ this._busy = busy;
2393
2534
  this.toggleAttribute("data-busy", busy);
2394
2535
  for (const bubble of this.querySelectorAll("aparte-chat-bubble")) {
2395
2536
  bubble.setTranscriptBusy?.(busy);
@@ -2415,10 +2556,10 @@ class AparteChatViewport extends HTMLElement {
2415
2556
  this._notifyBubble(messageId, "addSegment", stamped);
2416
2557
  this._autoScroll();
2417
2558
  }
2418
- updateSegment(a2, b2, c2) {
2559
+ updateSegment(a4, b2, c2) {
2419
2560
  const clientForm = c2 === void 0 && typeof b2 === "object";
2420
- const messageId = clientForm ? this._activeMessageId() : a2;
2421
- const segmentId = clientForm ? a2 : b2;
2561
+ const messageId = clientForm ? this._activeMessageId() : a4;
2562
+ const segmentId = clientForm ? a4 : b2;
2422
2563
  const updates = clientForm ? b2 : c2;
2423
2564
  if (!messageId) return;
2424
2565
  const message = this._repo.getMessageById(messageId);
@@ -2431,10 +2572,10 @@ class AparteChatViewport extends HTMLElement {
2431
2572
  this._notifyBubble(messageId, "updateSegment", { segmentId, updates: stamped });
2432
2573
  }
2433
2574
  }
2434
- removeSegment(a2, b2) {
2575
+ removeSegment(a4, b2) {
2435
2576
  const clientForm = b2 === void 0;
2436
- const messageId = clientForm ? this._activeMessageId() : a2;
2437
- const segmentId = clientForm ? a2 : b2;
2577
+ const messageId = clientForm ? this._activeMessageId() : a4;
2578
+ const segmentId = clientForm ? a4 : b2;
2438
2579
  if (!messageId || !segmentId) return;
2439
2580
  const message = this._repo.getMessageById(messageId);
2440
2581
  if (message?.segments) {
@@ -2529,7 +2670,9 @@ class AparteChatViewport extends HTMLElement {
2529
2670
  * which both records the message and creates its bubble element.
2530
2671
  */
2531
2672
  addMessage(message) {
2532
- this._repo.addOrUpdateMessage(this._repo.headId, adoptMessageSegments({ ...message }));
2673
+ const stored = adoptMessageSegments({ ...message });
2674
+ if (isAwaitingReply(message)) stored.isStreaming = true;
2675
+ this._repo.addOrUpdateMessage(this._repo.headId, stored);
2533
2676
  this._pruneRenderedBubbles();
2534
2677
  this._syncBusy();
2535
2678
  this._autoScroll();
@@ -2562,6 +2705,7 @@ class AparteChatViewport extends HTMLElement {
2562
2705
  const bubble = document.createElement("aparte-chat-bubble");
2563
2706
  bubble.setAttribute("message-id", message.id);
2564
2707
  bubble.setAttribute("data-role", message.role);
2708
+ if (message.compaction) bubble.setAttribute("data-kind", "compaction");
2565
2709
  if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
2566
2710
  if (message.content) bubble.setAttribute("content", message.content);
2567
2711
  if (isAwaitingReply(message)) {
@@ -2653,7 +2797,7 @@ class AparteChatViewport extends HTMLElement {
2653
2797
  * Triggers a full re-render of the active path.
2654
2798
  */
2655
2799
  navigateBranch(messageId, direction) {
2656
- if (this._streamingMessageId() !== null) return;
2800
+ if (this._busy) return;
2657
2801
  const siblings = this._repo.getBranches(messageId);
2658
2802
  const currentIdx = siblings.indexOf(messageId);
2659
2803
  if (currentIdx === -1) return;
@@ -2909,6 +3053,7 @@ class AparteChatViewport extends HTMLElement {
2909
3053
  const bubble = document.createElement("aparte-chat-bubble");
2910
3054
  bubble.setAttribute("message-id", message.id);
2911
3055
  bubble.setAttribute("data-role", message.role);
3056
+ if (message.compaction) bubble.setAttribute("data-kind", "compaction");
2912
3057
  if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
2913
3058
  if (isAwaitingReply(message)) {
2914
3059
  bubble.setAttribute("streaming", "");
@@ -2968,6 +3113,7 @@ class AparteChatViewport extends HTMLElement {
2968
3113
  container.setAttribute("aria-live", "polite");
2969
3114
  container.setAttribute("aria-atomic", "false");
2970
3115
  container.setAttribute("aria-relevant", "additions");
3116
+ this._nameScrollSurface(container);
2971
3117
  const wrapper = document.createElement("div");
2972
3118
  wrapper.className = "aparte-messages-wrapper";
2973
3119
  while (this.firstChild) {
@@ -3013,6 +3159,7 @@ class AparteChatViewport extends HTMLElement {
3013
3159
  return;
3014
3160
  }
3015
3161
  this.classList.add("aparte-viewport--framework");
3162
+ this._nameScrollSurface(this);
3016
3163
  const scrollBtn = document.createElement("button");
3017
3164
  scrollBtn.className = "aparte-btn aparte-btn--surface aparte-btn--circle aparte-btn--lg aparte-scroll-btn aparte-scroll-btn--hidden";
3018
3165
  scrollBtn.setAttribute("type", "button");
@@ -3069,12 +3216,12 @@ class AparteChatViewport extends HTMLElement {
3069
3216
  * 1 run in 6: the rebuild's height churn moved scrollTop within the second after
3070
3217
  * the click, and the click made that look like the reader's).
3071
3218
  */
3072
- _noteReaderInput = (e2) => {
3073
- if (e2.type === "keydown" && !this._scrollKeys.has(e2.key)) return;
3074
- if (e2.type === "pointerdown") {
3219
+ _noteReaderInput = (e) => {
3220
+ if (e.type === "keydown" && !this._scrollKeys.has(e.key)) return;
3221
+ if (e.type === "pointerdown") {
3075
3222
  const container = this._container;
3076
3223
  if (!container) return;
3077
- const { clientX } = e2;
3224
+ const { clientX } = e;
3078
3225
  const rect = container.getBoundingClientRect();
3079
3226
  const rtl = getComputedStyle(container).direction === "rtl";
3080
3227
  const inGutter = rtl ? clientX <= rect.right - container.clientWidth : clientX >= rect.left + container.clientWidth;
@@ -3082,8 +3229,8 @@ class AparteChatViewport extends HTMLElement {
3082
3229
  }
3083
3230
  this._readerInputAt = performance.now();
3084
3231
  };
3085
- _onBranchNavigate = (e2) => {
3086
- const evt = e2;
3232
+ _onBranchNavigate = (e) => {
3233
+ const evt = e;
3087
3234
  evt.stopPropagation();
3088
3235
  this.navigateBranch(evt.detail.messageId, evt.detail.direction);
3089
3236
  };
@@ -3149,6 +3296,8 @@ class AparteChatViewport extends HTMLElement {
3149
3296
  this._isAutoScrollEnabled = true;
3150
3297
  } else if (readerWentUp) {
3151
3298
  this._isAutoScrollEnabled = false;
3299
+ } else if (drop > 1 && settlingOurs && this._isAutoScrollEnabled) {
3300
+ this._settleAtBottom();
3152
3301
  }
3153
3302
  this._updateScrollButton();
3154
3303
  }
@@ -3156,7 +3305,7 @@ class AparteChatViewport extends HTMLElement {
3156
3305
  if (!this._container) return;
3157
3306
  this._ownScrollAt = performance.now();
3158
3307
  this._container.scrollTop = this._container.scrollHeight;
3159
- this._settleAtBottom(4);
3308
+ this._settleAtBottom();
3160
3309
  }
3161
3310
  /**
3162
3311
  * Confirm over the next few frames that we actually reached the bottom.
@@ -3179,19 +3328,38 @@ class AparteChatViewport extends HTMLElement {
3179
3328
  * A BOUNDED retry, not one corrective frame: a single frame lands on the same
3180
3329
  * stale layout and was measured leaving a wider gap than doing nothing. Bounded
3181
3330
  * so it always terminates; re-reads `_isAutoScrollEnabled` every frame so a
3182
- * reader who scrolls away mid-settle is left alone; stops as soon as the gap is
3183
- * closed, so the common case costs one frame that does nothing.
3331
+ * reader who scrolls away mid-settle is left alone.
3332
+ *
3333
+ * Bounded by TIME, and it does not stop at the first closed gap — both of those
3334
+ * were the second half of the same bug. It used to be four frames (~64ms on an
3335
+ * idle 60Hz machine) and to return permanently the frame the gap first closed.
3336
+ * A branch swap at the bottom of a long transcript on react-webkit falsified
3337
+ * both at once: the rebuild's scrollable max churned 891 -> 1091 -> 891, the gap
3338
+ * WAS closed for a frame against the tall layout, the chain returned, the height
3339
+ * fell back with WebKit holding `scrollTop` at 720 — and the transcript stood
3340
+ * 171px short with auto-follow still armed. A frame count is a proxy for time
3341
+ * that fails exactly on the slow engine, so the budget is real milliseconds now,
3342
+ * and a closed gap only ends the chain when the window is over. One chain, not
3343
+ * one per call: during a stream every token used to start its own.
3184
3344
  */
3185
- _settleAtBottom(framesLeft) {
3186
- if (framesLeft <= 0) return;
3187
- requestAnimationFrame(() => {
3188
- if (!this._container || !this._isAutoScrollEnabled) return;
3345
+ _settleAtBottom() {
3346
+ this._settleUntil = performance.now() + this._settleWindowMs;
3347
+ if (this._settleRafId !== null) return;
3348
+ const step = () => {
3349
+ this._settleRafId = null;
3350
+ if (!this.isConnected || !this._container || !this._isAutoScrollEnabled) {
3351
+ this._settleUntil = 0;
3352
+ return;
3353
+ }
3189
3354
  const max = this._container.scrollHeight - this._container.clientHeight;
3190
- if (max - this._container.scrollTop <= 1) return;
3191
- this._ownScrollAt = performance.now();
3192
- this._container.scrollTop = max;
3193
- this._settleAtBottom(framesLeft - 1);
3194
- });
3355
+ if (max - this._container.scrollTop > 1) {
3356
+ this._ownScrollAt = performance.now();
3357
+ this._container.scrollTop = max;
3358
+ }
3359
+ if (performance.now() < this._settleUntil) this._settleRafId = requestAnimationFrame(step);
3360
+ else this._settleUntil = 0;
3361
+ };
3362
+ this._settleRafId = requestAnimationFrame(step);
3195
3363
  }
3196
3364
  _smoothScrollToBottom() {
3197
3365
  if (!this._container) return;
@@ -3219,7 +3387,18 @@ class AparteChatViewport extends HTMLElement {
3219
3387
  * whatever a framework's render timing does in between.
3220
3388
  */
3221
3389
  _updateScrollButton() {
3222
- this._scrollBtn?.classList.toggle("aparte-scroll-btn--hidden", this._isAtBottom());
3390
+ const btn = this._scrollBtn;
3391
+ if (!btn) return;
3392
+ const hidden = this._isAtBottom();
3393
+ if (btn.classList.contains("aparte-scroll-btn--hidden") === hidden && btn.hasAttribute("tabindex") === hidden) return;
3394
+ btn.classList.toggle("aparte-scroll-btn--hidden", hidden);
3395
+ if (hidden) {
3396
+ btn.setAttribute("tabindex", "-1");
3397
+ btn.setAttribute("aria-hidden", "true");
3398
+ } else {
3399
+ btn.removeAttribute("tabindex");
3400
+ btn.removeAttribute("aria-hidden");
3401
+ }
3223
3402
  }
3224
3403
  /**
3225
3404
  * Recalculate the bottom spacer height so the last user message is always
@@ -3315,6 +3494,11 @@ class AparteChatViewport extends HTMLElement {
3315
3494
  cancelAnimationFrame(this._spacerRafId);
3316
3495
  this._spacerRafId = null;
3317
3496
  }
3497
+ if (this._settleRafId !== null) {
3498
+ cancelAnimationFrame(this._settleRafId);
3499
+ this._settleRafId = null;
3500
+ }
3501
+ this._settleUntil = 0;
3318
3502
  }
3319
3503
  }
3320
3504
  if (!customElements.get("aparte-chat-viewport")) {
@@ -3372,8 +3556,8 @@ class AparteComposer extends HTMLElement {
3372
3556
  return resolveConfig(this);
3373
3557
  }
3374
3558
  /** Only OUR config: a change on another chat's instance must not touch us. */
3375
- _onConfigChangeEvent = (e2) => {
3376
- const detail = e2.detail;
3559
+ _onConfigChangeEvent = (e) => {
3560
+ const detail = e.detail;
3377
3561
  if (detail?.config && detail.config !== this._cfg) return;
3378
3562
  this._onConfigChange();
3379
3563
  };
@@ -3526,9 +3710,8 @@ class AparteComposer extends HTMLElement {
3526
3710
  * are the user's state and not an action to offer — hiding them would look like
3527
3711
  * losing them; and the toolbar, because switching model still does something.
3528
3712
  *
3529
- * The send button is the part the PANEL decides, through `mode`. `'submit'` and
3530
- * `'advance'` keep it; `'none'` says this panel has no act for it and it is not
3531
- * drawn. That third value is what a panel whose options settle on the first click
3713
+ * The send button is the part the PANEL decides, through `mode`. `'submit'`
3714
+ * keeps it; `'none'` says this panel has no act for it and it is not drawn. That third value is what a panel whose options settle on the first click
3532
3715
  * needs — a single-choice question, an approval — and until it existed such a
3533
3716
  * panel left a permanently disabled button beside options that never routed
3534
3717
  * through it. Flip between them at any time with {@link setPanelSubmitEnabled},
@@ -3631,10 +3814,10 @@ class AparteComposer extends HTMLElement {
3631
3814
  /**
3632
3815
  * Update the send button's state while a panel is active.
3633
3816
  *
3634
- * `mode` moves with it because both change on the same event — answering the
3635
- * question you are on can enable the button AND turn it from "advance" into
3636
- * "submit" (when it was the last one), and two separate calls would flash a
3637
- * wrong icon between them.
3817
+ * `mode` moves with it because both can change on the same event — opening the
3818
+ * "Other…" field of a panel whose options settle on the click gives the button
3819
+ * an act it did not have and two separate calls would flash a wrong state
3820
+ * between them.
3638
3821
  */
3639
3822
  setPanelSubmitEnabled(enabled, mode) {
3640
3823
  if (!this._panelActive) return;
@@ -3742,8 +3925,8 @@ class AparteComposer extends HTMLElement {
3742
3925
  /** A window lifecycle event is for THIS composer when neither side is scoped
3743
3926
  * (single-instance broadcast) or the target ids match (multi-chat page).
3744
3927
  * Without this filter, streaming in one chat flips every composer's state. */
3745
- _isForThisComposer(e2) {
3746
- const evtTargetId = e2.detail?.targetId;
3928
+ _isForThisComposer(e) {
3929
+ const evtTargetId = e.detail?.targetId;
3747
3930
  return !evtTargetId || !this._ownTargetId() || evtTargetId === this._ownTargetId();
3748
3931
  }
3749
3932
  /**
@@ -3786,13 +3969,13 @@ class AparteComposer extends HTMLElement {
3786
3969
  }
3787
3970
  return void 0;
3788
3971
  }
3789
- _handleMessageStart(e2) {
3790
- if (!this._isForThisComposer(e2)) return;
3972
+ _handleMessageStart(e) {
3973
+ if (!this._isForThisComposer(e)) return;
3791
3974
  this._streaming = true;
3792
3975
  this._emit("streaming-change", { streaming: true });
3793
3976
  }
3794
- _handleMessageDone(e2) {
3795
- if (!this._isForThisComposer(e2)) return;
3977
+ _handleMessageDone(e) {
3978
+ if (!this._isForThisComposer(e)) return;
3796
3979
  this._streaming = false;
3797
3980
  this._emit("streaming-change", { streaming: false });
3798
3981
  if (this._panelActive) this._evictPanel();
@@ -3950,13 +4133,13 @@ class AparteComposerInput extends HTMLElement {
3950
4133
  const value = this.getValue();
3951
4134
  this._getRoot()?.setValue(value);
3952
4135
  }
3953
- _handleKeydown(e2) {
3954
- if (e2.isComposing || e2.keyCode === 229) return;
3955
- if (e2.key !== "Enter") return;
4136
+ _handleKeydown(e) {
4137
+ if (e.isComposing || e.keyCode === 229) return;
4138
+ if (e.key !== "Enter") return;
3956
4139
  const submitOnEnter = this._getRoot()?.submitOnEnter ?? true;
3957
- const submits = submitOnEnter ? !e2.shiftKey : e2.shiftKey;
4140
+ const submits = submitOnEnter ? !e.shiftKey : e.shiftKey;
3958
4141
  if (submits) {
3959
- e2.preventDefault();
4142
+ e.preventDefault();
3960
4143
  const root = this._getRoot();
3961
4144
  if (root) {
3962
4145
  if (root.streaming) return;
@@ -3965,7 +4148,7 @@ class AparteComposerInput extends HTMLElement {
3965
4148
  this.dispatchEvent(new CustomEvent("aparte-composer-submit", { bubbles: true }));
3966
4149
  }
3967
4150
  } else {
3968
- e2.preventDefault();
4151
+ e.preventDefault();
3969
4152
  if (!this._editor?.textContent) return;
3970
4153
  this.ownerDocument?.execCommand("insertLineBreak");
3971
4154
  }
@@ -3976,9 +4159,9 @@ class AparteComposerInput extends HTMLElement {
3976
4159
  _handleBlur() {
3977
4160
  this.classList.remove("aparte-is-focused");
3978
4161
  }
3979
- _handlePaste(e2) {
3980
- e2.preventDefault();
3981
- const cd = e2.clipboardData;
4162
+ _handlePaste(e) {
4163
+ e.preventDefault();
4164
+ const cd = e.clipboardData;
3982
4165
  if (!cd) return;
3983
4166
  const imageFile = Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile();
3984
4167
  if (imageFile) {
@@ -4128,14 +4311,14 @@ class AparteComposerSend extends HTMLElement {
4128
4311
  this._syncState();
4129
4312
  }
4130
4313
  /**
4131
- * Panel open: this one button now means "answer", and WHICH answer depends on
4132
- * where you are in the form.
4314
+ * Panel open: this one button now means "submit the answer".
4133
4315
  *
4134
4316
  * The icon has to move with the meaning: it drew a paper plane while the label
4135
4317
  * already said "Submit", so it read as "send a message" while it meant "answer
4136
- * this question". And a check on a form with three questions left was just as
4137
- * wrong hence a chevron while there is more ahead. The visual is what a user
4138
- * reads.
4318
+ * this question". It used to draw a chevron too, while a form had questions
4319
+ * ahead, and turn into a "Next" a second way to do what the panel's chips do,
4320
+ * and a button whose meaning changed under the pointer. It is disabled instead
4321
+ * until every question has an answer, which is what the chips' marks explain.
4139
4322
  */
4140
4323
  _syncPanelState() {
4141
4324
  const panel = this._panel;
@@ -4145,16 +4328,15 @@ class AparteComposerSend extends HTMLElement {
4145
4328
  this._button.disabled = true;
4146
4329
  return;
4147
4330
  }
4148
- const advancing = panel.mode === "advance";
4149
4331
  this._button.disabled = !panel.submitEnabled;
4150
- this._button.innerHTML = advancing ? cfg.getIcon("nextBranch") : this._getSubmitIcon();
4151
- const label = advancing ? cfg.t("elicitationNext") || "Next" : cfg.t("submitButton") || "Submit";
4332
+ this._button.innerHTML = this._getSubmitIcon();
4333
+ const label = cfg.t("submitButton") || "Submit";
4152
4334
  this._button.setAttribute("aria-label", label);
4153
4335
  this._button.setAttribute("title", label);
4154
4336
  this._button.classList.remove("aparte-is-streaming");
4155
4337
  }
4156
- _handleClick(e2) {
4157
- e2.preventDefault();
4338
+ _handleClick(e) {
4339
+ e.preventDefault();
4158
4340
  this._getRoot()?.submit();
4159
4341
  }
4160
4342
  _syncState() {
@@ -4241,8 +4423,8 @@ class AparteComposerCancel extends HTMLElement {
4241
4423
  );
4242
4424
  if (root.streaming && this._button) this._button.hidden = false;
4243
4425
  }
4244
- _handleClick(e2) {
4245
- e2.preventDefault();
4426
+ _handleClick(e) {
4427
+ e.preventDefault();
4246
4428
  this._getRoot()?.cancel();
4247
4429
  }
4248
4430
  /**
@@ -4299,19 +4481,21 @@ class AparteComposerAttachments extends HTMLElement {
4299
4481
  _render(files) {
4300
4482
  this.hidden = files.length === 0;
4301
4483
  this._revokeUrls();
4484
+ const cfg = resolveConfig(this);
4485
+ const removeLabel = (file) => (cfg.t("removeAttachment") || "Remove {name}").replace("{name}", file.name);
4302
4486
  this.innerHTML = files.map((file) => {
4303
- const name = this._escape(file.name);
4304
- const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="Remove ${name}">${resolveConfig(this).getIcon("close")}</button>`;
4487
+ const name = escapeHtml(file.name);
4488
+ const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="${escapeAttr(removeLabel(file))}">${cfg.getIcon("close")}</button>`;
4305
4489
  if (file.type.startsWith("image/")) {
4306
4490
  const url = URL.createObjectURL(file);
4307
4491
  this._objectUrls.push(url);
4308
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${escapeAttr(name)}"><img class="aparte-thumb__img" src="${escapeAttr(url)}" alt="${escapeAttr(name)}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4492
+ 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>`;
4309
4493
  }
4310
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${escapeAttr(name)}"><span class="aparte-thumb__ext">${this._escape(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4494
+ 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>`;
4311
4495
  }).join("");
4312
4496
  this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
4313
- btn.addEventListener("click", (e2) => {
4314
- e2.stopPropagation();
4497
+ btn.addEventListener("click", (e) => {
4498
+ e.stopPropagation();
4315
4499
  const root = this._getRoot();
4316
4500
  if (root) root.removeAttachment(root.attachments[i2]);
4317
4501
  });
@@ -4330,10 +4514,10 @@ class AparteComposerAttachments extends HTMLElement {
4330
4514
  }));
4331
4515
  };
4332
4516
  tile.addEventListener("click", open);
4333
- tile.addEventListener("keydown", (e2) => {
4334
- const key = e2.key;
4517
+ tile.addEventListener("keydown", (e) => {
4518
+ const key = e.key;
4335
4519
  if (key !== "Enter" && key !== " ") return;
4336
- e2.preventDefault();
4520
+ e.preventDefault();
4337
4521
  open();
4338
4522
  });
4339
4523
  });
@@ -4343,9 +4527,6 @@ class AparteComposerAttachments extends HTMLElement {
4343
4527
  const dot = filename.lastIndexOf(".");
4344
4528
  return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
4345
4529
  }
4346
- _escape(str) {
4347
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
4348
- }
4349
4530
  }
4350
4531
  if (!customElements.get("aparte-composer-attachments")) {
4351
4532
  customElements.define("aparte-composer-attachments", AparteComposerAttachments);
@@ -4443,24 +4624,24 @@ class AparteComposerAddAttachment extends HTMLElement {
4443
4624
  _setupDragDrop() {
4444
4625
  const root = this._getRoot();
4445
4626
  if (!root) return;
4446
- const prevent = (e2) => {
4447
- e2.preventDefault();
4448
- e2.stopPropagation();
4627
+ const prevent = (e) => {
4628
+ e.preventDefault();
4629
+ e.stopPropagation();
4449
4630
  };
4450
- const onDragOver = (e2) => {
4631
+ const onDragOver = (e) => {
4451
4632
  if (root.disabled) return;
4452
- prevent(e2);
4633
+ prevent(e);
4453
4634
  root.classList.add("aparte-is-dragover");
4454
4635
  };
4455
- const onDragLeave = (e2) => {
4456
- prevent(e2);
4636
+ const onDragLeave = (e) => {
4637
+ prevent(e);
4457
4638
  root.classList.remove("aparte-is-dragover");
4458
4639
  };
4459
- const onDrop = (e2) => {
4460
- prevent(e2);
4640
+ const onDrop = (e) => {
4641
+ prevent(e);
4461
4642
  root.classList.remove("aparte-is-dragover");
4462
4643
  if (root.disabled) return;
4463
- const files = e2.dataTransfer?.files;
4644
+ const files = e.dataTransfer?.files;
4464
4645
  if (files?.length) this._getRoot()?.addAttachments(files);
4465
4646
  };
4466
4647
  root.addEventListener("dragover", onDragOver);
@@ -4591,11 +4772,16 @@ class AparteComposerToolbar extends HTMLElement {
4591
4772
  if (!customElements.get("aparte-composer-toolbar")) {
4592
4773
  customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
4593
4774
  }
4775
+ const DAY = 864e5;
4776
+ const MENU_GAP = 4;
4777
+ const VIEWPORT_MARGIN = 8;
4594
4778
  class AparteConversationList extends HTMLElement {
4595
4779
  _conversations = [];
4596
4780
  _activeId = null;
4781
+ _open = null;
4782
+ _renaming = null;
4597
4783
  static get observedAttributes() {
4598
- return ["active-id"];
4784
+ return ["active-id", "no-groups"];
4599
4785
  }
4600
4786
  // ─── Lifecycle ────────────────────────────────────────────────────────
4601
4787
  connectedCallback() {
@@ -4605,10 +4791,17 @@ class AparteConversationList extends HTMLElement {
4605
4791
  if (!this.getAttribute("role")) {
4606
4792
  this.setAttribute("role", "navigation");
4607
4793
  }
4794
+ this.addEventListener("click", this._onClick);
4795
+ this.addEventListener("keydown", this._onKeydown);
4796
+ this.addEventListener("focusout", this._onFocusOut);
4608
4797
  this._render();
4609
4798
  window.addEventListener("aparte-config-change", this._onConfigChange);
4610
4799
  }
4611
4800
  disconnectedCallback() {
4801
+ this._closeMenu();
4802
+ this.removeEventListener("click", this._onClick);
4803
+ this.removeEventListener("keydown", this._onKeydown);
4804
+ this.removeEventListener("focusout", this._onFocusOut);
4612
4805
  window.removeEventListener("aparte-config-change", this._onConfigChange);
4613
4806
  }
4614
4807
  attributeChangedCallback(name, oldValue, newValue) {
@@ -4616,15 +4809,18 @@ class AparteConversationList extends HTMLElement {
4616
4809
  if (name === "active-id") {
4617
4810
  this._activeId = newValue;
4618
4811
  this._updateActiveState();
4812
+ } else if (name === "no-groups" && this.isConnected) {
4813
+ this._render();
4619
4814
  }
4620
4815
  }
4621
4816
  /**
4622
- * Re-render on a locale switch: every row's title fallback and both button
4623
- * labels come from the locale, so without this the list stayed in the previous
4624
- * language until something else happened to re-render it. Only OUR config.
4817
+ * Re-render on a locale switch: every row's title fallback, the `⋯` button's name
4818
+ * and the group headings come from the locale, so without this the list stayed in
4819
+ * the previous language until something else happened to re-render it. Only OUR
4820
+ * config.
4625
4821
  */
4626
- _onConfigChange = (e2) => {
4627
- const detail = e2.detail;
4822
+ _onConfigChange = (e) => {
4823
+ const detail = e.detail;
4628
4824
  if (detail?.config && detail.config !== resolveConfig(this)) return;
4629
4825
  this._render();
4630
4826
  };
@@ -4639,94 +4835,387 @@ class AparteConversationList extends HTMLElement {
4639
4835
  }
4640
4836
  // ─── Rendering ────────────────────────────────────────────────────────
4641
4837
  _render() {
4642
- this.innerHTML = this._conversations.map((conv) => this._renderItem(conv)).join("");
4643
- this._bindEvents();
4838
+ this._closeMenu();
4839
+ this._renaming = null;
4840
+ this.innerHTML = this._groups().map((g2) => this._renderGroup(g2)).join("");
4644
4841
  }
4645
- _renderItem(conv) {
4842
+ _t(key) {
4646
4843
  const locale = resolveConfig(this).getLocale();
4844
+ return locale[key] ?? APARTE_DEFAULT_LOCALE[key] ?? "";
4845
+ }
4846
+ /**
4847
+ * Pinned first, then the calendar buckets every chat product uses, then a heading
4848
+ * per month (newest first), then whatever carries no date. Flat when nothing is
4849
+ * dated or the host asked for `no-groups`: a heading over the only group would say
4850
+ * nothing.
4851
+ */
4852
+ _groups() {
4853
+ const items = this._conversations;
4854
+ if (this.hasAttribute("no-groups") || !items.some((c2) => typeof c2.updatedAt === "number")) {
4855
+ return [{ key: "all", label: null, items }];
4856
+ }
4857
+ const now = /* @__PURE__ */ new Date();
4858
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
4859
+ const tag = resolveConfig(this).getLocale().tag;
4860
+ const buckets = /* @__PURE__ */ new Map();
4861
+ const push = (key, label, item) => {
4862
+ let g2 = buckets.get(key);
4863
+ if (!g2) {
4864
+ g2 = { key, label, items: [] };
4865
+ buckets.set(key, g2);
4866
+ }
4867
+ g2.items.push(item);
4868
+ };
4869
+ for (const c2 of items) {
4870
+ if (c2.pinnedAt) {
4871
+ push("pinned", this._t("conversationGroupPinned"), c2);
4872
+ continue;
4873
+ }
4874
+ const at = c2.updatedAt;
4875
+ if (typeof at !== "number") {
4876
+ push("undated", null, c2);
4877
+ continue;
4878
+ }
4879
+ if (at >= today) push("today", this._t("conversationGroupToday"), c2);
4880
+ else if (at >= today - DAY) push("yesterday", this._t("conversationGroupYesterday"), c2);
4881
+ else if (at >= today - 7 * DAY) push("week", this._t("conversationGroupWeek"), c2);
4882
+ else if (at >= today - 30 * DAY) push("month", this._t("conversationGroupMonth"), c2);
4883
+ else {
4884
+ const d2 = new Date(at);
4885
+ const sameYear = d2.getFullYear() === now.getFullYear();
4886
+ const raw = new Intl.DateTimeFormat(tag, sameYear ? { month: "long" } : { month: "long", year: "numeric" }).format(d2);
4887
+ const label = raw.charAt(0).toLocaleUpperCase(tag) + raw.slice(1);
4888
+ push(`m-${d2.getFullYear()}-${String(d2.getMonth()).padStart(2, "0")}`, label, c2);
4889
+ }
4890
+ }
4891
+ const fixed = ["pinned", "today", "yesterday", "week", "month"].map((k2) => buckets.get(k2)).filter((g2) => !!g2);
4892
+ const months = [...buckets.keys()].filter((k2) => k2.startsWith("m-")).sort((a4, b2) => b2.localeCompare(a4)).map((k2) => buckets.get(k2));
4893
+ const undated = buckets.get("undated");
4894
+ return [...fixed, ...months, ...undated ? [undated] : []];
4895
+ }
4896
+ _renderGroup(group) {
4897
+ const rows = group.items.map((conv) => this._renderItem(conv)).join("");
4898
+ if (group.label === null) {
4899
+ return `<div class="aparte-conv-group">${rows}</div>`;
4900
+ }
4901
+ const label = escapeHtml(group.label);
4902
+ return `
4903
+ <div class="aparte-conv-group" role="group" aria-label="${escapeAttr(group.label)}">
4904
+ <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">${label}</div>
4905
+ ${rows}
4906
+ </div>`;
4907
+ }
4908
+ _renderItem(conv) {
4647
4909
  const isActive = conv.id === this._activeId;
4648
- const isArchived = !!conv.archivedAt;
4649
- const activeClass = isActive ? " aparte-conv-item--active" : "";
4650
- const archivedClass = isArchived ? " aparte-conv-item--archived" : "";
4651
- const escapedId = this._esc(conv.id);
4652
- const escapedTitle = this._esc(conv.title || locale.newChat);
4653
- const deleteLabel = this._esc(locale.deleteConversation);
4654
- const archiveLabel = this._esc(locale["archiveConversation"] ?? "Archive conversation");
4655
- const unarchiveLabel = this._esc(locale["unarchiveConversation"] ?? "Unarchive conversation");
4656
- const archiveAction = isArchived ? "unarchive" : "archive";
4657
- const archiveAriaLabel = isArchived ? unarchiveLabel : archiveLabel;
4658
- const archiveGlyph = resolveConfig(this).getIcon(isArchived ? "unarchive" : "archive");
4910
+ const classes = [
4911
+ "aparte-menu__item aparte-conv-item",
4912
+ isActive ? "aparte-conv-item--active" : "",
4913
+ conv.archivedAt ? "aparte-conv-item--archived" : "",
4914
+ conv.pinnedAt ? "aparte-conv-item--pinned" : ""
4915
+ ].filter(Boolean).join(" ");
4916
+ const escapedId = escapeAttr(conv.id);
4917
+ const title = escapeHtml(conv.title || this._t("newChat"));
4918
+ const actionsLabel = escapeAttr(this._t("conversationActions"));
4919
+ const moreGlyph = resolveConfig(this).getIcon("more");
4659
4920
  return `
4660
- <div
4661
- class="aparte-menu__item aparte-conv-item${activeClass}${archivedClass}"
4662
- role="button"
4663
- tabindex="0"
4664
- data-conv-id="${escapedId}"
4665
- aria-current="${isActive ? "page" : "false"}"
4666
- >
4667
- <span class="aparte-conv-item__title">${escapedTitle}</span>
4921
+ <div class="${escapeAttr(classes)}" data-conv-id="${escapedId}">
4668
4922
  <button
4669
- class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-item__archive"
4923
+ class="aparte-conv-item__select"
4670
4924
  type="button"
4671
- data-archive-id="${escapedId}"
4672
- data-archive-action="${escapeAttr(archiveAction)}"
4673
- aria-label="${escapeAttr(archiveAriaLabel)}"
4674
- tabindex="0"
4675
- >${archiveGlyph}</button>
4925
+ data-select-id="${escapedId}"
4926
+ aria-current="${isActive ? "page" : "false"}"
4927
+ ><span class="aparte-conv-item__title">${title}</span></button>
4676
4928
  <button
4677
- class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-item__delete"
4929
+ class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-item__more"
4678
4930
  type="button"
4679
- data-delete-id="${escapedId}"
4680
- aria-label="${deleteLabel}"
4681
- tabindex="0"
4682
- >
4683
- ${resolveConfig(this).getIcon("close")}
4684
- </button>
4931
+ data-more-id="${escapedId}"
4932
+ aria-label="${actionsLabel}"
4933
+ title="${actionsLabel}"
4934
+ aria-haspopup="menu"
4935
+ aria-expanded="false"
4936
+ >${moreGlyph}</button>
4685
4937
  </div>`;
4686
4938
  }
4687
- _bindEvents() {
4688
- this.addEventListener("click", this._onClick);
4689
- this.addEventListener("keydown", this._onKeydown);
4939
+ // ─── The row's menu ───────────────────────────────────────────────────
4940
+ _menuMarkup(conv) {
4941
+ const config = resolveConfig(this);
4942
+ const editGlyph = config.getIcon("edit");
4943
+ const pinGlyph = config.getIcon("pin");
4944
+ const archiveGlyph = config.getIcon(conv.archivedAt ? "unarchive" : "archive");
4945
+ const trashGlyph = config.getIcon("trash");
4946
+ const rename = escapeHtml(this._t("renameConversation"));
4947
+ const pin = escapeHtml(this._t(conv.pinnedAt ? "unpinConversation" : "pinConversation"));
4948
+ const archive = escapeHtml(this._t(conv.archivedAt ? "unarchiveConversation" : "archiveConversation"));
4949
+ const remove = escapeHtml(this._t("deleteConversation"));
4950
+ return `
4951
+ <button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="rename">${editGlyph}<span>${rename}</span></button>
4952
+ <button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="pin">${pinGlyph}<span>${pin}</span></button>
4953
+ <button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="archive">${archiveGlyph}<span>${archive}</span></button>
4954
+ <div class="aparte-menu__separator" role="separator"></div>
4955
+ <button type="button" class="aparte-menu__item aparte-conv-menu__item--danger" role="menuitem" data-menu-action="delete">${trashGlyph}<span>${remove}</span></button>`;
4956
+ }
4957
+ _confirmMarkup(conv) {
4958
+ const question = escapeHtml(this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat")));
4959
+ const cancel = escapeHtml(this._t("cancel"));
4960
+ const remove = escapeHtml(this._t("deleteConversation"));
4961
+ return `
4962
+ <div class="aparte-conv-menu__confirm" role="group" aria-label="${escapeAttr(question)}">
4963
+ <p class="aparte-conv-menu__question">${question}</p>
4964
+ <div class="aparte-conv-menu__actions">
4965
+ <button type="button" class="aparte-btn aparte-btn--sm aparte-btn--ghost" data-menu-action="cancel">${cancel}</button>
4966
+ <button type="button" class="aparte-btn aparte-btn--sm aparte-btn--danger aparte-btn--solid" data-menu-action="confirm-delete">${remove}</button>
4967
+ </div>
4968
+ </div>`;
4690
4969
  }
4691
- _onClick = (e2) => {
4692
- const target = e2.target;
4693
- const archiveBtn = target.closest("[data-archive-id]");
4694
- if (archiveBtn) {
4695
- e2.stopPropagation();
4696
- const id = archiveBtn.dataset["archiveId"];
4697
- const action = archiveBtn.dataset["archiveAction"];
4698
- const eventName = action === "unarchive" ? "aparte-unarchive-conversation" : "aparte-archive-conversation";
4699
- this.dispatchEvent(new CustomEvent(
4700
- eventName,
4701
- { detail: { id }, bubbles: true, composed: true }
4702
- ));
4970
+ _openMenu(row, button) {
4971
+ this._closeMenu();
4972
+ const id = row.dataset["convId"];
4973
+ const conv = this._conversations.find((c2) => c2.id === id);
4974
+ if (!conv) return;
4975
+ const menu = document.createElement("div");
4976
+ menu.className = "aparte-menu aparte-conv-menu";
4977
+ menu.setAttribute("role", "menu");
4978
+ menu.setAttribute("aria-label", conv.title || this._t("newChat"));
4979
+ menu.innerHTML = this._menuMarkup(conv);
4980
+ row.appendChild(menu);
4981
+ button.setAttribute("aria-expanded", "true");
4982
+ this._open = { id, row, button, menu };
4983
+ this._placeMenu(button, menu);
4984
+ menu.querySelector('[role="menuitem"]')?.focus();
4985
+ document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
4986
+ window.addEventListener("scroll", this._onWindowScroll, true);
4987
+ window.addEventListener("resize", this._onWindowScroll);
4988
+ }
4989
+ /**
4990
+ * Fixed, below the button's end edge, flipped above it when the viewport ends
4991
+ * first. Fixed rather than absolute because a sidebar scrolls: an absolute menu on
4992
+ * the last rows would be clipped by the list's own overflow. No anchoring library —
4993
+ * the menu closes on any scroll or resize, which is what makes the arithmetic
4994
+ * safe to do once.
4995
+ */
4996
+ _placeMenu(button, menu) {
4997
+ const r = button.getBoundingClientRect();
4998
+ const m2 = menu.getBoundingClientRect();
4999
+ let top = r.bottom + MENU_GAP;
5000
+ if (top + m2.height > window.innerHeight - VIEWPORT_MARGIN && r.top - m2.height - MENU_GAP >= VIEWPORT_MARGIN) {
5001
+ top = r.top - m2.height - MENU_GAP;
5002
+ }
5003
+ const rtl = getComputedStyle(this).direction === "rtl";
5004
+ let left = rtl ? r.left : r.right - m2.width;
5005
+ left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - m2.width - VIEWPORT_MARGIN));
5006
+ menu.style.top = `${top}px`;
5007
+ menu.style.left = `${left}px`;
5008
+ }
5009
+ _closeMenu(returnFocus = false) {
5010
+ const open = this._open;
5011
+ if (!open) return;
5012
+ this._open = null;
5013
+ open.menu.remove();
5014
+ open.button.setAttribute("aria-expanded", "false");
5015
+ document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
5016
+ window.removeEventListener("scroll", this._onWindowScroll, true);
5017
+ window.removeEventListener("resize", this._onWindowScroll);
5018
+ if (returnFocus && open.button.isConnected) open.button.focus();
5019
+ }
5020
+ _onDocumentPointerDown = (e) => {
5021
+ const open = this._open;
5022
+ if (!open) return;
5023
+ const target = e.target;
5024
+ if (open.menu.contains(target) || open.button.contains(target)) return;
5025
+ this._closeMenu();
5026
+ };
5027
+ _onWindowScroll = () => {
5028
+ this._closeMenu();
5029
+ };
5030
+ _onFocusOut = (e) => {
5031
+ const open = this._open;
5032
+ if (!open) return;
5033
+ const next = e.relatedTarget;
5034
+ if (next && (open.menu.contains(next) || open.button.contains(next))) return;
5035
+ if (open.menu.contains(e.target)) this._closeMenu();
5036
+ };
5037
+ _onMenuAction(action) {
5038
+ const open = this._open;
5039
+ if (!open) return;
5040
+ const conv = this._conversations.find((c2) => c2.id === open.id);
5041
+ if (!conv) {
5042
+ this._closeMenu();
4703
5043
  return;
4704
5044
  }
4705
- const deleteBtn = target.closest("[data-delete-id]");
4706
- if (deleteBtn) {
4707
- e2.stopPropagation();
4708
- const id = deleteBtn.dataset["deleteId"];
5045
+ const id = conv.id;
5046
+ switch (action) {
5047
+ case "rename":
5048
+ this._closeMenu();
5049
+ this._startRename(open.row, conv);
5050
+ return;
5051
+ // The two toggles dispatch each name as a LITERAL, in its own branch: the
5052
+ // manifest analyzer records a dispatch whose name is a ternary as an event
5053
+ // with no name (the Angular generator then crashes on it), and one whose
5054
+ // name is a local as an event called after the local.
5055
+ case "pin":
5056
+ this._closeMenu(true);
5057
+ if (conv.pinnedAt) {
5058
+ this.dispatchEvent(new CustomEvent(
5059
+ "aparte-unpin-conversation",
5060
+ { detail: { id }, bubbles: true, composed: true }
5061
+ ));
5062
+ } else {
5063
+ this.dispatchEvent(new CustomEvent(
5064
+ "aparte-pin-conversation",
5065
+ { detail: { id }, bubbles: true, composed: true }
5066
+ ));
5067
+ }
5068
+ return;
5069
+ case "archive":
5070
+ this._closeMenu(true);
5071
+ if (conv.archivedAt) {
5072
+ this.dispatchEvent(new CustomEvent(
5073
+ "aparte-unarchive-conversation",
5074
+ { detail: { id }, bubbles: true, composed: true }
5075
+ ));
5076
+ } else {
5077
+ this.dispatchEvent(new CustomEvent(
5078
+ "aparte-archive-conversation",
5079
+ { detail: { id }, bubbles: true, composed: true }
5080
+ ));
5081
+ }
5082
+ return;
5083
+ case "delete":
5084
+ open.menu.innerHTML = this._confirmMarkup(conv);
5085
+ this._placeMenu(open.button, open.menu);
5086
+ open.menu.querySelector('[data-menu-action="cancel"]')?.focus();
5087
+ return;
5088
+ case "cancel":
5089
+ this._closeMenu(true);
5090
+ return;
5091
+ case "confirm-delete":
5092
+ this._closeMenu(true);
5093
+ this.dispatchEvent(new CustomEvent(
5094
+ "aparte-delete-conversation",
5095
+ { detail: { id }, bubbles: true, composed: true }
5096
+ ));
5097
+ return;
5098
+ }
5099
+ }
5100
+ _menuFocusables() {
5101
+ const open = this._open;
5102
+ if (!open) return [];
5103
+ return Array.from(open.menu.querySelectorAll('[role="menuitem"], [data-menu-action]'));
5104
+ }
5105
+ _onMenuKeydown(e) {
5106
+ const items = this._menuFocusables();
5107
+ const index = items.indexOf(document.activeElement);
5108
+ const focusAt = (i2) => {
5109
+ e.preventDefault();
5110
+ items[(i2 + items.length) % items.length]?.focus();
5111
+ };
5112
+ switch (e.key) {
5113
+ case "Escape":
5114
+ e.preventDefault();
5115
+ this._closeMenu(true);
5116
+ return;
5117
+ case "Tab":
5118
+ e.preventDefault();
5119
+ this._closeMenu(true);
5120
+ return;
5121
+ case "ArrowDown":
5122
+ focusAt(index + 1);
5123
+ return;
5124
+ case "ArrowUp":
5125
+ focusAt(index - 1);
5126
+ return;
5127
+ case "Home":
5128
+ focusAt(0);
5129
+ return;
5130
+ case "End":
5131
+ focusAt(items.length - 1);
5132
+ return;
5133
+ }
5134
+ }
5135
+ // ─── Rename ───────────────────────────────────────────────────────────
5136
+ _startRename(row, conv) {
5137
+ const select = row.querySelector(".aparte-conv-item__select");
5138
+ if (!select) return;
5139
+ const input = document.createElement("input");
5140
+ input.type = "text";
5141
+ input.className = "aparte-field aparte-field--sm aparte-conv-item__input";
5142
+ input.value = conv.title;
5143
+ input.setAttribute("aria-label", this._t("conversationTitle"));
5144
+ input.dataset["renameId"] = conv.id;
5145
+ select.replaceWith(input);
5146
+ this._renaming = { id: conv.id, input, done: false };
5147
+ input.addEventListener("blur", () => this._finishRename(true, false));
5148
+ input.focus();
5149
+ input.select();
5150
+ }
5151
+ /**
5152
+ * One exit for the three ways out (Enter, Escape, leaving the field), guarded so
5153
+ * the blur a removal causes cannot commit a second time. The row is re-rendered
5154
+ * from the array either way: the list shows data, and the host's re-assignment is
5155
+ * what makes the new title appear.
5156
+ */
5157
+ _finishRename(commit, viaKeyboard) {
5158
+ const renaming = this._renaming;
5159
+ if (!renaming || renaming.done) return;
5160
+ renaming.done = true;
5161
+ const conv = this._conversations.find((c2) => c2.id === renaming.id);
5162
+ const title = renaming.input.value.trim();
5163
+ this._render();
5164
+ if (viaKeyboard) {
5165
+ this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
5166
+ }
5167
+ if (commit && conv && title && title !== conv.title) {
4709
5168
  this.dispatchEvent(new CustomEvent(
4710
- "aparte-delete-conversation",
4711
- { detail: { id }, bubbles: true, composed: true }
5169
+ "aparte-rename-conversation",
5170
+ { detail: { id: conv.id, title }, bubbles: true, composed: true }
4712
5171
  ));
5172
+ }
5173
+ }
5174
+ // ─── Events ───────────────────────────────────────────────────────────
5175
+ _onClick = (e) => {
5176
+ const target = e.target;
5177
+ const menuAction = target.closest("[data-menu-action]");
5178
+ if (menuAction && this._open?.menu.contains(menuAction)) {
5179
+ e.stopPropagation();
5180
+ this._onMenuAction(menuAction.dataset["menuAction"]);
5181
+ return;
5182
+ }
5183
+ const more = target.closest("[data-more-id]");
5184
+ if (more) {
5185
+ e.stopPropagation();
5186
+ if (this._open?.button === more) {
5187
+ this._closeMenu(true);
5188
+ } else {
5189
+ this._openMenu(more.closest("[data-conv-id]"), more);
5190
+ }
4713
5191
  return;
4714
5192
  }
4715
- const item = target.closest("[data-conv-id]");
4716
- if (item) {
4717
- const id = item.dataset["convId"];
5193
+ if (target.closest("[data-rename-id]")) return;
5194
+ const select = target.closest("[data-select-id]");
5195
+ if (select) {
5196
+ this._closeMenu();
4718
5197
  this.dispatchEvent(new CustomEvent(
4719
5198
  "aparte-select-conversation",
4720
- { detail: { id }, bubbles: true, composed: true }
5199
+ { detail: { id: select.dataset["selectId"] }, bubbles: true, composed: true }
4721
5200
  ));
4722
5201
  }
4723
5202
  };
4724
- _onKeydown = (e2) => {
4725
- if (e2.key !== "Enter" && e2.key !== " ") return;
4726
- const target = e2.target;
4727
- if (!target.matches("[data-conv-id]")) return;
4728
- e2.preventDefault();
4729
- target.click();
5203
+ _onKeydown = (e) => {
5204
+ const target = e.target;
5205
+ if (this._open?.menu.contains(target)) {
5206
+ this._onMenuKeydown(e);
5207
+ return;
5208
+ }
5209
+ if (target.matches("[data-rename-id]")) {
5210
+ if (e.key === "Enter") {
5211
+ e.preventDefault();
5212
+ this._finishRename(true, true);
5213
+ } else if (e.key === "Escape") {
5214
+ e.preventDefault();
5215
+ this._finishRename(false, true);
5216
+ }
5217
+ return;
5218
+ }
4730
5219
  };
4731
5220
  /** Update active class without full re-render (perf optimisation). */
4732
5221
  _updateActiveState() {
@@ -4734,13 +5223,9 @@ class AparteConversationList extends HTMLElement {
4734
5223
  items.forEach((el2) => {
4735
5224
  const isActive = el2.dataset["convId"] === this._activeId;
4736
5225
  el2.classList.toggle("aparte-conv-item--active", isActive);
4737
- el2.setAttribute("aria-current", isActive ? "page" : "false");
5226
+ el2.querySelector("[data-select-id]")?.setAttribute("aria-current", isActive ? "page" : "false");
4738
5227
  });
4739
5228
  }
4740
- // ─── Helpers ──────────────────────────────────────────────────────────
4741
- _esc(str) {
4742
- return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#039;");
4743
- }
4744
5229
  }
4745
5230
  if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
4746
5231
  const isSuggestion = (value) => typeof value === "string" || typeof value === "object" && value !== null && typeof value.label === "string";
@@ -4888,15 +5373,31 @@ const el = (tag, className) => {
4888
5373
  node.className = className;
4889
5374
  return node;
4890
5375
  };
5376
+ const SVG_NS = "http://www.w3.org/2000/svg";
5377
+ const ringSvg = () => {
5378
+ const svg = document.createElementNS(SVG_NS, "svg");
5379
+ svg.setAttribute("class", "aparte-context__ring");
5380
+ svg.setAttribute("viewBox", "0 0 36 36");
5381
+ for (const part of ["track", "value"]) {
5382
+ const circle = document.createElementNS(SVG_NS, "circle");
5383
+ circle.setAttribute("class", `aparte-context__${part}`);
5384
+ circle.setAttribute("cx", "18");
5385
+ circle.setAttribute("cy", "18");
5386
+ circle.setAttribute("r", "15.5");
5387
+ if (part === "value") circle.setAttribute("pathLength", "100");
5388
+ svg.appendChild(circle);
5389
+ }
5390
+ return svg;
5391
+ };
4891
5392
  class AparteContext extends HTMLElement {
4892
5393
  static get observedAttributes() {
4893
- return ["window", "warn", "danger", "target"];
5394
+ return ["window", "warn", "danger", "target", "variant"];
4894
5395
  }
4895
5396
  _used = null;
4896
5397
  _level = null;
4897
5398
  _compactRequested = false;
4898
- _onDone = (e2) => {
4899
- const detail = e2.detail;
5399
+ _onDone = (e) => {
5400
+ const detail = e.detail;
4900
5401
  if (!detail?.usage || !this._isMine(detail.targetId)) return;
4901
5402
  const { usage } = detail;
4902
5403
  this._used = usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
@@ -4905,16 +5406,24 @@ class AparteContext extends HTMLElement {
4905
5406
  /**
4906
5407
  * After a real compaction the context is smaller by an amount nobody has measured
4907
5408
  * yet — the next turn's usage will say. Until then the gauge shows nothing rather
4908
- * than a number it knows to be wrong. (`aparte-compact-done` names no chat: on a
4909
- * multi-chat page every gauge resets, and each is right again one turn later.)
5409
+ * than a number it knows to be wrong. The event names the chat it compacted, so on a
5410
+ * multi-chat page only that chat's gauge resets; an unnamed one resets every gauge,
5411
+ * and each is right again one turn later.
4910
5412
  */
4911
- _onCompacted = (e2) => {
4912
- const detail = e2.detail;
5413
+ _onCompacted = (e) => {
5414
+ const detail = e.detail;
5415
+ if (!this._isMine(detail?.targetId)) return;
5416
+ this._compactRequested = false;
4913
5417
  if (detail?.skipped) return;
4914
5418
  this._used = null;
4915
- this._compactRequested = false;
4916
5419
  this._render();
4917
5420
  };
5421
+ /** A failed compaction spends the request too; the next `danger` asks again. */
5422
+ _onCompactError = (e) => {
5423
+ const detail = e.detail;
5424
+ if (!this._isMine(detail?.targetId)) return;
5425
+ this._compactRequested = false;
5426
+ };
4918
5427
  _onRerender = () => this._render();
4919
5428
  /** Tokens the last turn reported, or `null` before the first turn. */
4920
5429
  get used() {
@@ -4931,12 +5440,14 @@ class AparteContext extends HTMLElement {
4931
5440
  connectedCallback() {
4932
5441
  window.addEventListener("aparte-message-done", this._onDone);
4933
5442
  window.addEventListener("aparte-compact-done", this._onCompacted);
5443
+ window.addEventListener("aparte-compact-error", this._onCompactError);
4934
5444
  window.addEventListener("aparte-config-change", this._onRerender);
4935
5445
  this._render();
4936
5446
  }
4937
5447
  disconnectedCallback() {
4938
5448
  window.removeEventListener("aparte-message-done", this._onDone);
4939
5449
  window.removeEventListener("aparte-compact-done", this._onCompacted);
5450
+ window.removeEventListener("aparte-compact-error", this._onCompactError);
4940
5451
  window.removeEventListener("aparte-config-change", this._onRerender);
4941
5452
  }
4942
5453
  attributeChangedCallback() {
@@ -4980,58 +5491,1345 @@ class AparteContext extends HTMLElement {
4980
5491
  const danger = fraction(this.getAttribute("danger"), 0.9);
4981
5492
  const level = ratio >= danger ? "danger" : ratio >= warn ? "warn" : "ok";
4982
5493
  this.setAttribute("data-level", level);
4983
- let bar = this.querySelector(".aparte-progress");
5494
+ const ring = this.getAttribute("variant") === "ring";
5495
+ let root = this.querySelector(".aparte-context");
5496
+ if (root && root.classList.contains("aparte-context--ring") !== ring) {
5497
+ this.replaceChildren();
5498
+ root = null;
5499
+ }
5500
+ let meter = this.querySelector('[role="meter"]');
4984
5501
  let text = this.querySelector(".aparte-context__text");
4985
- if (!bar || !text) {
4986
- const root = el("div", "aparte-context");
4987
- bar = el("div", "aparte-progress");
4988
- bar.setAttribute("role", "meter");
4989
- bar.appendChild(el("div", "aparte-progress__bar"));
5502
+ if (!root || !meter || !text) {
5503
+ root = el("div", ring ? "aparte-context aparte-context--ring" : "aparte-context");
5504
+ meter = ring ? ringSvg() : el("div", "aparte-progress");
5505
+ meter.setAttribute("role", "meter");
5506
+ if (!ring) meter.appendChild(el("div", "aparte-progress__bar"));
4990
5507
  text = el("span", "aparte-context__text");
4991
- root.append(bar, text);
5508
+ root.append(meter, text);
4992
5509
  this.replaceChildren(root);
4993
5510
  }
4994
- const format = new Intl.NumberFormat(void 0, { notation: "compact", maximumFractionDigits: 1 });
5511
+ const cfg = resolveConfig(this);
5512
+ const tag = cfg.getLocale().tag || void 0;
5513
+ const format = new Intl.NumberFormat(tag, { notation: "compact", maximumFractionDigits: 1 });
4995
5514
  const reading = `${format.format(used)} / ${format.format(capacity)}`;
4996
- const label = resolveConfig(this).t("contextLabel") || "Context window";
4997
- bar.style.setProperty("--aparte-progress-value", String(Math.round(ratio * 100)));
4998
- bar.setAttribute("aria-valuemin", "0");
4999
- bar.setAttribute("aria-valuemax", String(capacity));
5000
- bar.setAttribute("aria-valuenow", String(used));
5001
- bar.setAttribute("aria-label", `${label}: ${reading}`);
5002
- text.textContent = reading;
5515
+ const percent = Math.round(ratio * 100);
5516
+ const label = cfg.t("contextLabel") || "Context window";
5517
+ if (ring) {
5518
+ const value = meter.querySelector(".aparte-context__value");
5519
+ value?.style.setProperty("--aparte-context-ratio", String(percent));
5520
+ value?.classList.toggle("aparte-context__value--empty", percent === 0);
5521
+ root.title = reading;
5522
+ } else {
5523
+ meter.style.setProperty("--aparte-progress-value", String(percent));
5524
+ }
5525
+ meter.setAttribute("aria-valuemin", "0");
5526
+ meter.setAttribute("aria-valuemax", String(capacity));
5527
+ meter.setAttribute("aria-valuenow", String(used));
5528
+ meter.setAttribute("aria-label", `${label}: ${reading}`);
5529
+ text.textContent = ring ? new Intl.NumberFormat(tag, { style: "percent", maximumFractionDigits: 0 }).format(percent / 100) : reading;
5003
5530
  if (level !== this._level) {
5004
5531
  this._level = level;
5005
5532
  const targetId = this._ownTargetId();
5006
5533
  const detail = { level, used, window: capacity, ratio, targetId };
5007
5534
  this.dispatchEvent(new CustomEvent("aparte-context-threshold", { detail, bubbles: true, composed: true }));
5008
- if (level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
5009
- this._compactRequested = true;
5010
- window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId } }));
5011
- }
5012
5535
  if (level !== "danger") this._compactRequested = false;
5013
5536
  }
5537
+ if (level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
5538
+ this._compactRequested = true;
5539
+ window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId: this._ownTargetId() } }));
5540
+ }
5014
5541
  }
5015
5542
  }
5016
5543
  if (!customElements.get("aparte-context")) {
5017
5544
  customElements.define("aparte-context", AparteContext);
5018
5545
  }
5546
+ const EXCERPT_LENGTH = 60;
5547
+ class AparteScrollRail extends HTMLElement {
5548
+ static get observedAttributes() {
5549
+ return ["target", "every"];
5550
+ }
5551
+ _host = null;
5552
+ _mutations = null;
5553
+ _intersections = null;
5554
+ _rebuildQueued = false;
5555
+ _currentId = null;
5556
+ /** Visible ratio per message id, from the intersection observer. */
5557
+ _visible = /* @__PURE__ */ new Map();
5558
+ _onConfigChange = () => this._rebuild();
5559
+ /** The id of the message the rail currently marks, or `null`. */
5560
+ get currentMessageId() {
5561
+ return this._currentId;
5562
+ }
5563
+ connectedCallback() {
5564
+ if (!this.classList.contains("aparte-scroll-rail")) this.classList.add("aparte-scroll-rail");
5565
+ if (!this.getAttribute("role")) this.setAttribute("role", "navigation");
5566
+ this.addEventListener("click", this._onClick);
5567
+ this.addEventListener("keydown", this._onKeydown);
5568
+ window.addEventListener("aparte-config-change", this._onConfigChange);
5569
+ this._attach();
5570
+ }
5571
+ disconnectedCallback() {
5572
+ this._detach();
5573
+ this.removeEventListener("click", this._onClick);
5574
+ this.removeEventListener("keydown", this._onKeydown);
5575
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
5576
+ }
5577
+ attributeChangedCallback(name) {
5578
+ if (!this.isConnected) return;
5579
+ if (name === "target") this._attach();
5580
+ else this._rebuild();
5581
+ }
5582
+ // ─── Wiring ───────────────────────────────────────────────────────────
5583
+ /** The chat this rail follows: `target`, else the host above it — the gauge's rule. */
5584
+ _findHost() {
5585
+ const target = this.getAttribute("target");
5586
+ if (target) return document.getElementById(target);
5587
+ let node = this.parentElement;
5588
+ while (node) {
5589
+ if (node.tagName.toLowerCase() === "aparte-chat" || node.hasAttribute("data-aparte-chat")) return node;
5590
+ node = node.parentElement;
5591
+ }
5592
+ return null;
5593
+ }
5594
+ _attach() {
5595
+ this._detach();
5596
+ this._host = this._findHost();
5597
+ if (!this._host) {
5598
+ this._rebuild();
5599
+ return;
5600
+ }
5601
+ if (typeof MutationObserver !== "undefined") {
5602
+ this._mutations = new MutationObserver(() => this._queueRebuild());
5603
+ this._mutations.observe(this._host, { childList: true, subtree: true, characterData: true });
5604
+ }
5605
+ this._rebuild();
5606
+ }
5607
+ _detach() {
5608
+ this._mutations?.disconnect();
5609
+ this._mutations = null;
5610
+ this._intersections?.disconnect();
5611
+ this._intersections = null;
5612
+ this._visible.clear();
5613
+ this._host = null;
5614
+ }
5615
+ /** Coalesce a burst of mutations (a streaming reply is one per token) into one rebuild. */
5616
+ _queueRebuild() {
5617
+ if (this._rebuildQueued) return;
5618
+ this._rebuildQueued = true;
5619
+ requestAnimationFrame(() => {
5620
+ this._rebuildQueued = false;
5621
+ this._rebuild();
5622
+ });
5623
+ }
5624
+ _viewport() {
5625
+ return this._host?.querySelector("aparte-chat-viewport") ?? null;
5626
+ }
5627
+ /** The element that scrolls: the vanilla container, or the viewport itself under a wrapper. */
5628
+ _scrollSurface() {
5629
+ const viewport = this._viewport();
5630
+ if (!viewport) return null;
5631
+ return viewport.querySelector(".aparte-viewport-container") ?? viewport;
5632
+ }
5633
+ _bubbles() {
5634
+ const viewport = this._viewport();
5635
+ if (!viewport) return [];
5636
+ const every = this.getAttribute("every") === "message" ? "message" : "user";
5637
+ const all = Array.from(viewport.querySelectorAll("aparte-chat-bubble[message-id]"));
5638
+ return every === "message" ? all : all.filter((b2) => b2.getAttribute("data-role") === "user");
5639
+ }
5640
+ // ─── Rendering ────────────────────────────────────────────────────────
5641
+ /** The first words of a message, for the tick's name. An excerpt, never a summary. */
5642
+ _excerpt(bubble) {
5643
+ const body = bubble.querySelector(".aparte-message-content") ?? bubble;
5644
+ const text = (body.textContent ?? "").replace(/\s+/g, " ").trim();
5645
+ return text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 1).trimEnd()}…` : text;
5646
+ }
5647
+ _tickLabel(bubble) {
5648
+ const locale = resolveConfig(this).getLocale();
5649
+ const role = bubble.getAttribute("data-role") === "user" ? locale.roleNameUser ?? "You" : locale.roleNameAssistant ?? "Assistant";
5650
+ const excerpt = this._excerpt(bubble);
5651
+ return excerpt ? `${role}: ${excerpt}` : role;
5652
+ }
5653
+ _rebuild() {
5654
+ const bubbles = this._bubbles();
5655
+ const locale = resolveConfig(this).getLocale();
5656
+ this.setAttribute("aria-label", locale.scrollRailLabel ?? "Conversation outline");
5657
+ if (bubbles.length < 2) {
5658
+ this.setAttribute("data-empty", "");
5659
+ this.replaceChildren();
5660
+ this._intersections?.disconnect();
5661
+ this._intersections = null;
5662
+ this._visible.clear();
5663
+ this._currentId = null;
5664
+ return;
5665
+ }
5666
+ this.removeAttribute("data-empty");
5667
+ const list = document.createElement("ol");
5668
+ list.className = "aparte-scroll-rail__list";
5669
+ const known = /* @__PURE__ */ new Set();
5670
+ for (const bubble of bubbles) {
5671
+ const id = bubble.getAttribute("message-id");
5672
+ known.add(id);
5673
+ const item = document.createElement("li");
5674
+ const tick = document.createElement("button");
5675
+ tick.type = "button";
5676
+ tick.className = "aparte-scroll-rail__tick";
5677
+ tick.dataset["messageId"] = id;
5678
+ const label = this._tickLabel(bubble);
5679
+ tick.setAttribute("aria-label", label);
5680
+ tick.title = label;
5681
+ if (id === this._currentId) tick.setAttribute("aria-current", "true");
5682
+ item.appendChild(tick);
5683
+ list.appendChild(item);
5684
+ }
5685
+ this.replaceChildren(list);
5686
+ if (this._currentId && !known.has(this._currentId)) this._currentId = null;
5687
+ this._observe(bubbles);
5688
+ }
5689
+ /**
5690
+ * Which tick is current is read, not computed: the scroll surface is the root, and
5691
+ * the bubble with the largest visible share of a reading band near the top wins.
5692
+ * Scroll arithmetic was the alternative, and every scroll-position rule in the
5693
+ * viewport carries a regression note — this one has none to carry.
5694
+ */
5695
+ _observe(bubbles) {
5696
+ this._intersections?.disconnect();
5697
+ this._intersections = null;
5698
+ this._visible.clear();
5699
+ if (typeof IntersectionObserver === "undefined") return;
5700
+ const root = this._scrollSurface();
5701
+ if (!root) return;
5702
+ this._intersections = new IntersectionObserver((entries) => {
5703
+ for (const entry of entries) {
5704
+ const id = entry.target.getAttribute("message-id");
5705
+ if (id) this._visible.set(id, entry.isIntersecting ? entry.intersectionRatio : 0);
5706
+ }
5707
+ this._pickCurrent(bubbles);
5708
+ }, { root, rootMargin: "-15% 0px -55% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] });
5709
+ for (const bubble of bubbles) this._intersections.observe(bubble);
5710
+ }
5711
+ _pickCurrent(bubbles) {
5712
+ let best = null;
5713
+ let bestRatio = 0;
5714
+ for (const bubble of bubbles) {
5715
+ const id = bubble.getAttribute("message-id");
5716
+ const ratio = this._visible.get(id) ?? 0;
5717
+ if (ratio > bestRatio) {
5718
+ bestRatio = ratio;
5719
+ best = id;
5720
+ }
5721
+ }
5722
+ if (best !== null) this._setCurrent(best);
5723
+ }
5724
+ _setCurrent(id) {
5725
+ if (this._currentId === id) return;
5726
+ this._currentId = id;
5727
+ for (const tick of this.querySelectorAll(".aparte-scroll-rail__tick")) {
5728
+ if (tick.dataset["messageId"] === id) tick.setAttribute("aria-current", "true");
5729
+ else tick.removeAttribute("aria-current");
5730
+ }
5731
+ }
5732
+ // ─── Interaction ──────────────────────────────────────────────────────
5733
+ _onClick = (e) => {
5734
+ const tick = e.target.closest(".aparte-scroll-rail__tick");
5735
+ if (!tick || !this.contains(tick)) return;
5736
+ const messageId = tick.dataset["messageId"];
5737
+ if (!messageId) return;
5738
+ this.jumpTo(messageId);
5739
+ };
5740
+ /** The ticks are buttons; the arrows walk them, as in any toolbar. */
5741
+ _onKeydown = (e) => {
5742
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
5743
+ const ticks = Array.from(this.querySelectorAll(".aparte-scroll-rail__tick"));
5744
+ const index = ticks.indexOf(document.activeElement);
5745
+ if (index === -1) return;
5746
+ e.preventDefault();
5747
+ const next = e.key === "Home" ? 0 : e.key === "End" ? ticks.length - 1 : e.key === "ArrowDown" ? Math.min(ticks.length - 1, index + 1) : Math.max(0, index - 1);
5748
+ ticks[next]?.focus();
5749
+ };
5750
+ /**
5751
+ * Scroll the transcript to a message. Announced first, cancelable; then a
5752
+ * `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
5753
+ */
5754
+ jumpTo(messageId) {
5755
+ const announced = this.dispatchEvent(new CustomEvent("aparte-scroll-rail-jump", {
5756
+ detail: { messageId },
5757
+ bubbles: true,
5758
+ composed: true,
5759
+ cancelable: true
5760
+ }));
5761
+ if (!announced) return;
5762
+ const viewport = this._viewport();
5763
+ const bubble = viewport?.querySelector(`aparte-chat-bubble[message-id="${cssEscape(messageId)}"]`);
5764
+ if (!bubble) return;
5765
+ const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
5766
+ bubble.scrollIntoView({ block: "start", behavior: reduced ? "auto" : "smooth" });
5767
+ this._setCurrent(messageId);
5768
+ }
5769
+ }
5770
+ if (!customElements.get("aparte-scroll-rail")) {
5771
+ customElements.define("aparte-scroll-rail", AparteScrollRail);
5772
+ }
5773
+ const DRAWER_QUERY = "(max-width: 48rem)";
5774
+ class AparteSidebar extends HTMLElement {
5775
+ static get observedAttributes() {
5776
+ return ["collapsed", "breakpoint"];
5777
+ }
5778
+ _media = null;
5779
+ _scrim = null;
5780
+ /** The control that last opened the drawer, to hand the focus back to. */
5781
+ _opener = null;
5782
+ _lastCollapsed = null;
5783
+ /**
5784
+ * `connectedCallback` has run and the element knows what its markup asked for.
5785
+ *
5786
+ * Not `isConnected`: during an UPGRADE the element is already in the document, so
5787
+ * `attributeChangedCallback` fires for every authored attribute — connected, and
5788
+ * before `connectedCallback` — which is the ordinary case for a server-rendered
5789
+ * `<aparte-sidebar collapsed>` upgraded when the module loads. Announcing there
5790
+ * tells the host its sidebar just closed when all that happened is that the markup
5791
+ * was read, and says `drawer: false` because the media query has not run yet.
5792
+ */
5793
+ _ready = false;
5794
+ /**
5795
+ * The host (or the markup) asked for the column to be folded, so widening the
5796
+ * window must not unfold it. Only a collapse taken OUTSIDE the drawer state counts:
5797
+ * dismissing an overlay says nothing about what a wide window should show.
5798
+ */
5799
+ _closedByHost = false;
5800
+ /**
5801
+ * `_closedByHost` has been taken from the markup once. A re-parent runs
5802
+ * `connectedCallback` again, and by then `collapsed` may be the BREAKPOINT's own
5803
+ * write — a drawer closes itself on the way in — so reading it back a second time
5804
+ * records the element's own doing as the host's word and the column never reopens.
5805
+ * Like `_initialCaptured` on `<aparte-split>`, it must survive the move.
5806
+ */
5807
+ _hostIntentSeeded = false;
5808
+ /** Raised around `_applyDrawer`'s own writes, so the breakpoint is never read as a host's intent. */
5809
+ _auto = false;
5810
+ /**
5811
+ * The element wrote `inert`/`aria-hidden` and may take them back. A host that inerts
5812
+ * the sidebar behind its own modal wrote them itself, and `_syncHidden` runs on every
5813
+ * breakpoint re-evaluation: without this it would un-inert the page behind the overlay.
5814
+ * Same convention as `_relabel`, which leaves a host-authored `aria-label` alone.
5815
+ */
5816
+ _ownHidden = false;
5817
+ /** Whether the sidebar is collapsed (hidden in the flow, or closed as a drawer). */
5818
+ get collapsed() {
5819
+ return this.hasAttribute("collapsed");
5820
+ }
5821
+ set collapsed(value) {
5822
+ this.toggleAttribute("collapsed", value);
5823
+ }
5824
+ /** True while the window is under the breakpoint and the sidebar is a drawer. */
5825
+ get drawer() {
5826
+ return this.hasAttribute("data-drawer");
5827
+ }
5828
+ connectedCallback() {
5829
+ if (!this.classList.contains("aparte-sidebar")) this.classList.add("aparte-sidebar");
5830
+ if (!this.getAttribute("role")) this.setAttribute("role", "complementary");
5831
+ this._relabel();
5832
+ if (!this._hostIntentSeeded) {
5833
+ this._closedByHost = this.collapsed;
5834
+ this._hostIntentSeeded = true;
5835
+ }
5836
+ this.addEventListener("input", this._onInput);
5837
+ document.addEventListener("keydown", this._onKeydown);
5838
+ document.addEventListener("click", this._onDocumentClick);
5839
+ window.addEventListener("aparte-config-change", this._onConfigChange);
5840
+ this._watchBreakpoint();
5841
+ this._syncHidden();
5842
+ this._lastCollapsed = this.collapsed;
5843
+ this._ready = true;
5844
+ }
5845
+ /**
5846
+ * The drawer's media query, from the `breakpoint` attribute: a length (`48rem`, the
5847
+ * default, or `640px`), or `none` for a column that never becomes a drawer — which
5848
+ * a narrow host that has room for it wants, and a documentation frame needs.
5849
+ */
5850
+ _watchBreakpoint() {
5851
+ this._media?.removeEventListener("change", this._onMediaChange);
5852
+ this._media = null;
5853
+ const attr = (this.getAttribute("breakpoint") ?? "").trim();
5854
+ if (attr === "none") {
5855
+ this._applyDrawer(false);
5856
+ return;
5857
+ }
5858
+ if (typeof matchMedia !== "function") return;
5859
+ const query = attr ? `(max-width: ${attr})` : DRAWER_QUERY;
5860
+ this._media = matchMedia(query);
5861
+ this._media.addEventListener("change", this._onMediaChange);
5862
+ this._applyDrawer(this._media.matches);
5863
+ }
5864
+ disconnectedCallback() {
5865
+ this.removeEventListener("input", this._onInput);
5866
+ document.removeEventListener("keydown", this._onKeydown);
5867
+ document.removeEventListener("click", this._onDocumentClick);
5868
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
5869
+ this._media?.removeEventListener("change", this._onMediaChange);
5870
+ this._media = null;
5871
+ this._ready = false;
5872
+ }
5873
+ attributeChangedCallback(name) {
5874
+ if (!this._ready) return;
5875
+ if (name === "breakpoint") {
5876
+ this._watchBreakpoint();
5877
+ return;
5878
+ }
5879
+ if (name !== "collapsed") return;
5880
+ const collapsed = this.collapsed;
5881
+ if (collapsed === this._lastCollapsed) return;
5882
+ this._lastCollapsed = collapsed;
5883
+ if (!this._auto && !this.drawer) this._closedByHost = collapsed;
5884
+ this._syncScrim();
5885
+ this._syncHidden();
5886
+ if (collapsed && this.drawer && this._opener?.isConnected) {
5887
+ this._opener.focus();
5888
+ this._opener = null;
5889
+ } else if (!collapsed && this.drawer) {
5890
+ this._focusFirst();
5891
+ }
5892
+ this.dispatchEvent(new CustomEvent("aparte-sidebar-toggle", {
5893
+ detail: { collapsed, drawer: this.drawer },
5894
+ bubbles: true,
5895
+ composed: true
5896
+ }));
5897
+ }
5898
+ // ─── Public API ───────────────────────────────────────────────────────
5899
+ /** Open the sidebar. `opener` is the control to hand the focus back to when it closes. */
5900
+ open(opener) {
5901
+ if (opener) this._opener = opener;
5902
+ this.collapsed = false;
5903
+ }
5904
+ close() {
5905
+ this.collapsed = true;
5906
+ }
5907
+ toggle(opener) {
5908
+ if (this.collapsed) this.open(opener);
5909
+ else this.close();
5910
+ }
5911
+ // ─── Drawer ───────────────────────────────────────────────────────────
5912
+ _onMediaChange = (e) => this._applyDrawer(e.matches);
5913
+ /**
5914
+ * Entering the drawer state closes the sidebar: a narrow window that opens on an
5915
+ * overlay covering the chat is the wrong first screen. Leaving it reopens, since
5916
+ * a wide window has room for the column — unless the host had collapsed it before.
5917
+ */
5918
+ _applyDrawer(drawer) {
5919
+ const was = this.drawer;
5920
+ this.toggleAttribute("data-drawer", drawer);
5921
+ this._auto = true;
5922
+ try {
5923
+ if (drawer && !was) this.collapsed = true;
5924
+ else if (!drawer && was && !this._closedByHost) this.collapsed = false;
5925
+ } finally {
5926
+ this._auto = false;
5927
+ }
5928
+ this._syncScrim();
5929
+ this._syncHidden();
5930
+ }
5931
+ /**
5932
+ * A collapsed sidebar is invisible — folded to nothing in the flow, slid off the
5933
+ * screen as a drawer — so it must not keep its tab stops or its place in the
5934
+ * accessibility tree. `inert` covers focus and the pointer, `aria-hidden` covers
5935
+ * the readers that predate it; a browser without `inert` still gets the second.
5936
+ *
5937
+ * It removes only what it wrote. Both are standard global attributes a host sets
5938
+ * itself — inerting the sidebar behind its own modal is the ordinary pattern — and
5939
+ * this runs on every breakpoint re-evaluation, so clearing them unconditionally lets
5940
+ * an unrelated resize re-expose the page under an overlay. Same convention as
5941
+ * `_relabel`, which leaves a host-authored `aria-label` alone.
5942
+ *
5943
+ * Neither is declared `@attr`, and `data-drawer` is not the precedent: a `@attr` on a
5944
+ * STANDARD name displaces the wrappers' own prop for it — React's JSX props are
5945
+ * `Omit<HTMLAttributes, keyof T>`, so declaring `inert` here would retype
5946
+ * `<aparte-sidebar inert={busy}>` out of existence. The class docblock says it in prose.
5947
+ */
5948
+ _syncHidden() {
5949
+ if (this.collapsed) {
5950
+ this.toggleAttribute("inert", true);
5951
+ this.setAttribute("aria-hidden", "true");
5952
+ this._ownHidden = true;
5953
+ } else if (this._ownHidden) {
5954
+ this.removeAttribute("inert");
5955
+ this.removeAttribute("aria-hidden");
5956
+ this._ownHidden = false;
5957
+ }
5958
+ }
5959
+ /**
5960
+ * The drawer covers the page, so the focus has to follow it in — otherwise the next
5961
+ * Tab walks the transcript under the overlay. The host owns the children, so this
5962
+ * takes the first thing that can hold focus and leaves the rest alone; a drawer with
5963
+ * nothing focusable in it keeps the focus where it was, which is the honest outcome.
5964
+ */
5965
+ _focusFirst() {
5966
+ this.querySelector(FOCUSABLE)?.focus();
5967
+ }
5968
+ _syncScrim() {
5969
+ const wanted = this.drawer && !this.collapsed;
5970
+ if (wanted && !this._scrim) {
5971
+ this._scrim = document.createElement("div");
5972
+ this._scrim.className = "aparte-sidebar__scrim";
5973
+ this._scrim.setAttribute("aria-hidden", "true");
5974
+ this._scrim.addEventListener("click", () => this.close());
5975
+ this.appendChild(this._scrim);
5976
+ } else if (!wanted && this._scrim) {
5977
+ this._scrim.remove();
5978
+ this._scrim = null;
5979
+ }
5980
+ }
5981
+ _onKeydown = (e) => {
5982
+ if (e.key === "Escape" && this.drawer && !this.collapsed) {
5983
+ e.preventDefault();
5984
+ this.close();
5985
+ }
5986
+ };
5987
+ /** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
5988
+ _onDocumentClick = (e) => {
5989
+ const control = e.target?.closest?.("[data-aparte-sidebar-toggle]");
5990
+ if (!control) return;
5991
+ const named = control.getAttribute("data-aparte-sidebar-toggle");
5992
+ if (named) {
5993
+ if (named !== this.id) return;
5994
+ } else if (nearestSidebar(control) !== this) {
5995
+ return;
5996
+ }
5997
+ this.toggle(control);
5998
+ };
5999
+ // ─── Search ───────────────────────────────────────────────────────────
6000
+ _onInput = (e) => {
6001
+ const input = e.target;
6002
+ if (!input?.matches?.("[data-aparte-sidebar-search]")) return;
6003
+ this.filter(input.value);
6004
+ };
6005
+ /**
6006
+ * Hide the conversation rows whose title does not contain `query`, and the date
6007
+ * groups left empty. Case- and accent-insensitive. An empty query shows everything.
6008
+ */
6009
+ filter(query) {
6010
+ const needle = fold(query);
6011
+ for (const list of this.querySelectorAll("aparte-conversation-list")) {
6012
+ for (const row of list.querySelectorAll("[data-conv-id]")) {
6013
+ const title = row.querySelector(".aparte-conv-item__title")?.textContent ?? row.textContent ?? "";
6014
+ row.hidden = needle !== "" && !fold(title).includes(needle);
6015
+ }
6016
+ for (const group of list.querySelectorAll(".aparte-conv-group")) {
6017
+ group.hidden = needle !== "" && !Array.from(group.querySelectorAll("[data-conv-id]")).some((r) => !r.hidden);
6018
+ }
6019
+ }
6020
+ }
6021
+ // ─── Locale ───────────────────────────────────────────────────────────
6022
+ _onConfigChange = () => this._relabel();
6023
+ _relabel() {
6024
+ if (this.hasAttribute("aria-label") && this.dataset["ownLabel"] !== "locale") return;
6025
+ const locale = resolveConfig(this).getLocale();
6026
+ this.setAttribute("aria-label", locale.sidebarLabel ?? "Conversations");
6027
+ this.dataset["ownLabel"] = "locale";
6028
+ }
6029
+ }
6030
+ const FOCUSABLE = [
6031
+ "a[href]",
6032
+ "button:not([disabled])",
6033
+ 'input:not([disabled]):not([type="hidden"])',
6034
+ "select:not([disabled])",
6035
+ "textarea:not([disabled])",
6036
+ '[tabindex]:not([tabindex="-1"])'
6037
+ ].join(",");
6038
+ function nearestSidebar(control) {
6039
+ return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
6040
+ }
6041
+ function fold(text) {
6042
+ return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
6043
+ }
6044
+ if (!customElements.get("aparte-sidebar")) {
6045
+ customElements.define("aparte-sidebar", AparteSidebar);
6046
+ }
6047
+ function clamp(value, min, max) {
6048
+ if (!Number.isFinite(value)) return min;
6049
+ return value < min ? min : value > max ? max : value;
6050
+ }
6051
+ function nextPosition(step) {
6052
+ const start = Number.isFinite(step.startPercent) ? step.startPercent : 0;
6053
+ if (!Number.isFinite(step.containerPx) || step.containerPx <= 0) return clamp(start, 0, 100);
6054
+ const inverted = (!step.vertical && step.rtl) !== (step.primaryEnd === true);
6055
+ const sign = inverted ? -1 : 1;
6056
+ const moved = sign * step.deltaPx * 100 / step.containerPx;
6057
+ return clamp(start + moved, 0, 100);
6058
+ }
6059
+ function keyDelta(key, shift, rtl, vertical, primaryEnd = false) {
6060
+ if (key === "Home") return -100;
6061
+ if (key === "End") return 100;
6062
+ const step = (shift ? 10 : 1) * (primaryEnd ? -1 : 1);
6063
+ if (vertical) {
6064
+ if (key === "ArrowUp") return -step;
6065
+ if (key === "ArrowDown") return step;
6066
+ return null;
6067
+ }
6068
+ if (key === "ArrowLeft") return rtl ? step : -step;
6069
+ if (key === "ArrowRight") return rtl ? -step : step;
6070
+ return null;
6071
+ }
6072
+ const STACK_QUERY = "(max-width: 48rem)";
6073
+ const DEFAULT_POSITION = 38;
6074
+ const SCRIM_RETIRE_MS = 300;
6075
+ function toPercent(raw, fallback) {
6076
+ const value = Number.parseFloat(raw ?? "");
6077
+ if (!Number.isFinite(value)) return fallback;
6078
+ return value < 0 ? 0 : value > 100 ? 100 : value;
6079
+ }
6080
+ class AparteSplit extends HTMLElement {
6081
+ static get observedAttributes() {
6082
+ return ["position", "orientation", "primary", "collapsed", "breakpoint", "pane", "disabled", "label"];
6083
+ }
6084
+ _handle = null;
6085
+ _scrim = null;
6086
+ _media = null;
6087
+ _observer = null;
6088
+ /** The pending `_measureBounds()` from a resize, batched to one per frame. */
6089
+ _measureRafId = null;
6090
+ /** The host size the last resize tick measured, so an unchanged one does nothing. */
6091
+ _observedPx = 0;
6092
+ /** Ids for the panes `aria-controls` names — a counter, the way the primitives do it. */
6093
+ static _paneIdSeq = 0;
6094
+ /** The live position. The attribute holds it only after a commit. */
6095
+ _position = DEFAULT_POSITION;
6096
+ /** What `reset()` — and a double-click on the seam — goes back to. */
6097
+ _initialPosition = DEFAULT_POSITION;
6098
+ /**
6099
+ * `_initialPosition` has been taken from the markup once. A re-parent runs
6100
+ * `connectedCallback` again, and by then the attribute holds the LAST COMMIT, not
6101
+ * what the author wrote: capturing a second time makes a framework re-render, a tab
6102
+ * switch or a drag-and-drop of the panel redefine what "reset" means.
6103
+ */
6104
+ _initialCaptured = false;
6105
+ /** The size the primary pane had before it was collapsed, for the second Enter. */
6106
+ _preCollapsePosition = null;
6107
+ /** Committed values, so a reflection of our own does not read as a host's change. */
6108
+ _lastPosition = null;
6109
+ _lastCollapsed = null;
6110
+ _lastPane = null;
6111
+ /** What an attribute-driven commit blames. Raised around a key that changes state. */
6112
+ _source = "api";
6113
+ /**
6114
+ * `connectedCallback` has run and the element knows what its markup asked for.
6115
+ *
6116
+ * Not `isConnected`: during an UPGRADE the element is already in the document, so
6117
+ * `attributeChangedCallback` fires for every authored attribute — connected, and
6118
+ * before `connectedCallback` — which is the ordinary case for a server-rendered
6119
+ * `<aparte-split position="38">` upgraded when the module loads. Committing there
6120
+ * measures a layout the element has not set up yet (no `data-stacked`, so on a
6121
+ * phone the probe reads the stacked pane and writes `position="84"`), reflects that
6122
+ * over the authored number, and sends it to whatever the host persists.
6123
+ */
6124
+ _ready = false;
6125
+ _dragging = false;
6126
+ _captured = false;
6127
+ /** The gesture actually moved the seam. A press that did not commits nothing. */
6128
+ _moved = false;
6129
+ _pointerId = null;
6130
+ _dragStartPercent = DEFAULT_POSITION;
6131
+ _dragOriginPx = 0;
6132
+ _dragContainerPx = 0;
6133
+ /** A key is down and has moved the seam; its keyup is the commit. */
6134
+ _keying = false;
6135
+ // ─── Properties ───────────────────────────────────────────────────────
6136
+ /** The primary pane's size, as a percentage of the container. Setting it commits. */
6137
+ get position() {
6138
+ return this._position;
6139
+ }
6140
+ set position(value) {
6141
+ const next = toPercent(String(value), this._position);
6142
+ if (this.isConnected && next === this._lastPosition) return;
6143
+ this._position = next;
6144
+ this._setLive(next);
6145
+ if (this.isConnected) this._commit("api");
6146
+ }
6147
+ /** The container's axis: `horizontal` (side by side) or `vertical` (stacked). */
6148
+ get orientation() {
6149
+ return this.getAttribute("orientation") === "vertical" ? "vertical" : "horizontal";
6150
+ }
6151
+ set orientation(value) {
6152
+ this.setAttribute("orientation", value);
6153
+ }
6154
+ /** Which pane `position` sizes. */
6155
+ get primary() {
6156
+ return this.getAttribute("primary") === "end" ? "end" : "start";
6157
+ }
6158
+ set primary(value) {
6159
+ this.setAttribute("primary", value);
6160
+ }
6161
+ /** The primary pane is folded to its minimum. */
6162
+ get collapsed() {
6163
+ return this.hasAttribute("collapsed");
6164
+ }
6165
+ set collapsed(value) {
6166
+ this.toggleAttribute("collapsed", value);
6167
+ }
6168
+ /** Which pane is shown while the split is stacked. */
6169
+ get pane() {
6170
+ return this.getAttribute("pane") === "end" ? "end" : "start";
6171
+ }
6172
+ set pane(value) {
6173
+ this.setAttribute("pane", value);
6174
+ }
6175
+ /** No drag, no keys, no tab stop. The seam stays drawn. */
6176
+ get disabled() {
6177
+ return this.hasAttribute("disabled");
6178
+ }
6179
+ set disabled(value) {
6180
+ this.toggleAttribute("disabled", value);
6181
+ }
6182
+ /**
6183
+ * True while the split is showing one pane. Read-only.
6184
+ *
6185
+ * Both routes into that state count. `data-stacked` is the one this element writes
6186
+ * from its own breakpoint; `.aparte-split--only-start` / `--only-end` are the CSS
6187
+ * route a host takes when it owns its breakpoints and sets `breakpoint="none"`. The
6188
+ * sheet gives the two byte-identical rules, so the element has to read them the same
6189
+ * way — every guard downstream keys on this getter, and a split stacked by the class
6190
+ * alone would measure a one-track grid and commit `position="100"`.
6191
+ */
6192
+ get stacked() {
6193
+ return this.hasAttribute("data-stacked") || this.classList.contains("aparte-split--only-start") || this.classList.contains("aparte-split--only-end");
6194
+ }
6195
+ // ─── Public API ───────────────────────────────────────────────────────
6196
+ /** Fold the primary pane to its minimum, remembering the size it had. */
6197
+ collapse() {
6198
+ this.collapsed = true;
6199
+ }
6200
+ /** Unfold it, back to the size it had before it collapsed. */
6201
+ expand() {
6202
+ this.collapsed = false;
6203
+ }
6204
+ toggleCollapse() {
6205
+ this.collapsed = !this.collapsed;
6206
+ }
6207
+ /** Show one pane, which only has a visible effect while the split is stacked. */
6208
+ showPane(which) {
6209
+ this.pane = which;
6210
+ }
6211
+ /**
6212
+ * Back to the position the split was FIRST connected with — a re-parent restores
6213
+ * that, it does not redefine it. What a double-click on the seam does.
6214
+ */
6215
+ reset() {
6216
+ this._position = this._initialPosition;
6217
+ this._setLive(this._position);
6218
+ if (this.isConnected) this._commit("api");
6219
+ }
6220
+ // ─── Lifecycle ────────────────────────────────────────────────────────
6221
+ connectedCallback() {
6222
+ if (!this.classList.contains("aparte-split")) this.classList.add("aparte-split");
6223
+ this._handle = this._ensureHandle();
6224
+ const attr = this.getAttribute("position");
6225
+ if (attr !== null) {
6226
+ this._position = toPercent(attr, DEFAULT_POSITION);
6227
+ this._setLive(this._position);
6228
+ } else {
6229
+ this._position = toPercent(
6230
+ getComputedStyle(this).getPropertyValue("--aparte-split-position"),
6231
+ DEFAULT_POSITION
6232
+ );
6233
+ }
6234
+ if (!this._initialCaptured) {
6235
+ this._initialPosition = this._position;
6236
+ this._initialCaptured = true;
6237
+ }
6238
+ if (this.collapsed) {
6239
+ if (this._lastCollapsed !== true) this._preCollapsePosition = this._position;
6240
+ this._position = 0;
6241
+ this._setLive(0);
6242
+ }
6243
+ this._lastPosition = this._position;
6244
+ this._lastCollapsed = this.collapsed;
6245
+ this._lastPane = this.pane;
6246
+ this._stampHandle();
6247
+ this._relabel();
6248
+ this._measureBounds();
6249
+ this._handle.addEventListener("pointerdown", this._onPointerDown);
6250
+ this._handle.addEventListener("dblclick", this._onDoubleClick);
6251
+ this._handle.addEventListener("focusout", this._onFocusOut);
6252
+ this.addEventListener("keydown", this._onKeydown);
6253
+ this.addEventListener("keyup", this._onKeyup);
6254
+ document.addEventListener("click", this._onDocumentClick);
6255
+ window.addEventListener("aparte-config-change", this._onConfigChange);
6256
+ this._watchBreakpoint();
6257
+ this._watchSize();
6258
+ this._ready = true;
6259
+ }
6260
+ disconnectedCallback() {
6261
+ this._handle?.removeEventListener("pointerdown", this._onPointerDown);
6262
+ this._handle?.removeEventListener("dblclick", this._onDoubleClick);
6263
+ this._handle?.removeEventListener("focusout", this._onFocusOut);
6264
+ this.removeEventListener("keydown", this._onKeydown);
6265
+ this.removeEventListener("keyup", this._onKeyup);
6266
+ document.removeEventListener("click", this._onDocumentClick);
6267
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
6268
+ this._releasePointer();
6269
+ this._media?.removeEventListener("change", this._onMediaChange);
6270
+ this._media = null;
6271
+ this._observer?.disconnect();
6272
+ this._observer = null;
6273
+ if (this._measureRafId !== null) {
6274
+ cancelAnimationFrame(this._measureRafId);
6275
+ this._measureRafId = null;
6276
+ }
6277
+ this._removeScrim();
6278
+ this._handle?.removeAttribute("data-dragging");
6279
+ this._dragging = false;
6280
+ this._keying = false;
6281
+ this._ready = false;
6282
+ }
6283
+ attributeChangedCallback(name) {
6284
+ if (!this._ready) return;
6285
+ switch (name) {
6286
+ case "position": {
6287
+ const next = toPercent(this.getAttribute("position"), this._position);
6288
+ if (next === this._lastPosition) return;
6289
+ this._position = next;
6290
+ this._setLive(next);
6291
+ this._commit();
6292
+ return;
6293
+ }
6294
+ case "collapsed": {
6295
+ const collapsed = this.collapsed;
6296
+ if (collapsed === this._lastCollapsed) return;
6297
+ this._lastCollapsed = collapsed;
6298
+ if (collapsed) {
6299
+ this._preCollapsePosition = this._position;
6300
+ this._position = 0;
6301
+ } else {
6302
+ this._position = this._preCollapsePosition ?? this._initialPosition;
6303
+ }
6304
+ this._setLive(this._position);
6305
+ this._commit();
6306
+ return;
6307
+ }
6308
+ case "pane": {
6309
+ const pane = this.pane;
6310
+ if (pane === this._lastPane) return;
6311
+ this._lastPane = pane;
6312
+ this._commit();
6313
+ return;
6314
+ }
6315
+ case "orientation":
6316
+ case "primary":
6317
+ this._stampHandle();
6318
+ this._measureBounds();
6319
+ return;
6320
+ case "disabled":
6321
+ if (this.disabled && this._dragging) this._endDrag("pointer", this._dragStartPercent);
6322
+ this._stampHandle();
6323
+ return;
6324
+ case "breakpoint":
6325
+ this._watchBreakpoint();
6326
+ return;
6327
+ case "label":
6328
+ this._relabel();
6329
+ }
6330
+ }
6331
+ // ─── Structure ────────────────────────────────────────────────────────
6332
+ /**
6333
+ * The seam. An author-written `.aparte-split__handle` is ADOPTED — a second one
6334
+ * would be a second tab stop drawn over the first — otherwise one is inserted
6335
+ * between the first two children, which is where a two-pane grid needs it.
6336
+ *
6337
+ * An adopted one is MOVED there if the author put it elsewhere. The sheet is
6338
+ * positional — three tracks with the seam in the middle, and the stacked rules hide
6339
+ * `:nth-child(1)` / `:nth-child(3)` — so a handle written first would take the
6340
+ * primary pane's track and make stacking hide the wrong child.
6341
+ */
6342
+ _ensureHandle() {
6343
+ const own = Array.from(this.children).find(
6344
+ (child) => child instanceof HTMLElement && child.classList.contains("aparte-split__handle")
6345
+ );
6346
+ if (own) {
6347
+ if (this.children[1] !== own) {
6348
+ const second2 = Array.from(this.children).filter((child) => child !== own)[1] ?? null;
6349
+ this.insertBefore(own, second2);
6350
+ }
6351
+ return own;
6352
+ }
6353
+ const handle = document.createElement("div");
6354
+ handle.className = "aparte-split__handle";
6355
+ const second = this.children[1] ?? null;
6356
+ if (second) this.insertBefore(handle, second);
6357
+ else this.appendChild(handle);
6358
+ return handle;
6359
+ }
6360
+ /** The panes: every element child that is not the seam. */
6361
+ _panes() {
6362
+ return Array.from(this.children).filter(
6363
+ (child) => child instanceof HTMLElement && !child.classList.contains("aparte-split__handle")
6364
+ );
6365
+ }
6366
+ _primaryPane() {
6367
+ const panes = this._panes();
6368
+ return (this.primary === "end" ? panes[panes.length - 1] : panes[0]) ?? null;
6369
+ }
6370
+ /**
6371
+ * The APG's window splitter, plus the two states that take it out of the tab order.
6372
+ *
6373
+ * `aria-orientation` is the INVERSE of `orientation`: the attribute names the
6374
+ * separator's own axis, so a split whose panes sit side by side has a VERTICAL
6375
+ * separator between them.
6376
+ */
6377
+ _stampHandle() {
6378
+ const handle = this._handle;
6379
+ if (!handle) return;
6380
+ handle.setAttribute("role", "separator");
6381
+ handle.setAttribute("aria-orientation", this.orientation === "vertical" ? "horizontal" : "vertical");
6382
+ if (this.disabled || this.stacked) handle.removeAttribute("tabindex");
6383
+ else handle.setAttribute("tabindex", "0");
6384
+ handle.setAttribute("aria-valuenow", String(Math.round(this._achievedPercent())));
6385
+ const pane = this._primaryPane();
6386
+ if (pane) {
6387
+ if (!pane.id) pane.id = `aparte-split-pane-${++AparteSplit._paneIdSeq}`;
6388
+ handle.setAttribute("aria-controls", pane.id);
6389
+ }
6390
+ }
6391
+ // ─── Measurement ──────────────────────────────────────────────────────
6392
+ _measure(el2) {
6393
+ const box = el2.getBoundingClientRect();
6394
+ return this.orientation === "vertical" ? box.height : box.width;
6395
+ }
6396
+ /**
6397
+ * What the pane ACTUALLY got, which is not what was asked for: the CSS clamp between
6398
+ * `--aparte-split-min` and `--aparte-split-max` has the last word, and announcing the
6399
+ * requested value instead is how a splitter comes to tell a screen reader 5% while
6400
+ * the pane sits at 22%. Without layout (jsdom, `display: none`, before first paint)
6401
+ * the request is all there is, so it stands.
6402
+ *
6403
+ * While STACKED there is nothing to read: one pane is `display: none` and the other
6404
+ * spans the single track, so the ratio is 0 or 100 whatever the seam was set to.
6405
+ * Measuring it there is how tapping "Preview" on a phone used to write `position="0"`
6406
+ * into the attribute, the event and the host's storage.
6407
+ */
6408
+ _achievedPercent() {
6409
+ if (this.stacked) return this._position;
6410
+ const total = this._measure(this);
6411
+ const pane = this._primaryPane();
6412
+ if (!pane || !(total > 0)) return this._position;
6413
+ return Math.max(0, Math.min(100, this._measure(pane) / total * 100));
6414
+ }
6415
+ /**
6416
+ * The range the seam can actually reach, written onto the separator: park the pane
6417
+ * at 0% and read it, park it at 100% and read it, put the position back. Two forced
6418
+ * layouts in one synchronous task, so nothing paints in between.
6419
+ *
6420
+ * It also reconciles `aria-valuenow`, because the two have to be one story: the
6421
+ * requested percentage can sit outside the clamped range the probe just announced —
6422
+ * with a `--aparte-split-min` of `20rem`, `position="38"` on a 700px container asks
6423
+ * for 38 and gets ~46 — and a separator whose value is below its own minimum is the
6424
+ * unclamped-announcement bug this probe exists to prevent, just at rest. `_position`
6425
+ * is left alone: it is the REQUESTED value, and `reset()` means it.
6426
+ *
6427
+ * Gated on `!_dragging`: a ResizeObserver tick mid-gesture would move the pane to
6428
+ * both ends under the pointer. Gated on `!stacked` for the reason
6429
+ * `_achievedPercent()` is: a pane that spans everything measures the same at 0% and
6430
+ * at 100%, which would announce a range of one number.
6431
+ */
6432
+ _measureBounds() {
6433
+ const handle = this._handle;
6434
+ if (!handle || this._dragging || this.stacked) return;
6435
+ const pane = this._primaryPane();
6436
+ const total = this._measure(this);
6437
+ if (!pane || !(total > 0)) {
6438
+ handle.setAttribute("aria-valuemin", "0");
6439
+ handle.setAttribute("aria-valuemax", "100");
6440
+ return;
6441
+ }
6442
+ const saved = this.style.getPropertyValue("--aparte-split-position");
6443
+ this.style.setProperty("--aparte-split-position", "0%");
6444
+ const min = this._measure(pane) / total * 100;
6445
+ this.style.setProperty("--aparte-split-position", "100%");
6446
+ const max = this._measure(pane) / total * 100;
6447
+ if (saved) this.style.setProperty("--aparte-split-position", saved);
6448
+ else this.style.removeProperty("--aparte-split-position");
6449
+ handle.setAttribute("aria-valuemin", String(Math.round(min)));
6450
+ handle.setAttribute("aria-valuemax", String(Math.round(max)));
6451
+ handle.setAttribute("aria-valuenow", String(Math.round(this._achievedPercent())));
6452
+ }
6453
+ /**
6454
+ * Re-measure when the container's size changes — and only then. The probe writes the
6455
+ * host's `style` three times, and the reflection is commit-only precisely because a
6456
+ * `MutationObserver({ attributes: true })` (the docs' preview frame has one) reposts
6457
+ * a frame height per mutation, which resizes the split, which re-ticks this observer.
6458
+ * So: ignore a tick that did not move the size, and batch the rest to one per frame.
6459
+ */
6460
+ _watchSize() {
6461
+ if (typeof ResizeObserver !== "function") return;
6462
+ this._observer = new ResizeObserver(() => {
6463
+ const px = this._measure(this);
6464
+ if (Math.abs(px - this._observedPx) <= 1) return;
6465
+ this._observedPx = px;
6466
+ if (this._measureRafId !== null) return;
6467
+ this._measureRafId = requestAnimationFrame(() => {
6468
+ this._measureRafId = null;
6469
+ this._measureBounds();
6470
+ });
6471
+ });
6472
+ this._observer.observe(this);
6473
+ }
6474
+ // ─── The one write, and the one event ─────────────────────────────────
6475
+ _setLive(value) {
6476
+ this.style.setProperty("--aparte-split-position", `${Math.round(value * 1e3) / 1e3}%`);
6477
+ }
6478
+ /**
6479
+ * The commit: reflect, announce, dispatch — once. Everything downstream reads the
6480
+ * ACHIEVED size, so the attribute, `aria-valuenow` and the detail are one number.
6481
+ *
6482
+ * There is no per-frame event on purpose. A bubbling, composed CustomEvent per
6483
+ * pointermove is a reconciler in the drag loop, and no pane in this repo re-lays
6484
+ * itself out in JS — they resize in CSS. The trigger for adding
6485
+ * `aparte-split-resizing` is a consumer whose pane needs a JS measurement mid-drag.
6486
+ */
6487
+ _commit(source = this._source) {
6488
+ const position = Math.round(this._achievedPercent());
6489
+ this._position = position;
6490
+ this._setLive(position);
6491
+ this._lastPosition = position;
6492
+ if (this.getAttribute("position") !== String(position)) this.setAttribute("position", String(position));
6493
+ this._handle?.setAttribute("aria-valuenow", String(position));
6494
+ this.dispatchEvent(
6495
+ new CustomEvent("aparte-split-resize", {
6496
+ detail: {
6497
+ position,
6498
+ collapsed: this.collapsed,
6499
+ stacked: this.stacked,
6500
+ pane: this.pane,
6501
+ orientation: this.orientation,
6502
+ source
6503
+ },
6504
+ bubbles: true,
6505
+ composed: true
6506
+ })
6507
+ );
6508
+ }
6509
+ // ─── Pointer ──────────────────────────────────────────────────────────
6510
+ /** The computed direction, never `document.dir`: a host can flip one subtree. */
6511
+ _rtl() {
6512
+ return getComputedStyle(this).direction === "rtl";
6513
+ }
6514
+ _onPointerDown = (event) => {
6515
+ if (this.disabled || this.stacked || this._dragging) return;
6516
+ if (event.button > 0) return;
6517
+ event.preventDefault();
6518
+ this._handle?.focus();
6519
+ this._dragging = true;
6520
+ this._captured = false;
6521
+ this._moved = false;
6522
+ this._pointerId = event.pointerId;
6523
+ this._dragStartPercent = this._position;
6524
+ this._dragOriginPx = this.orientation === "vertical" ? event.clientY : event.clientX;
6525
+ this._dragContainerPx = this._measure(this);
6526
+ this._handle?.setAttribute("data-dragging", "");
6527
+ this._addScrim();
6528
+ window.addEventListener("pointermove", this._onPointerMove);
6529
+ window.addEventListener("pointerup", this._onPointerUp);
6530
+ window.addEventListener("pointercancel", this._onPointerCancel);
6531
+ };
6532
+ /**
6533
+ * Only the pointer that started the gesture. On a hybrid device a hovering pen or a
6534
+ * stray touch delivers a `pointermove` with `buttons === 0`, which would otherwise
6535
+ * fall into the release branch below and end someone else's mouse drag.
6536
+ */
6537
+ _isOurPointer(event) {
6538
+ return this._pointerId === null || event.pointerId === this._pointerId;
6539
+ }
6540
+ _onPointerMove = (event) => {
6541
+ if (!this._dragging || !this._isOurPointer(event)) return;
6542
+ if (event.buttons === 0) {
6543
+ this._endDrag("pointer");
6544
+ return;
6545
+ }
6546
+ if (!this._captured && this._pointerId !== null) {
6547
+ this._captured = true;
6548
+ try {
6549
+ this._handle?.setPointerCapture(this._pointerId);
6550
+ } catch {
6551
+ }
6552
+ }
6553
+ const point = this.orientation === "vertical" ? event.clientY : event.clientX;
6554
+ this._position = nextPosition({
6555
+ startPercent: this._dragStartPercent,
6556
+ deltaPx: point - this._dragOriginPx,
6557
+ containerPx: this._dragContainerPx,
6558
+ rtl: this._rtl(),
6559
+ vertical: this.orientation === "vertical",
6560
+ primaryEnd: this.primary === "end"
6561
+ });
6562
+ this._moved = true;
6563
+ this._setLive(this._position);
6564
+ };
6565
+ _onPointerUp = (event) => {
6566
+ if (this._isOurPointer(event)) this._endDrag("pointer");
6567
+ };
6568
+ _onPointerCancel = (event) => {
6569
+ if (this._isOurPointer(event)) this._endDrag("pointer");
6570
+ };
6571
+ _endDrag(source, restore) {
6572
+ if (!this._dragging) return;
6573
+ const moved = this._moved;
6574
+ this._dragging = false;
6575
+ this._moved = false;
6576
+ this._releasePointer();
6577
+ this._handle?.removeAttribute("data-dragging");
6578
+ this._removeScrim(!moved);
6579
+ if (restore !== void 0) {
6580
+ this._position = restore;
6581
+ this._setLive(restore);
6582
+ }
6583
+ if (!moved && restore === void 0) return;
6584
+ this._measureBounds();
6585
+ this._commit(source);
6586
+ }
6587
+ _releasePointer() {
6588
+ window.removeEventListener("pointermove", this._onPointerMove);
6589
+ window.removeEventListener("pointerup", this._onPointerUp);
6590
+ window.removeEventListener("pointercancel", this._onPointerCancel);
6591
+ if (this._captured && this._pointerId !== null) {
6592
+ try {
6593
+ this._handle?.releasePointerCapture(this._pointerId);
6594
+ } catch {
6595
+ }
6596
+ }
6597
+ this._captured = false;
6598
+ this._pointerId = null;
6599
+ }
6600
+ /**
6601
+ * The drag overlay: a fixed, transparent child of the HANDLE, alive for one gesture.
6602
+ * Topmost, so it swallows an iframe pane's hit-testing — the case this exists for —
6603
+ * and paints the drag cursor across the page. A child of the handle rather than of
6604
+ * the body so `click`, and therefore `dblclick`, still dispatches at the handle.
6605
+ */
6606
+ _addScrim() {
6607
+ if (this._scrim || !this._handle) return;
6608
+ const scrim = document.createElement("div");
6609
+ scrim.className = "aparte-split__scrim";
6610
+ scrim.setAttribute("aria-hidden", "true");
6611
+ this._handle.appendChild(scrim);
6612
+ this._scrim = scrim;
6613
+ }
6614
+ /**
6615
+ * Take the overlay off.
6616
+ *
6617
+ * `retire` is the press that never moved — the one a click, and then a double-click,
6618
+ * is built from. Its release happened ON the scrim (nothing captured the pointer
6619
+ * yet), and WebKit works out what a click hit by walking the pointerup target's LIVE
6620
+ * ancestors: take the node out here and that chain is rooted nowhere, so no `click`
6621
+ * fires at all, no `dblclick` follows, and the seam never resets. Chromium and Gecko
6622
+ * resolve the target before dispatch and do not care.
6623
+ *
6624
+ * So a retired scrim goes INERT immediately — `pointer-events: none`, which is what
6625
+ * actually matters, since a full-page overlay left hit-testable is a page dead to the
6626
+ * pointer — and leaves the document a moment later. Not on the next task: a first
6627
+ * measurement removed it there and it still raced the click under a loaded machine
6628
+ * (the trace is in `e2e/tests/layout.spec.ts`). The reference is dropped either way,
6629
+ * so the next press builds its own; an inert one on its way out costs nothing.
6630
+ *
6631
+ * Teardown never retires: `disconnectedCallback` takes the live one out at once.
6632
+ */
6633
+ _removeScrim(retire = false) {
6634
+ const scrim = this._scrim;
6635
+ this._scrim = null;
6636
+ if (!scrim) return;
6637
+ if (!retire) {
6638
+ scrim.remove();
6639
+ return;
6640
+ }
6641
+ scrim.style.pointerEvents = "none";
6642
+ setTimeout(() => scrim.remove(), SCRIM_RETIRE_MS);
6643
+ }
6644
+ _onDoubleClick = () => {
6645
+ if (this.disabled || this.stacked) return;
6646
+ this.reset();
6647
+ };
6648
+ // ─── Keyboard ─────────────────────────────────────────────────────────
6649
+ _onKeydown = (event) => {
6650
+ if (this._dragging && event.key === "Escape") {
6651
+ event.preventDefault();
6652
+ this._endDrag("pointer", this._dragStartPercent);
6653
+ return;
6654
+ }
6655
+ if (this.disabled || this.stacked || event.target !== this._handle) return;
6656
+ if (event.key === "Enter") {
6657
+ event.preventDefault();
6658
+ this._source = "keyboard";
6659
+ try {
6660
+ this.toggleCollapse();
6661
+ } finally {
6662
+ this._source = "api";
6663
+ }
6664
+ return;
6665
+ }
6666
+ const delta = keyDelta(
6667
+ event.key,
6668
+ event.shiftKey,
6669
+ this._rtl(),
6670
+ this.orientation === "vertical",
6671
+ this.primary === "end"
6672
+ );
6673
+ if (delta === null) return;
6674
+ event.preventDefault();
6675
+ this._keying = true;
6676
+ this._position = toPercent(String(this._position + delta), this._position);
6677
+ this._setLive(this._position);
6678
+ };
6679
+ /**
6680
+ * The keyup commits, not the keydown: a held arrow repeats, and one release should
6681
+ * write one attribute and send one event, not thirty.
6682
+ */
6683
+ _onKeyup = (event) => {
6684
+ if (!this._keying || event.target !== this._handle) return;
6685
+ if (keyDelta(event.key, event.shiftKey, this._rtl(), this.orientation === "vertical") === null) return;
6686
+ this._keying = false;
6687
+ this._commit("keyboard");
6688
+ };
6689
+ /**
6690
+ * The other end of a key step. If focus leaves the seam while the key is still down —
6691
+ * alt-tab, a click into a pane, a host moving focus — the keyup never arrives, and
6692
+ * the pane would stay where the keydown put it while the attribute and
6693
+ * `aria-valuenow` kept the old number for good. What is rendered is what commits.
6694
+ */
6695
+ _onFocusOut = () => {
6696
+ if (!this._keying) return;
6697
+ this._keying = false;
6698
+ this._commit("keyboard");
6699
+ };
6700
+ // ─── The breakpoint ───────────────────────────────────────────────────
6701
+ /**
6702
+ * The stacking query, from the `breakpoint` attribute: a length (`48rem`, the
6703
+ * default, or `640px`), or `none` for a split that stays two panes at any width —
6704
+ * which a documentation frame needs and a host with its own breakpoint wants.
6705
+ */
6706
+ _watchBreakpoint() {
6707
+ this._media?.removeEventListener("change", this._onMediaChange);
6708
+ this._media = null;
6709
+ const attr = (this.getAttribute("breakpoint") ?? "").trim();
6710
+ if (attr === "none") {
6711
+ this._applyStacked(false);
6712
+ return;
6713
+ }
6714
+ if (typeof matchMedia !== "function") return;
6715
+ const query = attr ? `(max-width: ${attr})` : STACK_QUERY;
6716
+ this._media = matchMedia(query);
6717
+ this._media.addEventListener("change", this._onMediaChange);
6718
+ this._applyStacked(this._media.matches);
6719
+ }
6720
+ _onMediaChange = (e) => this._applyStacked(e.matches);
6721
+ /**
6722
+ * Entering the stacked state shows the START pane: the chat, never a preview of
6723
+ * nothing — the same judgement that makes the sidebar's drawer enter closed. Unless
6724
+ * the markup already named one, which is an answer to the same question and a better
6725
+ * one. Leaving it restores both panes, and with them the seam and its tab stop.
6726
+ *
6727
+ * `was` is the ATTRIBUTE, not the `stacked` getter: the getter also counts
6728
+ * `.aparte-split--only-*`, the CSS route a host takes when it owns its breakpoints
6729
+ * and sets `breakpoint="none"`. Read through the getter, that mount looks like a
6730
+ * split leaving a stacked state it never entered — and the `pane` the author wrote
6731
+ * is deleted on the way in.
6732
+ */
6733
+ _applyStacked(stacked) {
6734
+ const was = this.hasAttribute("data-stacked");
6735
+ if (stacked && !was && this._dragging) this._endDrag("pointer", this._dragStartPercent);
6736
+ this.toggleAttribute("data-stacked", stacked);
6737
+ if (stacked && !was) {
6738
+ if (!this.hasAttribute("pane")) this.showPane("start");
6739
+ } else if (!stacked && was) {
6740
+ this.removeAttribute("pane");
6741
+ }
6742
+ this._lastPane = this.pane;
6743
+ this._stampHandle();
6744
+ if (stacked !== was) this._measureBounds();
6745
+ }
6746
+ /**
6747
+ * A `[data-aparte-split-pane]` anywhere on the page switches a split's pane, so a
6748
+ * two-button toggle in a header needs no script.
6749
+ *
6750
+ * The value is `start` or `end` — the pane to show. Any other non-empty value names
6751
+ * a split's `id` and TOGGLES that one; empty toggles the nearest split, else the
6752
+ * first on the page. Same shape as `[data-aparte-sidebar-toggle]`.
6753
+ */
6754
+ _onDocumentClick = (e) => {
6755
+ const control = e.target?.closest?.("[data-aparte-split-pane]");
6756
+ if (!control) return;
6757
+ const value = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
6758
+ const which = value === "start" || value === "end" ? value : null;
6759
+ const named = which ? "" : value;
6760
+ if (named) {
6761
+ if (named !== this.id) return;
6762
+ } else if ((control.closest("aparte-split") ?? document.querySelector("aparte-split")) !== this) {
6763
+ return;
6764
+ }
6765
+ this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
6766
+ };
6767
+ // ─── Locale ───────────────────────────────────────────────────────────
6768
+ _onConfigChange = () => this._relabel();
6769
+ /**
6770
+ * The seam's accessible name. A host that wrote its own `aria-label` — or an
6771
+ * `aria-labelledby` pointing at a heading — keeps it: `data-own-label` marks the
6772
+ * one we set, so a language switch replaces ours and never theirs.
6773
+ */
6774
+ _relabel() {
6775
+ const handle = this._handle;
6776
+ if (!handle) return;
6777
+ if (handle.hasAttribute("aria-labelledby")) return;
6778
+ if (handle.hasAttribute("aria-label") && handle.dataset["ownLabel"] !== "locale") return;
6779
+ const locale = resolveConfig(this).getLocale();
6780
+ handle.setAttribute("aria-label", this.getAttribute("label") ?? locale.splitHandleLabel ?? "Resize the panes");
6781
+ handle.dataset["ownLabel"] = "locale";
6782
+ }
6783
+ }
6784
+ if (!customElements.get("aparte-split")) {
6785
+ customElements.define("aparte-split", AparteSplit);
6786
+ }
6787
+ const APARTE_ELEMENTS = [
6788
+ // Chat shell + transcript
6789
+ ["aparte-chat", AparteChat],
6790
+ ["aparte-chat-viewport", AparteChatViewport],
6791
+ ["aparte-chat-bubble", AparteChatBubble],
6792
+ ["aparte-chat-status", AparteChatStatus],
6793
+ // Composer and its primitives
6794
+ ["aparte-composer", AparteComposer],
6795
+ ["aparte-composer-input", AparteComposerInput],
6796
+ ["aparte-composer-send", AparteComposerSend],
6797
+ ["aparte-composer-cancel", AparteComposerCancel],
6798
+ ["aparte-composer-attachments", AparteComposerAttachments],
6799
+ ["aparte-composer-add-attachment", AparteComposerAddAttachment],
6800
+ ["aparte-composer-action", AparteComposerAction],
6801
+ ["aparte-composer-toolbar", AparteComposerToolbar],
6802
+ // Standalone components
6803
+ ["aparte-conversation-list", AparteConversationList],
6804
+ ["aparte-elicitation", AparteElicitation],
6805
+ ["aparte-scroll-rail", AparteScrollRail],
6806
+ ["aparte-sidebar", AparteSidebar],
6807
+ ["aparte-split", AparteSplit],
6808
+ ["aparte-suggestions", AparteSuggestions],
6809
+ ["aparte-context", AparteContext],
6810
+ // Primitives
6811
+ ["aparte-select", AparteSelect],
6812
+ ["aparte-option", AparteOption],
6813
+ ["aparte-optgroup", AparteOptgroup],
6814
+ ["aparte-progress-spinner", AparteProgressSpinner],
6815
+ ["aparte-icon", AparteIcon]
6816
+ ];
5019
6817
  function registerAllComponents() {
5020
- const _chat = customElements.get("aparte-chat");
5021
- const _viewport = customElements.get("aparte-chat-viewport");
5022
- const _bubble = customElements.get("aparte-chat-bubble");
5023
- const _status = customElements.get("aparte-chat-status");
5024
- if (!_chat || !_viewport || !_bubble || !_status) {
5025
- console.warn("[Aparte] Some components may not be registered. Ensure all component files are imported.");
6818
+ const missing = APARTE_ELEMENTS.filter(([tag]) => !customElements.get(tag)).map(([tag]) => tag);
6819
+ if (missing.length > 0) {
6820
+ console.warn(
6821
+ `[aparte] ${missing.length} of ${APARTE_ELEMENTS.length} elements are not registered: ${missing.join(", ")}. Import '@aparte/core' at module scope (not behind a lazy import()), and check that your bundler is honouring the package's sideEffects field.`
6822
+ );
5026
6823
  }
5027
6824
  }
6825
+ if (typeof document !== "undefined") installDialogTriggersOnce();
5028
6826
  export {
5029
6827
  A as APARTE_CONFIG_CHANGE,
5030
6828
  a as APARTE_CONVERSATION_SCHEMA_VERSION,
5031
6829
  b as APARTE_DEFAULT_BUBBLE_ACTIONS,
5032
6830
  c as APARTE_DEFAULT_HOST_HANDLERS,
5033
6831
  d as APARTE_DEFAULT_ICON_FALLBACKS,
5034
- e as APARTE_DEFAULT_LOCALE,
6832
+ APARTE_DEFAULT_LOCALE,
5035
6833
  f as APARTE_DEFAULT_UI_EVENTS,
5036
6834
  g as APARTE_HOST_ATTR,
5037
6835
  h as AparteBackendTransport,
@@ -5064,7 +6862,10 @@ export {
5064
6862
  AparteOptgroup,
5065
6863
  AparteOption,
5066
6864
  AparteProgressSpinner,
6865
+ AparteScrollRail,
5067
6866
  AparteSelect,
6867
+ AparteSidebar,
6868
+ AparteSplit,
5068
6869
  s as AparteStreamParser,
5069
6870
  AparteSuggestions,
5070
6871
  t as aparteGlobalConfig,
@@ -5075,38 +6876,39 @@ export {
5075
6876
  y as collectRendererStyles,
5076
6877
  z as contentToText,
5077
6878
  B as contextConfig,
5078
- C as createAparteChatHandler,
5079
- D as createStreamAdapter,
6879
+ copyText,
6880
+ D as createAparteChatHandler,
6881
+ E as createStreamAdapter,
5080
6882
  cssEscape,
5081
- F as declineDefaultRenderers,
5082
- G as defaultSanitizer,
5083
- deriveArtifactKind,
5084
- H as detachConfig,
6883
+ G as declineDefaultRenderers,
6884
+ H as defaultSanitizer,
6885
+ I as detachConfig,
5085
6886
  escapeAttr,
5086
6887
  escapeHtml,
5087
- K as filesToAttachments,
5088
- L as getAllRenderers,
6888
+ L as filesToAttachments,
6889
+ M as getAllRenderers,
5089
6890
  getSegmentRenderer,
5090
6891
  installDefaultRenderersOnce,
6892
+ installDialogTriggersOnce,
5091
6893
  isAwaitingReply,
5092
- P as isFormatAdapter,
5093
- Q as isSafeUrl,
5094
- R as isSegmentSettled,
5095
- S as parseAparteEventStream,
5096
- T as parseMarkdownToSegments,
6894
+ R as isFormatAdapter,
6895
+ S as isSafeUrl,
6896
+ T as isSegmentSettled,
6897
+ U as parseAparteEventStream,
6898
+ V as parseMarkdownToSegments,
5097
6899
  populateBubbleFromMessage,
5098
- V as readableToAsyncIterable,
6900
+ X as readableToAsyncIterable,
5099
6901
  registerAllComponents,
5100
- W as registerDefaultRenderers,
5101
- X as registerSegmentRenderer,
5102
- Y as requestUserInput,
6902
+ Y as registerDefaultRenderers,
6903
+ Z as registerSegmentRenderer,
6904
+ _ as requestUserInput,
5103
6905
  resolveConfig,
5104
6906
  revokeAttachmentUrls,
5105
6907
  runWithConfig,
5106
- a0 as segmentDuration,
5107
- a1 as segmentTiming,
6908
+ a2 as segmentDuration,
6909
+ a3 as segmentTiming,
5108
6910
  subscribeConfigChange,
5109
- a3 as unregisterSegmentRenderer,
6911
+ a5 as unregisterSegmentRenderer,
5110
6912
  uuid
5111
6913
  };
5112
6914
  //# sourceMappingURL=index.js.map