@aceshooting/lyra-ui 2.0.0 → 2.1.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 (224) hide show
  1. package/README.md +87 -14
  2. package/custom-elements.json +11555 -5539
  3. package/dist/components/app-rail/app-rail-item.d.ts +30 -0
  4. package/dist/components/app-rail/app-rail-item.js +68 -0
  5. package/dist/components/app-rail/app-rail-item.styles.d.ts +1 -0
  6. package/dist/components/app-rail/app-rail-item.styles.js +65 -0
  7. package/dist/components/app-rail/app-rail.d.ts +21 -24
  8. package/dist/components/app-rail/app-rail.js +68 -132
  9. package/dist/components/app-rail/app-rail.styles.js +5 -2
  10. package/dist/components/attachment-chip/attachment-chip.d.ts +38 -11
  11. package/dist/components/attachment-chip/attachment-chip.js +73 -29
  12. package/dist/components/attachment-trigger/attachment-trigger.d.ts +13 -0
  13. package/dist/components/attachment-trigger/attachment-trigger.js +13 -1
  14. package/dist/components/attachment-trigger/attachment-trigger.styles.js +28 -6
  15. package/dist/components/chart/bar-chart.js +2 -18
  16. package/dist/components/chart/box-plot.d.ts +25 -1
  17. package/dist/components/chart/box-plot.js +136 -6
  18. package/dist/components/chart/box-plot.styles.js +13 -0
  19. package/dist/components/chart/bubble-chart.js +2 -18
  20. package/dist/components/chart/chart.d.ts +54 -10
  21. package/dist/components/chart/chart.js +182 -39
  22. package/dist/components/chart/doughnut-chart.js +2 -18
  23. package/dist/components/chart/histogram-bin.d.ts +5 -2
  24. package/dist/components/chart/histogram-bin.js +12 -4
  25. package/dist/components/chart/histogram.js +8 -16
  26. package/dist/components/chart/line-chart.js +2 -18
  27. package/dist/components/chart/lite-chart.d.ts +80 -3
  28. package/dist/components/chart/lite-chart.js +250 -53
  29. package/dist/components/chart/pie-chart.js +2 -18
  30. package/dist/components/chart/polar-area-chart.js +2 -18
  31. package/dist/components/chart/radar-chart.js +2 -18
  32. package/dist/components/chart/scatter-chart.js +2 -18
  33. package/dist/components/chat-composer/chat-composer.d.ts +42 -1
  34. package/dist/components/chat-composer/chat-composer.js +84 -3
  35. package/dist/components/chat-composer/chat-composer.styles.js +11 -1
  36. package/dist/components/chat-message/chat-message.d.ts +32 -4
  37. package/dist/components/chat-message/chat-message.js +57 -17
  38. package/dist/components/checkbox/checkbox.d.ts +66 -8
  39. package/dist/components/checkbox/checkbox.js +173 -50
  40. package/dist/components/checkbox/checkbox.styles.js +9 -2
  41. package/dist/components/chip/chip-group.js +8 -2
  42. package/dist/components/chip/chip.js +9 -2
  43. package/dist/components/citation-badge/citation-badge.js +21 -3
  44. package/dist/components/code-block/code-block.d.ts +38 -0
  45. package/dist/components/code-block/code-block.js +72 -7
  46. package/dist/components/code-block/code-loader.d.ts +44 -1
  47. package/dist/components/code-block/code-loader.js +63 -0
  48. package/dist/components/combobox/combobox.d.ts +46 -5
  49. package/dist/components/combobox/combobox.js +175 -35
  50. package/dist/components/combobox/combobox.styles.js +3 -3
  51. package/dist/components/context-meter/context-meter.js +17 -8
  52. package/dist/components/context-meter/context-meter.styles.js +0 -6
  53. package/dist/components/conversation-item/conversation-item.d.ts +13 -16
  54. package/dist/components/conversation-item/conversation-item.js +29 -20
  55. package/dist/components/date-picker/calendar-core.d.ts +24 -0
  56. package/dist/components/date-picker/calendar-core.js +51 -4
  57. package/dist/components/date-picker/date-input.d.ts +77 -10
  58. package/dist/components/date-picker/date-input.js +278 -73
  59. package/dist/components/date-picker/date-picker.d.ts +28 -4
  60. package/dist/components/date-picker/date-picker.js +95 -37
  61. package/dist/components/dialog/confirm.js +3 -6
  62. package/dist/components/dialog/dialog.d.ts +75 -31
  63. package/dist/components/dialog/dialog.js +147 -131
  64. package/dist/components/dialog/dialog.styles.js +46 -2
  65. package/dist/components/dock-panel/dock-panel.d.ts +8 -0
  66. package/dist/components/dock-panel/dock-panel.js +15 -1
  67. package/dist/components/dock-panel/dock-panel.styles.js +15 -1
  68. package/dist/components/document-preview/document-preview.d.ts +15 -7
  69. package/dist/components/document-preview/document-preview.js +29 -13
  70. package/dist/components/empty/empty.d.ts +1 -2
  71. package/dist/components/empty/empty.js +18 -12
  72. package/dist/components/empty/empty.styles.js +0 -3
  73. package/dist/components/export-button/export-button.d.ts +12 -0
  74. package/dist/components/export-button/export-button.js +33 -5
  75. package/dist/components/export-button/export-button.styles.js +5 -1
  76. package/dist/components/file-input/accept.js +2 -0
  77. package/dist/components/file-input/file-input.d.ts +10 -1
  78. package/dist/components/file-input/file-input.js +36 -5
  79. package/dist/components/flag/flag.d.ts +1 -1
  80. package/dist/components/flag/flag.js +15 -1
  81. package/dist/components/flag/flag.styles.js +1 -1
  82. package/dist/components/flag/language-map.d.ts +4 -1
  83. package/dist/components/flag/language-map.js +6 -3
  84. package/dist/components/gauge/gauge.d.ts +10 -1
  85. package/dist/components/gauge/gauge.js +28 -9
  86. package/dist/components/generation-status/generation-status.d.ts +13 -5
  87. package/dist/components/generation-status/generation-status.js +59 -5
  88. package/dist/components/graph/graph.d.ts +37 -3
  89. package/dist/components/graph/graph.js +132 -20
  90. package/dist/components/heatmap/calendar-grid.d.ts +15 -6
  91. package/dist/components/heatmap/calendar-grid.js +17 -6
  92. package/dist/components/heatmap/heatmap-scale.d.ts +5 -1
  93. package/dist/components/heatmap/heatmap-scale.js +8 -2
  94. package/dist/components/heatmap/heatmap.d.ts +166 -25
  95. package/dist/components/heatmap/heatmap.js +372 -89
  96. package/dist/components/json-viewer/json-viewer.d.ts +22 -5
  97. package/dist/components/json-viewer/json-viewer.js +115 -37
  98. package/dist/components/json-viewer/json-viewer.styles.js +2 -1
  99. package/dist/components/kbd/kbd.js +24 -7
  100. package/dist/components/live-region/live-region.d.ts +10 -1
  101. package/dist/components/live-region/live-region.js +42 -6
  102. package/dist/components/map/map.d.ts +24 -1
  103. package/dist/components/map/map.js +89 -24
  104. package/dist/components/markdown/markdown-loader.d.ts +24 -0
  105. package/dist/components/markdown/markdown-loader.js +35 -1
  106. package/dist/components/markdown/markdown.d.ts +46 -8
  107. package/dist/components/markdown/markdown.js +136 -31
  108. package/dist/components/markdown/markdown.styles.js +7 -1
  109. package/dist/components/media-card/media-card.d.ts +25 -17
  110. package/dist/components/media-card/media-card.js +43 -65
  111. package/dist/components/mention-popover/mention-popover.d.ts +4 -1
  112. package/dist/components/mention-popover/mention-popover.js +23 -5
  113. package/dist/components/menu/menu-item.d.ts +32 -3
  114. package/dist/components/menu/menu-item.js +94 -7
  115. package/dist/components/menu/menu-item.styles.js +8 -0
  116. package/dist/components/menu/menu.d.ts +25 -1
  117. package/dist/components/menu/menu.js +99 -3
  118. package/dist/components/menu/menu.styles.js +3 -2
  119. package/dist/components/model-select/model-select.d.ts +48 -3
  120. package/dist/components/model-select/model-select.js +120 -22
  121. package/dist/components/model-select/model-select.styles.js +17 -3
  122. package/dist/components/model-settings-panel/model-settings-panel.d.ts +25 -1
  123. package/dist/components/model-settings-panel/model-settings-panel.js +65 -1
  124. package/dist/components/playback/playback.d.ts +6 -2
  125. package/dist/components/playback/playback.js +72 -29
  126. package/dist/components/playback/playback.styles.js +6 -1
  127. package/dist/components/responsive-panel/responsive-panel.d.ts +29 -16
  128. package/dist/components/responsive-panel/responsive-panel.js +130 -132
  129. package/dist/components/responsive-panel/responsive-panel.styles.js +1 -1
  130. package/dist/components/result-card/result-card.d.ts +8 -2
  131. package/dist/components/result-card/result-card.js +9 -3
  132. package/dist/components/result-card/result-card.styles.js +0 -1
  133. package/dist/components/select/select.d.ts +59 -20
  134. package/dist/components/select/select.js +125 -43
  135. package/dist/components/skeleton/skeleton.d.ts +3 -0
  136. package/dist/components/skeleton/skeleton.js +7 -1
  137. package/dist/components/skeleton/skeleton.styles.js +5 -4
  138. package/dist/components/slider/slider.d.ts +57 -5
  139. package/dist/components/slider/slider.js +195 -80
  140. package/dist/components/source-card/source-card.d.ts +17 -5
  141. package/dist/components/source-card/source-card.js +55 -8
  142. package/dist/components/source-card/source-card.styles.js +1 -1
  143. package/dist/components/source-list/source-list.js +9 -1
  144. package/dist/components/sparkline/sparkline.js +34 -10
  145. package/dist/components/split/split.d.ts +141 -14
  146. package/dist/components/split/split.js +357 -52
  147. package/dist/components/split/split.styles.js +18 -1
  148. package/dist/components/stat/stat.d.ts +10 -4
  149. package/dist/components/stat/stat.js +46 -11
  150. package/dist/components/stream-status/stream-status.d.ts +27 -11
  151. package/dist/components/stream-status/stream-status.js +95 -13
  152. package/dist/components/streaming-text/streaming-text.d.ts +9 -3
  153. package/dist/components/streaming-text/streaming-text.js +25 -10
  154. package/dist/components/streaming-text/streaming-text.styles.js +3 -2
  155. package/dist/components/switch/switch.d.ts +52 -7
  156. package/dist/components/switch/switch.js +111 -40
  157. package/dist/components/switch/switch.styles.js +1 -1
  158. package/dist/components/table/table.d.ts +18 -3
  159. package/dist/components/table/table.js +88 -20
  160. package/dist/components/tabs/tabs.d.ts +23 -0
  161. package/dist/components/tabs/tabs.js +76 -12
  162. package/dist/components/tabs/tabs.styles.js +11 -0
  163. package/dist/components/thinking-panel/thinking-panel.d.ts +3 -1
  164. package/dist/components/thinking-panel/thinking-panel.js +34 -9
  165. package/dist/components/thinking-panel/thinking-panel.styles.js +6 -0
  166. package/dist/components/time-range/time-range.d.ts +59 -2
  167. package/dist/components/time-range/time-range.js +163 -67
  168. package/dist/components/toast/toast-item.d.ts +12 -3
  169. package/dist/components/toast/toast-item.js +90 -53
  170. package/dist/components/toast/toast-item.styles.js +59 -28
  171. package/dist/components/toast/toast.js +17 -5
  172. package/dist/components/toast/toaster.js +10 -1
  173. package/dist/components/tool-approval-dialog/tool-approval-dialog.d.ts +11 -10
  174. package/dist/components/tool-approval-dialog/tool-approval-dialog.js +50 -124
  175. package/dist/components/tool-approval-dialog/tool-approval-dialog.styles.js +1 -1
  176. package/dist/components/tool-call-chip/tool-call-chip.d.ts +18 -2
  177. package/dist/components/tool-call-chip/tool-call-chip.js +86 -25
  178. package/dist/components/tool-param-form/tool-param-form.d.ts +89 -28
  179. package/dist/components/tool-param-form/tool-param-form.js +350 -106
  180. package/dist/components/tool-result-dialog/tool-result-dialog.d.ts +27 -7
  181. package/dist/components/tool-result-dialog/tool-result-dialog.js +80 -115
  182. package/dist/components/tool-result-dialog/tool-result-dialog.styles.js +1 -1
  183. package/dist/components/tool-result-view/registry.d.ts +0 -8
  184. package/dist/components/tool-result-view/registry.js +7 -2
  185. package/dist/components/tool-result-view/tool-result-view.d.ts +6 -3
  186. package/dist/components/tool-result-view/tool-result-view.js +18 -4
  187. package/dist/components/tool-select-dialog/tool-select-dialog.d.ts +11 -11
  188. package/dist/components/tool-select-dialog/tool-select-dialog.js +44 -133
  189. package/dist/components/tool-select-dialog/tool-select-dialog.styles.js +13 -4
  190. package/dist/components/tree/tree-node.d.ts +4 -1
  191. package/dist/components/tree/tree-node.js +4 -1
  192. package/dist/components/tree/tree.d.ts +17 -8
  193. package/dist/components/tree/tree.js +21 -20
  194. package/dist/components/typing-indicator/typing-indicator.d.ts +12 -2
  195. package/dist/components/typing-indicator/typing-indicator.js +16 -4
  196. package/dist/components/typing-indicator/typing-indicator.styles.js +2 -2
  197. package/dist/components/virtual-list/virtual-list.d.ts +56 -19
  198. package/dist/components/virtual-list/virtual-list.js +184 -38
  199. package/dist/components/virtual-list/virtual-list.styles.js +12 -0
  200. package/dist/components/widget/widget.d.ts +5 -4
  201. package/dist/components/widget/widget.js +54 -120
  202. package/dist/components/widget/widget.styles.js +8 -2
  203. package/dist/components/word-cloud/word-cloud-layout.d.ts +7 -0
  204. package/dist/components/word-cloud/word-cloud-layout.js +20 -6
  205. package/dist/components/word-cloud/word-cloud.d.ts +22 -2
  206. package/dist/components/word-cloud/word-cloud.js +45 -6
  207. package/dist/components/word-cloud/word-cloud.styles.js +13 -1
  208. package/dist/internal/anchored-validity.d.ts +1 -0
  209. package/dist/internal/anchored-validity.js +86 -0
  210. package/dist/internal/form-associated.d.ts +7 -0
  211. package/dist/internal/form-associated.js +43 -4
  212. package/dist/internal/numbers.d.ts +12 -0
  213. package/dist/internal/numbers.js +26 -0
  214. package/dist/internal/overlay-manager.d.ts +51 -0
  215. package/dist/internal/overlay-manager.js +523 -0
  216. package/dist/internal/positioner.js +27 -3
  217. package/dist/internal/safe-url.d.ts +12 -0
  218. package/dist/internal/safe-url.js +41 -0
  219. package/dist/internal/tokens.styles.js +17 -11
  220. package/dist/lyra.d.ts +5 -2
  221. package/dist/lyra.js +3 -1
  222. package/llms-full.txt +970 -426
  223. package/llms.txt +5 -4
  224. package/package.json +19 -2
package/llms-full.txt CHANGED
@@ -49,37 +49,62 @@ Not a custom element itself — infrastructure every `lyra-*` component in the l
49
49
  `{ bubbles: true, composed: true, cancelable: true }` `CustomEvent` and returns it, so callers can
50
50
  do `const ev = this.emit('lyra-export', {...}); if (ev.defaultPrevented) return;`.
51
51
  - **`FormAssociated(Base)`** mixin (`internal/form-associated.ts`) — makes a `LitElement`
52
- form-associated: `static formAssociated = true`, `attachInternals()` in the constructor. Adds
53
- properties `name: string`, `value: string` (hand-written accessor via Lit's `noAccessor` so
54
- `internals.setFormValue()` fires synchronously on assignment, not on the async update cycle),
55
- `disabled: boolean` (reflected), `required: boolean` (reflected). Provides
56
- `checkValidity()`/`reportValidity()` (delegate to `internals`), `formDisabledCallback(disabled)`,
57
- and a real `updateValidity()` that calls `internals.setValidity({ valueMissing: true }, ...)`
58
- whenever `required` is set and `value === ''` (re-run on every `value`/`required` change and once
59
- from `connectedCallback()`) so `checkValidity()`/`reportValidity()`/`:invalid`/`:user-invalid`
60
- now reflect real constraint state instead of always reporting valid. `formResetCallback()`
61
- restores the value captured from the element's original `value` *content attribute* (native
62
- `defaultValue` semantics), not a hardcoded blank only a later `setAttribute('value', ...)` or
63
- declarative markup updates that captured default; assigning the `.value` IDL property never does.
64
- Only `lyra-date-input` uses this mixin; `lyra-combobox` and `lyra-select` each hand-roll an
65
- equivalent independently instead (same `setValidity`/default-capture behavior`lyra-combobox`
66
- needs its own copy because its value can be an array in `multiple` mode, `lyra-select` because its
67
- default is captured from a declared-`selected` `<lyra-option>` rather than a `value` content
68
- attribute see each component's own gotchas for where its copy diverges from the mixin).
52
+ form-associated: `static formAssociated = true`, `attachInternals()` in the constructor (which also
53
+ eagerly calls `internals.setFormValue('')`, so an untouched control is present in `FormData` as
54
+ `""` from construction matching native `<input>` instead of being absent). Adds properties
55
+ `name: string`, `value: string`, `disabled: boolean` (reflected), `required: boolean` (reflected)
56
+ **all four** use a hand-written accessor via Lit's `noAccessor` (not a plain reactive property), so
57
+ the attribute write/`internals` call fires synchronously on assignment instead of on Lit's async
58
+ update cycle (e.g. `internals.setFormValue()` runs synchronously off `value`; `disabled`'s
59
+ attribute reflection lands before same-tick form APIs run). Provides
60
+ `checkValidity()`/`reportValidity()` (delegate to `internals`), readonly `form`, `labels`,
61
+ `validity`, `validationMessage`, `willValidate`, and `effectiveDisabled` getters
62
+ `effectiveDisabled` is own `disabled` OR an ancestor `<fieldset disabled>`'s cascaded state;
63
+ `formDisabledCallback(fieldsetDisabled)` stores that ancestor state in a private field rather than
64
+ writing into `disabled` itself, so `disabled` always reflects only the consumer's own
65
+ attribute/property (never mutated by fieldset cascading, matching native `<input>`) **read
66
+ `effectiveDisabled`, not `disabled`, to get the merged state**. A real `updateValidity()` calls
67
+ `internals.setValidity({ valueMissing: true }, ...)` whenever `required` is set and `value === ''`
68
+ (re-run on every `value`/`required` change and once from `connectedCallback()`) so
69
+ `checkValidity()`/`reportValidity()`/`:invalid`/`:user-invalid` now reflect real constraint state
70
+ instead of always reporting valid. Validity is routed through an internal
71
+ `AnchoredValidityController` (`internal/anchored-validity.ts`): it calls
72
+ `internals.setValidity(flags, message, anchor)` with `anchor` = the first focusable descendant in
73
+ the shadow root (`input:not([type='hidden']), textarea, select, button,
74
+ [tabindex]:not([tabindex='-1'])`), re-resolved after every render — required because the browser
75
+ can't focus the (non-focusable) custom-element host itself when `reportValidity()`/native
76
+ validation UI tries to focus the invalid control. `formResetCallback()` restores the value captured
77
+ from the element's original `value` *content attribute* (native `defaultValue` semantics), not a
78
+ hardcoded blank — only a later `setAttribute('value', ...)` or declarative markup updates that
79
+ captured default; assigning the `.value` IDL property never does. `formStateRestoreCallback()`
80
+ restores string state synchronously without emitting a user event. `lyra-date-input`,
81
+ `lyra-chat-composer`, and `lyra-slider` use this mixin; controls with non-string values and
82
+ controls whose defaults come from child markup hand-roll an equivalent independently instead (same
83
+ `setValidity`/default-capture behavior — `lyra-combobox` needs its own copy because its value can
84
+ be an array in `multiple` mode, `lyra-select` because its default is captured from a
85
+ declared-`selected` `<lyra-option>` rather than a `value` content attribute — see each component's
86
+ own gotchas for where its copy diverges from the mixin). Every form-associated control exposes the
87
+ same six readonly native-like getters (`form`, `labels`, `validity`, `validationMessage`,
88
+ `willValidate`, `effectiveDisabled`), including `lyra-time-range`, whose form association exists
89
+ only for fieldset-cascaded disablement and has no submission-value/state-restoration contract.
69
90
  - **`positioner.ts` → `place(anchor, popup, opts?): () => void`** — thin wrapper over
70
91
  `@floating-ui/dom`'s `computePosition` + `autoUpdate`. Forces `strategy: 'fixed'` (to match the
71
92
  popup's own `position:fixed` CSS — otherwise it lands offset by the page scroll position),
72
93
  middleware `offset(opts.offset ?? 4)`, `flip()`, `shift({ padding: 8 })`, default
73
94
  `placement: 'bottom-start'`. Returns a cleanup function that stops the `autoUpdate` loop — call it
74
95
  in `disconnectedCallback()`. Used by `lyra-combobox`, `lyra-select`, `lyra-date-input`,
75
- `lyra-export-button`.
96
+ `lyra-export-button`, `lyra-model-select`, `lyra-mention-popover`, `lyra-tool-call-chip`,
97
+ `lyra-citation-badge`, and `lyra-menu` (which resolves its `placement` through
98
+ `rtlAwarePlacement()` first — see `rtl.ts` below — before handing it to `place()`).
76
99
  - **`tokens.styles.ts` → `tokens`** (a `css` template prepended to every component's stylesheet) —
77
100
  the whole design-token layer. Every value chains through a `--wa-*` Web Awesome token first, with
78
101
  a hard-coded fallback, e.g. `--lyra-color-brand: var(--wa-color-brand-fill-loud, #0969da)`. Full
79
102
  set: `--lyra-color-surface / -text / -text-quiet / -border / -brand / -brand-quiet / -success /
80
- -warning / -danger`, `--lyra-color-on-brand` (text/icon color for content painted on a solid
81
- brand-fill background, e.g. selected calendar days — not itself light/dark-swapped, since it's
82
- white on both fills), `--lyra-space-xs / -s / -m / -l`, `--lyra-radius`, `--lyra-shadow`,
103
+ -success-quiet / -warning / -warning-quiet / -danger / -danger-quiet`,
104
+ `--lyra-color-on-brand / -on-success / -on-warning / -on-danger` (paired
105
+ text/icon colors for content painted on the corresponding solid semantic fill; the standalone
106
+ fallbacks switch from light content in light mode to dark content on the brighter dark-mode
107
+ fills), `--lyra-space-xs / -s / -m / -l`, `--lyra-radius`, `--lyra-shadow`,
83
108
  `--lyra-font`, `--lyra-transition-fast` / `-base` (motion timing every animating component should
84
109
  share), `--lyra-opacity-disabled` (one shared disabled-state opacity), `--lyra-focus-ring-width` /
85
110
  `-color` / `-offset` (every `:focus-visible` rule should reference these), `--lyra-icon-button-size`
@@ -100,6 +125,16 @@ Not a custom element itself — infrastructure every `lyra-*` component in the l
100
125
  - **`scroll-lock.ts` → `lockScroll(): () => void`** — ref-counted `document.documentElement`
101
126
  scroll lock (used by `lyra-widget`'s fullscreen mode); safe to acquire/release more than once
102
127
  concurrently, restores the original `overflow` only once the last outstanding lock releases.
128
+ - **`overlay-manager.ts` → `activateOverlay(options): OverlayHandle`** — per-`Document`
129
+ coordination for `lyra-dialog`, overlay-mode `lyra-responsive-panel`, the three tool dialogs,
130
+ mobile `lyra-app-rail`, and fullscreen `lyra-widget`. Overlays from different families share one
131
+ visual/topmost stack: only the top entry handles Escape, Tab trapping, and backdrop dismissal.
132
+ Content outside the active modal's composed path is inert, including lower overlays and page
133
+ content added while it is open. Focus traversal crosses slots and open shadow roots; activation
134
+ preserves focus already inside but moves outside focus into the panel, and an open-state close
135
+ restores the still-connected opener even after a direct property/attribute transition. Nested
136
+ closes restore into the surviving overlay before eventually returning to the original page
137
+ trigger.
103
138
  - **`rtl.ts` → `isRtl(el): boolean`** — reads `getComputedStyle(el).direction === 'rtl'`, for the
104
139
  pointer/keyboard math (drag ratios, arrow-key direction) in `lyra-split`, `lyra-time-range`, and
105
140
  `lyra-dock-panel` that reasons about physical left/right and can't rely on logical CSS properties
@@ -148,16 +183,20 @@ shared `FormAssociated` mixin — see gotchas).
148
183
  - `maxOptionsVisible: number = 3` (attribute `max-options-visible` — caps how many selected tags
149
184
  show before collapsing to `+N`)
150
185
  - `emptyText: string = 'No results'` (attribute `empty-text`)
186
+ - `loadingText: string = 'Loading…'` (attribute `loading-text` — listbox row shown while a `source`
187
+ fetch is in flight)
188
+ - `overflowText: string = '+{n} more — refine your search'` (attribute `overflow-text` — listbox row
189
+ shown when `maxRender` caps the row list; `{n}` is replaced with the hidden count)
151
190
  - `filter: OptionFilter | null = null` (attribute: false — `(option, query) => boolean`; default
152
191
  matches `label`/`searchText` case-insensitively; ignored while `source` is set)
153
192
  - `source: ComboboxSource | null = null` (attribute: false — `(query: string) =>
154
193
  Promise<ComboboxSourceRow[]>`; when set, replaces the light-DOM `<lyra-option>` list with an async
155
- lookup, debounced ~200ms after each keystroke and re-run on clear/pick. A "Loading…" row is shown
194
+ lookup, debounced ~200ms after each keystroke and re-run on clear/pick. `loadingText` is shown
156
195
  while a call is in flight; a stale in-flight call that resolves after a newer one (or after
157
196
  disconnect) is dropped via a monotonic token)
158
197
  - `maxRender: number = 200` (attribute `max-render` — caps how many rows render at once, always
159
- keeping the current selection visible even if it's outside the cap; the excess shows as an
160
- "+N more — refine your search" row instead of being dropped silently)
198
+ keeping the current selection visible even if it's outside the cap; the excess renders as one
199
+ `overflowText` row instead of being dropped silently)
161
200
  - `value: string | string[]` — a getter/setter: plain `string` in single mode, `string[]` in
162
201
  `multiple` mode
163
202
 
@@ -165,8 +204,13 @@ shared `FormAssociated` mixin — see gotchas).
165
204
  string; disabled?: boolean }` — the row shape both the async `source` and the light-DOM
166
205
  `<lyra-option>` path normalize to internally.
167
206
 
168
- **Events:** `change` (native-style selection changed), `input` (native-style typed or selection
169
- changed), `lyra-show`, `lyra-hide`, `lyra-clear`
207
+ **Events:** typing in the filter exposes the original bubbling/composed, non-cancelable `InputEvent`
208
+ as exactly one host `input` event and does not fire `change`. An actual user selection mutation —
209
+ pointer or keyboard selection, multiple-value toggle, tag/Backspace removal, or clear — emits exactly
210
+ one bubbling/composed, non-cancelable plain `input` `Event`, immediately followed by the same shape
211
+ of `change` `Event`. Re-picking the current single value and programmatic/default/reset/restore
212
+ writes are silent. The clear button emits one `lyra-clear` after its `input`/`change` pair.
213
+ `lyra-show` and `lyra-hide` report listbox visibility transitions.
170
214
 
171
215
  **Slots:** default (`<lyra-option>` children), `label`, `hint`, `error` (overrides the `errorText`
172
216
  attribute when provided)
@@ -222,6 +266,11 @@ Multi-select submits as **repeated `FormData` entries** (not a joined string)
222
266
  (`multiple` with no `name`) contributes nothing to the form at all, matching a nameless native
223
267
  `<select multiple>`, rather than falling back to a shared literal key that could collide with
224
268
  another unnamed combobox in the same form.
269
+ Session-history/autofill state is stored as a name-independent JSON string array. A valid string
270
+ array restores the selection (single mode keeps its first entry); malformed or wrong-shape state
271
+ restores an empty selection. Restored state wins even when it arrives before the first option
272
+ collection, while `form.reset()` still returns to the declarative selected default. Restoration is
273
+ synchronous and fires no `input`/`change` event.
225
274
 
226
275
  **Known gotchas:**
227
276
  - naming drift from Web Awesome parity: the shipped attribute is **`with-clear`**, not
@@ -257,19 +306,22 @@ as `lyra-combobox`, see the shared-foundation notes above). The trigger is a `<b
257
306
  input: click/Enter/Space/ArrowDown opens it, and there's no typing-to-filter. Options are
258
307
  `<lyra-option value>` children — the same element `<lyra-combobox>` uses — reconciled the same way
259
308
  combobox does, and the popup reuses `internal/positioner.ts` for placement.
309
+ Session-history/autofill restoration assigns the stored string through the same synchronous
310
+ value/form/validity path as a programmatic value write and does not emit `input` or `change`.
260
311
 
261
312
  Single-select only, with no `filter`/`source`/`with-clear`/`max-options-visible`/`empty-text`/
262
313
  `max-render`/`multiple` surface — reach for `<lyra-combobox>` instead whenever any of those apply.
263
314
 
264
- **Single-option auto-commit.** When exactly one option is enabled (regardless of how many disabled
265
- ones exist alongside it), the popup never opens at all: a click, Enter, Space, ArrowDown, or ArrowUp
266
- on the trigger commits that sole option directly, and the trigger renders as a plain `role="button"`
267
- with no chevron/`aria-haspopup`/`aria-expanded`/`aria-controls`/`aria-activedescendant` rather than a
268
- combobox with a permanently inert popup state. This is unconditional default behavior, not
269
- opt-in opening a one-row list to pick the only available choice is pure friction with no real
270
- decision behind it. It never changes `value`/validity defaults on its own: an unselected
271
- single-option select stays unselected (and a `required` one stays invalid) exactly like the
272
- multi-option case, until the trigger is actually activated.
315
+ **Single-option auto-commit.** Opt-in via `autoCommitSingleOption` (default `false` a select always
316
+ renders the normal combobox/listbox/chevron trigger unless enabled, matching pre-1.3.0 behavior).
317
+ When set and exactly one option is enabled (regardless of how many disabled ones exist alongside
318
+ it), the popup never opens at all: a click, Enter, Space, ArrowDown, or ArrowUp on the trigger
319
+ commits that sole option directly, and the trigger renders as a plain `role="button"` with no
320
+ chevron/`aria-haspopup`/`aria-expanded`/`aria-controls`/`aria-activedescendant` rather than a
321
+ combobox with a permanently inert popup state opening a one-row list to pick the only available
322
+ choice is pure friction with no real decision behind it. It never changes `value`/validity defaults
323
+ on its own: an unselected single-option select stays unselected (and a `required` one stays invalid)
324
+ exactly like the multi-option case, until the trigger is actually activated.
273
325
 
274
326
  **Properties:**
275
327
  - `placeholder: string = ''`
@@ -283,6 +335,8 @@ multi-option case, until the trigger is actually activated.
283
335
  - `open: boolean = false` (reflected)
284
336
  - `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected — same scale as `lyra-toast-item`'s `size`, for
285
337
  compact toolbar placements that don't fit the default trigger height)
338
+ - `autoCommitSingleOption: boolean = false` (attribute `auto-commit-single-option`) — opts in to the
339
+ single-option auto-commit behavior described above
286
340
  - `value: string` — a getter/setter; always a single string (no `multiple` mode)
287
341
 
288
342
  **Events:** `change` (native-style — selection changed), `input` (fired alongside `change` on every
@@ -292,11 +346,17 @@ selection change — a native `<select>` doesn't meaningfully distinguish the tw
292
346
  **Slots:** default (`<lyra-option>` children), `label`, `hint`, `error` (overrides the `errorText`
293
347
  attribute when provided)
294
348
 
349
+ When hint/error content is present, the trigger's `aria-describedby` references stable shadow-local
350
+ IDs for both messages (error first, then hint), so the visible supporting text is part of the
351
+ control's accessible description.
352
+
295
353
  **CSS parts:** `form-control`, `form-control-label`, `trigger`, `listbox`, `option`, `option-dot`
296
354
  (the leading status dot, when a row's `dotColor` is set), `option-label`, `option-sub` (a row's
297
355
  secondary line, when `sub` is set), `expand-icon`, `error`, `hint`
298
356
 
299
- **Themeable custom properties:** shared tokens only.
357
+ **Themeable custom properties:** `--lyra-select-trigger-padding`, `--lyra-select-trigger-min-height`,
358
+ `--lyra-select-font-size` — all three auto-swapped per `size` (`xs`…`xl`), the same pattern
359
+ `lyra-toast-item`'s `--padding`/`--font-size` use.
300
360
 
301
361
  **Optional peer deps:** none.
302
362
 
@@ -327,10 +387,11 @@ secondary line, when `sub` is set), `expand-icon`, `error`, `hint`
327
387
  - The trigger's accessible name now checks a host-level `aria-label` attribute first, before falling
328
388
  back to `label`/`placeholder`/`"Select"` — a plain `aria-label` on `<lyra-select>` is no longer
329
389
  silently ignored.
330
- - A select with exactly one enabled option never exposes `role="combobox"`/opens a listbox at
331
- all — see "Single-option auto-commit" above. Testing/automation code that always expects a
332
- `role="combobox"` trigger, or that opens the listbox before asserting on a row, needs at least two
333
- enabled options to observe the normal dropdown chrome.
390
+ - With `autoCommitSingleOption` set, a select with exactly one enabled option never exposes
391
+ `role="combobox"`/opens a listbox at all — see "Single-option auto-commit" above.
392
+ Testing/automation code that always expects a `role="combobox"` trigger, or that opens the
393
+ listbox before asserting on a row, either needs at least two enabled options or should leave
394
+ `autoCommitSingleOption` unset to observe the normal dropdown chrome.
334
395
 
335
396
  ---
336
397
 
@@ -346,24 +407,30 @@ Inline month-grid calendar, not form-associated (used standalone or embedded ins
346
407
 
347
408
  **Properties:**
348
409
  - `value: string = ''`
349
- - `mode: 'single'|'range' = 'single'`
410
+ - `mode: 'single'|'range' = 'single'` — unknown runtime values fall back to `single`
350
411
  - `min: string = ''`
351
412
  - `max: string = ''`
352
413
  - `disabled: boolean = false` (reflected)
353
414
  - `readonly: boolean = false` (reflected)
354
- - `months: 1|2 = 1`
355
- - `locale: string = ''`
415
+ - `months: 1|2 = 1` — finite runtime values are truncated and clamped to `1..2`; non-finite
416
+ values fall back to `1`
417
+ - `locale: string = ''` — malformed locale tags fall back to the platform locale
356
418
  - `firstDayOfWeek: string = 'auto'` (attribute `first-day-of-week` — see gotchas)
357
- - `weekdayFormat: 'narrow'|'short'|'long' = 'short'` (attribute `weekday-format`)
419
+ - `weekdayFormat: 'narrow'|'short'|'long' = 'short'` (attribute `weekday-format`; unknown runtime
420
+ values fall back to `short`)
358
421
  - `disablePast: boolean = false` (attribute `disable-past`)
359
422
  - `disableFuture: boolean = false` (attribute `disable-future`)
360
423
  - `withOutsideDays: boolean = false` (attribute `with-outside-days`)
424
+ - `previousLabel: string = 'Previous month'` (attribute `previous-label` — accessible label for the
425
+ previous-month nav button; override for a non-English `locale`)
426
+ - `nextLabel: string = 'Next month'` (attribute `next-label` — accessible label for the next-month
427
+ nav button)
361
428
 
362
429
  **Getters:** `selection: { from: Date|null; to: Date|null }`, `valueAsDate: Date | null` (single
363
430
  mode only)
364
431
 
365
- **Methods:** `clear()`, `goToToday()`, `goToDate(date: string | Date)` (clamped to `min`/`max` before
366
- navigating/focusing)
432
+ **Methods:** `clear()`, `goToToday()`, `goToDate(date: string | Date)` (valid dates are clamped to
433
+ `min`/`max` before navigating/focusing; invalid `Date` objects and strings are ignored)
367
434
 
368
435
  **Events:** `input` (during interaction — for range mode, fires after the first click of a pair),
369
436
  `change` (committed value)
@@ -388,16 +455,18 @@ Text field + calendar popover, **form-associated** via the shared `FormAssociate
388
455
  - `mode: 'single'|'range' = 'single'`
389
456
  - `min: string = ''`
390
457
  - `max: string = ''`
391
- - `readonly: boolean = false` (reflected)
458
+ - `readonly: boolean = false` (reflected; preserves the submitted value but bars required and
459
+ date-bound constraint validation until removed)
392
460
  - `open: boolean = false` (reflected)
393
461
  - `withClear: boolean = false` (attribute `with-clear`)
394
462
  - `label: string = ''`
395
463
  - `hint: string = ''`
396
464
  - `placeholder: string = ''`
397
- - `locale: string = ''`
398
- - `months: 1|2 = 1`
465
+ - `locale: string = ''` — malformed locale tags fall back to the platform locale
466
+ - `months: 1|2 = 1` — finite runtime values are truncated and clamped to `1..2`; non-finite
467
+ values fall back to `1`
399
468
  - `firstDayOfWeek: string = 'auto'`
400
- - `weekdayFormat: 'narrow'|'short'|'long' = 'short'`
469
+ - `weekdayFormat: 'narrow'|'short'|'long' = 'short'` — unknown runtime values fall back to `short`
401
470
  - `disablePast: boolean = false` (attribute `disable-past`)
402
471
  - `disableFuture: boolean = false` (attribute `disable-future`)
403
472
  - `withOutsideDays: boolean = false` (attribute `with-outside-days`)
@@ -405,6 +474,11 @@ Text field + calendar popover, **form-associated** via the shared `FormAssociate
405
474
  **Properties (own, continued):**
406
475
  - `errorText: string = ''` (attribute `error-text` — static error copy; overridden by slotted
407
476
  `error` content when provided)
477
+ - `clearLabel: string = 'Clear'` (attribute `clear-label` — accessible label for the clear button)
478
+ - `openLabel: string = 'Open calendar'` (attribute `open-label` — accessible label for the
479
+ calendar-toggle button)
480
+ - `dialogLabel: string = 'Choose date'` (attribute `dialog-label` — accessible name for the
481
+ `role="dialog"` calendar popover)
408
482
 
409
483
  **Methods:** `show()`, `hide()`, `clear()`
410
484
 
@@ -430,9 +504,13 @@ Text field + calendar popover, **form-associated** via the shared `FormAssociate
430
504
 
431
505
  `calendar-core.ts` exports the pure date math both components share (**internal — not re-exported
432
506
  from the package root**, but worth knowing when reasoning about behavior): `parseISO`, `formatISO`,
433
- `isSameDay`, `addMonths`, `clampDate`, `monthMatrix(year, month, firstDayOfWeek)`,
434
- `weekdayLabels(firstDayOfWeek, format, locale)`, `monthTitle(year, month, locale)`,
435
- `resolveFirstDayOfWeek(value, locale?)`.
507
+ `isSameDay`, `addMonths`, `addMonthsClampingDay(date, n)` (like `addMonths` but clamps the result to
508
+ the target month's last day instead of overflowing into the month after — e.g. Jan 31 + 1 month
509
+ lands on Feb 28/29, not Mar 3; backs `lyra-date-picker`'s PageUp/PageDown), `clampDate`,
510
+ `monthMatrix(year, month, firstDayOfWeek)`, `weekdayLabels(firstDayOfWeek, format, locale)`,
511
+ `monthTitle(year, month, locale)`, `resolveFirstDayOfWeek(value, locale?)`,
512
+ `normalizeCalendarMode(value)`, `normalizeCalendarMonths(value)`, `normalizeWeekdayFormat(value)`,
513
+ and `dateTimeFormat(locale, options)`.
436
514
 
437
515
  **Known gotchas:**
438
516
  - `first-day-of-week="auto"` now derives from `locale` when the runtime's `Intl.Locale` exposes
@@ -442,16 +520,30 @@ from the package root**, but worth knowing when reasoning about behavior): `pars
442
520
  show; on an older runtime it still falls back to Sunday-first silently. **If you need a guaranteed
443
521
  locale-correct week start regardless of runtime**, pass an explicit
444
522
  `first-day-of-week="mon"` (accepted values: `sun`/`mon`/`tue`/`wed`/`thu`/`fri`/`sat`).
445
- - (date-input only) opening the calendar doesn't move focus into it (`show()` only flips
446
- `open`) a keyboard user opening via `Alt+ArrowDown` lands on a visible popover with focus still
447
- on the text input. Escape *does* close it from anywhere inside the form control (including the
448
- nested picker's own day/nav buttons), returning focus to the expand button.
523
+ - Runtime attribute/property inputs are normalized before calendar math or `Intl` formatting:
524
+ unsupported modes and weekday formats use their documented defaults, month counts cannot exceed
525
+ the two-grid API, malformed locale tags use the platform locale, and invalid `Date` objects do
526
+ not replace the current view.
527
+ - (date-input only) — opening the calendar does not move focus into it: `Alt+ArrowDown` leaves focus
528
+ on the text input, and the expand button keeps focus when clicked. The focused opener is
529
+ remembered. Escape and a finalized calendar selection close the popup and return focus to that
530
+ same connected element; a direct `.open = false` also restores it when focus would otherwise
531
+ remain inside the now-hidden popup. Outside-pointer dismissal deliberately leaves focus at the
532
+ clicked target.
449
533
  - (date-input only) — typing an unparseable string and blurring/committing sets `badInput` via
450
- `internals.setValidity()` and reverts the displayed text to the last valid commit, but does not
451
- reflect `aria-invalid` onto the visible `<input part="input">` itself (unlike `lyra-combobox`,
452
- which does reflect `aria-invalid`/`aria-required` onto its own input) rely on `data-invalid`
453
- (a host attribute, toggled the same way) or `reportValidity()` rather than assuming the input's own
454
- ARIA state.
534
+ `internals.setValidity()` and reverts the displayed text to the last valid commit. The visible
535
+ `<input part="input">` mirrors `required` through native `required`/`aria-required` and exposes
536
+ touched outer validity through `aria-invalid`; this includes required, bad-input, and range
537
+ failures owned by the form-associated host. A host `aria-label` is reactively forwarded to the
538
+ input, including later changes/removal, and takes precedence over the component's
539
+ label/placeholder fallback. Native validation attempts reveal `aria-invalid`; `form.reset()`
540
+ clears that touched presentation. A parseable typed date outside an active bound is committed
541
+ instead and reports the precise `rangeUnderflow`/`rangeOverflow` state.
542
+ - (date-input only) — declarative, IDL, reset, and restored values are sanitized to exact ISO dates:
543
+ calendar-invalid or malformed strings become `''` (and therefore `valueMissing` when required),
544
+ while valid dates outside `min`/`max` or `disable-past`/`disable-future` remain submitted and expose
545
+ `rangeUnderflow`/`rangeOverflow`. Changes to these constraints, `mode`, and `readonly` recompute
546
+ validity synchronously; range mode validates both endpoints.
455
547
  - The grid keyboard pattern (Arrow/PageUp/PageDown/Home/End navigation with correct focus
456
548
  sequencing) is implemented correctly and safe to rely on, as is the selected/range-day text color
457
549
  (`--lyra-color-on-brand`, not a hardcoded literal — safe to override `--lyra-color-brand` without
@@ -604,11 +696,14 @@ the reviewed family (pure CSS cascade, no JS/`getComputedStyle` bridging needed)
604
696
  a glance-value sparkline, worth reconsidering if it's ever the *sole* representation of a metric.
605
697
  - flat data (every value equal, so the auto-computed range spans zero) now renders a centered
606
698
  midline/mid-height bars instead of collapsing every point to the bottom edge, and a single-value
607
- series renders a visible flat line (a zero-length path was previously invisible). A `type="bar"`
608
- series past 500 points is decimated to at most 500 rendered `<rect>`s (evenly sampled, not
609
- aggregated/averaged) so an unbounded `values` array can't turn directly into that many shadow-DOM
610
- nodes; `line`/`area` types have no such cap (they already collapse to one `<path>` regardless of
611
- point count).
699
+ series renders a visible flat line (a zero-length path was previously invisible). **Every** `type`
700
+ (`line`/`area`/`bar`) decimates a `values` array past 500 points down to at most 500 plotted
701
+ samples evenly sampled by index, always keeping the first and last value exactly, not
702
+ aggregated/averaged. `type="bar"` caps at 500 rendered `<rect>`s directly; `line`/`area` cap the
703
+ point count baked into the single `<path>`'s `d` string instead (an uncapped path string also
704
+ grows unbounded, even though the element count stays at one `<path>`). Auto `min`/`max` is still
705
+ scanned from the *full* pre-decimation `values` array, so a real extreme value that decimation
706
+ happens to drop can't silently narrow the rendered scale.
612
707
 
613
708
  ---
614
709
 
@@ -629,9 +724,16 @@ Country/language flag image. Flag artwork ships in a **separate, optional peer p
629
724
  - `label?: string` (accessible name / `alt` text — **defaults to a localized, human-readable region
630
725
  name derived from the *resolved country* code via `Intl.DisplayNames` if omitted**, see gotchas)
631
726
  - `round: boolean = false` (reflected — circular crop)
632
- - `detailed: boolean = false` (reflected requests the pristine, pre-optimization source SVG
633
- instead of the default icon-optimized one; a no-op for the majority of codes whose source art was
634
- never large enough to need optimizing. Has no effect when `src` is set. See gotchas.)
727
+ - `variant?: 'compact' | 'standard' | 'detailed'` (attribute `variant`, not reflected picks a
728
+ fidelity tier for the ~65 codes whose source art embeds a coat of arms/seal/emblem; every other
729
+ code resolves to the same file regardless of `variant`. `'compact'` = a tiny WebP raster for
730
+ icon-scale use (menus, language pickers, ~12–28px); `'standard'` (the effective default, when both
731
+ `variant` and `detailed` are unset) = the icon-optimized vector for card/row sizes (~28–96px);
732
+ `'detailed'` = the pristine full-fidelity vector for hero-scale display. Takes precedence over the
733
+ deprecated `detailed` below. No effect when `src` is set.)
734
+ - `detailed: boolean = false` (reflected — **deprecated: use `variant="detailed"` instead**. Kept as
735
+ an alias for one minor cycle — when `variant` is left unset, `detailed` still maps to the detailed
736
+ tier — scheduled for removal in the next major. No effect when `src` is set.)
635
737
 
636
738
  **Events:** none.
637
739
 
@@ -639,8 +741,10 @@ Country/language flag image. Flag artwork ships in a **separate, optional peer p
639
741
 
640
742
  **CSS parts:** `image`
641
743
 
642
- **Themeable custom properties:** `--lyra-flag-radius` (default `2px`, non-`round` corner radius);
643
- also consumes `--lyra-color-border` (the inset ring around the flag image).
744
+ **Themeable custom properties:** `--lyra-flag-radius` (default `calc(var(--lyra-radius) * 0.33)`
745
+ ≈2px at the library's default `--lyra-radius` of `0.375rem`; scales with `--lyra-radius` unless
746
+ overridden directly — non-`round` corner radius); also consumes `--lyra-color-border` (the inset
747
+ ring around the flag image).
644
748
 
645
749
  **Optional peer deps:** `@aceshooting/lyra-flags` — required for the component to actually render an
646
750
  image; without it, renders an empty template (see gotchas).
@@ -653,6 +757,8 @@ need different defaults).
653
757
  ```html
654
758
  <lyra-flag country="fr" label="France"></lyra-flag>
655
759
  <lyra-flag language="en-US" round></lyra-flag>
760
+ <lyra-flag country="es" variant="compact"></lyra-flag> <!-- tiny WebP raster, icon-scale -->
761
+ <lyra-flag country="es" variant="detailed"></lyra-flag> <!-- pristine full-fidelity vector -->
656
762
  ```
657
763
 
658
764
  ```bash
@@ -690,12 +796,16 @@ pnpm add @aceshooting/lyra-flags # required peer — without it, <lyra-flag> r
690
796
  shipped ~28 KB total, not all 249). If you already have a flag's URL at build time, `src` skips
691
797
  the peer-package round trip (and its loading-skeleton flash) entirely.
692
798
  - 65 of `@aceshooting/lyra-flags`' 249 flags (any whose design includes a detailed coat of
693
- arms/seal/emblem, e.g. `es`, `pt`, `sv`) ship a second, pristine full-detail source SVG alongside
694
- the default icon-optimized one (`flagUrl(code, { variant: 'detailed' })`, or `detailed` on
695
- `<lyra-flag>`) the default variant is the one `country`/`language` resolve to unless `detailed`
696
- is set, and is what almost every consumer wants (an icon-scale rendering, ~65% smaller on average
697
- for the 65 affected codes, no visible fidelity loss at that scale). `detailed` is a safe no-op for
698
- the other 184 codes same file either way.
799
+ arms/seal/emblem, e.g. `es`, `pt`, `sv`) ship **three** fidelity tiers, selected via the `variant`
800
+ property (`flagUrl(code, { variant })` under the hood): `"compact"` — a tiny WebP raster for
801
+ icon-scale use (menus, language pickers, dense lists); `"standard"` the default, the
802
+ icon-optimized vector for card/row sizes, ~65% smaller on average than the pristine source for the
803
+ 65 affected codes with no visible fidelity loss at that scale; `"detailed"` the pristine
804
+ full-fidelity vector, for hero-scale display where the extra illustrative detail is actually
805
+ visible. The other 184 codes resolve to the same file regardless of `variant` — a safe no-op. The
806
+ older boolean `detailed` attribute predates `variant` and is now **deprecated**
807
+ (`variant="detailed"` is the replacement); left unset, `variant` falls back to honoring `detailed`
808
+ for one more minor version before removal.
699
809
 
700
810
  ---
701
811
 
@@ -749,6 +859,9 @@ Loading placeholder (`text`/`circle`/`rect` shapes, `pulse`/`sheen` effects).
749
859
  - `effect: 'pulse'|'sheen' = 'pulse'` (reflected)
750
860
  - `width?: string`
751
861
  - `height?: string`
862
+ - `label: string = 'Loading…'` — accessible name for this instance's own `role="status"` (rendered as
863
+ visually-hidden text inside `[part="base"]`); override with a description of what's actually
864
+ loading, e.g. `label="Loading chart"`
752
865
 
753
866
  **Events:** none.
754
867
 
@@ -763,16 +876,18 @@ Loading placeholder (`text`/`circle`/`rect` shapes, `pulse`/`sheen` effects).
763
876
 
764
877
  ```html
765
878
  <lyra-skeleton variant="circle" width="3rem" height="3rem"></lyra-skeleton>
766
- <lyra-skeleton variant="text"></lyra-skeleton>
879
+ <lyra-skeleton variant="text" label="Loading name"></lyra-skeleton>
767
880
  ```
768
881
 
769
882
  **Known gotchas:**
770
- - every instance is an independent, simultaneously-firing `role="status"` live region
771
- (via `willUpdate` setting `role="status"` plus visually-hidden "Loading…" text). A realistic
772
- loading state (e.g. a 10×5 skeleton table = 50 instances) mounts 50 separate live regions at
773
- nearly the same tick — expect screen readers to announce "Loading…" dozens of times, not once.
774
- There's no grouping primitive to dedupe this; if it matters, wrap your own single `role="status"`
775
- region around a set of skeletons and mark the individual ones `aria-hidden`.
883
+ - every instance is an independent, simultaneously-firing `role="status"` live region (set via
884
+ `willUpdate`) with its own visually-hidden `label` text (default `"Loading…"`). A realistic loading
885
+ state (e.g. a 10×5 skeleton table = 50 instances) mounts 50 separate live regions at nearly the same
886
+ tick — expect screen readers to announce the label dozens of times, not once, even if every
887
+ instance shares the same default `label`. A more specific per-instance `label` doesn't fix the
888
+ fan-out by itself; there's still no grouping primitive to dedupe it — if it matters, wrap your own
889
+ single `role="status"` region around the set of skeletons and mark the individual ones
890
+ `aria-hidden`.
776
891
  - no `lines`/`count` shorthand for "N lines of skeleton text" — stamp out N elements
777
892
  yourself.
778
893
  - Respects `prefers-reduced-motion` (both effects) — safe to leave as-is for that concern.
@@ -824,7 +939,10 @@ render one itself), `sub` (rich sub-line content — overrides the `sub` attribu
824
939
  is provided)
825
940
 
826
941
  **CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `sub`, `spark`,
827
- `caption`, `rows`, `row`, `row-label`, `row-value`
942
+ `caption`, `rows`, `row`, `row-label`, `row-value` — `[part="value"]` gets `aria-labelledby` pairing
943
+ it with `[part="label"]`'s generated id whenever `label` is non-empty (so tabbing straight to the
944
+ `exactValue`-focusable value still announces e.g. "Revenue $1.2K", not just the bare value); each
945
+ `[part="row-value"]` is paired the same way with its own row's `[part="row-label"]`.
828
946
 
829
947
  **Themeable custom properties:** shared tokens only (`--lyra-color-success/-warning/-danger` drive
830
948
  the `variant`-colored value text and up/down trend pill; `--lyra-color-brand` drives `emphasis`'s
@@ -860,7 +978,9 @@ of `rows` — this component only renders and emits intents.
860
978
  (`'low'` hides first, under a ~900px container width; `'medium'` next, under ~640px; both
861
979
  breakpoints are fixed in `table.styles.ts`, not themeable tokens), reversible via
862
980
  `[part='reveal-columns-button']`; `sticky` pins that column's header cell/cells to the inline-start
863
- edge (`position: sticky`) while the table scrolls horizontally
981
+ edge (`position: sticky`) while the table scrolls horizontally — multiple `sticky` columns stack
982
+ left-to-right (each measures every earlier sticky column's rendered width via
983
+ `--lyra-table-sticky-offset`) instead of overlapping at the same edge
864
984
  - `columnsHidden: boolean = false` (attribute `columns-hidden`, reflected) — computed/read-only: true
865
985
  when a `priority` column is *actually* hidden right now by the `@container` breakpoints above, or
866
986
  `showAllColumns` force-visible mode is currently active. Measured via a `ResizeObserver` on
@@ -873,7 +993,10 @@ of `rows` — this component only renders and emits intents.
873
993
  - `rows: T[] = []` (attribute: false)
874
994
  - `sortKey: string = ''` (attribute `sort-key`)
875
995
  - `sortDir: 'asc'|'desc' = 'asc'` (attribute `sort-dir`)
876
- - `rowKey?: (row: T) => string | number` (attribute: false)
996
+ - `rowKey?: (row: T) => string | number` (attribute: false) — derives each row's stable identity for
997
+ DOM-reconciliation and the delegated row click/keydown lookup; falls back to the row's array index
998
+ when omitted, which is only safe while `rows` never reorders — set it whenever `rows` can be
999
+ sorted/filtered/re-ordered across renders, or selection/click can silently attach to the wrong row
877
1000
  - `selectedKey: string | number | null = null` (attribute: false) — **single**-selection only
878
1001
  - `hasMore: boolean = false` (attribute `has-more`, reflected)
879
1002
  - `moreLabel: string = 'Load more'` (attribute `more-label`)
@@ -920,18 +1043,23 @@ body's `max-block-size`).
920
1043
  - only single-row selection is modeled (`selectedKey: string | number | null`);
921
1044
  there's no bulk-select/checkbox-column API — you must hand-roll a checkbox column entirely inside
922
1045
  a `cell()` callback if you need multi-select.
923
- - no `caption`/`aria-label` property, so the rendered `role="grid"` has no accessible name
924
- distinguishing it from any other grid on the page.
1046
+ - no `caption`/`aria-label` *property* exists, but a plain `aria-label` HTML attribute set on the
1047
+ host **is** forwarded into the shadow-DOM `<table role="grid">` (read via
1048
+ `this.getAttribute('aria-label')` at render time — a plain global-attribute read, not a reactive
1049
+ Lit `@property`): `<lyra-table aria-label="Scores">` gives the grid an accessible name; omit it and
1050
+ the shadow table simply has none.
925
1051
  - Full roving-tabindex grid keyboard pattern (one `tabindex="0"` stop among header cells, one among
926
1052
  body rows) — Left/Right/Home/End move within the header row, Up/Down/Home/End move within the
927
1053
  body, Down from the header enters the body's roving stop and Up from the body's first row returns
928
1054
  to the header, Enter/Space still only sort/activate — a genuine strength versus most siblings in
929
1055
  this family. A `priority`-hidden header/cell is skipped when computing the visible header stops,
930
1056
  so arrow-key navigation never strands the roving stop on a hidden column.
931
- - a `cell()` template can render its own interactive content (a button, link, input, etc.) without
932
- it being swallowed by row/column activation: clicks and Enter/Space landing on (or bubbling
933
- through) anything matching `button, a[href], input, select, textarea, [role="button"]` are left
934
- alone by the table's own delegated `click`/`keydown` handlers.
1057
+ - a `cell()` template can render its own interactive content without it being swallowed by
1058
+ row/column activation: clicks and Enter/Space landing on (or bubbling through) anything matching
1059
+ `button, a[href], input, select, textarea, [role="button"], [role="combobox"], [role="listbox"],
1060
+ [role="slider"]`, **or any custom element** (any tag name containing a hyphen — e.g. a
1061
+ `<lyra-select>`/`<lyra-combobox>` rendered inside a `cell()`), are left alone by the table's own
1062
+ delegated `click`/`keydown` handlers instead of triggering `lyra-sort`/`lyra-row-click`.
935
1063
 
936
1064
  ---
937
1065
 
@@ -979,11 +1107,9 @@ gauge-specific custom property exists).
979
1107
  element instead.
980
1108
  - Divide-by-zero guarded (`max - min || 1`), and radial/linear share one component via the `type`
981
1109
  attribute.
982
- - a non-finite `value`/`min`/`max` (e.g. left `NaN` mid-binding) no longer leaks a literal `"NaN"`
983
- into `aria-valuenow`/`aria-valuemax`/the rendered value text the affected ARIA attribute is
984
- omitted entirely instead, and a finite `value` is clamped into `[min, max]` before being written
985
- to `aria-valuenow` (the SVG arc/bar fill was already clamped via `ratio`; the ARIA number now
986
- matches what's drawn).
1110
+ - non-finite `value` text remains blank, while non-finite `min`/`max` use finite default domain
1111
+ bounds; no `NaN`/`Infinity` value leaks into the SVG geometry or ARIA attributes, and a finite
1112
+ value is clamped into the resolved domain before being announced.
987
1113
 
988
1114
  ---
989
1115
 
@@ -994,15 +1120,18 @@ opens a small menu).
994
1120
 
995
1121
  **Properties:**
996
1122
  - `rows: Record<string, unknown>[] = []` (attribute: false)
997
- - `columns: CsvColumn[] = []` (attribute: false) — `{ key, label }`; also acts as a field allow-list
998
- for **both** export formats when non-empty (CSV always projected through `columns`; JSON now picks
999
- the same `columns[].key` set rather than dumping every field on `rows`, so it can't leak a field
1000
- CSV would have hidden) leave `columns` empty to export every field of `rows` as-is in JSON
1123
+ - `columns: CsvColumn[] = []` (attribute: false) — `{ key, label }`; acts as a field allow-list **and**
1124
+ CSV header-label source for **both** export formats when non-empty. Left empty, **both** CSV and
1125
+ JSON fall back to the union of the rows' own keys (`key`/`label` both set to the key name) instead
1126
+ of CSV degrading to a header-less/blank file while only JSON had a fallback so an unconfigured
1127
+ export still produces a proper header + data file in either format
1001
1128
  - `filename: string = 'export'`
1002
1129
  - `formats: ExportFormat[] = ['csv']` (attribute: false; `ExportFormat = 'csv' | 'json'` **only** —
1003
1130
  no XLSX despite the family sometimes being described as covering it)
1004
- - `disabled: boolean = false` (reflected)
1005
- - `label: string = 'Export'`
1131
+ - `disabled: boolean = false` (reflected) — also disables every `[part="menu-item"]` button, not just
1132
+ the trigger
1133
+ - `label: string = 'Export'` — trigger button text; also feeds the format-choice menu's `aria-label`
1134
+ as `` `${label} format` `` so assistive tech gets an accessible name for the menu
1006
1135
  - `open: boolean = false` (reflected)
1007
1136
 
1008
1137
  **Events:** `lyra-export` (`detail: { format }`, **cancelable** — call `preventDefault()` to
@@ -1083,6 +1212,20 @@ auto-inserted between each adjacent pair.
1083
1212
  - `floatBreakpoint: number = 400` (attribute `float-breakpoint`, px) — below this narrower container
1084
1213
  width, the collapsing panel instead becomes an absolutely-positioned overlay ("floating card") on
1085
1214
  top of the other pane(s), removed from the normal flex flow; the sibling(s) take the full width.
1215
+ - `collapseState: 'wide'|'rail'|'floating'` (reflected, attribute `collapse-state`) — a public
1216
+ accessor with force/auto semantics mirroring `<lyra-app-rail>`'s `mode`: normally derived
1217
+ automatically from the measured container width, but assigning it a concrete value pins it there
1218
+ (stopping automatic breakpoint tracking) — useful for a consumer-driven toggle (e.g. a button that
1219
+ forces `'floating'` regardless of width). Assigning the write-only `'auto'` sentinel releases the
1220
+ pin and immediately re-derives the state from the current measured width; the getter never returns
1221
+ `'auto'`.
1222
+ - `open: boolean = false` (reflected) — whether the `'floating'` collapse state's drawer is shown.
1223
+ While `collapseState` is `'floating'` and `open` is `false` (the default), the collapsing panel
1224
+ renders nothing (`hidden`, out of the accessibility tree) instead of the always-visible overlay
1225
+ card this state rendered before `open` existed. Setting `open = true` reveals it as a
1226
+ focus-trapped floating panel with a `[part="backdrop"]` scrim; Escape or a backdrop click set
1227
+ `open` back to `false`. Leaving `'floating'` while `open` is still `true` also closes it, the same
1228
+ way `<lyra-app-rail>` closes its mobile overlay when leaving `'mobile'` while open.
1086
1229
 
1087
1230
  `collapse`'s three resulting states — `'wide'` (default, today's plain layout) / `'rail'` / `'floating'`
1088
1231
  — are exposed as: a `data-collapse-state` attribute on both the host and the collapsing panel element
@@ -1092,12 +1235,17 @@ collapsed. `collapse="none"` (the default) is byte-for-byte identical to pre-col
1092
1235
 
1093
1236
  **Events:** `lyra-resize` (`detail: { sizes }`, fired on every drag step/release **and** every
1094
1237
  keyboard step), `lyra-split-collapse-change` (`detail: { state: 'wide'|'rail'|'floating' }`, fired only
1095
- on a real `collapse`-state transition, never on every resize/render)
1238
+ on a real `collapse`-state transition, never on every resize/render),
1239
+ `lyra-split-constraints-invalid` (`detail: SplitConstraintIssueDetail`, fired once when the configured
1240
+ panel minimums/maximums cannot fit the track; the infeasible set is rejected for interaction and a
1241
+ normalized percent minimum is used instead)
1096
1242
 
1097
1243
  **Slots:** default (each direct child element is one panel).
1098
1244
 
1099
1245
  **CSS parts:** `base` (`position: relative`, so the `'floating'` state can anchor to it), `divider`
1100
- (carries `aria-disabled="true"` and is drag/keyboard-inert while its adjacent panel is collapsed)
1246
+ (carries `aria-disabled="true"` and is drag/keyboard-inert while its adjacent panel is collapsed),
1247
+ `backdrop` (the `'floating'` drawer's scrim — only rendered while `collapseState === 'floating'` and
1248
+ `open`)
1101
1249
 
1102
1250
  **Themeable custom properties:** shared tokens only.
1103
1251
 
@@ -1128,8 +1276,9 @@ the visually-adjacent panel).
1128
1276
  `aria-valuemin`/`aria-valuemax` report. Each divider also now has its own `aria-label` ("Resize
1129
1277
  divider between panel N and panel N+1") distinguishing it from any other divider in a
1130
1278
  multi-divider layout.
1131
- - no feasibility check on `min` vs. panel count (e.g. 3 panels with `min=40` is
1132
- unsatisfiable) result is a silently frozen splitter with no warning.
1279
+ - infeasible aggregate constraints (for example, three panels with `min=40`) are reported through
1280
+ `lyra-split-constraints-invalid`; interaction rejects that set and uses a normalized percent minimum
1281
+ with aggregate slack, so the divider remains operable instead of silently freezing.
1133
1282
  - concurrent drags are tracked per `pointerId` (not a single scalar), so a multi-touch drag on two
1134
1283
  different dividers moves both independently instead of the second pointer clobbering the first's
1135
1284
  drag state; `pointercancel`/`lostpointercapture` (not just `pointerup`) both end a drag.
@@ -1201,10 +1350,10 @@ fullscreen panel/backdrop — it overrides the default `var(--lyra-space-l)` ins
1201
1350
  both `[part="base"]` and `[part="backdrop"]`. Set `compact` for tighter header/body padding.
1202
1351
 
1203
1352
  **Known gotchas:**
1204
- - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
1205
- scroll lock and the Tab-trap `keydown` listener if `fullscreen` was still `true` across the
1206
- move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
1207
- `willUpdate()` alone wouldn't otherwise notice.
1353
+ - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) resumes its
1354
+ shared overlay registration and re-acquires the scroll lock if `fullscreen` was still `true`
1355
+ across the move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in
1356
+ between, so `willUpdate()` alone wouldn't otherwise notice.
1208
1357
  - `collapsed` hides the body via `hidden` rather than an animated height transition — collapsing is
1209
1358
  instant, not a slide.
1210
1359
 
@@ -1215,15 +1364,27 @@ both `[part="base"]` and `[part="backdrop"]`. Set `compact` for tighter header/b
1215
1364
  Dependency-free SVG word/tag cloud. First-party invention (no Web Awesome equivalent). Lays words
1216
1365
  out via an outward Archimedean-spiral search — heaviest word placed first, each word spiraling from
1217
1366
  the center until it clears every word already placed. Unlike sibling `lyra-sparkline`/`lyra-heatmap`
1218
- (one `role="img"` glyph standing in for an aggregate value), each word here is its own focusable
1219
- `role="button"`, since the individual words are the meaningful interactive content.
1367
+ (one `role="img"` glyph standing in for an aggregate value), the individual words here *are* the
1368
+ meaningful interactive content but with up to `MAX_WORDS` (150) of them, making every single one
1369
+ its own tab stop would be a poor keyboard experience. Instead, like `lyra-heatmap`'s cells, the whole
1370
+ `[part="svg"]` is **one tab stop with roving arrow-key focus**: `ArrowRight`/`ArrowDown` move the
1371
+ focus cursor to the next word in **declaration order** (not weight/placement order),
1372
+ `ArrowLeft`/`ArrowUp` to the previous, `Home`/`End` to the first/last, and `Enter`/`Space` fires
1373
+ `lyra-word-click` for the currently-focused word. A `[part="focus-ring"]` `<rect>` is drawn around
1374
+ the focused word (absent until a word has actually been focused via keyboard or click), and a
1375
+ visually-hidden `[part="live-region"]` (`role="status" aria-live="polite"`) announces
1376
+ `"${text}, ${weight}"` on every focus move.
1220
1377
 
1221
1378
  **Properties:**
1222
1379
  - `words: WordCloudWord[] = []` (attribute: false) — `{ text: string, weight: number, color?:
1223
- string, group?: string }`; `weight` drives font size, `color` overrides the palette for that word,
1224
- `group` shares one palette color across every word with the same `group` value
1225
- - `minFontSize: number = 12` (attribute `min-font-size`) px, applied to the lowest-weight word
1226
- - `maxFontSize: number = 48` (attribute `max-font-size`) — px, applied to the highest-weight word
1380
+ string, group?: string }`; `weight` drives font size (a negative/non-finite `weight` is clamped to
1381
+ `0` for sizing purposes only the original value is still echoed verbatim in `lyra-word-click`'s
1382
+ `detail`), `color` overrides the palette for that word, `group` shares one palette color across
1383
+ every word with the same `group` value
1384
+ - `minFontSize: number = 12` (attribute `min-font-size`) — px, applied to the lowest-weight word;
1385
+ layout clamps positive finite values to at most 512px and uses 1px for invalid/non-positive values
1386
+ - `maxFontSize: number = 48` (attribute `max-font-size`) — px, applied to the highest-weight word;
1387
+ normalized by the same 1–512px layout bound (reversed min/max bounds are swapped)
1227
1388
  - `scale: 'linear'|'sqrt' = 'linear'` — `sqrt` compresses the weight→font-size mapping so one heavy
1228
1389
  word doesn't dwarf the rest, matching `lyra-heatmap`'s `scale` property
1229
1390
  - `orientations: 'horizontal'|'mixed' = 'horizontal'` — `mixed` lets ~25% of words render rotated
@@ -1231,12 +1392,20 @@ the center until it clears every word already placed. Unlike sibling `lyra-spark
1231
1392
  - `palette?: string[]` (attribute: false) — custom categorical colors, cycled by word index (or by
1232
1393
  `group`); defaults to the `--lyra-word-cloud-color-1..8` tokens
1233
1394
 
1395
+ **Methods:** `refreshTheme(): void` — forces a relayout so the `--lyra-font` custom property is
1396
+ re-read from computed style (font-family affects the canvas text measurement layout depends on);
1397
+ call this from your own theme-toggle handler, since there's no global theme-change event to
1398
+ subscribe to automatically (mirrors `lyra-chart`'s `refreshTheme()`).
1399
+
1234
1400
  **Events:** `lyra-word-click` (`detail: { text, weight, group }`, fires on click, or Enter/Space on
1235
- a focused word)
1401
+ the currently-focused word — a no-op if nothing is focused yet)
1236
1402
 
1237
1403
  **Slots:** none.
1238
1404
 
1239
- **CSS parts:** `base`, `svg`, `word` (each `<text>`), `empty` (the no-data placeholder)
1405
+ **CSS parts:** `base`, `svg`, `word` (each `<text>`), `focus-ring` (the rect around the roving-focus
1406
+ cursor's word), `live-region` (visually-hidden `role="status" aria-live="polite"` announcement text),
1407
+ `empty` (the no-data placeholder — hardcoded `"No data"` text, no property to customize it, unlike
1408
+ `lyra-empty`/`lyra-table`)
1240
1409
 
1241
1410
  **Themeable custom properties:** `--lyra-word-cloud-color-1` through `-8` (the default categorical
1242
1411
  palette, a data-driven literal exception like `lyra-heatmap`'s scale-ramp endpoints — exposed as
@@ -1257,20 +1426,34 @@ retheme-able custom properties instead of hardcoded), plus shared tokens (`--lyr
1257
1426
  </script>
1258
1427
  ```
1259
1428
 
1429
+ The host itself gets `role="group"` and an auto-computed `aria-label` (e.g. `"Word cloud of 12
1430
+ words"` / `"Word cloud of 1 word"`, counting only words actually rendered — post `MAX_WORDS`-cap and
1431
+ post-drop, not the raw `words.length`) **unless** the host already carried its own `role`/`aria-label`
1432
+ attribute at first render. That opt-out check runs exactly once (the very first update) and is never
1433
+ re-checked afterwards, so setting `role`/`aria-label` yourself *after* the component has already
1434
+ rendered at least once only sticks until the next `words`-driven relayout, which overwrites it back
1435
+ to the auto default — set it in the initial markup (or before first paint) to opt out permanently.
1436
+
1260
1437
  **Known gotchas:**
1261
1438
  - capped at 150 words (`MAX_WORDS` in `word-cloud-layout.ts`, mirroring `lyra-sparkline`'s
1262
- `MAX_BARS` DOM-node-count guard) — anything beyond that is silently dropped from layout (not
1263
- rendered, not an error); a pathological input (e.g. one huge word repeated many times) can also
1264
- exhaust the spiral search's radius bound and get dropped the same way.
1265
- - every word is its own tab stop (`tabindex="0"` `role="button"`), not a single roving-tabindex
1266
- container like `lyra-heatmap` fine for a modest word count, but tabbing through a cloud near the
1267
- 150-word cap is a lot of stops; there's no non-visual "get the whole word+weight list at once"
1268
- alternative yet.
1269
- - text width is measured via a detached `<canvas>` 2D context (`ctx.measureText`), not the actual
1270
- rendered SVG glyph metrics close enough for collision-avoidance spacing, but not pixel-exact.
1439
+ `MAX_BARS` DOM-node-count guard) — over the cap, the **heaviest** 150 survive and the rest are
1440
+ dropped, regardless of where they fell in the input array (it is not simply "first 150 in, rest
1441
+ dropped"). A pathological input (e.g. one huge word repeated many times) can also exhaust the
1442
+ spiral search's radius bound and get dropped the same way; blank/whitespace-only `text` is dropped
1443
+ too. Every drop reason logs one deduplicated `console.warn` per distinct skipped-count (not one
1444
+ warning per word, and not repeated twice for the same count) nothing throws.
1445
+ - each word's spiral search tests at most 4,096 candidate positions. Together with the 150-word and
1446
+ 512px font-size caps, this bounds placement work even for dense or adversarial layouts; a word
1447
+ that exhausts the search budget is reported through the same skipped-word path.
1448
+ - text width is measured via a detached `<canvas>` 2D context (`ctx.measureText`) at a hardcoded
1449
+ `font-weight: 600` matching `[part='word']`'s own default CSS — close enough for
1450
+ collision-avoidance spacing, but not pixel-exact, and overriding `[part='word']`'s `font-weight` via
1451
+ `::part()` desyncs measurement from what's actually painted (looser/denser packing, not a crash).
1271
1452
  - rotation (`orientations="mixed"`) is genuinely random per layout (`Math.random()`, not seeded), so
1272
1453
  which words render rotated changes on every re-layout (any `words`/`minFontSize`/`maxFontSize`/
1273
1454
  `scale`/`orientations` change) — don't rely on rotation being stable across renders.
1455
+ - only one word is ever in the page's tab sequence at a time (the roving cursor on `[part="svg"]`) —
1456
+ there's no way to Tab directly to the Nth word; arrow-key/Home/End your way there, or click it.
1274
1457
 
1275
1458
  ---
1276
1459
 
@@ -1279,7 +1462,10 @@ retheme-able custom properties instead of hardcoded), plus shared tokens (`--lyr
1279
1462
  ## `lyra-time-range`
1280
1463
 
1281
1464
  A two-handle brush/scrubber over a numeric domain (no date logic — callers map their own time axis
1282
- onto `[min, max]`).
1465
+ onto `[min, max]`). Form-associated (`static formAssociated = true`, via `ElementInternals`): an
1466
+ ancestor `<fieldset disabled>` disables both handles and every preset button through an internal
1467
+ `effectiveDisabled` getter, the same way it would a native `<input>`, without ever mutating the
1468
+ consumer-facing `disabled` property/attribute itself.
1283
1469
 
1284
1470
  **Properties:**
1285
1471
  - `min: number = 0`
@@ -1288,6 +1474,8 @@ onto `[min, max]`).
1288
1474
  - `end: number = 100`
1289
1475
  - `step: number = 1`
1290
1476
  - `disabled: boolean = false` (reflected)
1477
+ - `startLabel: string = 'Range start'` (attribute `start-label`) — `aria-label` for the start handle
1478
+ - `endLabel: string = 'Range end'` (attribute `end-label`) — `aria-label` for the end handle
1291
1479
  - `presets: TimeRangePreset[] = []` (attribute: false) — `TimeRangePreset { label: string; start:
1292
1480
  number; end: number }`; optional discrete presets (e.g. "Last 7 days") rendered as a
1293
1481
  `[part="presets"]` button row above the track — purely additive, the continuous brush is
@@ -1303,9 +1491,10 @@ key-up-commit, or when a preset button is clicked, `detail: { start, end }`)
1303
1491
  **CSS parts:** `base`, `track`, `range`, `handle-start`, `handle-end`, `presets`, `preset-button`
1304
1492
 
1305
1493
  **Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-brand`,
1306
- `--lyra-color-surface`, `--lyra-shadow` (track/handles), plus (for `presets`) `--lyra-color-text`,
1307
- `--lyra-color-on-brand` (the active preset button's text), `--lyra-radius`, `--lyra-space-xs/-s`,
1308
- `--lyra-transition-fast`, `--lyra-focus-ring-*`. No component-local tokens.
1494
+ `--lyra-color-surface`, `--lyra-shadow` (track/handles), `--lyra-opacity-disabled` (`:host([disabled])`
1495
+ dimming), plus (for `presets`) `--lyra-color-text`, `--lyra-color-on-brand` (the active preset
1496
+ button's text), `--lyra-radius`, `--lyra-space-xs/-s`, `--lyra-transition-fast`,
1497
+ `--lyra-focus-ring-*`. No component-local tokens.
1309
1498
 
1310
1499
  **Optional peer deps:** none.
1311
1500
 
@@ -1339,6 +1528,14 @@ key-up-commit, or when a preset button is clicked, `detail: { start, end }`)
1339
1528
  moves both independently instead of the second pointer hijacking which handle the first pointer's
1340
1529
  moves apply to; `pointercancel`/`lostpointercapture` (not just `pointerup`) both end a drag, same
1341
1530
  fix as `lyra-split`.
1531
+ - Non-finite domain/handle values use finite fallback geometry, and non-finite or negative steps are
1532
+ treated as unstepped; invalid values never become `NaN`/`Infinity` CSS or ARIA strings.
1533
+ - `startLabel`/`endLabel` only override each handle's `aria-label`; they don't affect
1534
+ `aria-valuenow`/`aria-valuemin`/`aria-valuemax` (still raw numbers) or any visible text.
1535
+ - An ancestor `<fieldset disabled>` toggling is reflected via `formDisabledCallback` into
1536
+ `effectiveDisabled` (tracked separately from the consumer's own `disabled`), so re-enabling the
1537
+ fieldset correctly restores a handle that had `disabled` set explicitly by the consumer, and vice
1538
+ versa — mirrors `lyra-combobox`'s identical pattern.
1342
1539
 
1343
1540
  ---
1344
1541
 
@@ -1367,7 +1564,8 @@ tick and manual step)
1367
1564
 
1368
1565
  **Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-color-border`,
1369
1566
  `--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`, `--lyra-icon-button-size` (the
1370
- play button's box).
1567
+ play button's box), `--lyra-opacity-disabled` (play button/slider dimming at `length <= 1`),
1568
+ `--lyra-focus-ring-*`.
1371
1569
 
1372
1570
  **Optional peer deps:** none.
1373
1571
 
@@ -1389,6 +1587,12 @@ play button's box).
1389
1587
  long-lived `setInterval`), so `intervalMs` is re-read fresh before every tick — changing
1390
1588
  `interval-ms` while `playing` takes effect on the very next step instead of only after a
1391
1589
  pause/play cycle.
1590
+ - `length` and `index` are normalized to finite non-negative integer counts, with `index` clamped
1591
+ into `[0, length)`; fractional, negative, `NaN`, infinite, and oversized values cannot poison
1592
+ end conditions or the slider.
1593
+ - `interval-ms` is clamped to the 16ms floor and the browser's finite timer ceiling: a non-finite or
1594
+ lower value ticks at 16ms, while an oversized value uses the timer ceiling. Each distinct invalid
1595
+ value is warned once (deduplicated per value, not a single once-ever flag).
1392
1596
  - No `aria-valuetext`/visible "N of M" position label on the range input.
1393
1597
  - Calling `play()`/`pause()` programmatically (not via the button) gives no `aria-live`
1394
1598
  announcement of the Play/Pause state change.
@@ -1411,19 +1615,27 @@ click, or Enter/Space on the focused cell, fires `lyra-cell-click`.
1411
1615
  - `colLabels: string[] = []` (attribute: false — matrix mode only)
1412
1616
  - `values: number[][] = []` (attribute: false — matrix mode only) — `-1` or any non-finite value is
1413
1617
  the "no data" sentinel; ragged/sparse rows are safe (`?? -1`)
1414
- - `cellSize: number = 22` (attribute `cell-size` — matrix mode only, ignored when `fitToWidth` is
1415
- set)
1416
- - `fitToWidth: boolean = false` (attribute `fit-to-width` matrix mode only; derives `cellSize` from
1417
- the host's measured `clientWidth` on every draw/resize instead of the fixed `cell-size`, so the
1418
- grid actually fills the available width see gotchas for the default, non-`fit-to-width` behavior)
1618
+ - `cellSize: number = 22` (attribute `cell-size` — default `22` in matrix mode, `11` in calendar
1619
+ mode when left unset; explicitly setting it now governs both modes' per-cell size alike, and it's
1620
+ ignored in either mode when `fitToWidth` is set)
1621
+ - `fitToWidth: boolean = false` (attribute `fit-to-width` derives `cellSize` from the host's
1622
+ measured `clientWidth` on every draw/resize instead of the fixed `cell-size`, so the grid actually
1623
+ fills the available width; now applies to calendar mode as well as matrix mode — see gotchas for
1624
+ the default, non-`fit-to-width` behavior)
1419
1625
  - `valueLabel: string = 'value'` (attribute `value-label`)
1420
- - `scale: 'linear' | 'sqrt' = 'linear'` (matrix mode only calendar mode always buckets by quartile)
1626
+ - `scale: 'linear' | 'sqrt' = 'linear'` governs both modes: in matrix mode, `'sqrt'` compresses the
1627
+ color ramp via `sqrtStep()` instead of mapping linearly; in calendar mode, the default `'linear'`
1628
+ still buckets by quartile (`quartileBucket()`, unchanged), while `'sqrt'` instead compresses via the
1629
+ same `sqrtStep()` magnitude compression as matrix mode, so one heavy day doesn't wash out the rest
1421
1630
  - `mode: 'matrix' | 'calendar' = 'matrix'`
1422
1631
  - `days: CalendarDay[] = []` (attribute: false — calendar mode only) — `CalendarDay { date:
1423
1632
  string /* ISO yyyy-mm-dd */; value: number }`; need not be sorted or contiguous, and an entry whose
1424
1633
  `date` doesn't parse is dropped rather than poisoning the whole grid
1634
+ - `firstDayOfWeek: number = 0` (attribute `first-day-of-week` — calendar mode only, no-op in matrix
1635
+ mode) — anchors the calendar grid at a different weekday instead of always Sunday; `0`-`6`, same
1636
+ numbering as `CalendarCellPos.weekday` (`0` Sunday .. `6` Saturday)
1425
1637
  - `bucketCount: number = 5` (attribute `bucket-count` — calendar mode only; non-finite values fall
1426
- back to 5 with a one-time console warning, and the count is floored to an integer >= 2)
1638
+ back to 5, while finite values are floored and clamped to 2–256 before the color-ramp allocation)
1427
1639
  - `annotations: HeatmapAnnotation[] = []` (attribute: false) — `HeatmapAnnotation { row?: number;
1428
1640
  col?: number; date?: string; label?: string }`: matrix mode matches by `row`/`col`, calendar mode
1429
1641
  by `date` (whichever pair matches the active `mode`; the other fields are ignored). Draws a
@@ -1432,14 +1644,21 @@ click, or Enter/Space on the focused cell, fires `lyra-cell-click`.
1432
1644
  - `cellText?: (pos: MatrixCellPos | CalendarCellPos, value: number) => string` (attribute: false) —
1433
1645
  formats the per-cell hover tooltip and keyboard live-region announcement text; receives the cell
1434
1646
  position (`{ row, col }` in matrix mode, `{ week, weekday }` in calendar mode) and its value.
1435
- Unset (the default) falls back to the built-in English "Row X, Col Y: value" / "Mon DD: value"
1436
- templateadditive, not breaking.
1647
+ Unset (the default) falls back to the built-in English "Row X, Col Y: value" (matrix) / "Jan 15:
1648
+ value"short month + day, **not** a weekday abbreviation (calendar) — template. Additive, not
1649
+ breaking.
1437
1650
  - `columnX?: (index: number) => number` (attribute: false, calendar mode only) — overrides the
1438
1651
  internal week-column x-coordinate formula (`CAL_PAD_LEFT + week * (CAL_CELL + CAL_GAP)`) used
1439
1652
  consistently across drawing, hit-testing, the focus ring, and month-label positioning, so a
1440
1653
  consumer can pixel-align this calendar's week columns with a sibling `<lyra-lite-chart>`'s bars
1441
1654
  (see that component's own `barX`) by supplying the same coordinate function to both. Unset (the
1442
1655
  default) is the original formula, unchanged.
1656
+ - `rowY?: (weekday: number) => number` (attribute: false, calendar mode only) — the vertical
1657
+ analogue of `columnX`: overrides the internal weekday-row y-coordinate formula (`CAL_LABEL_H +
1658
+ weekday * (cellSize + CAL_GAP)`), consulted consistently by drawing, hit-testing, and the focus
1659
+ ring (also consulted at `weekday = 7` to size the canvas height, mirroring `columnX` at
1660
+ `week = weekCount`). Unset (the default) is the original formula, unchanged. Ignored in matrix
1661
+ mode.
1443
1662
 
1444
1663
  **Events:** `lyra-cell-click` (fired on click, or Enter/Space on the keyboard-focused cell —
1445
1664
  `detail: { row, col, value }` in matrix mode, `detail: { date, value }` in calendar mode)
@@ -1466,7 +1685,9 @@ visible regardless of what it's drawn over). `--lyra-heatmap-tooltip-bg` (defaul
1466
1685
  `var(--lyra-color-surface)`) and `--lyra-heatmap-tooltip-text` (default `var(--lyra-color-text)`) —
1467
1686
  unlike the canvas-drawn tokens above, `[part="tooltip"]` is a real DOM element and consumes these
1468
1687
  directly, no `getComputedStyle` bridging needed. Also consumes `--lyra-color-text-quiet` (axis label
1469
- color) and `--lyra-space-xs`.
1688
+ color), `--lyra-space-xs`, `--lyra-radius`/`--lyra-shadow` (tooltip box), and `--lyra-focus-ring-width`/
1689
+ `--lyra-focus-ring-offset` (the real `[part="canvas"]:focus-visible` DOM outline, stroked in the
1690
+ same color as `--lyra-heatmap-focus-ring-color`).
1470
1691
 
1471
1692
  **Optional peer deps:** none.
1472
1693
 
@@ -1493,13 +1714,12 @@ color) and `--lyra-space-xs`.
1493
1714
  ```
1494
1715
 
1495
1716
  **Known gotchas:**
1496
- - the `ResizeObserver` only actually resizes the drawn grid in matrix mode **when
1497
- `fit-to-width` is set**. Without it (the default), `draw()` sizes the canvas as
1498
- `PAD_LEFT + cols * cellSize`, never from the host's measured width, so a container-resize redraw is
1499
- a geometric no-op; the stylesheet's `canvas { inline-size: 100% }` is also dead code in that case,
1500
- since `draw()` unconditionally sets an inline `canvas.style.width/height` that wins over it.
1501
- Calendar mode ignores `fit-to-width` entirely its canvas is always sized from the computed
1502
- `weekCount`.
1717
+ - the `ResizeObserver` only actually resizes the drawn grid **when `fit-to-width` is set**, in either
1718
+ mode. Without it (the default), `draw()` sizes the canvas as `PAD_LEFT + cols * cellSize` (matrix
1719
+ mode) or `CAL_PAD_LEFT + weekCount * cellSize` (calendar mode), never from the host's measured
1720
+ width, so a container-resize redraw is a geometric no-op; the stylesheet's
1721
+ `canvas { inline-size: 100% }` is also dead code in that case, since `draw()` unconditionally sets
1722
+ an inline `canvas.style.width/height` that wins over it.
1503
1723
  - the host is `role="group"` (not `role="img"`) with a dimensions+range summary `aria-label`
1504
1724
  (calendar mode: a day-count + range summary instead) — `[part="canvas"]` inside it is a real
1505
1725
  focusable, keyboard-operable, per-cell-interactive control (roving arrow-key focus,
@@ -1525,7 +1745,8 @@ A force-directed node-link diagram with pan/zoom/drag, built on `d3-force`.
1525
1745
 
1526
1746
  **Properties:**
1527
1747
  - `nodes: GraphNode[] = []` (attribute: false) — `GraphNode { id: string; label?: string; radius?:
1528
- number; color?: string }`
1748
+ number; color?: string }`; `radius` is clamped to `[6, 24]` (an unset/non-finite value falls back
1749
+ to the midpoint, `15`) so a node can never render invisibly small or absurdly large
1529
1750
  - `links: GraphLink[] = []` (attribute: false) — `GraphLink { source: string; target: string;
1530
1751
  width?: number }` (source/target are node ids; links whose endpoints don't resolve are silently
1531
1752
  dropped)
@@ -1547,7 +1768,8 @@ A force-directed node-link diagram with pan/zoom/drag, built on `d3-force`.
1547
1768
  **CSS parts:** `base`, `svg`, `node`, `link`, `label`
1548
1769
 
1549
1770
  **Themeable custom properties:** `--lyra-node-fill` (set inline per-node from `GraphNode.color`,
1550
- falls back to `--lyra-color-brand`); also uses `--lyra-color-border`, `--lyra-color-text`.
1771
+ falls back to `--lyra-color-brand`); also uses `--lyra-color-border` (link stroke), `--lyra-color-text`
1772
+ + `--lyra-font` (label text), `--lyra-focus-ring-*` (node/link `:focus-visible` outline).
1551
1773
 
1552
1774
  **Optional peer deps:** `d3-force`, `d3-drag`, `d3-zoom`, `d3-selection` (all four required
1553
1775
  together; lazy-`import()`ed once per page, `console.warn` once and renders empty if missing —
@@ -1574,6 +1796,10 @@ install with `pnpm add d3-force d3-drag d3-zoom d3-selection`).
1574
1796
  - `chargeStrength`/`linkDistance` **are** live-reactive post-mount now (retuned on the existing
1575
1797
  force objects and the simulation nudged via `alpha(0.3).restart()`) — no need to also touch
1576
1798
  `nodes`/`links` to see the effect.
1799
+ - `width`/`height` are also live-reactive post-mount: changing either re-centers the `forceCenter`
1800
+ force on the new midpoint and nudges the simulation via `alpha(0.1).restart()`, in addition to
1801
+ resizing the rendered `viewBox` — both branches apply independently, so setting `width` and
1802
+ `chargeStrength` in the same synchronous batch retunes both, not just one.
1577
1803
  - zoom is bounded via `minZoom`/`maxZoom` (`d3-zoom`'s `.scaleExtent(...)`, live-reactive); pan/
1578
1804
  zoom/drag are still pointer-only with no keyboard equivalent. Links (`<line part="link">`) are now
1579
1805
  keyboard-operable too (`tabindex="0"`, `role="button"`, `aria-label`, Enter/Space), matching nodes.
@@ -1636,7 +1862,9 @@ observed internally to keep the roving `activeId` in sync with clicks).
1636
1862
  **CSS parts:** `base`
1637
1863
 
1638
1864
  **Themeable custom properties:** shared tokens `--lyra-space-xs`/`-s`, `--lyra-color-brand-quiet`,
1639
- `--lyra-color-text-quiet`, `--lyra-color-border`, `--lyra-radius`.
1865
+ `--lyra-color-text-quiet`, `--lyra-color-border`, `--lyra-color-text`, `--lyra-radius`,
1866
+ `--lyra-focus-ring-*` (row `:focus-visible` ring, driven by `:host(:focus-visible)` since the host
1867
+ itself is the focusable `role="treeitem"`).
1640
1868
 
1641
1869
  **Optional peer deps:** none.
1642
1870
 
@@ -1710,7 +1938,8 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1710
1938
  **Properties:**
1711
1939
  - `type: LyraChartType = 'line'` — `LyraChartType = 'line' | 'bar' | 'scatter' | 'pie' | 'doughnut' |
1712
1940
  'radar' | 'polarArea' | 'bubble'` — every type string the typed subclasses below lock `type` to is
1713
- already a first-class member, so `<lyra-chart type="pie">` needs no subclass or cast to work
1941
+ already a first-class member, so `<lyra-chart type="pie">` needs no subclass or cast to work;
1942
+ unknown runtime attribute/property values fall back to `line` before reaching Chart.js
1714
1943
  - `labels: string[] = []` (attribute: false)
1715
1944
  - `datasets: Series[] = []` (attribute: false) — `Series { label: string; data?: (number|null)[];
1716
1945
  points?: {x,y,label?}[]; color?: string|string[]; fill?: boolean; width?: number; dash?: boolean;
@@ -1730,7 +1959,8 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1730
1959
  meaningful for `bar`/`line` types (scatter/bubble's linear `x` scale and the radial `r` scale used
1731
1960
  by radar/polar-area are out of scope)
1732
1961
  - `config?: Partial<ChartConfiguration>` (attribute: false) — deep-merged over the generated
1733
- config; any nested key wins without clobbering sibling generated keys
1962
+ config; any nested key wins without clobbering sibling generated keys. This is the raw Chart.js
1963
+ escape hatch, so a caller-supplied `config.type` is passed through rather than normalized.
1734
1964
 
1735
1965
  **Methods:** `resetZoom()` (reset any active zoom/pan to the original view), `refreshTheme()`
1736
1966
  (forces a redraw so the `--lyra-chart-*` tokens below are re-read from the current computed style —
@@ -1753,11 +1983,16 @@ properties only cascade downward); `--lyra-chart-grid-color` (default `var(--lyr
1753
1983
  (default `var(--lyra-color-text)`), `--lyra-chart-tooltip-bg` (default `var(--lyra-color-surface)`),
1754
1984
  `--lyra-chart-tooltip-text` (default `var(--lyra-color-text)`) — each resolved fresh via
1755
1985
  `getComputedStyle` on every draw (Chart.js renders to canvas, not the DOM, so it can't consume CSS
1756
- `var()` directly), driving the grid lines, tick labels, legend text, and tooltip background/text
1757
- respectively; plus shared `--lyra-space-xs`.
1986
+ `var()` directly), driving the grid lines, tick labels **and axis titles** (`xLabel`/`yLabel`/
1987
+ `y2Label` title text reuses `--lyra-chart-tick-color` too — there's no separate title-color token),
1988
+ legend text, and tooltip background/text respectively; plus shared `--lyra-space-xs`.
1758
1989
 
1759
- **Optional peer deps:** `chart.js`, `chartjs-plugin-zoom` (lazy-loaded once per page via
1760
- `chart-loader.ts`; registers only the tree-shaken controller/element/scale subset used).
1990
+ **Optional peer deps:** `chart.js` (mandatory peer, lazy-imported on every `connectedCallback()`
1991
+ regardless of `zoom`), `chartjs-plugin-zoom` (lazy-imported *additionally* only when `zoom` is or
1992
+ later becomes — `true`; never fetched for a chart that keeps `zoom` unset/false, since the plugin
1993
+ has a hard dependency on `hammerjs`). Both loads are memoized once per page via `chart-loader.ts`
1994
+ (`loadChartJs()` / `loadChartJsWithZoom()`), registering only the tree-shaken controller/element/scale
1995
+ subset actually used.
1761
1996
 
1762
1997
  ```html
1763
1998
  <lyra-chart type="line" x-label="Day" y-label="kWh" legend></lyra-chart>
@@ -1777,20 +2012,26 @@ respectively; plus shared `--lyra-space-xs`.
1777
2012
  a consumer flipping something upstream (e.g. a `data-theme` attribute) that doesn't otherwise
1778
2013
  change any `lyra-chart` property must call `refreshTheme()` itself to make Chart.js re-read the
1779
2014
  `--lyra-chart-*` tokens above.
1780
- - generated `scales` are type-appropriate: no scale at all for `lyra-pie-chart`/
1781
- `lyra-doughnut-chart`, and a single radial `r` scale (respecting `beginAtZero`) for
1782
- `lyra-radar-chart`/`lyra-polar-area-chart`, instead of always generating the cartesian `x`/`y`/`y2`
1783
- block. `xLabel`/`yLabel`/`y2Label` are still silently inert for all four of those subclasses (a
2015
+ - generated `scales` are keyed off the *effective* type (`config.type` ?? `type`, see
2016
+ `effectiveType()`) and are type-appropriate: no scale at all for `type="pie"`/`"doughnut"` (true of
2017
+ `<lyra-chart type="pie">` directly, not just the `lyra-pie-chart`/`lyra-doughnut-chart` subclasses),
2018
+ and a single radial `r` scale (respecting `beginAtZero`) for `type="radar"`/`"polarArea"`
2019
+ (`lyra-radar-chart`/`lyra-polar-area-chart`), instead of always generating the cartesian `x`/`y`/
2020
+ `y2` block. `xLabel`/`yLabel`/`y2Label` are still silently inert for all four of those types (a
1784
2021
  radial scale and "no scale" both have nowhere to put an axis title) — reach a titled radial scale
1785
2022
  only via raw `config`.
1786
2023
  - No `chartjs-plugin-annotation` is registered by default — reachable only by importing it
1787
2024
  separately and using the raw `config` passthrough (Chart.js's registry is a global singleton).
1788
- - while the `chart.js` peer is resolving, the host shows a `<lyra-skeleton variant="rect">` with
1789
- `aria-busy="true"` (same lazy-load pattern as `lyra-graph`/`lyra-map`/`lyra-flag`); Chart.js's own
1790
- ~1s draw-in animation is skipped under `prefers-reduced-motion: reduce`. The raw `config`
1791
- passthrough is deep-merged with `__proto__`/`constructor`/`prototype` keys skipped unconditionally,
1792
- so a JSON-sourced `config` (e.g. parsed from an API response) can't reach up and pollute
1793
- `Object.prototype` through the merge.
2025
+ - while the `chart.js` peer is resolving, `render()` swaps in a `<lyra-skeleton variant="rect">` for
2026
+ the canvas, and the **host element itself** (not the skeleton) carries `aria-busy="true"` set/
2027
+ cleared in `updated()` off the private `loading` state (same lazy-load pattern as
2028
+ `lyra-graph`/`lyra-map`/`lyra-flag`). Chart.js's own ~1000ms draw-in animation only ever fires on
2029
+ initial construction or a type change that rebuilds the `Chart` instance (every in-place data
2030
+ update already passes `'none'` to `Chart#update()` and never animates regardless); that
2031
+ construction-time animation is additionally skipped outright under `prefers-reduced-motion: reduce`.
2032
+ The raw `config` passthrough is deep-merged with `__proto__`/`constructor`/`prototype` keys skipped
2033
+ unconditionally, so a JSON-sourced `config` (e.g. parsed from an API response) can't reach up and
2034
+ pollute `Object.prototype` through the merge.
1794
2035
  - lazy-redraw + memoization: an `IntersectionObserver` gates `draw()` — while the host is scrolled
1795
2036
  off-screen, property changes that would otherwise trigger a Chart.js redraw are skipped (and a
1796
2037
  single redraw fires once it re-enters the viewport). Independently, a content-signature check
@@ -1843,6 +2084,25 @@ passthrough). Not a subclass of `LyraChart`.
1843
2084
  consumer can pixel-align this chart's bars with a sibling `<lyra-heatmap>` calendar's week columns
1844
2085
  (see that component's own `columnX`) by supplying the same coordinate function to both. Unset (the
1845
2086
  default) is the original formula, unchanged.
2087
+ - `pointText?: (label: string, value: number, datasetIndex: number) => string` (attribute: false) —
2088
+ overrides the per-bar/per-point `<title>`/`aria-label` tooltip text (mirrors `lyra-heatmap`'s
2089
+ `cellText`). Falls back to the built-in raw-value template when unset.
2090
+ - `roundedBars: boolean = false` (attribute `rounded-bars`, bar type only) — draws each bar as a
2091
+ rounded-top-corner shape instead of a square-cornered `<rect>`.
2092
+ - `skipZero: boolean = false` (attribute `skip-zero`, bar type only) — omits a bar entirely (no
2093
+ mark/tabindex/tooltip) for a value that is exactly `0`; `null`/non-finite values are always
2094
+ skipped regardless.
2095
+ - `padLeft?: number` (attribute `pad-left`) — overrides the internal 36px `PAD_LEFT` plot-left-
2096
+ padding constant. Unset keeps the fixed 36px.
2097
+ - `barGapRatio?: number` (attribute `bar-gap-ratio`) — overrides the internal 0.2 `BAR_GROUP_GAP`
2098
+ fraction of a category slot left as a gap between categories. Unset keeps the fixed 0.2.
2099
+ - `scale: 'linear' | 'sqrt' = 'linear'` (bar type only) — `'sqrt'` maps a bar's value to height via
2100
+ `Math.sqrt(value / domainMax)` instead of the standard linear `niceDomain` fraction (mirroring
2101
+ `lyra-heatmap`'s matrix-mode `sqrt` scale), so a skewed dataset's smaller bars aren't washed out
2102
+ by one dominant value; gridlines/tick labels stay on the linear domain either way, and `type="line"`
2103
+ ignores it entirely.
2104
+ - `hideAxis: boolean = false` (attribute `hide-axis`) — suppresses gridlines/y-axis tick labels
2105
+ altogether; x-axis category labels (rendered separately) are unaffected.
1846
2106
 
1847
2107
  **Events:** `lyra-point-click` — fired when a bar/point is activated (click, or Enter/Space while
1848
2108
  focused). `detail: { datasetIndex: number, index: number, label: string | undefined, value: number
@@ -1934,7 +2194,8 @@ Bins `values` into `bins` equal-width buckets and renders as a bar chart (extend
1934
2194
  `type` fixed to `'bar'`).
1935
2195
 
1936
2196
  **Properties:**
1937
- - `bins: number = 10`
2197
+ - `bins: number = 10` — finite values are floored and clamped to 0–1,000 before allocation;
2198
+ non-finite values produce no buckets
1938
2199
  - `values: number[] = []` (attribute: false)
1939
2200
  - `label: string = 'Frequency'`
1940
2201
  - `labels`/`datasets`/`type` are **derived, read-only** (installed as getter/setter pairs on the
@@ -1957,6 +2218,8 @@ Bins `values` into `bins` equal-width buckets and renders as a bar chart (extend
1957
2218
  **Known gotchas:**
1958
2219
  - `bins <= 0` no longer crashes: `binValues()` now returns an empty bucket array for `binCount <= 0`
1959
2220
  (or empty `values`), so the histogram just renders with no bars instead of throwing.
2221
+ - excessively large finite bin counts are capped at 1,000, preventing an attribute or direct
2222
+ property write from requesting an unbounded bucket array.
1960
2223
 
1961
2224
  ---
1962
2225
 
@@ -2024,12 +2287,16 @@ raw `map` escape hatch for anything unexposed.
2024
2287
  `[value, color]` pair — an empty array is ignored, leaving whatever fill layer already exists, if
2025
2288
  any, untouched, rather than being applied)
2026
2289
  - `markers: MapMarker[] = []` (attribute: false) — `MapMarker { id?: string; lngLat: [number,
2027
- number]; color?: string; label?: string; html?: string }`; reconciled by `id` (falling back to a
2028
- `lng,lat` key when `id` is omitted) so an unchanged marker isn't torn down and recreated on every
2029
- `markers` reassignment its `lngLat` **and** its popup content (`html`/`label`, in that
2030
- precedence) are both updated in place, and the popup is removed if a later update sets neither.
2031
- `html` is rendered via `Popup.setHTML()` **raw markup, inline event handlers included** only
2032
- pass trusted content, sanitize anything derived from user input first.
2290
+ number]; color?: string; label?: string; unsafeHtml?: string }`; reconciled by `id` (falling back
2291
+ to a `lng,lat` key, disambiguated by occurrence order for duplicate-coordinate id-less markers,
2292
+ when `id` is omitted) so an unchanged marker isn't torn down and recreated on every `markers`
2293
+ reassignment its `lngLat` **and** its popup content (`unsafeHtml`/`label`, in that precedence)
2294
+ are both updated in place, and the popup is removed if a later update sets neither. `unsafeHtml` is
2295
+ rendered via `Popup.setHTML()` — **raw markup, inline event handlers included** only pass trusted
2296
+ content, sanitize anything derived from user input first; prefer `label` (`Popup.setText()`,
2297
+ escaped) when the content is plain text. A marker whose `color` changes for a persisting `id`
2298
+ can't be recolored in place (no `Marker.setColor()`) and is torn down/reconstructed instead — see
2299
+ gotchas.
2033
2300
 
2034
2301
  **Getters:** `map` → the raw `maplibregl.Map` instance.
2035
2302
 
@@ -2041,7 +2308,7 @@ exists and was hit)
2041
2308
 
2042
2309
  **CSS parts:** `base`, `container`, `legend`, `legend-swatch`
2043
2310
 
2044
- **Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-color-surface`,
2311
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs/-s`, `--lyra-color-surface`,
2045
2312
  `--lyra-color-border`, `--lyra-shadow`, `--lyra-radius`.
2046
2313
 
2047
2314
  **Optional peer deps:** `maplibre-gl` (lazy-loaded; consumer must **separately** `import
@@ -2075,16 +2342,27 @@ exists and was hit)
2075
2342
  about.
2076
2343
  - Point markers now have a declarative API (`markers`, above) with popup support — the `.map` escape
2077
2344
  hatch and manual `new maplibregl.Marker()` are no longer the only way to place pins.
2345
+ - a marker whose `color` changes for a persisting `id` is torn down and reconstructed (maplibre-gl's
2346
+ `Marker` has no `setColor()`) rather than mutated in place — this also closes any popup the user
2347
+ currently has open on that marker (a fresh, closed `Popup` is built for the new instance); an
2348
+ accepted side effect of the reconstruction fallback, not a bug.
2078
2349
  - No click-select visual highlight on choropleth features (no `setFeatureState` call) — clicking
2079
2350
  only fires the event, no built-in visual feedback. Popups are still only reachable declaratively
2080
- through `markers`' `html`/`label` — a choropleth-feature click still has no built-in popup, only the
2081
- raw `lyra-map-click` event.
2351
+ through `markers`' `unsafeHtml`/`label` — a choropleth-feature click still has no built-in popup,
2352
+ only the raw `lyra-map-click` event.
2082
2353
  - `LegendEntry.color` is validated against a strict CSS-color-syntax allowlist before being applied
2083
2354
  to the legend swatch's `background`, rejecting anything that isn't recognizable color syntax
2084
2355
  (notably `url(...)`, which `background` also accepts and would otherwise fetch as soon as the
2085
2356
  swatch renders).
2086
2357
  - while the `maplibre-gl` peer is resolving, the host shows a `<lyra-skeleton variant="rect">` with
2087
2358
  `aria-busy="true"` in place of the map container.
2359
+ - construction of the real `maplibregl.Map` (and its WebGL context) is additionally gated on this
2360
+ element being observed intersecting the viewport (`IntersectionObserver`), independent of whether
2361
+ the `maplibre-gl` peer has already loaded — an off-screen `<lyra-map>` swaps its skeleton for the
2362
+ empty `[part="container"]` div as soon as the peer resolves, but `map` stays `undefined` and
2363
+ `lyra-map-load` never fires until the element is actually scrolled into view. Deliberate: caps
2364
+ concurrent WebGL contexts when many `<lyra-map>`s sit in one dashboard/grid. Skipped entirely
2365
+ (constructs immediately once the peer loads) when `IntersectionObserver` itself is unavailable.
2088
2366
 
2089
2367
  ---
2090
2368
 
@@ -2122,7 +2400,9 @@ element carrying the drag accept/reject announcement)
2122
2400
 
2123
2401
  **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-space-l`,
2124
2402
  `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`, `--lyra-color-text-quiet`,
2125
- `--lyra-color-success` (drag-accept state), `--lyra-color-danger` (drag-reject state).
2403
+ `--lyra-color-success` (drag-accept state), `--lyra-color-danger` (drag-reject state),
2404
+ `--lyra-focus-ring-width/-color/-offset` (`[part="base"]:focus-visible` outline),
2405
+ `--lyra-opacity-disabled` (`:host([disabled])` dimming).
2126
2406
 
2127
2407
  **Optional peer deps:** none.
2128
2408
 
@@ -2167,35 +2447,58 @@ General-purpose modal/overlay plus a promise-based confirmation helper built on
2167
2447
  ### `lyra-dialog`
2168
2448
 
2169
2449
  A modal/overlay: `role="dialog"`, focus-trapped while open, dismissible via Escape or a backdrop
2170
- click, and scroll-locks the document for as long as it's open. Chrome stays minimal — no built-in
2171
- title bar or close button; a consumer supplies a heading and any close affordance itself via the
2172
- default/`footer` slots.
2450
+ click, and scroll-locks the document for as long as it's open. Chrome stays minimal by default — no
2451
+ built-in title bar or close button; a consumer supplies a heading and any close affordance itself via
2452
+ the default/`footer` slots. `heading`/`closable` are an opt-in convenience for the common case where
2453
+ hand-building that chrome isn't worth it.
2173
2454
 
2174
2455
  **Properties:**
2175
2456
  - `open: boolean = false` (reflected) — there is no separate `show()`/`hide()` pair; set this (or
2176
2457
  call `close()`)
2177
- - `label: string = ''` — accessible name used only when no heading is slotted (see below)
2458
+ - `label: string = ''` — accessible name used only when no heading is slotted and `heading` is unset
2459
+ (see below)
2460
+ - `heading?: string` — visible header text (rendered in `[part="header"]`/`[part="heading"]`), used
2461
+ only when no light-DOM heading is slotted into the default slot; has no effect (renders nothing)
2462
+ when a heading element *is* slotted, which keeps working completely unchanged either way
2463
+ - `closable: boolean = false` (attribute `closable`) — renders a built-in close (X) button in the
2464
+ header row (creating one, with no heading text, if `heading` is unset), wired to the same
2465
+ `close()` path Escape/backdrop-dismiss already use, with reason `'close-button'`
2178
2466
 
2179
2467
  **Methods:** `close(reason: DialogCloseReason = 'api'): void` — closes the dialog, emits
2180
2468
  `lyra-dialog-close` with `reason`, and returns focus to whatever had it right before the dialog
2181
- opened. `DialogCloseReason = 'escape' | 'backdrop' | 'api' | string` `'escape'`/`'backdrop'` are
2182
- emitted by the dialog's own built-in dismiss triggers; any other string is whatever a caller passes
2183
- (e.g. a footer Cancel button calling `dlg.close('cancel')`, or `confirm()`'s own `'confirm'`/`'cancel'`).
2469
+ opened. `DialogCloseReason = 'escape' | 'backdrop' | 'close-button' | 'api' | 'unmount' | string`
2470
+ `'escape'`/`'backdrop'` are emitted by the dialog's own built-in dismiss triggers; `'close-button'`
2471
+ by the built-in header close button (rendered when `closable` is set); `'unmount'` is emitted
2472
+ automatically if the dialog is removed from the DOM while still `open` by anything other than its
2473
+ own `close()` (a consumer's own cleanup code, a parent re-render that drops it); any other string is
2474
+ whatever a caller passes (e.g. a footer Cancel button calling `dlg.close('cancel')`, or `confirm()`'s
2475
+ own `'confirm'`/`'cancel'`).
2184
2476
 
2185
2477
  **Events:** `lyra-dialog-close` (`detail: DialogCloseReason`) — fired on every dismissal path
2186
- (Escape, backdrop click, or any `close()` call).
2478
+ (Escape, backdrop click, any `close()` call, or an `'unmount'` removal as above).
2479
+
2480
+ **Stacking:** participates in the shared per-document overlay stack described above. A dialog can be
2481
+ stacked with another dialog or any other modal family; only the visually topmost overlay receives
2482
+ Escape, Tab trapping, or backdrop dismissal, while overlays beneath remain open until the top one
2483
+ closes.
2187
2484
 
2188
2485
  **Slots:** default (the dialog body), `footer` (action buttons, rendered in a bottom row, hidden
2189
2486
  entirely when empty)
2190
2487
 
2191
2488
  **CSS parts:** `backdrop` (the full-viewport scrim), `panel` (the dialog panel, `role="dialog"`
2192
- while open), `label` (the invisible label-text element used for `aria-labelledby` when no heading
2193
- is slotted), `body` (wrapper around the default slot), `footer` (wrapper around the `footer` slot)
2489
+ while open; its max-inline-size is controlled by `--lyra-dialog-max-width`), `header` (the header
2490
+ row, rendered when `heading` is set and no heading is slotted and/or `closable` is `true`),
2491
+ `heading` (the visible `heading`-text element inside `header`, rendered only when `heading` is set
2492
+ and no heading is slotted), `close-button` (the built-in close button inside `header`, rendered only
2493
+ when `closable` is `true`), `label` (the invisible label-text element used for `aria-labelledby`
2494
+ when no heading is slotted and `heading` is unset), `body` (wrapper around the default slot),
2495
+ `footer` (wrapper around the `footer` slot)
2194
2496
 
2195
2497
  **Themeable custom properties:** `--lyra-dialog-overlay-color` (default `rgb(0 0 0 / 0.5)` — the
2196
2498
  backdrop scrim color; component-specific since no shared `--wa-*`/`--lyra-*` overlay token exists),
2197
- plus shared tokens `--lyra-space-l/-m/-s`, `--lyra-color-surface/-border`, `--lyra-radius`,
2198
- `--lyra-shadow`.
2499
+ `--lyra-dialog-max-width` (default `32rem` — the panel's max-inline-size, via
2500
+ `min(var(--lyra-dialog-max-width, 32rem), 100%)`), plus shared tokens `--lyra-space-l/-m/-s`,
2501
+ `--lyra-color-surface/-border`, `--lyra-radius`, `--lyra-shadow`.
2199
2502
 
2200
2503
  **Optional peer deps:** none.
2201
2504
 
@@ -2216,14 +2519,21 @@ plus shared tokens `--lyra-space-l/-m/-s`, `--lyra-color-surface/-border`, `--ly
2216
2519
  </script>
2217
2520
  ```
2218
2521
 
2219
- Accessible name resolution: if a heading element (`h1`–`h6` or `[role="heading"]`) is a *direct
2220
- child* (not inside `slot="footer"`), its text content becomes `aria-label` on the panel. Otherwise,
2221
- when `label` is set, an invisible (`.sr-only`, exposed as the `label` part) element carrying that
2222
- text is rendered inside the panel and `aria-labelledby` points at it instead. Either way `label`
2223
- itself never renders visible chrome a slotted heading is what a sighted user sees. The heading
2224
- case deliberately uses `aria-label` (a copied string) rather than `aria-labelledby` pointing at the
2225
- heading's `id`, because the heading is light-DOM content while `[part="panel"]` lives in shadow DOM
2226
- and an ID-reference attribute can't resolve across that boundary.
2522
+ Accessible name / visible header, in priority order: (1) if a heading element (`h1`–`h6` or
2523
+ `[role="heading"]`) is a *direct child* (not inside `slot="footer"`), its text content becomes
2524
+ `aria-label` on the panel takes priority over `heading` below so an existing consumer that already
2525
+ slots its own heading keeps rendering it exactly as before; (2) otherwise, when `heading` is set, a
2526
+ visible header row (`[part="header"]`) renders containing that text (`[part="heading"]`), which
2527
+ becomes the `aria-labelledby` target; (3) otherwise, when `label` is set, an invisible (`.sr-only`,
2528
+ exposed as the `label` part) element carrying that text is rendered inside the panel and
2529
+ `aria-labelledby` points at it instead. Only one of cases 2/3 ever renders at a time. `label` itself
2530
+ never renders visible chrome on its own — `::part(label)` can be restyled to make the sr-only text
2531
+ visible, or `heading` can be set instead, for visible chrome without slotting a real heading element.
2532
+ The slotted-heading case (1) deliberately uses `aria-label` (a copied string) rather than
2533
+ `aria-labelledby` pointing at the heading's `id`, because the heading is light-DOM content while
2534
+ `[part="panel"]` lives in shadow DOM and an ID-reference attribute can't resolve across that
2535
+ boundary; cases 2/3 use `aria-labelledby` safely since their target renders inside the same shadow
2536
+ root it labels.
2227
2537
 
2228
2538
  **Known gotchas:**
2229
2539
  - `role="dialog"`/`aria-modal="true"` are only present on `[part="panel"]` while `open` is `true` —
@@ -2233,8 +2543,8 @@ and an ID-reference attribute can't resolve across that boundary.
2233
2543
  `aria-label`; set `label` instead for a title that needs to change live.
2234
2544
  - Only *direct* children are scanned for a heading — one nested several layers deep, or inside a
2235
2545
  slotted custom element's own shadow root, is left to the consumer to label explicitly via `label`.
2236
- - A reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
2237
- scroll lock and the Escape/Tab-trap `keydown` listener if `open` was still `true` across the
2546
+ - A reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) resumes its
2547
+ shared overlay registration and re-acquires the scroll lock if `open` was still `true` across the
2238
2548
  move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
2239
2549
  `willUpdate()` alone wouldn't otherwise notice.
2240
2550
  - Tab-trap focus order follows the default (body) slot, then the `footer` slot — the same order the
@@ -2247,7 +2557,7 @@ and an ID-reference attribute can't resolve across that boundary.
2247
2557
  A drop-in async replacement for `window.confirm()`, built on `<lyra-dialog>`.
2248
2558
 
2249
2559
  ```ts
2250
- import { confirm } from '@aceshooting/lyra-ui';
2560
+ import { confirm } from '@aceshooting/lyra-ui/components/dialog/confirm.js';
2251
2561
 
2252
2562
  const ok = await confirm({
2253
2563
  title: 'Delete conversation?',
@@ -2278,9 +2588,10 @@ used is still a `--lyra-*` token reference, never a raw literal.
2278
2588
  `<lyra-dialog>`'s own `close()`/`lyra-dialog-close` event, so there is exactly one place that
2279
2589
  resolves the promise and tears the dialog down — a consumer never needs to (and shouldn't) call
2280
2590
  `.remove()` itself.
2281
- - Both buttons reuse `--lyra-color-on-brand` for their text color, even in the `danger` tone — there
2282
- is no separate `--lyra-color-on-danger` token, and `--lyra-color-on-brand` happens to be white in
2283
- both light and dark themes so it reads fine over the danger fill too.
2591
+ - The neutral confirm button pairs `--lyra-color-on-brand` with `--lyra-color-brand`; the danger
2592
+ tone pairs `--lyra-color-on-danger` with `--lyra-color-danger`. Each token chains through Web
2593
+ Awesome's matching `*-on-loud` semantic role and has contrast-tested standalone light/dark
2594
+ fallbacks.
2284
2595
  - Importing `confirm` alone is enough to register `<lyra-dialog>` — `confirm.ts` imports
2285
2596
  `./dialog.js` for its side effect, so a consumer doesn't need a separate import for the dialog
2286
2597
  element.
@@ -2296,6 +2607,14 @@ child, simply never produces a tab. Implements the WAI-ARIA APG tabs pattern wit
2296
2607
  activation: Left/Right (swapped under RTL) move focus *and* selection together, Home/End jump to the
2297
2608
  first/last enabled tab, and a roving `tabindex` follows whichever tab is currently selected.
2298
2609
 
2610
+ A tab button's *visible* content can carry a leading icon without ever changing its *accessible
2611
+ name* (always exactly `label`'s text): give a tab an extra direct-child sibling of `<lyra-tabs>`
2612
+ carrying `slot="<id>-icon"` (that sibling's own content — inline SVG, emoji span, a custom icon
2613
+ element, anything — is entirely up to the consumer). It renders ahead of the label inside that tab's
2614
+ button, wrapped in an `aria-hidden="true"` `[part="tab-icon"]` so it's excluded from accessible-name
2615
+ computation regardless of content. A tab with no matching `<id>-icon` sibling renders no icon
2616
+ wrapper at all, so existing text-only tabs are unaffected.
2617
+
2299
2618
  **Properties:**
2300
2619
  - `active: string = ''` (reflected) — the active tab's `slot`/id; falls back to the first enabled
2301
2620
  tab whenever the current value doesn't resolve to one (including on every children/attribute
@@ -2306,13 +2625,15 @@ click or keyboard. Not fired when `active` self-corrects to a valid tab (initial
2306
2625
  disappearing/becoming disabled underneath the current selection).
2307
2626
 
2308
2627
  **Slots:** default — direct children with `slot="<id>" label="<text>"` (and optionally `disabled`);
2309
- one becomes each tab's panel.
2628
+ one becomes each tab's panel. `<id>-icon` — optional sibling direct child supplying a tab's leading
2629
+ icon content; excluded from the tab button's accessible name.
2310
2630
 
2311
2631
  **CSS parts:** `base` (root wrapper around the tablist and panels), `tablist` (the `role="tablist"`
2312
- row of tab buttons), `tab` (a single tab button), `panel` (a single `role="tabpanel"` wrapper, one
2313
- per tab, hidden unless active)
2632
+ row of tab buttons), `tab` (a single tab button), `tab-icon` (the optional leading-icon wrapper
2633
+ inside a tab button; only rendered when that tab has a matching `<id>-icon` sibling), `panel` (a
2634
+ single `role="tabpanel"` wrapper, one per tab, hidden unless active)
2314
2635
 
2315
- **Themeable custom properties:** shared tokens only — `--lyra-space-s/-m`,
2636
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs/-s/-m`,
2316
2637
  `--lyra-color-border/-text-quiet/-text/-brand`, `--lyra-transition-fast`, `--lyra-radius`,
2317
2638
  `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
2318
2639
 
@@ -2371,8 +2692,8 @@ showing the checkmark/indeterminate dash), `checkmark` (the checkmark or indeter
2371
2692
  `label` (wrapper around the default slot)
2372
2693
 
2373
2694
  **Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-icon-button-size`,
2374
- `--lyra-color-border/-surface/-on-brand/-brand/-text`, `--lyra-radius`, `--lyra-transition-fast`,
2375
- `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
2695
+ `--lyra-color-border/-surface/-on-brand/-brand/-text/-danger`, `--lyra-radius`,
2696
+ `--lyra-transition-fast`, `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
2376
2697
 
2377
2698
  **Optional peer deps:** none.
2378
2699
 
@@ -2389,6 +2710,11 @@ Form-associated via a directly-attached `ElementInternals` (not the shared `Form
2389
2710
  whose `value` accessor assumes a plain string default flow) with its own hand-rolled
2390
2711
  `updateValidity()` — same shape as `<lyra-combobox>`'s and `<lyra-switch>`'s direct-`ElementInternals`
2391
2712
  handling.
2713
+ Session-history/autofill restoration uses four explicit state tokens: `checked`, `unchecked`,
2714
+ `checked/indeterminate`, and `unchecked/indeterminate`. This preserves both public booleans while
2715
+ keeping an unchecked control distinguishable from a checked control whose submitted value is an
2716
+ empty string. Restoration updates state, form data, and validity synchronously without firing
2717
+ `lyra-change`.
2392
2718
 
2393
2719
  **Known gotchas:**
2394
2720
  - `formResetCallback()` restores `checked` to whatever the declarative `checked` attribute parsed to
@@ -2447,6 +2773,8 @@ Form-associated the same way as `<lyra-checkbox>`: a directly-attached `ElementI
2447
2773
  hand-rolled `updateValidity()`, not the shared `FormAssociated` mixin. The thumb animates the
2448
2774
  logical `inset-inline-start` property (not a physical `transform: translateX()`), so the slide
2449
2775
  direction mirrors correctly under `dir="rtl"`.
2776
+ Session-history/autofill restoration uses the same explicit `checked`/`unchecked` state tokens as
2777
+ checkbox and does not emit `lyra-change`.
2450
2778
 
2451
2779
  **Known gotchas:**
2452
2780
  - `formResetCallback()` restores `checked` to the value captured from the declarative `checked`
@@ -2463,7 +2791,9 @@ A collapsible, copyable tree view for an arbitrary JSON-serializable value (obje
2463
2791
  number, boolean, null, or `undefined`). Serves as a fallback renderer wherever a raw payload needs
2464
2792
  inspecting without a bespoke view. Expand/collapse state is keyed by structural path (not object
2465
2793
  identity), so it survives a `data` reassignment that keeps the same shape — e.g. a streaming result
2466
- being patched in place.
2794
+ being patched in place. A container value that self-references (directly or through a longer cycle)
2795
+ renders as a leaf `Circular reference` marker (`data-type="circular"`) instead of recursing — no
2796
+ stack overflow on cyclic `data`.
2467
2797
 
2468
2798
  **Properties:**
2469
2799
  - `data: unknown` (attribute `false` — property-only, not settable via an HTML attribute)
@@ -2485,12 +2815,14 @@ context, older browser, a stubbed-out test environment), so a consumer can still
2485
2815
 
2486
2816
  **CSS parts:** `base` (root scroll container, respects `max-height`), `toolbar` (wrapper around the
2487
2817
  top-level copy button, only rendered when `copyable`), `tree` (wrapper around the rendered node
2488
- tree), `key` (an object property key or array index label), `value` (a primitive value's text —
2489
- carries `data-type` of `string`/`number`/`boolean`/`null`/`undefined` for per-type coloring, and
2490
- `data-match` while it matches `search`), `bracket` (a `{`, `}`, `[`, or `]` delimiter), `toggle` (a
2491
- container node's expand/collapse button; hidden but present for row alignment on leaf/empty nodes),
2492
- `copy-button` (a copy-to-clipboard button the top-level one in `toolbar`, or a per-node one; only
2493
- rendered when `copyable`)
2818
+ tree), `key` (an object property key or array index label, `data-match` while it matches `search`),
2819
+ `value` (a primitive value's text — carries `data-type` of
2820
+ `string`/`number`/`boolean`/`null`/`undefined`/`circular` for per-type coloring, and `data-match`
2821
+ while it matches `search`), `bracket` (a `{`, `}`, `[`, or `]` delimiter), `toggle` (a container
2822
+ node's expand/collapse button; hidden but present for row alignment on leaf/empty nodes),
2823
+ `copy-button` (a copy-to-clipboard button — the top-level one in `toolbar` (aria-label "Copy JSON to
2824
+ clipboard") or a per-node one (aria-label `Copy ${key/type}`, e.g. "Copy age"); only rendered when
2825
+ `copyable`)
2494
2826
 
2495
2827
  **Themeable custom properties:** `--lyra-json-viewer-max-height` (default `none` — grows with content
2496
2828
  until `max-height` is set), `--lyra-json-viewer-font` (default `ui-monospace, SFMono-Regular, Menlo,
@@ -2582,7 +2914,7 @@ window and flushes immediately.
2582
2914
 
2583
2915
  **Slots:** none.
2584
2916
 
2585
- **CSS parts:** `region` — the visually-hidden element carrying `role`/`aria-live`.
2917
+ **CSS parts:** `region` — the visually-hidden element carrying `role`/`aria-live`/`aria-atomic="true"`.
2586
2918
 
2587
2919
  **Themeable custom properties:** none component-specific — the region is hidden via the shared
2588
2920
  `.sr-only` helper class (`internal/a11y.ts`), not tokenized CSS.
@@ -2607,14 +2939,16 @@ A parent Lit component would instead hold the reference via `@query('lyra-live-r
2607
2939
  - Re-announcing text identical to what was last written is special-cased: screen readers announce a
2608
2940
  live region only on text-content *change*, so the component clears `textContent` first and
2609
2941
  re-sets it on the next animation frame (not the same tick, which can coalesce into nothing ever
2610
- appearing to change) to give assistive tech a real empty-to-populated transition to observe.
2942
+ appearing to change) to give assistive tech a real empty-to-populated transition to observe. The
2943
+ frame is scheduled and canceled through the region's own document window, including after the
2944
+ element is adopted into an iframe.
2611
2945
  - The region's DOM is tracked outside Lit's own template bindings (`write()` mutates
2612
2946
  `regionEl.textContent` directly) — an `announce()`/flush landing before `firstUpdated()` has run
2613
2947
  (e.g. a consumer creates, appends, and calls `announce()` synchronously) is buffered and applied
2614
2948
  on the next `firstUpdated()` rather than dropped.
2615
- - `disconnectedCallback()` cancels any pending (unflushed) announcement and any in-flight re-announce
2616
- animation-frame callback — an element removed mid-throttle-window silently drops whatever was
2617
- queued.
2949
+ - `disconnectedCallback()` cancels any pending (unflushed) announcement, any before-first-render
2950
+ buffered write, and any in-flight re-announce animation-frame callback — an element removed before
2951
+ a deferred write lands silently drops it, including across a later reconnect.
2618
2952
  - Changing `throttle-ms` updates the live `Announcer`'s window immediately, but a flush already
2619
2953
  scheduled under the old window keeps the deadline it was scheduled with.
2620
2954
 
@@ -2626,20 +2960,34 @@ Sanitized Markdown-to-HTML rendering (GFM tables, fenced code blocks, links, blo
2626
2960
  two optional peer dependencies — `marked` (parsing) and `dompurify` (sanitizing) — both lazy-loaded
2627
2961
  independently via `markdown-loader.ts`'s `loadMarkdownDeps()` on first connect, cached per page the
2628
2962
  same way `chart-loader.ts`/`map-loader.ts` cache their load promise so every `<lyra-markdown>`
2629
- instance on a page shares one load. `heading`/`code`/`blockquote`/`table`/`link` tokens are rendered
2630
- through a `marked` renderer override that injects `part="..."` attributes directly into the produced
2631
- HTML in a single pass (no second DOM walk after insertion).
2963
+ instance on a page shares one load. `heading`/`code`/`blockquote`/`table`/`link`/`image` tokens are
2964
+ rendered through a `marked` renderer override that injects `part="..."` attributes directly into the
2965
+ produced HTML in a single pass (no second DOM walk after insertion).
2632
2966
 
2633
2967
  **Properties:**
2634
2968
  - `content: string = ''` — the Markdown source to render
2635
2969
  - `sanitize: boolean = true` — sanitize `marked`'s HTML output with DOMPurify before rendering
2636
2970
  - `gfm: boolean = true` — GitHub-flavored Markdown (tables, strikethrough, autolinks, task lists)
2637
- - `linkTarget: string = '_blank'` (attribute `link-target`) — `target` applied to every rendered
2638
- `<a>`; `rel="noopener noreferrer"` is always added alongside it regardless of this value
2971
+ - `linkTarget: string | null = '_blank'` (attribute `link-target`) — `target` applied to every
2972
+ rendered `<a>`, with `rel="noopener noreferrer"` always added alongside it whenever a `target` is
2973
+ emitted. `'_blank'` (the default) preserves the original output; a falsy value (`null`, or the
2974
+ empty string via `link-target=""`) omits `target`/`rel` entirely instead of always defaulting to
2975
+ `_blank`, so rendered links open in the same tab
2639
2976
  - `internalLinkPrefix: string = ''` (attribute `internal-link-prefix`) — when set, a rendered link
2640
2977
  whose `href` *attribute* (not the browser-resolved `.href` property) starts with this prefix is
2641
2978
  intercepted on click and reported via `lyra-link-click` instead of navigating; empty (the default)
2642
2979
  means every link is treated as external
2980
+ - `headingOffset: number = 0` (attribute `heading-offset`) — added to every rendered heading's
2981
+ source `token.depth` before emitting `<h${depth}>` (e.g. `heading-offset="2"` renders a source `#`
2982
+ as `<h3>`); clamped to `[1, 6]` so a source `######` with a positive offset stays at `<h6>` rather
2983
+ than overflowing past the HTML heading levels. `0` (the default) preserves the original
2984
+ `<h${token.depth}>` output
2985
+ - `eagerLoad: boolean = false` (attribute `eager-load`) — when `true`, `connectedCallback()` skips
2986
+ awaiting the async `loadMarkdownDeps()` import and renders synchronously if the shared
2987
+ `marked`/`dompurify` module cache is *already* warm (e.g. an earlier `<lyra-markdown>` instance on
2988
+ the page already finished loading); falls back to the normal async path (with its brief
2989
+ plain-text-fallback first paint) when the cache isn't warm yet. `false` (the default) is
2990
+ byte-identical to always taking the async path
2643
2991
  - `streaming: boolean = false` (reflected) — forward-compatible hint for a future streaming renderer
2644
2992
  expected to build on this component (coalescing partial tokens as they arrive); setting it has no
2645
2993
  rendering effect yet, it only lets a consumer already target `lyra-markdown[streaming]` in CSS
@@ -2651,9 +2999,12 @@ fired whenever rendering falls back to plain text — see the fallback matrix be
2651
2999
 
2652
3000
  **Slots:** none — content comes from the `content` property, not light-DOM children.
2653
3001
 
2654
- **CSS parts:** `content` (the wrapper around the rendered or plain-text-fallback output), `heading`
2655
- (every rendered `<h1>`–`<h6>`), `code-block` (every rendered fenced/indented `<pre>`), `link` (every
2656
- rendered `<a>`), `table` (every rendered `<table>`), `blockquote` (every rendered `<blockquote>`)
3002
+ **CSS parts:** `content` (the wrapper around the rendered or plain-text-fallback output; carries
3003
+ `data-fallback` while showing the plain-text fallback still-loading peers or a failed render —
3004
+ so a consumer can target `lyra-markdown [part='content'][data-fallback]` to style it distinctly),
3005
+ `heading` (every rendered `<h1>`–`<h6>`, shifted by `heading-offset`), `code-block` (every rendered
3006
+ fenced/indented `<pre>`), `link` (every rendered `<a>`), `table` (every rendered `<table>`),
3007
+ `blockquote` (every rendered `<blockquote>`), `img` (every rendered `<img>`)
2657
3008
 
2658
3009
  **Themeable custom properties:** `--lyra-markdown-font-mono` (default `ui-monospace, SFMono-Regular,
2659
3010
  Menlo, Consolas, monospace` — the code/code-block font; component-specific since no shared
@@ -2718,9 +3069,10 @@ a `<lyra-markdown>`, a custom template, anything) and this component only suppli
2718
3069
  chrome: alignment/coloring by `role`, an avatar/badges header row, an optional collapse toggle, an
2719
3070
  attachments strip, and a status-aware footer (a live-updating status dot + text, the formatted
2720
3071
  `timestamp`, a built-in retry affordance for `status="failed"`, and an `actions` slot for everything
2721
- else). No built-in copy button is rendered — slot a copy control into `actions` and fire `lyra-copy`
2722
- from it if you want one (matching `<lyra-json-viewer>`'s copy-affordance event name for anything
2723
- listening at the conversation-surface level).
3072
+ else). No built-in copy button is rendered — slot a copy control into `actions` and fire
3073
+ `lyra-copy` (`detail: { text: string }`) from it if you want one (matching `<lyra-json-viewer>`'s
3074
+ and `<lyra-code-block>`'s copy-affordance event name/shape, for anything listening at the
3075
+ conversation-surface level).
2724
3076
 
2725
3077
  **Properties:**
2726
3078
  - `role: ChatMessageRole = 'assistant'` (`'user' | 'assistant' | 'system'`) — reflects to
@@ -2885,7 +3237,8 @@ a compact call summary is useful, with or without a detail surface behind it.
2885
3237
  - `category: string = ''` — optional grouping label, e.g. `research`
2886
3238
  - `status: 'pending'|'running'|'success'|'error'|'denied' = 'pending'` (reflected) — drives the
2887
3239
  glyph, accent color, and `status-text`; same status vocabulary as `<lyra-tool-result-dialog>` so a
2888
- call's chip and its detail dialog always agree
3240
+ call's chip and its detail dialog always agree; unknown runtime values render the pending icon,
3241
+ text, and accessible label instead of failing the update
2889
3242
  - `summary: string = ''` — short human-readable status text, e.g. `Searching web…`
2890
3243
  - `durationMs?: number` (attribute `duration-ms`) — how long the call took, in milliseconds; the
2891
3244
  `duration` part is omitted entirely when unset
@@ -2956,8 +3309,9 @@ sub-1000ms `"820ms"`, else trimmed to at most one decimal of seconds (`"1.5s"`,
2956
3309
  ## `lyra-tool-result-view`
2957
3310
 
2958
3311
  Renders a tool call's result via whichever custom renderer a host app has registered for it,
2959
- falling back to `<lyra-json-viewer>` whenever no renderer matches, a renderer's optional `load()`
2960
- rejects, or its `render()` throws. First-party invention (no Web Awesome equivalent). This component
3312
+ falling back to `<lyra-json-viewer>` whenever no renderer matches, a candidate renderer's
3313
+ `matches()` predicate throws during dispatch, a renderer's optional `load()` rejects, or its
3314
+ `render()` throws. First-party invention (no Web Awesome equivalent). This component
2961
3315
  owns none of the actual visual weight of a populated tool result — that's entirely whatever the
2962
3316
  registered renderer returns; `<lyra-tool-result-view>` is just the dispatch + fallback + loading-state
2963
3317
  shell around it.
@@ -2978,8 +3332,9 @@ shell around it.
2978
3332
  once more fallback kinds land
2979
3333
 
2980
3334
  **Events:** `lyra-render-error` (`detail: { toolName: string; error: unknown }`) — fired immediately
2981
- before falling back to `<lyra-json-viewer>`, whether because no renderer matched, a renderer's
2982
- `load()` rejected, or its `render()` threw.
3335
+ before falling back to `<lyra-json-viewer>`, whether because no renderer matched, a candidate
3336
+ renderer's `matches()` predicate threw during dispatch, a renderer's `load()` rejected, or its
3337
+ `render()` threw.
2983
3338
 
2984
3339
  **Slots:** none.
2985
3340
 
@@ -3097,10 +3452,10 @@ registerToolRenderer('web_search', {
3097
3452
  A full tool-call detail overlay: a status/duration header plus a `body` slot where a consumer
3098
3453
  typically places a `<lyra-tabs>` with Input/Preview/JSON/Raw panels. First-party invention (no Web
3099
3454
  Awesome equivalent). This component knows nothing about what's inside that slot — it only supplies
3100
- the modal chrome around it. It is its own standalone overlay implementation (`role="dialog"`,
3101
- focus-trapped, Escape/backdrop-dismissible, scroll-locking) rather than nesting a `<lyra-dialog>` in
3102
- its shadow template, so that slot-forwarding doesn't put a forwarding `<slot>` where a slotted
3103
- `<lyra-tabs>`'s own light-DOM child scan expects real projected content.
3455
+ the modal chrome around it. It keeps its own shadow template rather than nesting a `<lyra-dialog>`,
3456
+ so slot-forwarding does not put a forwarding `<slot>` where a slotted `<lyra-tabs>`'s own light-DOM
3457
+ child scan expects real projected content, while its modal behavior participates in the shared
3458
+ overlay stack.
3104
3459
 
3105
3460
  **Properties:**
3106
3461
  - `open: boolean = false` (reflected) — whether the dialog is open; set this (or call `close()`) —
@@ -3151,8 +3506,8 @@ tokens `--lyra-color-surface/-border/-text-quiet/-brand/-brand-quiet/-success/-s
3151
3506
  @lyra-maximize-change=${(e) => console.log('maximized:', e.detail)}
3152
3507
  >
3153
3508
  <lyra-tabs slot="body">
3154
- <lyra-tab-panel label="Preview">…</lyra-tab-panel>
3155
- <lyra-tab-panel label="JSON"><lyra-json-viewer .data=${result}></lyra-json-viewer></lyra-tab-panel>
3509
+ <div slot="preview" label="Preview">…</div>
3510
+ <div slot="json" label="JSON"><lyra-json-viewer .data=${result}></lyra-json-viewer></div>
3156
3511
  </lyra-tabs>
3157
3512
  <button slot="footer">Rerun</button>
3158
3513
  </lyra-tool-result-dialog>
@@ -3170,14 +3525,13 @@ open/close lifecycle — unlike `<lyra-widget>`'s fullscreen mode there's no sep
3170
3525
  state, so no additional scroll-lock/focus-trap bookkeeping is needed for that transition alone.
3171
3526
 
3172
3527
  **Known gotchas:**
3173
- - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
3174
- scroll lock and the document `keydown` listener if `open` was still `true` across the move —
3175
- `disconnectedCallback`/`connectedCallback` fire back-to-back with no intervening update, so
3176
- `willUpdate` never reruns to notice `open` didn't change
3177
- - this component deliberately does **not** compose `<lyra-dialog>` internally, even though the two
3178
- share nearly identical overlay mechanics (focus trap, scroll lock, Escape/backdrop dismiss) the
3179
- duplication is intentional so a slotted `<lyra-tabs>` (or any other light-DOM-scanning child) sees
3180
- real projected content rather than a forwarding `<slot>` from a nested dialog
3528
+ - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) resumes its
3529
+ shared overlay registration and re-acquires the scroll lock if `open` was still `true` across the
3530
+ move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no intervening update, so
3531
+ `willUpdate` never reruns to notice `open` did not change
3532
+ - this component deliberately does **not** compose `<lyra-dialog>` internally; it keeps its own
3533
+ panel template so a slotted `<lyra-tabs>` (or any other light-DOM-scanning child) sees real
3534
+ projected content rather than a forwarding `<slot>`, while still sharing the overlay stack
3181
3535
  - `close()` is a no-op when `open` is already `false` — calling it twice in a row only fires
3182
3536
  `lyra-dialog-close` once
3183
3537
  - the `maximize`/`close` buttons are always the first elements in the Tab order while open,
@@ -3194,6 +3548,11 @@ built-in send/stop button. **Form-associated** via the shared `FormAssociated` m
3194
3548
  `checkValidity()`/`reportValidity()`, so it participates in native `<form>` submission/validation/
3195
3549
  reset like any other text control.
3196
3550
 
3551
+ The inner textarea mirrors `required` through native `required`/`aria-required`. Its
3552
+ `aria-invalid` remains false until the textarea has been blurred, then follows the host's
3553
+ `ElementInternals` validity as `value` or `required` changes. A native validation attempt also
3554
+ reveals the invalid state, and `form.reset()` clears the touched presentation.
3555
+
3197
3556
  **Properties (own):**
3198
3557
  - `placeholder: string = ''`
3199
3558
  - `minRows: number = 1` (attribute `min-rows`) — floored to `1` at render time
@@ -3250,7 +3609,11 @@ entirely when it has assigned content)
3250
3609
  Auto-resize (`resizeTextarea()`) reads the textarea's own *computed* line-height/padding/border at
3251
3610
  call time rather than assuming a fixed px-per-row constant, so it stays correct under a consumer's
3252
3611
  own font-size/line-height overrides; it grows between `min-rows` and `max-rows`, then switches to
3253
- internal scrolling (`overflow-y: auto`) past `max-rows`. Enter-to-send only fires while
3612
+ internal scrolling (`overflow-y: auto`) past `max-rows`. A `ResizeObserver` on the textarea itself
3613
+ also re-runs this fit (one animation frame later, to avoid a `ResizeObserver`-loop console error)
3614
+ whenever the textarea's own *width* changes — a sidebar collapsing, a responsive breakpoint, a
3615
+ window resize — even though `value`/`min-rows`/`max-rows` never did, since the same text now wraps
3616
+ across a different number of lines. Enter-to-send only fires while
3254
3617
  `submit-on-enter` is `true` (the default): plain Enter submits and prevents the default newline;
3255
3618
  Shift+Enter always inserts a newline regardless of `submit-on-enter`; an IME composition step
3256
3619
  (checked via `isComposing`, with `keyCode === 229` as a defense-in-depth fallback for browsers that
@@ -3302,6 +3665,15 @@ wins when both are present.
3302
3665
  - `progress: number = 0` — upload completion, 0-100; only meaningful while `status="uploading"`, a
3303
3666
  value of `0` or `NaN` falls back to the indeterminate spinner
3304
3667
  - `removable: boolean = true` (reflected) — shows the remove (×) button
3668
+ - `removeLabel: string = 'Remove'` (attribute `remove-label`) — verb used in the remove button's
3669
+ `aria-label`, interpolated as `` `${removeLabel} ${displayName}` ``; override for i18n/locale
3670
+ - `retryLabel: string = 'Retry'` (attribute `retry-label`) — verb used in the retry button's
3671
+ `aria-label`, interpolated as `` `${retryLabel} ${displayName}` ``; override for i18n/locale
3672
+ - `uploadingLabel: string = 'Uploading'` (attribute `uploading-label`) — verb used in the visible
3673
+ uploading status text, rendered as `` `${uploadingLabel} ${percent}%` `` once progress is a
3674
+ meaningful number, else `` `${uploadingLabel}…` ``; override for i18n/locale
3675
+ - `uploadFailedLabel: string = 'Upload failed'` (attribute `upload-failed-label`) — visible status
3676
+ text shown for `status="error"`; override for i18n/locale
3305
3677
 
3306
3678
  The component identifies *which* attachment a `lyra-remove`/`lyra-retry` event is about via the
3307
3679
  platform's own `id` attribute/property rather than a second, differently-named prop. Set `id="..."`
@@ -3384,7 +3756,9 @@ reads as stalled.
3384
3756
  - `stallThresholdMs: number = 10000` (attribute `stall-threshold-ms`) — how long `phase` may stay
3385
3757
  `'streaming'` with no `recordActivity()` call before the component auto-transitions to
3386
3758
  `'stalled'`. A non-finite or `<= 0` value disables the stall timer entirely (arming becomes a
3387
- no-op, so the phase will never auto-stall).
3759
+ no-op, so the phase will never auto-stall). Changing this value while already `'streaming'`
3760
+ re-arms the timer immediately against the new value, rather than waiting for the next
3761
+ `recordActivity()` call or phase change.
3388
3762
 
3389
3763
  **Methods:**
3390
3764
  - `recordActivity(): void` — call on every semantic (non-heartbeat) frame received while
@@ -3445,8 +3819,13 @@ the instant `phase` becomes anything else, including a host-driven reassignment
3445
3819
  on. Phase transitions into/out of `'stalled'` are announced through an internal
3446
3820
  `<lyra-live-region>` rather than a hand-rolled `aria-live` region: entering `'stalled'` announces
3447
3821
  "Connection stalled." with `mode="assertive"` (a stall can need the user's attention before they
3448
- give up and navigate away); recovering announces "Connection restored." with `mode="polite"` (good
3449
- news doesn't need to interrupt). Calling `recordActivity()` itself never announces anything, no
3822
+ give up and navigate away). Leaving `'stalled'` always announces with `mode="polite"` (good news
3823
+ doesn't need to interrupt), but the *wording* depends on the destination phase: "Connection
3824
+ restored." only when leaving `'stalled'` for `'streaming'` (a genuine recovery, typically via
3825
+ `recordActivity()`); a neutral "No longer stalled." when the destination is `'idle'`/`'connecting'`
3826
+ instead (the host gave up on the stream, which is not the same thing as it recovering — a
3827
+ screen-reader user must not be told the opposite of what a sighted user sees). Calling
3828
+ `recordActivity()` itself never announces anything, no
3450
3829
  matter how often the host calls it — only the phase *transition* announces, exactly once. The
3451
3830
  decorative indicator dot is `aria-hidden` (a color/motion cue only) and only pulses while
3452
3831
  `phase="streaming"`; `'stalled'` is styled as a warning tone, not danger, since a stall is usually
@@ -3466,6 +3845,10 @@ instead. The pulse animation is suppressed under `prefers-reduced-motion: reduce
3466
3845
  `phase`.
3467
3846
  - `phase` remains directly settable at all times; assigning `'stalled'` yourself fires `lyra-stall`
3468
3847
  and the assertive announcement exactly as if the timer had fired.
3848
+ - reconnecting the element while still `phase === 'streaming'` (e.g. a drag-and-drop reparent that
3849
+ keeps the same instance) automatically re-arms the stall timer in `connectedCallback` —
3850
+ `disconnectedCallback` always disarms it, and disconnect/reconnect fire back-to-back with no
3851
+ `updated()` cycle in between, so nothing else would otherwise notice.
3469
3852
  - The `actions` slot's wrapper is always present in the DOM and toggled with the `hidden` attribute
3470
3853
  based on slotted content, not gated by `phase` — content placed there is visible regardless of
3471
3854
  the current phase.
@@ -3500,7 +3883,9 @@ invention (no Web Awesome equivalent).
3500
3883
  - `rowHeight: string = 'auto'` (attribute `row-height`) — `'auto'` measures each row's real height via
3501
3884
  `ResizeObserver`; a numeric string (e.g. `"56"`) fixes every row to that many pixels. Anything else
3502
3885
  (non-numeric, zero, negative, non-finite) silently falls back to `'auto'` rather than throwing.
3503
- - `overscan: number = 6` — extra rows rendered beyond the visible viewport on each side.
3886
+ - `overscan: number = 6` — extra rows rendered beyond the visible viewport on each side; finite
3887
+ values are floored and clamped to 0–100, while non-finite values use the default 6, so an invalid
3888
+ runtime value cannot disable windowing and render the entire collection.
3504
3889
  - `activeId: string = ''` (attribute `active-id`) — when set and it matches a row's `keyFunction`
3505
3890
  result (compared as a string), that row is smoothly scrolled into view whenever this changes, and
3506
3891
  rendered with `aria-current="true"`.
@@ -3555,13 +3940,17 @@ so it isn't clipped by the container's own `overflow: auto`).
3555
3940
  ```
3556
3941
 
3557
3942
  Every row — in both `row-height` modes — is positioned by a `transform: translateY(offset)` computed
3558
- from a single cumulative offsets array rebuilt on every update, rather than by page flow; this is what
3559
- lets a small DOM window exist while the scrollbar still reflects the true total content height. In
3560
- `row-height="auto"` mode, unmeasured rows contribute a fixed estimate to that array until their real
3561
- `ResizeObserver`-reported height lands, so only rows after a newly-measured one shift on each
3562
- measurement rather than the whole list reflowing. The offsets rebuild is an `O(n)` loop appropriate for
3563
- hundreds to a few thousand rows (a scrollable history sidebar); it is not the right approach for a
3564
- hundred-thousand-row list without further work.
3943
+ from a single cumulative offsets array, rather than by page flow; this is what lets a small DOM window
3944
+ exist while the scrollbar still reflects the true total content height. That array is rebuilt only
3945
+ when `items`, `row-height`, or `keyFunction` change, or a row's measured height changes **not** on
3946
+ every update, so a pure scroll-position tick (potentially every `rAF` while scrolling) only re-runs the
3947
+ cheap range/visibility math, never the `O(n)` offsets rebuild (which, in `row-height="auto"` mode, also
3948
+ means a `keyFunction` call per item). In `row-height="auto"` mode, unmeasured rows contribute a fixed
3949
+ estimate to that array until their real `ResizeObserver`-reported height lands, so only rows after a
3950
+ newly-measured one shift on each measurement rather than the whole list reflowing. The offsets rebuild,
3951
+ when it does run, is an `O(n)` loop appropriate for hundreds to a few thousand rows (a scrollable
3952
+ history sidebar); it is not the right approach for a hundred-thousand-row list without further work
3953
+ (e.g. a Fenwick/segment tree for `O(log n)` offset queries+updates).
3565
3954
 
3566
3955
  **Known gotchas:**
3567
3956
  - `items`, `renderItem`, `keyFunction`, and `groups` are all `attribute: false` — they must be set as
@@ -3600,7 +3989,9 @@ First-party invention (no Web Awesome equivalent).
3600
3989
  - `active: boolean = false` (reflected) — whether this is the currently-selected/open session; drives
3601
3990
  the brand-quiet background treatment.
3602
3991
  - `editable: boolean = true` (reflected) — whether inline-rename is available at all. When `false`, the
3603
- rename button never renders and the row can never enter its editing state.
3992
+ rename button never renders and the row can never enter its editing state; flipping it to `false`
3993
+ while a rename is already in progress cancels that edit (discards the draft, like Escape) rather
3994
+ than leaving it stranded and still committable.
3604
3995
 
3605
3996
  **Events:** `lyra-select` (no detail payload — identify the row via the platform `id` attribute on the
3606
3997
  event's `target`/`currentTarget`, the same convention `<lyra-attachment-chip>` uses; fires on a click on
@@ -3656,6 +4047,9 @@ just visually swapping content.
3656
4047
  single-click `lyra-select`.
3657
4048
  - While renaming, `[part="option"]` has no `role`/`tabindex`/`aria-selected`/`aria-label` at all — a
3658
4049
  screen reader briefly stops announcing it as a listbox option for the duration of the edit.
4050
+ - Setting `editable = false` mid-rename silently discards the in-progress draft (no `lyra-rename`
4051
+ fires) — a consumer toggling `editable` off (e.g. in response to some other row entering rename
4052
+ mode) should not expect the previous edit to be committed first.
3659
4053
 
3660
4054
  ---
3661
4055
 
@@ -3668,6 +4062,8 @@ shared `FormAssociated` mixin — same reasoning as `lyra-combobox`/`lyra-select
3668
4062
  notes). Built on the same trigger-button/`aria-activedescendant` listbox technique `<lyra-select>` uses
3669
4063
  and the filter-as-you-type suggestion-popup technique `<lyra-combobox>` uses, without composing either
3670
4064
  element. First-party invention (no Web Awesome equivalent).
4065
+ Session-history/autofill restoration synchronously restores the model id and form entry without
4066
+ emitting `lyra-change`.
3671
4067
 
3672
4068
  **Exported types:**
3673
4069
  - `LyraModelCatalogEntry { id: string; label: string }` — one catalog row.
@@ -3681,13 +4077,19 @@ element. First-party invention (no Web Awesome equivalent).
3681
4077
  back to plain free-text entry.
3682
4078
  - `allowCustom: boolean = false` (attribute `allow-custom`, reflected) — let the user type/commit a
3683
4079
  value that isn't in `catalog`, even when `catalog` is non-empty.
4080
+ - `label: string = ''` — optional visible title above the control, mirroring `<lyra-select>`'s own
4081
+ `label` exactly: rendered via a `[part="form-control-label"]` `<label>` paired with the control's
4082
+ id, and once non-empty it takes over as the accessible-name source (an `aria-label` override is
4083
+ then only consulted as the fallback). Empty (the default) keeps the original
4084
+ `aria-label || placeholder || 'Model'` accessible-name chain untouched.
3684
4085
  - `placeholder: string = ''`
3685
- - `name: string = ''`
4086
+ - `name: string = ''` (reflected)
3686
4087
  - `disabled: boolean = false` (reflected)
3687
4088
  - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
3688
4089
  - `open: boolean = false` (reflected)
3689
4090
  - `value: string` — getter/setter (hand-rolled, not the `FormAssociated` mixin); the current model id,
3690
- `''` when nothing is selected. Writing it calls `internals.setFormValue()` synchronously.
4091
+ `''` when nothing is selected. Writing it calls `internals.setFormValue()` synchronously. A named,
4092
+ untouched model-select contributes `''` to `FormData` instead of omitting its key.
3691
4093
 
3692
4094
  **Mode switching:** `closedMode` (private) is `true` whenever `normalizedCatalog.length > 0 &&
3693
4095
  !allowCustom` — a non-empty `catalog` with `allowCustom` left `false` renders the closed dropdown
@@ -3710,8 +4112,13 @@ from a real catalog pick)
3710
4112
 
3711
4113
  **Slots:** none.
3712
4114
 
3713
- **CSS parts:** `trigger`, `combobox`, `combobox-input`, `provider-badge`, `listbox`, `option`,
3714
- `option-label`, `option-badge`
4115
+ **CSS parts:** `form-control-label` (the `<label>` element — only rendered, and only contributes to
4116
+ the accessible name, once `label` is non-empty), `trigger` (closed-dropdown mode's
4117
+ `<button role="combobox">`, also its positioning anchor), `combobox` (free-text mode's input
4118
+ container, also its positioning anchor), `combobox-input` (the free-text `<input>`),
4119
+ `provider-badge` (the optional leading `provider` label), `listbox` (the options popover, shared by
4120
+ both modes), `option`, `option-label`, `option-badge` (the "not in catalog" badge on a synthetic
4121
+ stale-value row), `expand-icon` (the dropdown chevron, present in both modes)
3715
4122
 
3716
4123
  **Themeable custom properties:** shared tokens only — `--lyra-space-xs/-s`,
3717
4124
  `--lyra-color-border/-surface/-brand/-brand-quiet/-text-quiet`, `--lyra-radius`, `--lyra-shadow`,
@@ -3757,7 +4164,10 @@ A numeric range control (e.g. an LLM "temperature" setting). **Form-associated**
3757
4164
  `FormAssociated` mixin (`name`, `value`, `disabled`, `required` all inherited). Mirrors native `<input
3758
4165
  type="range">` semantics: `value` is the string form-submitted via the mixin, `valueAsNumber` is the
3759
4166
  ergonomic numeric accessor (matching native `<input type=range>`'s IDL attribute of the same name) kept
3760
- in sync with it in both directions. First-party invention (no Web Awesome equivalent).
4167
+ in sync with it in both directions. Clicking anywhere on `[part="base"]` (not just the thumb) jumps the
4168
+ thumb to that point and continues the same gesture as a drag, matching native `<input type=range>`
4169
+ click-to-seek — the thumb is also `.focus()`ed on that click, so keyboard interaction can continue
4170
+ seamlessly right after. First-party invention (no Web Awesome equivalent).
3761
4171
 
3762
4172
  **Properties:**
3763
4173
  - `min: number = 0`
@@ -3832,10 +4242,9 @@ native range input either.
3832
4242
  ## `lyra-tool-select-dialog`
3833
4243
 
3834
4244
  A category-grouped, filterable, searchable tool-enablement dialog for picking which agent tools are
3835
- available in a conversation. Its own standalone overlay implementation (`role="dialog"`, focus-trapped,
3836
- Escape/backdrop-dismissible, scroll-locking) rather than nesting `<lyra-dialog>` in its shadow template
3837
- duplicated locally the same way `<lyra-tool-result-dialog>` does, so this component has no dependency on
3838
- the general-purpose dialog. First-party invention (no Web Awesome equivalent).
4245
+ available in a conversation. It keeps its own panel template rather than nesting `<lyra-dialog>`, so
4246
+ it has no dependency on the general-purpose dialog, while its modal behavior participates in the
4247
+ shared overlay stack. First-party invention (no Web Awesome equivalent).
3839
4248
 
3840
4249
  **Exported types:**
3841
4250
  - `ToolSelectDialogTool { id: string; name: string; description?: string; category?: string; icon?:
@@ -3889,7 +4298,7 @@ assigned elements.
3889
4298
  the backdrop scrim color; component-specific since no shared overlay token exists, mirrors
3890
4299
  `<lyra-dialog>`'s/`<lyra-tool-result-dialog>`'s identical pattern), plus shared `--lyra-space-*`,
3891
4300
  `--lyra-color-surface/-border/-text/-text-quiet/-warning`, `--lyra-radius`, `--lyra-shadow`,
3892
- `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`, `--lyra-icon-button-size`.
4301
+ `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
3893
4302
 
3894
4303
  **Optional peer deps:** none — internally renders `<lyra-checkbox>` and `<lyra-switch>`, both bundled
3895
4304
  dependencies of this package imported directly, not optional peers.
@@ -3931,7 +4340,7 @@ checkboxes for editing.
3931
4340
  into a trailing "Other" bucket that's always rendered last. A category left with zero matches after
3932
4341
  filtering is dropped entirely, not rendered as an empty heading.
3933
4342
  - Reconnecting the element while still `open` (e.g. a drag-and-drop reparent that keeps the same
3934
- instance) automatically re-acquires the scroll lock and keydown trap it dropped in
4343
+ instance) resumes its shared overlay registration and re-acquires the scroll lock dropped in
3935
4344
  `disconnectedCallback`.
3936
4345
  - The search input is the first focusable element in the panel and receives focus automatically on open.
3937
4346
 
@@ -3975,10 +4384,10 @@ popover on hover/focus. This is *not* the badge's visible content (the badge alw
3975
4384
  **Themeable custom properties:** `--lyra-citation-badge-accent` / `--lyra-citation-badge-bg` /
3976
4385
  `--lyra-citation-badge-border` (internal per-status accent variables, not typically overridden
3977
4386
  directly — set instead by the `:host([status=...])` rules), plus shared tokens
3978
- `--lyra-color-text-quiet`, `--lyra-color-success` / `-success-quiet`, `--lyra-color-warning` /
3979
- `-warning-quiet`, `--lyra-color-danger` / `-danger-quiet`, `--lyra-radius`, `--lyra-color-surface`,
3980
- `--lyra-color-border`, `--lyra-shadow`, `--lyra-space-s`/`-m`, `--lyra-transition-fast`,
3981
- `--lyra-focus-ring-*`.
4387
+ `--lyra-color-text-quiet`, `--lyra-color-text`, `--lyra-color-success` / `-success-quiet`,
4388
+ `--lyra-color-warning` / `-warning-quiet`, `--lyra-color-danger` / `-danger-quiet`, `--lyra-radius`,
4389
+ `--lyra-color-surface`, `--lyra-color-border`, `--lyra-shadow`, `--lyra-space-s`/`-m`,
4390
+ `--lyra-transition-fast`, `--lyra-focus-ring-*`.
3982
4391
 
3983
4392
  **Optional peer deps:** none.
3984
4393
 
@@ -4081,13 +4490,15 @@ while collapsed).
4081
4490
  component never navigates on its own (a controlled component, the same convention
4082
4491
  `<lyra-tool-call-chip>`'s `lyra-tool-chip-select` follows); a listener decides what "open" means.
4083
4492
 
4084
- **Slots:** `excerpt` (a short preview, always visible), `full` (the complete source text/chunk,
4085
- hidden behind the "Show more"/"Show less" toggle — when left empty, no toggle renders at all).
4493
+ **Slots:** `excerpt` (a short preview; when left empty, the `excerpt` part collapses away entirely —
4494
+ its wrapper is `hidden`, not just visually empty), `full` (the complete source text/chunk, hidden
4495
+ behind the "Show more"/"Show less" toggle — when left empty, no toggle renders at all; removing all
4496
+ `full`-slotted content while expanded auto-collapses it back).
4086
4497
 
4087
4498
  **CSS parts:** `base` (outer container), `title` (the clickable title/page heading, a `<button>`),
4088
- `excerpt` (wrapper around the `excerpt` slot), `full` (wrapper around the `full` slot, `hidden` while
4089
- collapsed), `toggle` (the "Show more"/"Show less" button — only rendered when the `full` slot has
4090
- content).
4499
+ `excerpt` (wrapper around the `excerpt` slot, `hidden` when the slot has no assigned content), `full`
4500
+ (wrapper around the `full` slot, `hidden` while collapsed), `toggle` (the "Show more"/"Show less"
4501
+ button — only rendered when the `full` slot has content).
4091
4502
 
4092
4503
  **Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-surface`,
4093
4504
  `--lyra-color-text` / `-text-quiet`, `--lyra-color-brand`, `--lyra-radius`, `--lyra-space-xs`/`-s`,
@@ -4134,6 +4545,10 @@ time.
4134
4545
  independent of the parent list's `expanded`/`lyra-toggle` — collapsing the list doesn't reset an
4135
4546
  individual card's `fullExpanded` state, and there is no cross-talk between the two components at
4136
4547
  all beyond DOM nesting.
4548
+ - `lyra-source-card` actively strips a bare host-level `title` attribute right after Lit syncs it into
4549
+ the `title` property — otherwise the whole card would grow an unsolicited native tooltip repeating
4550
+ the title text on hover. Set `title` only as a property/attribute meant to become the rendered
4551
+ heading; don't rely on it surviving as a DOM attribute afterward.
4137
4552
 
4138
4553
  ---
4139
4554
 
@@ -4218,10 +4633,10 @@ exposed standalone so a consumer can compute or unit-test the same three-state r
4218
4633
  real browser window. `mobileMatches` wins over `iconOnlyMatches` when both are true (the viewport is
4219
4634
  narrower than both breakpoints at once).
4220
4635
 
4221
- This is its own standalone focus-trapped overlay implementation for the `'mobile'` state
4222
- (`role="dialog"` while open, Escape/backdrop-dismissible, scroll-locking) rather than nesting a
4223
- `<lyra-dialog>` in its shadow template. It's a plain `<div>` with an explicit `role="navigation"`
4224
- (swapping to `role="dialog"` while the overlay is open) rather than a literal `<nav>` tag, since a
4636
+ The mobile state keeps its own panel template rather than nesting `<lyra-dialog>`, while its modal
4637
+ behavior participates in the shared overlay stack. It is a plain `<div>` with an explicit
4638
+ `role="navigation"` (swapping to `role="dialog"` while the overlay is open) rather than a literal
4639
+ `<nav>` tag, since a
4225
4640
  `<nav>`'s implicit role can't be overridden to `role="dialog"` without an `aria-allowed-role`
4226
4641
  violation. In `'icon-only'` mode, slotted nav items lose their visible text label — give each one a
4227
4642
  real accessible name (`aria-label`, visually hidden text, or `title`) regardless, since this
@@ -4247,10 +4662,10 @@ component only lays out whatever is slotted and can't inspect or fix up a consum
4247
4662
  selector (`translateX(100%)`), not through the shared `internal/rtl.ts` JS helper used for pointer/
4248
4663
  keyboard math elsewhere in this library — a physical `transform` isn't expressible with logical
4249
4664
  properties, so this one case is handled purely in CSS.
4250
- - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
4251
- scroll lock and the Tab-trap `keydown` listener if the overlay was still active across the move —
4252
- `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
4253
- `willUpdate()` alone wouldn't otherwise notice.
4665
+ - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) resumes its
4666
+ shared overlay registration and re-acquires the scroll lock if the overlay was still active
4667
+ across the move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in
4668
+ between, so `willUpdate()` alone wouldn't otherwise notice.
4254
4669
 
4255
4670
  ---
4256
4671
 
@@ -4270,8 +4685,14 @@ sidebar that's a permanent docked pane on a wide screen but a modal on a phone.
4270
4685
  presentation's visual treatment: `'fullscreen'` covers the whole viewport; `'bottom-sheet'` slides
4271
4686
  up from the bottom and doesn't cover the full height. Has no visual effect while the effective
4272
4687
  presentation resolves to `'inline'`.
4273
- - `label: string = ''` — accessible name for the overlay presentation's `role="dialog"`. Unused in
4274
- the inline presentation, which has no dialog semantics to name.
4688
+ - `label: string = ''` — accessible name for the overlay presentation's `role="dialog"`, used
4689
+ verbatim when set. When empty, falls back to the `header` slot's content: a heading element
4690
+ (`h1`–`h6` or `[role="heading"]`) among the slotted header content wins if present, otherwise the
4691
+ header slot's combined text content is used (mirrors `lyra-dialog`'s `detectHeading()` fallback,
4692
+ via `aria-label` rather than `aria-labelledby` since the header content is light DOM while
4693
+ `[part="panel"]` is in shadow DOM). A panel opened with neither `label` nor header content still
4694
+ renders `role="dialog"` with no accessible name. Unused in the inline presentation, which has no
4695
+ dialog semantics to name.
4275
4696
  - `mobileBreakpoint: string = '768px'` (attribute `mobile-breakpoint`) — CSS length passed to
4276
4697
  `matchMedia` as `(max-width: <this>)` to decide, in `mode="auto"`, whether the effective
4277
4698
  presentation is `'overlay'` (below/at this width) or `'inline'` (above it).
@@ -4313,16 +4734,13 @@ shared token exists), plus shared tokens (`--lyra-color-border`, `--lyra-color-s
4313
4734
 
4314
4735
  Breakpoint detection uses `matchMedia('(max-width: ' + mobileBreakpoint + ')')`, re-evaluated live
4315
4736
  while connected — resizing/rotating a device that crosses the breakpoint while `mode="auto"` (the
4316
- default) updates the effective presentation without unmounting or re-creating the slotted content:
4317
- inline and overlay presentation share the exact same shadow DOM structure (only a CSS class, and the
4318
- overlay-only `role`/`aria-modal` attributes and backdrop element, differ), so lit-html's diffing
4319
- keeps `[part="body"]` and its `<slot>` as the same DOM node across the transition scroll position
4320
- and focus inside the slotted content survive it for free. The one exception is focus: crossing the
4321
- breakpoint while already open does *not* forcibly move focus into the panel (unlike a genuine `open`
4322
- transition, which does) doing so on a resize could yank focus away from an input the user is
4323
- actively typing into inside the now-newly-modal panel. The overlay presentation duplicates
4324
- `<lyra-dialog>`'s `role="dialog"` + focus-trap + Escape/backdrop-dismiss + scroll-lock mechanics
4325
- locally rather than nesting a `<lyra-dialog>` inside this component's shadow DOM.
4737
+ default) updates the effective presentation without unmounting or re-creating the slotted content.
4738
+ Inline and overlay presentations share the same shadow DOM, so slotted content and scroll position
4739
+ survive the transition. Focus already inside the panel is preserved. If focus is outside when an
4740
+ open inline panel becomes an overlay, focus moves to the first composed focus target (falling back
4741
+ to the panel), so it cannot remain behind `aria-modal="true"`. Closing restores the element captured
4742
+ when the panel originally opened, even when that original open happened inline. The overlay
4743
+ presentation participates in the shared modal stack rather than nesting a `<lyra-dialog>`.
4326
4744
 
4327
4745
  The package root also exports the pure `resolveEffectiveMode(mode: ResponsivePanelMode,
4328
4746
  belowBreakpoint: boolean): ResponsivePanelEffectiveMode` resolver — renamed on export to
@@ -4335,17 +4753,18 @@ exposed standalone so a consumer can compute or unit-test the same resolution wi
4335
4753
  window.
4336
4754
 
4337
4755
  **Known gotchas:**
4338
- - setting `open = true`/`open = false` directly does not fire `lyra-close` and does not run the
4339
- focus-return-to-trigger logic either only calling `close()` does both; a consumer that toggles
4340
- the property directly instead of calling `close()` silently loses both behaviors.
4341
- - crossing the breakpoint while the panel is already open does not move focus into it, unlike a
4342
- genuine `open` transition see above; don't rely on a resize alone to place initial focus.
4756
+ - assigning `open` directly still does not emit `lyra-close`; use `close()` when the dismissal
4757
+ event/reason is required. While overlay chrome is active, however, the `true` `false` state
4758
+ transition restores opener focus regardless of whether it came from `close()`, a property write,
4759
+ or attribute removal.
4760
+ - crossing inline overlay while already open preserves focus that is already inside and moves
4761
+ outside focus into the panel; do not expect focus to remain on page content behind the modal.
4343
4762
  - `variant="bottom-sheet"` has no visible effect at all while the effective presentation is
4344
4763
  `'inline'` — it only changes the overlay presentation's anchoring/height.
4345
- - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
4346
- scroll lock and the Tab-trap `keydown` listener if overlay chrome was still active across the move
4347
- — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
4348
- `willUpdate()` alone wouldn't otherwise notice.
4764
+ - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) resumes its
4765
+ shared overlay registration and re-acquires the scroll lock if overlay chrome was still active
4766
+ across the move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in
4767
+ between, so `willUpdate()` alone wouldn't otherwise notice.
4349
4768
  - the exported pure function is named `resolveEffectiveMode` in the component module but re-exported
4350
4769
  from the package root as `resolveResponsivePanelEffectiveMode` — importing the un-prefixed name
4351
4770
  from `@aceshooting/lyra-ui` will fail.
@@ -4381,10 +4800,13 @@ own listbox.
4381
4800
  also wants to wire `aria-controls`.
4382
4801
 
4383
4802
  **Methods:** `handleKeyDown(e: KeyboardEvent): boolean` — the host's own text-control `keydown`
4384
- handler calls this while the popover is open. Handles `ArrowDown`/`ArrowUp` (moves the highlight),
4385
- `Enter`/`Tab` (commits the highlighted row, no-op that returns `false` if nothing is highlighted),
4386
- and `Escape` (closes with no selection). Returns `true` when the key was intercepted
4387
- (`preventDefault()` already called) and the host should not also act on it.
4803
+ handler calls this while the popover is open. Handles `ArrowDown`/`ArrowUp` (moves the highlight) and
4804
+ `Enter`/`Tab` (commits the highlighted row) both pairs return `false` with no `preventDefault()`
4805
+ when `filteredItems` is empty, letting the keystroke fall through to the host's own control unchanged
4806
+ (e.g. the textarea's caret still moves to the next line on ArrowDown when there's nothing to
4807
+ navigate); `Escape` always closes with no selection and returns `true`. Returns `true` whenever the
4808
+ key was intercepted (`preventDefault()` already called) and the host should not also act on it,
4809
+ `false` otherwise (including for any key this method doesn't recognize).
4388
4810
 
4389
4811
  **Exported types:** `MentionItem { id: string; label: string; description?: string; icon?: string
4390
4812
  }`; `MentionFilter = (item: MentionItem, query: string) => boolean`; `MentionSelectDetail { id:
@@ -4464,6 +4886,10 @@ fresh `query` is the proxy for "the caret may have moved").
4464
4886
  `open` or reassigning `anchor`.
4465
4887
  - `activeIndex` resets to `0` whenever `query` or `items` changes, but not when only `anchor`
4466
4888
  changes — reassigning `anchor` alone preserves whatever row was last highlighted.
4889
+ - There's no persisted "selection" the way `<lyra-combobox>`'s own listbox has one — a mention is
4890
+ either committed (closing the popover) or dismissed with nothing chosen. `aria-selected="true"`
4891
+ here marks whichever row is currently *active* (what Enter/Tab would commit right now, per the
4892
+ WAI-ARIA combobox-with-list-autocomplete pattern), not a separate persisted value.
4467
4893
 
4468
4894
  ---
4469
4895
 
@@ -4501,10 +4927,11 @@ of the stream arrives.
4501
4927
  **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`)
4502
4928
 
4503
4929
  **Themeable custom properties:** `--lyra-streaming-text-cursor-width` (default `0.125rem` — the
4504
- cursor bar's inline size; component-specific, since no shared "inline cursor bar" token exists,
4505
- the same pattern `<lyra-typing-indicator>`'s own `--lyra-typing-cursor-width`/`-height` use), plus
4506
- shared `--lyra-space-xs` (cursor's `margin-inline-start`) and `--lyra-transition-base` (blink
4507
- animation cycle length).
4930
+ cursor bar's inline size), `--lyra-streaming-text-cursor-height` (default `1em`) both
4931
+ component-specific, since no shared "inline cursor bar" token exists, the same pattern
4932
+ `<lyra-typing-indicator>`'s own `--lyra-typing-cursor-width`/`-height` use, plus shared
4933
+ `--lyra-space-xs` (cursor's `margin-inline-start`) and `--lyra-transition-base` (blink animation
4934
+ cycle length).
4508
4935
 
4509
4936
  **Optional peer deps:** none — internally imports and auto-registers `<lyra-markdown>` for
4510
4937
  Markdown-mode rendering (a side-effect import; the host never needs to import or register it
@@ -4529,8 +4956,9 @@ purely as a generic "coalesce rapid calls, flush the latest" timing primitive
4529
4956
  class's usual DOM/ARIA plumbing. Within any `coalesce-ms` window, only the *last* `content` value
4530
4957
  assigned actually reaches the rendered DOM. Two cases always bypass the throttle and flush
4531
4958
  immediately: the very first `content` assignment after mount, and any transition of `streaming`
4532
- from `true` to `false`, so the final chunk of a finished stream can never be left stranded mid-
4533
- window.
4959
+ between `true` and `false` in *either* direction — so the final chunk of a finished stream can
4960
+ never be left stranded mid-window, and a stream restarting on a reused element can never keep
4961
+ showing the previous stream's stale final content for the length of the window.
4534
4962
 
4535
4963
  Rendering itself is never reimplemented here: Markdown mode composes `<lyra-markdown>` directly,
4536
4964
  forwarding this component's own `streaming` through as that component's `streaming` hint prop;
@@ -4543,8 +4971,10 @@ happens to end with.
4543
4971
  **Known gotchas:**
4544
4972
  - `content` must always be the complete string so far, never a delta — this component does no
4545
4973
  accumulation of its own.
4546
- - Only the very *first* `content` assignment after mount bypasses `coalesceMs` — every later
4547
- assignment is throttled normally until `streaming` flips to `false`.
4974
+ - Only the very *first* `content` assignment after mount bypasses `coalesceMs` unconditionally
4975
+ every later assignment is throttled normally except when it lands in the same update as a
4976
+ `streaming` transition (either `true → false` or `false → true`), which also forces an immediate
4977
+ flush.
4548
4978
  - `markdown="false"` (any string value other than exactly `"false"` is treated as `true` by the
4549
4979
  converter) forces plain-text mode even if the text obviously contains Markdown syntax.
4550
4980
  - Purely presentational: no events, and it does not announce anything to assistive tech itself — a
@@ -4612,8 +5042,8 @@ Live-mode auto-scroll ("stick to bottom") is the classic chat-transcript convent
4612
5042
  user has manually scrolled up to re-read earlier content (tracked via a `scroll` listener on
4613
5043
  `[part="body"]`: every user-driven scroll records whether the body was left within 48px of its own
4614
5044
  max scroll position, and only a mutation that arrives while that's still true triggers a follow-up
4615
- scroll). Opening an already-`'live'` panel always resets this to "anchored" and jumps to the latest
4616
- content. New content is detected via a `MutationObserver` on this element's own light DOM
5045
+ scroll). Opening an already-`'live'` panel or a still-`expanded` panel later becoming `'live'`
5046
+ always resets this to "anchored" and jumps to the latest content. New content is detected via a `MutationObserver` on this element's own light DOM
4617
5047
  (`childList`+`subtree`+`characterData`), not `slotchange`, since streamed reasoning typically
4618
5048
  appends chunks to an existing node's `textContent` rather than re-slotting a whole new element per
4619
5049
  token; scroll-to-bottom calls are coalesced to at most one per animation frame under a fast token
@@ -4625,9 +5055,11 @@ stream.
4625
5055
  `<lyra-markdown>` re-rendering its shadow tree after a `content` change). A slotted element whose
4626
5056
  own internal updates should drive auto-scroll needs to append/mutate visible light-DOM text
4627
5057
  itself (as `<lyra-streaming-text>` does), or the host can call `scrollToBottom()` directly.
4628
- - Switching `mode` from `'post-hoc'` to `'live'` while the panel is already `expanded` does not by
4629
- itself jump to the bottom or reset stickiness — only an `expanded` transition to `true` while
4630
- `mode === 'live'` does that.
5058
+ - Either half of the pair can trigger the jump-to-bottom/reset-stickiness behavior, as long as the
5059
+ *other* half already holds: an `expanded` transition to `true` while `mode` is already `'live'`,
5060
+ **or** a `mode` transition to `'live'` while the panel is already `expanded`, both jump to the
5061
+ bottom and reset stickiness. Only a change that leaves the panel in some other combination
5062
+ (collapsed, or `mode !== 'live'`) skips it.
4631
5063
  - The sticky-bottom flag starts `true` internally, so a panel that mounts already `expanded` and
4632
5064
  `mode="live"` follows its very first content mutation even before any `scroll` event has fired.
4633
5065
 
@@ -4643,17 +5075,24 @@ equivalent). Renders as e.g. `12.3s · 340 tokens · 27 tok/s [Stop]`.
4643
5075
  - `active: boolean = false` (reflected) — whether generation is currently in progress. The
4644
5076
  elapsed-time ticker (a ~1s `setInterval`) runs only while this is `true`.
4645
5077
  - `startedAt?: number` (attribute `started-at`) — epoch-ms timestamp of when generation began.
4646
- Optional — when unset while `active` is `true`, this component captures `Date.now()` itself the
4647
- moment `active` becomes `true` and counts from there instead.
5078
+ Optional — when unset, or set to a value that fails to parse as a finite number (e.g. an ISO-8601
5079
+ date string, which `type: Number` conversion turns into `NaN`), while `active` is `true`, this
5080
+ component captures `Date.now()` itself the moment `active` becomes `true` and counts from there
5081
+ instead — an invalid value is treated identically to "unset", never rendered as literal `"NaNs"`.
4648
5082
  - `tokenCount?: number` (attribute `token-count`) — running token count so far. Omitted from the
4649
5083
  readout entirely (no `tokens` segment) while unset.
4650
5084
  - `tokensPerSecond?: number` (attribute `tokens-per-second`) — host-computed tokens/sec figure,
4651
5085
  used as-is when set. When unset, derived from `token-count`/elapsed time instead (see prose).
4652
- - `showStop: boolean = true` (attribute `show-stop`) — whether the built-in Stop button renders at
4653
- all. **Not reflected**, and its class-field default is `true` assign `false` via a PROPERTY
4654
- binding, e.g. `.showStop=${false}` a `?show-stop=${false}` boolean-attribute binding can never
4655
- override a `true` default, since removing an attribute that was never present fires no
4656
- `attributeChangedCallback` (see AGENTS.md).
5086
+ - `showStop: boolean = true` (attribute `show-stop`, **not reflected**) — whether the built-in Stop
5087
+ button renders at all. Uses a string-value-aware `ComplexAttributeConverter` (not Lit's default
5088
+ presence-based `type: Boolean`), so a plain-HTML `show-stop="false"` content attribute correctly
5089
+ turns it off — the literal string `"false"` maps to `false`; the attribute's mere presence with any
5090
+ other value (or no value) maps to `true`. A Lit template can instead use a `.showStop=${false}`
5091
+ property binding. **Caveat:** a `?show-stop=${false}` boolean-attribute *binding* still can't turn
5092
+ it off when the attribute was never present in markup to begin with — that binding only ever
5093
+ removes the attribute when falsy, and removing an attribute that's already absent fires no
5094
+ `attributeChangedCallback` (see AGENTS.md); use `.showStop=${false}` or the plain
5095
+ `show-stop="false"` string form instead.
4657
5096
 
4658
5097
  **Events:** `lyra-stop` (no detail payload — `this.emit('lyra-stop')` is called with no second
4659
5098
  argument, so `event.detail` is `null`, not `undefined`) — fired when the built-in Stop button is
@@ -4704,8 +5143,10 @@ something that announces state *transitions* instead. The Stop button gets a nor
4704
5143
  `aria-label="Stop generating"`, no different from any other icon-only button in this library.
4705
5144
 
4706
5145
  **Known gotchas:**
4707
- - `showStop` defaults to `true` and is not a reflected property see the property list above for
4708
- the property-vs-attribute-binding footgun this creates.
5146
+ - `showStop` defaults to `true` and is not a reflected property. Its `ComplexAttributeConverter`
5147
+ makes the plain content attribute `show-stop="false"` work correctly, but a `?show-stop=${false}`
5148
+ Lit boolean-attribute *binding* still can't turn it off starting from absent markup — see the
5149
+ property list above for the exact footgun.
4709
5150
  - The derived `tokens-per-second` figure only appears once `elapsedMs >= 1000`; before that, the
4710
5151
  `throughput` part simply doesn't render — supply `tokens-per-second` yourself for a stable figure
4711
5152
  from the very first tick.
@@ -4741,9 +5182,19 @@ and it's what every instance renders at zero extra bytes until shiki resolves.
4741
5182
  cannot override a true default)
4742
5183
  - `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"20rem"`); once set, the
4743
5184
  code scrolls internally past this height instead of growing the page
5185
+ - `languages?: Record<string, ShikiLanguageInput>` (attribute: false) — a map of language id to an
5186
+ already-imported shiki grammar module (e.g. `{ bash: bashGrammar }` from a module-scope
5187
+ `import bash from 'shiki/langs/bash.mjs'`). When `language` matches a key here, highlighting is
5188
+ seeded from exactly that pre-supplied grammar via a fine-grained `createHighlighterCore()`
5189
+ highlighter, bypassing the default ~200-language dynamic-import path (`loadShikiHighlighter()`)
5190
+ for that language — an additive, opt-in escape hatch for a build scoped to just the languages a
5191
+ consumer actually needs. A `language` absent from this map (or `languages` left unset) falls back
5192
+ to the default dynamic-import path unchanged.
4744
5193
 
4745
5194
  **Events:** `lyra-copy` (`detail: { text: string }` — always the raw `code` value, never the
4746
- highlighted HTML, and always fires regardless of whether the actual OS clipboard write succeeded)
5195
+ highlighted HTML, and always fires regardless of whether the actual OS clipboard write succeeded),
5196
+ `lyra-toggle` (`detail: { collapsed: boolean }` — fired when the built-in collapse/expand header
5197
+ button is activated, same event name/shape convention as `<lyra-thinking-panel>`'s own `lyra-toggle`)
4747
5198
 
4748
5199
  **Slots:** none.
4749
5200
 
@@ -4810,10 +5261,9 @@ theme data.
4810
5261
 
4811
5262
  A human-in-the-loop gate: presents one proposed tool/function call (`toolName` + `args`) and blocks an
4812
5263
  agent from executing it until a person explicitly approves or denies it, with an optional inline
4813
- "edit the arguments before approving" step. First-party invention (no Web Awesome equivalent). It is
4814
- its own standalone overlay implementation (`role="dialog"`, focus-trapped, Escape/backdrop-dismissible,
4815
- scroll-locking) rather than nesting `<lyra-dialog>` in its shadow template, so it has no dependency on
4816
- the general-purpose dialog component.
5264
+ "edit the arguments before approving" step. First-party invention (no Web Awesome equivalent). It
5265
+ keeps its own panel template rather than nesting `<lyra-dialog>`, so it has no dependency on the
5266
+ general-purpose dialog component, while its modal behavior participates in the shared overlay stack.
4817
5267
 
4818
5268
  Approve/Deny/Edit are built-in chrome, not a `footer` slot a consumer must assemble — there is exactly
4819
5269
  one correct action set for "approve this call". The `footer` slot is offered only for *supplementary*
@@ -4893,10 +5343,9 @@ Initial focus deliberately does *not* land on Approve: approving a tool call is
4893
5343
  potentially irreversible action, so a user who opens the dialog and reflexively presses Enter/Space
4894
5344
  before reading anything should deny, not approve. Deny gets the initial focus instead — the same
4895
5345
  "focus the safe action" convention a native destructive-confirmation dialog typically follows for its
4896
- own Cancel button. Tab/Shift+Tab are bounded to the panel's own focusable content (shadow-piercingly,
4897
- so `<lyra-json-viewer>`'s internal toggle/copy buttons and any slotted custom element's real focusable
4898
- target are found too) via a locally-duplicated focus-trap helper, the same shape `<lyra-dialog>`'s and
4899
- `<lyra-tool-select-dialog>`'s own overlays use.
5346
+ own Cancel button. Tab/Shift+Tab are bounded to the panel's own focusable content, and
5347
+ `<lyra-json-viewer>`'s internal controls plus slotted custom-element controls are found through the
5348
+ shared composed-tree focus traversal used by the other modal families.
4900
5349
 
4901
5350
  **Known gotchas:**
4902
5351
  - `editable` defaults to `true` and reflects — see the property note above about overriding it with a
@@ -4905,12 +5354,11 @@ target are found too) via a locally-duplicated focus-trap helper, the same shape
4905
5354
  - a consumer flipping `editable` off while an edit is already in progress automatically exits edit mode
4906
5355
  and discards the draft, so an unreachable "Cancel" affordance is never left stranded on screen.
4907
5356
  - reconnecting the element while still `open` (e.g. a drag-and-drop reparent that keeps the same
4908
- instance) automatically re-acquires the ref-counted scroll lock and keydown trap it dropped in
4909
- `disconnectedCallback` — `willUpdate()` alone wouldn't otherwise notice, since disconnect/reconnect
4910
- fire back-to-back with no update in between.
5357
+ instance) resumes its shared overlay registration and re-acquires the ref-counted scroll lock
5358
+ dropped in `disconnectedCallback` — `willUpdate()` alone wouldn't otherwise notice, since
5359
+ disconnect/reconnect fire back-to-back with no update in between.
4911
5360
  - the Approve button's native `disabled` attribute (while the draft is invalid JSON) automatically
4912
- excludes it from the Tab trap, since the shared `FOCUSABLE_SELECTOR` used to compute the trap's
4913
- focusable set already skips disabled controls.
5361
+ excludes it from the shared Tab trap, whose focusable-set computation skips disabled controls.
4914
5362
 
4915
5363
  ---
4916
5364
 
@@ -4920,20 +5368,22 @@ Renders one form control per top-level property of a JSON Schema object, for ad
4920
5368
  approval-editing UIs (e.g. "the agent wants to call `create_event(title, attendees, allDay)` — let the
4921
5369
  user tweak the arguments before running it"). First-party invention (no Web Awesome equivalent).
4922
5370
 
4923
- **Scope limitation (intentional, not accidental):** this component only understands a *flat* object
4924
- schema — every `properties` entry must be `'string'`, `'number'`, `'integer'`, `'boolean'`, or a string
4925
- `enum`. Nested objects, arrays, `oneOf`/`anyOf`/`allOf`, `$ref`, and any other JSON Schema keyword are
4926
- not read. A property whose `type` isn't one of the four above renders a visible "Unsupported field
4927
- type" note instead of silently dropping it or throwing.
5371
+ **Supported schema subset:** a *flat* object whose properties use one primitive `type`
5372
+ (`'string'`, `'number'`, `'integer'`, or `'boolean'`), `required` property presence, string `enum`,
5373
+ primitive `const`, and the `title`/`description`/`default` annotations. Nested objects, arrays, type
5374
+ unions, `oneOf`/`anyOf`/`allOf`, `$ref`, string/numeric constraints, and schema-valued
5375
+ `additionalProperties` are not interpreted. An unsupported property type renders a visible fallback
5376
+ and makes the form invalid instead of being silently accepted.
4928
5377
 
4929
5378
  **Exported types:**
4930
5379
  - `ToolParamFormPropertyType = 'string' | 'number' | 'integer' | 'boolean'` — the four leaf property
4931
5380
  types this renderer understands
5381
+ - `ToolParamFormPrimitive = string | number | boolean` — values accepted by the supported `const`
4932
5382
  - `ToolParamFormProperty { type: ToolParamFormPropertyType; enum?: string[]; description?: string;
4933
- title?: string; default?: unknown }` — one `schema.properties` entry. `enum` is only meaningful when
4934
- `type` is `'string'` (rendered as a `<lyra-select>`); `title` is the display label, falling back to
4935
- the property key itself; `description` renders as helper text under the control; `default` pre-fills
4936
- a field whenever `value` doesn't already have that key.
5383
+ title?: string; default?: unknown; const?: ToolParamFormPrimitive }` — one `schema.properties`
5384
+ entry. `enum` is only meaningful when `type` is `'string'` (rendered as a `<lyra-select>`); `const`
5385
+ enforces one exact primitive value; `title` is the display label; `description` is helper text;
5386
+ `default` pre-fills a field whenever `value` doesn't already have that key.
4937
5387
  - `ToolParamFormSchema { type: 'object'; properties: Record<string, ToolParamFormProperty>; required?:
4938
5388
  string[] }` — the (intentionally flat) schema shape this component can render.
4939
5389
 
@@ -4942,7 +5392,9 @@ type" note instead of silently dropping it or throwing.
4942
5392
  - `value: Record<string, unknown> = {}` (attribute: false) — exactly what the consumer last set it to.
4943
5393
  A field with no entry in `value` but a schema `default` *displays* (and is *emitted*, via
4944
5394
  `lyra-input`) as that default, but the `value` property itself is left alone until the user actually
4945
- edits that field mirrors an uncontrolled `<input placeholder>` not writing to `.value`.
5395
+ edits that field. JSON Schema ordinarily treats `default` as an annotation; this renderer
5396
+ deliberately materializes it before validation/submission, so a valid default can satisfy
5397
+ `required`.
4946
5398
  - `name: string = ''` — submission key for optional native `<form>` participation
4947
5399
  - `disabled: boolean = false` (reflected)
4948
5400
 
@@ -4952,21 +5404,23 @@ type" note instead of silently dropping it or throwing.
4952
5404
  A key the user has explicitly cleared (a real own property set to `undefined`) stays cleared rather
4953
5405
  than snapping back to its default — only a key genuinely absent from `value` falls back.
4954
5406
  - `errors: Record<string, string>` — the current per-field validation errors (`{ [propertyKey]:
4955
- message }`), required-and-empty only; independent of which fields have been visited, so it doesn't
4956
- need a prior `reportValidity()` call to be accurate.
5407
+ message }`) for required presence, primitive type, finite number/integer, enum, const, and
5408
+ unsupported type; independent of which fields have been visited.
5409
+ - `formError: string` — a schema-wide/JSON-serialization error that has no honest field key; empty
5410
+ when the current effective value is safe to submit.
4957
5411
 
4958
5412
  **Methods:**
4959
- - `checkValidity(): boolean` — non-mutating; delegates to the underlying `ElementInternals`, never
4960
- reveals inline errors.
4961
- - `reportValidity(): boolean` — reveals inline errors for every currently-invalid required field (as
4962
- if each had been visited) and returns overall validity; the hook a consumer's own Submit/Approve
4963
- button should call right before acting, mirroring a native `<form>`'s `reportValidity()`.
5413
+ - `checkValidity(): boolean` — synchronously re-snapshots even an in-place-mutated value/schema,
5414
+ updates `ElementInternals`, and returns validity without revealing inline errors.
5415
+ - `reportValidity(): boolean` — performs the same resynchronization, reveals all current field/root
5416
+ errors, focuses the first invalid generated field when one exists, and delegates to native
5417
+ `ElementInternals.reportValidity()`.
4964
5418
 
4965
5419
  **Events:** `lyra-input` (`detail: { value: Record<string, unknown> }` — the full current value
4966
5420
  object, every property with defaults resolved, not just the field that changed), `lyra-validity-change`
4967
- (`detail: { valid: boolean; errors: Record<string, string> }` — fired whenever overall validity
4968
- changes, including once up front at connect time, so a consumer sees `valid: false` immediately if a
4969
- required field starts out empty without waiting for user interaction)
5421
+ (`detail: { valid: boolean; errors: Record<string, string> }` — fired whenever overall validity or
5422
+ the field-error set changes, including once up front at connect time; serialization-only failures
5423
+ set `valid: false` while `formError`, rather than a fabricated field key, carries the root message)
4970
5424
 
4971
5425
  **Slots:** none.
4972
5426
 
@@ -5007,29 +5461,39 @@ This component owns no Submit/Cancel/Approve chrome — a consumer composes it i
5007
5461
  that's `<lyra-checkbox>`'s documented way to give itself an accessible name, unlike `aria-describedby`
5008
5462
  (neither `<lyra-select>` nor `<lyra-checkbox>` forward a host-level `aria-describedby` to their
5009
5463
  internal focusable element, so for the enum-select case the error message is folded into `aria-label`
5010
- instead, alongside the always-visible adjacent `[part="description"]` text). A `required` boolean
5011
- field means "must be checked" there is no third "unset" visual state a rendered checkbox can
5012
- represent, so `isFieldEmpty()` treats anything other than `true` as empty for that field's type.
5464
+ instead, alongside the always-visible adjacent `[part="description"]` text). The outer component owns
5465
+ schema validity rather than forwarding HTML `required` constraints: JSON Schema `required` means an
5466
+ own property is present, so `''`, `0`, and `false` are valid present values. Use
5467
+ `{ type: 'boolean', const: true }` together with `required` for a must-confirm checkbox.
5013
5468
 
5014
5469
  Optional native `<form>` participation is implemented via `ElementInternals` attached directly in the
5015
5470
  constructor (`static formAssociated = true`) rather than a string-value mixin, since this component's
5016
5471
  value is a whole object: `formResetCallback()` clears `value`/touched state back to `{}`, and
5017
- `formDisabledCallback(disabled)` mirrors the wrapping form's disabled state onto the `disabled`
5018
- property. This is a nice-to-have layered on top of the primary `value` + `lyra-input`/
5019
- `lyra-validity-change` contract, not a requirement a consumer that never puts this inside a `<form>`
5020
- loses nothing.
5472
+ `formDisabledCallback(disabled)` tracks inherited fieldset state separately from the author-owned
5473
+ `disabled` property. JSON serialization is guarded: circular values, `BigInt`, throwing getters/`toJSON`, and
5474
+ non-finite numbers cannot escape from an assignment or leave stale form data; the form entry is
5475
+ temporarily removed and `formError`/custom validity are set until a serializable value replaces it.
5476
+ This is layered on top of the primary `value` + `lyra-input`/`lyra-validity-change` contract.
5477
+ The same safe serialized object is used as session-history/autofill state. Restoration accepts only
5478
+ a JSON object, falls back to `{}` for malformed/non-object state, and does not emit `lyra-input`.
5021
5479
 
5022
5480
  **Known gotchas:**
5023
5481
  - a schema property whose `type` isn't `'string'`/`'number'`/`'integer'`/`'boolean'` renders an inline
5024
5482
  "Unsupported field type" message (a plain `.unsupported`-classed element, not one of the documented
5025
- CSS parts) instead of throwing or being silently dropped.
5483
+ CSS parts) and fails closed with custom validity instead of throwing or being silently dropped.
5026
5484
  - inline per-field errors only render once a field has been visited (`focusout`) at least once, or
5027
5485
  after an explicit `reportValidity()` call — `checkValidity()` alone never reveals them, matching
5028
5486
  every other form control in this library (`<lyra-select>`/`<lyra-combobox>`/`<lyra-model-select>`
5029
5487
  all avoid flashing red before the user has touched anything).
5030
5488
  - `effectiveValue` distinguishes "key absent from `value`" (falls back to `default`) from "key present
5031
- but `undefined`" (stays cleared) via `hasOwnProperty`, not an `=== undefined` check — relevant if you
5032
- ever construct `value` objects by hand rather than round-tripping `lyra-input`'s detail.
5489
+ but `undefined`" (stays cleared and counts as absent for `required`) via `hasOwnProperty`, not an
5490
+ `=== undefined` fallback check.
5491
+ - additional value keys are retained and submitted (matching JSON Schema's default open-object
5492
+ behavior), but schema-valued/false `additionalProperties` is outside this renderer's subset.
5493
+ - reassign `value`/`schema` after changing them. Direct in-place mutation is resnapshotted by the
5494
+ component's own `checkValidity()`/`reportValidity()`, but native `form.checkValidity()` and
5495
+ submission use the last synchronized snapshot because the browser cannot observe arbitrary object
5496
+ mutation.
5033
5497
  - `lyra-validity-change` fires once immediately at connect time even before any user interaction, so a
5034
5498
  form with an unmet required field announces `valid: false` on mount, not only after the first edit.
5035
5499
 
@@ -5090,17 +5554,32 @@ internal shadow-DOM button; `<lyra-menu>` is the sole owner of this element's `t
5090
5554
  `<lyra-menu>`'s roving-tabindex navigation entirely)
5091
5555
  - `destructive: boolean = false` (reflected — tints the row with `--lyra-color-danger`, for a
5092
5556
  dangerous action like "Delete")
5557
+ - `type: 'normal' | 'checkbox' = 'normal'` — `'checkbox'` (mirroring `wa-dropdown-item`'s identical
5558
+ `type` option) renders `role="menuitemcheckbox"` in place of `role="menuitem"`, with `aria-checked`
5559
+ reflecting `checked` and a checkmark glyph shown once `checked` is `true`. `'normal'` (the default)
5560
+ renders and behaves exactly as before this option existed.
5561
+ - `checked: boolean = false` (reflected) — whether a `type="checkbox"` item is checked; meaningless
5562
+ (ignored) for `type="normal"`
5563
+
5564
+ **Methods:** `select(): void` — fires `lyra-menu-item-select` (no-op while `disabled`). Called
5565
+ internally by this element's own click handler and by `<lyra-menu>`'s Enter/Space keydown handling
5566
+ of the roving-focused item; also the cleanest way for a consumer/test to trigger selection
5567
+ programmatically instead of clicking the shadow-DOM `[part="base"]` element (see the gotcha below).
5568
+ For `type="checkbox"`, also toggles `checked` and fires `lyra-menu-item-change` first.
5093
5569
 
5094
5570
  **Events:** `lyra-menu-item-select` (no detail payload — `this.emit('lyra-menu-item-select')` is
5095
5571
  called with no second argument, so `event.detail` is `null`, not `undefined`; fires on click, or
5096
5572
  when the parent `<lyra-menu>`'s own Enter/Space keydown handling calls `select()` on the currently
5097
- roving-focused item)
5573
+ roving-focused item), `lyra-menu-item-change` (`detail: { value, checked }` — fired when a
5574
+ `type="checkbox"` item is activated and its `checked` state toggled, in addition to — never instead
5575
+ of — `lyra-menu-item-select`; never fired for `type="normal"`)
5098
5576
 
5099
5577
  **Slots:** default (the item's label content), `icon` (optional leading icon)
5100
5578
 
5101
5579
  **CSS parts:** `base` (the row — `role` lives on the host, not this part), `icon` (wrapper around
5102
5580
  the `icon` slot; not rendered/hidden entirely while the slot is empty), `label` (wrapper around the
5103
- default slot)
5581
+ default slot), `checkmark` (the checkmark glyph shown when a `type="checkbox"` item is `checked`;
5582
+ not rendered at all for `type="normal"`)
5104
5583
 
5105
5584
  **Themeable custom properties:** shared tokens only (`--lyra-radius`, `--lyra-focus-ring-width`,
5106
5585
  `--lyra-focus-ring-color`, `--lyra-space-xs`, `--lyra-space-s`, `--lyra-color-brand-quiet`,
@@ -5125,17 +5604,24 @@ The popup is always rendered in the DOM (never `display:none`) so `.focus()` cal
5125
5604
  work synchronously the instant it opens — closed state is conveyed via `visibility`/`opacity`
5126
5605
  instead. `visibility` is an inherited CSS property that pierces the `<slot>` projection boundary, so
5127
5606
  every closed-state `<lyra-menu-item>` is automatically excluded from sequential (Tab-key)
5128
- navigation with no separate JS bookkeeping. Once open, ArrowDown/ArrowUp move the roving focus among
5129
- non-disabled items and wrap past either end; Home/End jump to the first/last non-disabled item;
5130
- Enter/Space activate the focused item; Escape closes and refocuses the trigger. A click outside both
5131
- the trigger and the open popup also closes it, but deliberately does *not* refocus the trigger the
5132
- outside click itself already moved focus somewhere the user chose.
5607
+ navigation with no separate JS bookkeeping. ArrowDown/ArrowUp *on the trigger while closed* also
5608
+ opens the menu, focusing the first/last non-disabled item respectively (mirrors native `<select>`).
5609
+ Once open, ArrowDown/ArrowUp move the roving focus among non-disabled items and wrap past either end
5610
+ (unlike `<lyra-select>`'s clamped listbox nav); Home/End jump to the first/last non-disabled item;
5611
+ Enter/Space activate the focused item; Escape closes and refocuses the trigger; Tab closes without
5612
+ `preventDefault()`, letting the browser's own Tab navigation proceed untouched. A printable keypress
5613
+ runs type-ahead: roving focus jumps to the next non-disabled item whose text starts with the
5614
+ accumulated buffer (cycling from just after the active item, buffer resets ~500ms after the last
5615
+ keystroke) — mirrors `<lyra-select>`'s identical listbox type-ahead. A click outside both the trigger
5616
+ and the open popup also closes it, but deliberately does *not* refocus the trigger — the outside
5617
+ click itself already moved focus somewhere the user chose.
5133
5618
 
5134
5619
  **Known gotchas:**
5135
5620
  - `<lyra-menu-item>`'s click handler lives on an inner shadow-DOM element (`[part="base"]`), not the
5136
5621
  host — calling `.click()` directly on the `<lyra-menu-item>` host element in a test does **not**
5137
- trigger selection; you must click (or dispatch on) the element returned by
5138
- `menuItemEl.shadowRoot.querySelector('[part="base"]')`.
5622
+ trigger selection; either click (or dispatch on) the element returned by
5623
+ `menuItemEl.shadowRoot.querySelector('[part="base"]')`, or just call the item's own `select()`
5624
+ method directly.
5139
5625
  - `lyra-show`/`lyra-hide` are suppressed on the very first render even if `open` is already `true`
5140
5626
  in markup — only later `open` transitions fire them.
5141
5627
  - `lyra-menu-item-select` carries no detail payload (`event.detail === null`); read
@@ -5279,6 +5765,10 @@ of `catalog`/`allowCustom` and `temperatureMin`/`temperatureMax`/`temperatureSte
5279
5765
  - `layout: 'vertical' | 'compact' = 'vertical'` (reflected) — `vertical` stacks full-width rows with
5280
5766
  visible labels; `compact` runs the same two rows side by side with a smaller, uppercase temperature
5281
5767
  caption, for toolbars/sidebars where the vertical layout's height doesn't fit.
5768
+ - `disabled: boolean = false` (reflected) — disables the panel as a unit by forwarding to *both*
5769
+ internal `lyra-model-select` and `lyra-slider`; a wrapping `<fieldset disabled>` alone would not
5770
+ reach either, since a form-associated control's own `disabled` IDL property/attribute is never
5771
+ mutated by fieldset cascading.
5282
5772
 
5283
5773
  **Events:** `lyra-change` — `detail: { modelValue: string; inCatalog: boolean; temperature: number }`.
5284
5774
  Fires whenever *either* child control's own `lyra-change` fires, and always carries the full current
@@ -5313,7 +5803,13 @@ internally (both imported unconditionally as side effects, not optional).
5313
5803
  The internal `lyra-slider` renders with its own value readout suppressed (`.showValue=${false}`);
5314
5804
  the current temperature is instead shown via this component's own `[part="temperature-value"]` span,
5315
5805
  which interpolates `temperature` verbatim with no `toFixed`/formatting applied — a value like `0.1`
5316
- shows as `0.1`, and any floating-point noise a slider drag produces would render digit-for-digit.
5806
+ shows as `0.1`, and any floating-point noise a slider drag produces would render digit-for-digit. The
5807
+ panel's own `temperature` property mirrors the nested slider's *live* value on every one of its
5808
+ `lyra-input` events (drag/key-repeat), not just its committed `lyra-change` — so `temperature` (and
5809
+ the visible readout) tick continuously during a drag, but the panel's own `lyra-change` event only
5810
+ fires once the slider's own `lyra-change` commits (pointerup/keyup) or the model changes; reading
5811
+ `.temperature` mid-drag will already reflect the live position even though no `lyra-change` has fired
5812
+ yet for it.
5317
5813
 
5318
5814
  **Known gotchas:**
5319
5815
  - `catalog` is JS-only (`attribute: false`) — set it via a property binding (`.catalog=`), never as
@@ -5355,8 +5851,8 @@ capacity), `segment` (one occupied segment — carries `data-tone` for styling),
5355
5851
 
5356
5852
  **Themeable custom properties:** no component-specific custom properties; consumes shared tokens
5357
5853
  `--lyra-space-xs`, `--lyra-color-text-quiet`, `--lyra-font`, `--lyra-radius`, `--lyra-color-border`,
5358
- `--lyra-color-brand`, `--lyra-color-success`, `--lyra-color-warning`, `--lyra-color-danger`,
5359
- `--lyra-transition-base`.
5854
+ `--lyra-color-surface` (the bar variant's inter-segment seam), `--lyra-color-brand`,
5855
+ `--lyra-color-success`, `--lyra-color-warning`, `--lyra-color-danger`, `--lyra-transition-base`.
5360
5856
 
5361
5857
  **Optional peer deps:** none.
5362
5858
 
@@ -5377,15 +5873,17 @@ capacity), `segment` (one occupied segment — carries `data-tone` for styling),
5377
5873
  `role="img"` and a computed `aria-label` are set imperatively on the *host* element itself in
5378
5874
  `willUpdate` (mirroring `lyra-gauge`'s "meter" role convention) — every internal node (`track`,
5379
5875
  `segment`, the ring's `<svg>`, `label`) is `aria-hidden`, so a screen reader gets one meaningful
5380
- summary string instead of the raw markup. That summary's "used" figure is the plain, unclamped sum of
5381
- `segments[].value` (`"150,000 of 128,000 used"` is possible if segments overshoot `total`), even
5382
- though the *visual* meter itself never overflows: each segment's ratio is clamped so the running
5383
- cumulative fill across all segments can never exceed 100% of the bar/ring an over-`total` `segments`
5384
- array renders as a fully (not over-) filled meter, with later segments truncated or squeezed to zero
5385
- width/arc-length as the budget runs out. `total <= 0` (or non-finite) renders zero segments — an empty
5386
- track/ring regardless of what's in `segments`. Ring geometry (a 40-radius circle, 12px stroke,
5387
- centered at 50,50) intentionally matches `lyra-gauge`'s own radial numbers, so the two circular-meter
5388
- components in the library share one visual scale.
5876
+ summary string instead of the raw markup. That summary's "used" figure is the sum of
5877
+ `segments[].value`, clamped to `total` whenever `total > 0` so the announced text can never claim
5878
+ more than 100% used (e.g. `segments` summing to `150000` against `total="128000"` still announces
5879
+ `"128,000 of 128,000 used"`) matching what the *visual* meter shows, since each segment's ratio is
5880
+ independently clamped so the running cumulative fill across all segments can never exceed 100% of the
5881
+ bar/ring either: an over-`total` `segments` array renders as a fully (not over-) filled meter, with
5882
+ later segments truncated or squeezed to zero width/arc-length as the budget runs out. `total <= 0`
5883
+ (or non-finite) renders zero segments an empty track/ring — and the announced summary falls back to
5884
+ just `"{used} used"` with no `"of {total}"` clause, regardless of what's in `segments`. Ring geometry
5885
+ (a 40-radius circle, 12px stroke, centered at 50,50) intentionally matches `lyra-gauge`'s own radial
5886
+ numbers, so the two circular-meter components in the library share one visual scale.
5389
5887
 
5390
5888
  **Known gotchas:**
5391
5889
  - The ring variant's per-segment `<title>` and the bar variant's per-segment `title=` attribute are
@@ -5523,10 +6021,21 @@ way to get a `<pre>`'s text content from a URL — gated behind a generation-cou
5523
6021
  (`lyra-tool-result-view`'s `resolve()` uses the identical pattern) so a `src` reassigned mid-fetch
5524
6022
  can't have a stale response clobber a newer one.
5525
6023
 
6024
+ Every `src` is validated (via `internal/safe-url.ts`) against a scheme allowlist specific to the DOM/
6025
+ API sink it's about to reach: `fetch(src)` (text preview) and an `<img src>` (image preview) both
6026
+ allow relative URLs plus `http:`, `https:`, `blob:`, and `data:`; the download `<a href>` deliberately
6027
+ excludes `data:` (following a `data:text/html` URL can create an active document, unlike using it as
6028
+ inert media/fetch data). A `src` that fails its sink's check never reaches `fetch()`/`<img>`/the
6029
+ anchor: the text preview renders `[part="error"]` with `"Document URL is not allowed."`, the image
6030
+ preview silently falls back to the generic download-or-`unsupported`-slot fallback, and the generic
6031
+ fallback simply omits `[part="download-link"]` entirely.
6032
+
5526
6033
  **Properties:**
5527
6034
  - `src: string = ''` — URL to fetch (for `text`/`application/json`) or display (`image`, or as the
5528
6035
  generic fallback's download `href`). Optional — gracefully absent while, e.g., a conversion is
5529
- still in progress.
6036
+ still in progress. Validated per-sink before use — see the URL-safety note above; an unsafe/
6037
+ malformed value is treated as if `src` were unusable for that sink, never passed to `fetch()`/
6038
+ `<img>`/the anchor.
5530
6039
  - `mimeType: string = ''` (attribute `mime-type`) — drives format dispatch (see above).
5531
6040
  - `filename: string = ''` — shown in the header and used as the download link's suggested filename.
5532
6041
  - `status: 'idle' | 'converting' | 'ready' | 'error' = 'idle'` (reflected) — host-owned lifecycle
@@ -5557,7 +6066,8 @@ download fallback for any `mime-type` this component doesn't natively support. I
5557
6066
  `spinner` (indeterminate `role="status"`, or `role="progressbar"` once numeric progress is known —
5558
6067
  used both for `status="converting"` and this component's own in-flight text fetch), `error`
5559
6068
  (`role="alert"` — used both for `status="error"` and a failed text fetch), `download-link` (only
5560
- rendered when `src` is set)
6069
+ rendered when `src` is set *and* passes the link-safe scheme allowlist — see the URL-safety note
6070
+ above; excludes `data:` even though the other two sinks allow it)
5561
6071
 
5562
6072
  **Themeable custom properties:** `--lyra-document-preview-max-height` (default `none`) — the
5563
6073
  consumer-tunable scroll cap on `[part="body"]`, set from `max-height`; `none` means the preview grows
@@ -5612,6 +6122,13 @@ available, the region becomes a standard `role="progressbar"` instead, self-desc
5612
6122
  just via two different mechanisms depending on timing.
5613
6123
  - `download-link` (and thus `lyra-download`) only renders/fires when `src` is set — a generic-fallback
5614
6124
  state with no `src` shows only the file glyph and message, with no download affordance at all.
6125
+ - `download-link` also never renders for a `data:` URL, even though `data:` is accepted for text/image
6126
+ sinks — a `src="data:..."` document renders/fetches fine but falls back to no download affordance
6127
+ in the generic state.
6128
+ - A `src` that fails its sink's URL-safety check does **not** raise `lyra-render-error` — that event is
6129
+ reserved for a `fetch()` that was actually attempted and failed at the network layer; an unsafe/
6130
+ malformed `src` is silently treated as unusable instead (a rendered `[part="error"]` message for
6131
+ text, a silent fallback for image/download).
5615
6132
 
5616
6133
  ---
5617
6134
 
@@ -5633,13 +6150,20 @@ final.
5633
6150
  filename, and folded into the accessible name.
5634
6151
  - `alt: string = ''` — alt text for the image case (and reused as a video label fallback). Falls
5635
6152
  back to `filename`, then a generic per-kind description.
6153
+ - `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"16rem"`); once set,
6154
+ overrides the `--lyra-media-card-max-height` custom property for this instance only (applied
6155
+ inline on `[part="base"]`, so it reliably wins over a `:host{}`-declared default from outside the
6156
+ shadow root) — same contract as `<lyra-document-preview>`'s identically-named prop.
5636
6157
 
5637
6158
  **Events:** `lyra-open` (`detail: { src: string; filename: string }`, cancelable) — fired when the
5638
- card (or, for `kind="video"`, its separate `open-button`) is activated. This component never
5639
- navigates on its own for `image`/`video` a host decides what "open" means. The `file`-chip case is
5640
- the exception: when `src` passes the stricter href safety check, the chip is a real `<a href
5641
- download>` so a bare drop-in still does something useful, but `lyra-open` fires first a host
5642
- calling `preventDefault()` on it suppresses that default download/open so it can substitute its own
6159
+ card (or, for `kind="video"`, its separate `open-button`) is activated. `detail.src` is whichever
6160
+ safe-URL sink actually rendered (`safeMediaSrc(src) ?? safeLinkHref(src) ?? src.trim()`), not
6161
+ necessarily the raw `src` property verbatim a whitespace-padded value is trimmed, so `detail.src`
6162
+ always matches what the DOM would show if it were safe. This component never navigates on its own
6163
+ for `image`/`video` a host decides what "open" means. The `file`-chip case is the exception: when
6164
+ `src` passes the stricter href safety check, the chip is a real `<a href download>` so a bare
6165
+ drop-in still does something useful, but `lyra-open` fires first — a host calling
6166
+ `preventDefault()` on it suppresses that default download/open so it can substitute its own
5643
6167
  handling.
5644
6168
 
5645
6169
  **Slots:** none.
@@ -5649,8 +6173,13 @@ an `<a>` or `<span>` for the file-chip fallback depending on href safety), `medi
5649
6173
  `<video>`), `file-icon`, `filename` (file-chip fallback only), `open-button` (video only — see
5650
6174
  below).
5651
6175
 
5652
- **Themeable custom properties:** shared tokens only`--lyra-space-xs`/`-s`, `--lyra-color-border`,
5653
- `--lyra-color-surface`, `--lyra-color-text`/`-text-quiet`, `--lyra-radius`, `--lyra-focus-ring-*`.
6176
+ **Themeable custom properties:** `--lyra-media-card-max-height` (default `20rem`caps `[part="media"]`'s
6177
+ block-size so one oversized image/video can't blow out a chat bubble; same naming/contract as
6178
+ `<lyra-document-preview>`'s identical `--lyra-document-preview-max-height`; override per-instance via
6179
+ the `max-height` attribute instead of this property directly). Plus shared tokens
6180
+ `--lyra-space-xs`/`-s`, `--lyra-color-border`, `--lyra-color-surface`, `--lyra-color-text`/`-text-quiet`,
6181
+ `--lyra-color-brand` (hover border), `--lyra-radius`, `--lyra-icon-button-size` (video's `open-button`
6182
+ sizing), `--lyra-focus-ring-*`, `--lyra-transition-fast`.
5654
6183
 
5655
6184
  **Optional peer deps:** none.
5656
6185
 
@@ -5715,6 +6244,11 @@ capability as a row.
5715
6244
  - `multiple: boolean = true` (reflected) — forwarded to the hidden file input's own `multiple`
5716
6245
  attribute.
5717
6246
  - `disabled: boolean = false` (reflected)
6247
+ - `triggerLabel?: string` (attribute `trigger-label`) — overrides the single-capability trigger
6248
+ button's `aria-label`, which otherwise comes from `CAPABILITY_META[capability].triggerLabel` (e.g.
6249
+ `'Attach files'`); only affects the single-capability button (`[part='trigger']`) — the
6250
+ multi-capability menu's own trigger keeps its fixed `'Add attachment'` label regardless. Unset
6251
+ (the default) keeps the built-in English default.
5718
6252
 
5719
6253
  **Events:** `lyra-pick` (`detail: { capability: 'files' | 'image'; files: FileList }`) — fired once a
5720
6254
  file-backed capability's hidden input produces a real selection. The `FileList` is an independent
@@ -5729,10 +6263,13 @@ native wrapper's own camera API).
5729
6263
 
5730
6264
  **CSS parts:** `trigger` (the single-capability button, only rendered when
5731
6265
  `capabilities.length === 1`), `menu` (the `lyra-menu` wrapper, only rendered when
5732
- `capabilities.length > 1`).
6266
+ `capabilities.length > 1`), `menu-trigger` (the multi-capability button slotted into `lyra-menu`'s own
6267
+ `trigger` slot, only rendered when `capabilities.length > 1`), `expand-icon` (the disclosure chevron
6268
+ inside the multi-capability trigger button, only rendered when `capabilities.length > 1`).
5733
6269
 
5734
- **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-text`,
5735
- `--lyra-icon-button-size`, `--lyra-focus-ring-*`, `--lyra-opacity-disabled`, `--lyra-radius`.
6270
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-text`/
6271
+ `-text-quiet`, `--lyra-icon-button-size`, `--lyra-focus-ring-*`, `--lyra-opacity-disabled`,
6272
+ `--lyra-radius`, `--lyra-transition-fast`.
5736
6273
 
5737
6274
  **Optional peer deps:** none.
5738
6275
 
@@ -5786,13 +6323,14 @@ string.
5786
6323
 
5787
6324
  **Slots:** default — an escape hatch for fully custom key-cap content (e.g. an icon instead of a
5788
6325
  text glyph). When it has any real (non-whitespace) content, it replaces the `keys`-driven rendering
5789
- entirely and this component stops asserting its own `aria-label`, leaving the slotted content to
5790
- carry its own accessible name.
6326
+ entirely and this component stops *computing* its own `aria-label` from `keys`, leaving the slotted
6327
+ content to carry its own accessible name — a host-supplied `aria-label` attribute is still forwarded
6328
+ onto the rendered chip in either mode, custom content included.
5791
6329
 
5792
6330
  **CSS parts:** `base` (the chip root), `key` (one per rendered token).
5793
6331
 
5794
6332
  **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-surface`/
5795
- `-border`/`-text-quiet`, `--lyra-radius`, `--lyra-font`.
6333
+ `-border`/`-text`/`-text-quiet`, `--lyra-radius`, `--lyra-font`.
5796
6334
 
5797
6335
  **Optional peer deps:** none.
5798
6336
 
@@ -5811,7 +6349,10 @@ The rendered chip carries `role="img"` with a single spelled-out `aria-label` (e
5811
6349
  rather than exposing each glyph/`+`-separator as separate accessible-tree text, since the individual
5812
6350
  pieces aren't real words and would read worse piecemeal than as one label — glyphs like ⌘/⇧/⌥ are
5813
6351
  not reliably announced by every screen reader/platform combination, which is exactly why the
5814
- spelled-out word form exists at all.
6352
+ spelled-out word form exists at all. An empty `keys` with no explicit `aria-label` override and no
6353
+ slotted content renders nothing visible and is marked `aria-hidden="true"` (no `role="img"`) instead
6354
+ of exposing a nameless image element — `role`/`aria-hidden` are both derived from the same
6355
+ computed-label value so the two can never disagree.
5815
6356
 
5816
6357
  ---
5817
6358
 
@@ -5839,7 +6380,10 @@ title).
5839
6380
 
5840
6381
  **CSS parts:** `base` (outer bordered container), `header` (present in the DOM at all times so a
5841
6382
  later `slotchange` on `actions` is still observed, but `hidden` whenever there's no `title` and no
5842
- `actions` content), `title`, `actions` (`hidden` whenever the slot has no assigned content), `body`.
6383
+ `actions` content), `title` (truncates with an ellipsis when it overflows; carries its own native
6384
+ `title` attribute — the full string — so hovering the truncated text reveals it via the browser's
6385
+ default tooltip, scoped to just this element rather than the whole card), `actions` (`hidden`
6386
+ whenever the slot has no assigned content), `body`.
5843
6387
 
5844
6388
  **Themeable custom properties:** shared tokens only — `--lyra-space-xs`/`-s`, `--lyra-color-border`/
5845
6389
  `-surface`/`-text`, `--lyra-radius`.