@aceshooting/lyra-ui 1.2.0 → 2.0.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 (330) hide show
  1. package/README.md +11 -0
  2. package/custom-elements.json +23341 -11202
  3. package/dist/components/app-rail/app-rail.d.ts +171 -0
  4. package/dist/components/app-rail/app-rail.js +483 -0
  5. package/dist/components/app-rail/app-rail.styles.d.ts +1 -0
  6. package/dist/components/app-rail/app-rail.styles.js +125 -0
  7. package/dist/components/attachment-chip/attachment-chip.d.ts +114 -0
  8. package/dist/components/attachment-chip/attachment-chip.js +312 -0
  9. package/dist/components/attachment-chip/attachment-chip.styles.d.ts +1 -0
  10. package/dist/components/attachment-chip/attachment-chip.styles.js +184 -0
  11. package/dist/components/attachment-trigger/attachment-trigger.d.ts +83 -0
  12. package/dist/components/attachment-trigger/attachment-trigger.js +258 -0
  13. package/dist/components/attachment-trigger/attachment-trigger.styles.d.ts +1 -0
  14. package/dist/components/attachment-trigger/attachment-trigger.styles.js +68 -0
  15. package/dist/components/chart/box-plot.js +11 -1
  16. package/dist/components/chart/chart-loader.d.ts +44 -13
  17. package/dist/components/chart/chart-loader.js +65 -18
  18. package/dist/components/chart/chart.d.ts +1 -2
  19. package/dist/components/chart/chart.js +67 -12
  20. package/dist/components/chart/histogram-bin.d.ts +4 -1
  21. package/dist/components/chart/histogram-bin.js +24 -14
  22. package/dist/components/chart/lite-chart.d.ts +54 -0
  23. package/dist/components/chart/lite-chart.js +140 -22
  24. package/dist/components/chart/lite-chart.styles.js +9 -0
  25. package/dist/components/chat-composer/chat-composer.d.ts +92 -0
  26. package/dist/components/chat-composer/chat-composer.js +285 -0
  27. package/dist/components/chat-composer/chat-composer.styles.d.ts +1 -0
  28. package/dist/components/chat-composer/chat-composer.styles.js +135 -0
  29. package/dist/components/chat-message/chat-message.d.ts +113 -0
  30. package/dist/components/chat-message/chat-message.js +291 -0
  31. package/dist/components/chat-message/chat-message.styles.d.ts +1 -0
  32. package/dist/components/chat-message/chat-message.styles.js +203 -0
  33. package/dist/components/checkbox/checkbox.d.ts +59 -0
  34. package/dist/components/checkbox/checkbox.js +230 -0
  35. package/dist/components/checkbox/checkbox.styles.d.ts +1 -0
  36. package/dist/components/checkbox/checkbox.styles.js +70 -0
  37. package/dist/components/chip/chip-group.d.ts +56 -0
  38. package/dist/components/chip/chip-group.js +124 -0
  39. package/dist/components/chip/chip-group.styles.d.ts +1 -0
  40. package/dist/components/chip/chip-group.styles.js +57 -0
  41. package/dist/components/chip/chip.d.ts +64 -0
  42. package/dist/components/chip/chip.js +115 -0
  43. package/dist/components/chip/chip.styles.d.ts +1 -0
  44. package/dist/components/chip/chip.styles.js +115 -0
  45. package/dist/components/citation-badge/citation-badge.d.ts +113 -0
  46. package/dist/components/citation-badge/citation-badge.js +300 -0
  47. package/dist/components/citation-badge/citation-badge.styles.d.ts +1 -0
  48. package/dist/components/citation-badge/citation-badge.styles.js +96 -0
  49. package/dist/components/code-block/code-block.d.ts +89 -0
  50. package/dist/components/code-block/code-block.js +317 -0
  51. package/dist/components/code-block/code-block.styles.d.ts +1 -0
  52. package/dist/components/code-block/code-block.styles.js +187 -0
  53. package/dist/components/code-block/code-loader.d.ts +44 -0
  54. package/dist/components/code-block/code-loader.js +79 -0
  55. package/dist/components/combobox/combobox.d.ts +19 -1
  56. package/dist/components/combobox/combobox.js +84 -14
  57. package/dist/components/combobox/option.d.ts +5 -1
  58. package/dist/components/combobox/option.js +35 -0
  59. package/dist/components/context-meter/context-meter.d.ts +53 -0
  60. package/dist/components/context-meter/context-meter.js +150 -0
  61. package/dist/components/context-meter/context-meter.styles.d.ts +1 -0
  62. package/dist/components/context-meter/context-meter.styles.js +120 -0
  63. package/dist/components/conversation-item/conversation-item.d.ts +128 -0
  64. package/dist/components/conversation-item/conversation-item.js +322 -0
  65. package/dist/components/conversation-item/conversation-item.styles.d.ts +1 -0
  66. package/dist/components/conversation-item/conversation-item.styles.js +155 -0
  67. package/dist/components/date-picker/date-input.d.ts +24 -0
  68. package/dist/components/date-picker/date-input.js +134 -22
  69. package/dist/components/date-picker/date-picker.d.ts +7 -0
  70. package/dist/components/date-picker/date-picker.js +85 -16
  71. package/dist/components/dialog/confirm.d.ts +40 -0
  72. package/dist/components/dialog/confirm.js +79 -0
  73. package/dist/components/dialog/dialog.d.ts +85 -0
  74. package/dist/components/dialog/dialog.js +272 -0
  75. package/dist/components/dialog/dialog.styles.d.ts +1 -0
  76. package/dist/components/dialog/dialog.styles.js +52 -0
  77. package/dist/components/dock-panel/dock-panel.d.ts +134 -0
  78. package/dist/components/dock-panel/dock-panel.js +345 -0
  79. package/dist/components/dock-panel/dock-panel.styles.d.ts +1 -0
  80. package/dist/components/dock-panel/dock-panel.styles.js +145 -0
  81. package/dist/components/document-preview/document-preview.d.ts +135 -0
  82. package/dist/components/document-preview/document-preview.js +372 -0
  83. package/dist/components/document-preview/document-preview.styles.d.ts +1 -0
  84. package/dist/components/document-preview/document-preview.styles.js +191 -0
  85. package/dist/components/export-button/csv.js +1 -1
  86. package/dist/components/export-button/export-button.js +2 -0
  87. package/dist/components/flag/flag.d.ts +29 -13
  88. package/dist/components/flag/flag.js +39 -13
  89. package/dist/components/gauge/gauge.js +19 -10
  90. package/dist/components/generation-status/generation-status.d.ts +109 -0
  91. package/dist/components/generation-status/generation-status.js +273 -0
  92. package/dist/components/generation-status/generation-status.styles.d.ts +1 -0
  93. package/dist/components/generation-status/generation-status.styles.js +84 -0
  94. package/dist/components/heatmap/calendar-grid.d.ts +4 -1
  95. package/dist/components/heatmap/calendar-grid.js +25 -12
  96. package/dist/components/heatmap/heatmap-scale.d.ts +6 -0
  97. package/dist/components/heatmap/heatmap-scale.js +7 -1
  98. package/dist/components/heatmap/heatmap.d.ts +74 -0
  99. package/dist/components/heatmap/heatmap.js +135 -23
  100. package/dist/components/json-viewer/json-viewer.d.ts +53 -0
  101. package/dist/components/json-viewer/json-viewer.js +283 -0
  102. package/dist/components/json-viewer/json-viewer.styles.d.ts +1 -0
  103. package/dist/components/json-viewer/json-viewer.styles.js +176 -0
  104. package/dist/components/kbd/kbd.d.ts +77 -0
  105. package/dist/components/kbd/kbd.js +214 -0
  106. package/dist/components/kbd/kbd.styles.d.ts +1 -0
  107. package/dist/components/kbd/kbd.styles.js +52 -0
  108. package/dist/components/live-region/live-region.d.ts +81 -0
  109. package/dist/components/live-region/live-region.js +153 -0
  110. package/dist/components/live-region/live-region.styles.d.ts +1 -0
  111. package/dist/components/live-region/live-region.styles.js +10 -0
  112. package/dist/components/map/map.d.ts +8 -6
  113. package/dist/components/map/map.js +44 -8
  114. package/dist/components/markdown/markdown-loader.d.ts +34 -0
  115. package/dist/components/markdown/markdown-loader.js +43 -0
  116. package/dist/components/markdown/markdown.d.ts +81 -0
  117. package/dist/components/markdown/markdown.js +284 -0
  118. package/dist/components/markdown/markdown.styles.d.ts +1 -0
  119. package/dist/components/markdown/markdown.styles.js +82 -0
  120. package/dist/components/media-card/media-card.d.ts +122 -0
  121. package/dist/components/media-card/media-card.js +295 -0
  122. package/dist/components/media-card/media-card.styles.d.ts +1 -0
  123. package/dist/components/media-card/media-card.styles.js +132 -0
  124. package/dist/components/mention-popover/mention-popover.d.ts +200 -0
  125. package/dist/components/mention-popover/mention-popover.js +475 -0
  126. package/dist/components/mention-popover/mention-popover.styles.d.ts +1 -0
  127. package/dist/components/mention-popover/mention-popover.styles.js +101 -0
  128. package/dist/components/menu/menu-item.d.ts +62 -0
  129. package/dist/components/menu/menu-item.js +119 -0
  130. package/dist/components/menu/menu-item.styles.d.ts +1 -0
  131. package/dist/components/menu/menu-item.styles.js +67 -0
  132. package/dist/components/menu/menu.d.ts +143 -0
  133. package/dist/components/menu/menu.js +365 -0
  134. package/dist/components/menu/menu.styles.d.ts +1 -0
  135. package/dist/components/menu/menu.styles.js +66 -0
  136. package/dist/components/model-select/model-select.d.ts +125 -0
  137. package/dist/components/model-select/model-select.js +490 -0
  138. package/dist/components/model-select/model-select.styles.d.ts +1 -0
  139. package/dist/components/model-select/model-select.styles.js +189 -0
  140. package/dist/components/model-settings-panel/model-settings-panel.d.ts +69 -0
  141. package/dist/components/model-settings-panel/model-settings-panel.js +145 -0
  142. package/dist/components/model-settings-panel/model-settings-panel.styles.d.ts +1 -0
  143. package/dist/components/model-settings-panel/model-settings-panel.styles.js +82 -0
  144. package/dist/components/playback/playback.d.ts +13 -1
  145. package/dist/components/playback/playback.js +47 -14
  146. package/dist/components/responsive-panel/responsive-panel.d.ts +150 -0
  147. package/dist/components/responsive-panel/responsive-panel.js +365 -0
  148. package/dist/components/responsive-panel/responsive-panel.styles.d.ts +1 -0
  149. package/dist/components/responsive-panel/responsive-panel.styles.js +103 -0
  150. package/dist/components/result-card/result-card.d.ts +44 -0
  151. package/dist/components/result-card/result-card.js +83 -0
  152. package/dist/components/result-card/result-card.styles.d.ts +1 -0
  153. package/dist/components/result-card/result-card.styles.js +50 -0
  154. package/dist/components/result-card/result-field.d.ts +36 -0
  155. package/dist/components/result-card/result-field.js +87 -0
  156. package/dist/components/result-card/result-field.styles.d.ts +1 -0
  157. package/dist/components/result-card/result-field.styles.js +32 -0
  158. package/dist/components/select/select.d.ts +43 -2
  159. package/dist/components/select/select.js +150 -17
  160. package/dist/components/slider/slider.d.ts +89 -0
  161. package/dist/components/slider/slider.js +352 -0
  162. package/dist/components/slider/slider.styles.d.ts +1 -0
  163. package/dist/components/slider/slider.styles.js +87 -0
  164. package/dist/components/source-card/source-card.d.ts +92 -0
  165. package/dist/components/source-card/source-card.js +148 -0
  166. package/dist/components/source-card/source-card.styles.d.ts +1 -0
  167. package/dist/components/source-card/source-card.styles.js +69 -0
  168. package/dist/components/source-list/source-list.d.ts +63 -0
  169. package/dist/components/source-list/source-list.js +122 -0
  170. package/dist/components/source-list/source-list.styles.d.ts +1 -0
  171. package/dist/components/source-list/source-list.styles.js +59 -0
  172. package/dist/components/sparkline/sparkline.js +27 -13
  173. package/dist/components/split/split.d.ts +93 -1
  174. package/dist/components/split/split.js +284 -15
  175. package/dist/components/split/split.styles.js +30 -0
  176. package/dist/components/stat/stat.d.ts +6 -1
  177. package/dist/components/stat/stat.js +8 -2
  178. package/dist/components/stream-status/stream-status.d.ts +110 -0
  179. package/dist/components/stream-status/stream-status.js +210 -0
  180. package/dist/components/stream-status/stream-status.styles.d.ts +1 -0
  181. package/dist/components/stream-status/stream-status.styles.js +103 -0
  182. package/dist/components/streaming-text/streaming-text.d.ts +98 -0
  183. package/dist/components/streaming-text/streaming-text.js +209 -0
  184. package/dist/components/streaming-text/streaming-text.styles.d.ts +1 -0
  185. package/dist/components/streaming-text/streaming-text.styles.js +62 -0
  186. package/dist/components/switch/switch.d.ts +58 -0
  187. package/dist/components/switch/switch.js +179 -0
  188. package/dist/components/switch/switch.styles.d.ts +1 -0
  189. package/dist/components/switch/switch.styles.js +75 -0
  190. package/dist/components/table/table.d.ts +73 -4
  191. package/dist/components/table/table.js +169 -20
  192. package/dist/components/table/table.styles.js +6 -1
  193. package/dist/components/tabs/tabs.d.ts +51 -0
  194. package/dist/components/tabs/tabs.js +190 -0
  195. package/dist/components/tabs/tabs.styles.d.ts +1 -0
  196. package/dist/components/tabs/tabs.styles.js +63 -0
  197. package/dist/components/thinking-panel/thinking-panel.d.ts +121 -0
  198. package/dist/components/thinking-panel/thinking-panel.js +235 -0
  199. package/dist/components/thinking-panel/thinking-panel.styles.d.ts +1 -0
  200. package/dist/components/thinking-panel/thinking-panel.styles.js +117 -0
  201. package/dist/components/toast/toast-item.d.ts +1 -0
  202. package/dist/components/toast/toast-item.js +31 -6
  203. package/dist/components/tool-approval-dialog/tool-approval-dialog.d.ts +130 -0
  204. package/dist/components/tool-approval-dialog/tool-approval-dialog.js +403 -0
  205. package/dist/components/tool-approval-dialog/tool-approval-dialog.styles.d.ts +1 -0
  206. package/dist/components/tool-approval-dialog/tool-approval-dialog.styles.js +160 -0
  207. package/dist/components/tool-call-chip/tool-call-chip.d.ts +89 -0
  208. package/dist/components/tool-call-chip/tool-call-chip.js +288 -0
  209. package/dist/components/tool-call-chip/tool-call-chip.styles.d.ts +1 -0
  210. package/dist/components/tool-call-chip/tool-call-chip.styles.js +203 -0
  211. package/dist/components/tool-param-form/tool-param-form.d.ts +145 -0
  212. package/dist/components/tool-param-form/tool-param-form.js +366 -0
  213. package/dist/components/tool-param-form/tool-param-form.styles.d.ts +1 -0
  214. package/dist/components/tool-param-form/tool-param-form.styles.js +75 -0
  215. package/dist/components/tool-result-dialog/tool-result-dialog.d.ts +102 -0
  216. package/dist/components/tool-result-dialog/tool-result-dialog.js +393 -0
  217. package/dist/components/tool-result-dialog/tool-result-dialog.styles.d.ts +1 -0
  218. package/dist/components/tool-result-dialog/tool-result-dialog.styles.js +176 -0
  219. package/dist/components/tool-result-view/registry.d.ts +81 -0
  220. package/dist/components/tool-result-view/registry.js +84 -0
  221. package/dist/components/tool-result-view/tool-result-view.d.ts +55 -0
  222. package/dist/components/tool-result-view/tool-result-view.js +138 -0
  223. package/dist/components/tool-result-view/tool-result-view.styles.d.ts +1 -0
  224. package/dist/components/tool-result-view/tool-result-view.styles.js +13 -0
  225. package/dist/components/tool-select-dialog/tool-select-dialog.d.ts +151 -0
  226. package/dist/components/tool-select-dialog/tool-select-dialog.js +439 -0
  227. package/dist/components/tool-select-dialog/tool-select-dialog.styles.d.ts +1 -0
  228. package/dist/components/tool-select-dialog/tool-select-dialog.styles.js +182 -0
  229. package/dist/components/tree/tree-node.d.ts +11 -2
  230. package/dist/components/tree/tree-node.js +16 -6
  231. package/dist/components/tree/tree.d.ts +8 -2
  232. package/dist/components/tree/tree.js +18 -11
  233. package/dist/components/typing-indicator/typing-indicator.d.ts +60 -0
  234. package/dist/components/typing-indicator/typing-indicator.js +96 -0
  235. package/dist/components/typing-indicator/typing-indicator.styles.d.ts +1 -0
  236. package/dist/components/typing-indicator/typing-indicator.styles.js +132 -0
  237. package/dist/components/virtual-list/virtual-list.d.ts +173 -0
  238. package/dist/components/virtual-list/virtual-list.js +461 -0
  239. package/dist/components/virtual-list/virtual-list.styles.d.ts +1 -0
  240. package/dist/components/virtual-list/virtual-list.styles.js +44 -0
  241. package/dist/components/word-cloud/word-cloud-layout.js +14 -1
  242. package/dist/components/word-cloud/word-cloud.d.ts +6 -0
  243. package/dist/components/word-cloud/word-cloud.js +25 -14
  244. package/dist/components/word-cloud/word-cloud.styles.js +3 -2
  245. package/dist/internal/announcer.d.ts +57 -0
  246. package/dist/internal/announcer.js +71 -0
  247. package/dist/internal/form-associated.d.ts +1 -0
  248. package/dist/internal/form-associated.js +55 -11
  249. package/dist/internal/group-by-recency.d.ts +50 -0
  250. package/dist/internal/group-by-recency.js +80 -0
  251. package/dist/internal/rtl.d.ts +23 -0
  252. package/dist/internal/rtl.js +28 -0
  253. package/dist/internal/scroll-lock.d.ts +10 -5
  254. package/dist/internal/scroll-lock.js +30 -14
  255. package/dist/internal/tokens.styles.js +6 -0
  256. package/dist/lyra.d.ts +130 -31
  257. package/dist/lyra.js +94 -26
  258. package/llms-full.txt +3836 -17
  259. package/llms.txt +16 -1
  260. package/package.json +23 -5
  261. package/dist/components/chart/bar-chart.stories.d.ts +0 -5
  262. package/dist/components/chart/bar-chart.stories.js +0 -20
  263. package/dist/components/chart/box-plot.stories.d.ts +0 -5
  264. package/dist/components/chart/box-plot.stories.js +0 -29
  265. package/dist/components/chart/bubble-chart.stories.d.ts +0 -5
  266. package/dist/components/chart/bubble-chart.stories.js +0 -26
  267. package/dist/components/chart/chart.stories.d.ts +0 -32
  268. package/dist/components/chart/chart.stories.js +0 -141
  269. package/dist/components/chart/doughnut-chart.stories.d.ts +0 -5
  270. package/dist/components/chart/doughnut-chart.stories.js +0 -26
  271. package/dist/components/chart/histogram.stories.d.ts +0 -5
  272. package/dist/components/chart/histogram.stories.js +0 -17
  273. package/dist/components/chart/line-chart.stories.d.ts +0 -22
  274. package/dist/components/chart/line-chart.stories.js +0 -99
  275. package/dist/components/chart/lite-chart.stories.d.ts +0 -14
  276. package/dist/components/chart/lite-chart.stories.js +0 -114
  277. package/dist/components/chart/pie-chart.stories.d.ts +0 -5
  278. package/dist/components/chart/pie-chart.stories.js +0 -26
  279. package/dist/components/chart/polar-area-chart.stories.d.ts +0 -5
  280. package/dist/components/chart/polar-area-chart.stories.js +0 -20
  281. package/dist/components/chart/radar-chart.stories.d.ts +0 -5
  282. package/dist/components/chart/radar-chart.stories.js +0 -20
  283. package/dist/components/chart/scatter-chart.stories.d.ts +0 -5
  284. package/dist/components/chart/scatter-chart.stories.js +0 -31
  285. package/dist/components/combobox/combobox.stories.d.ts +0 -24
  286. package/dist/components/combobox/combobox.stories.js +0 -102
  287. package/dist/components/date-picker/date-input.stories.d.ts +0 -10
  288. package/dist/components/date-picker/date-input.stories.js +0 -48
  289. package/dist/components/date-picker/date-picker.stories.d.ts +0 -10
  290. package/dist/components/date-picker/date-picker.stories.js +0 -25
  291. package/dist/components/empty/empty.stories.d.ts +0 -7
  292. package/dist/components/empty/empty.stories.js +0 -34
  293. package/dist/components/export-button/export-button.stories.d.ts +0 -7
  294. package/dist/components/export-button/export-button.stories.js +0 -37
  295. package/dist/components/file-input/file-input.stories.d.ts +0 -7
  296. package/dist/components/file-input/file-input.stories.js +0 -19
  297. package/dist/components/flag/flag.stories.d.ts +0 -6
  298. package/dist/components/flag/flag.stories.js +0 -31
  299. package/dist/components/gauge/gauge.stories.d.ts +0 -8
  300. package/dist/components/gauge/gauge.stories.js +0 -19
  301. package/dist/components/graph/graph.stories.d.ts +0 -8
  302. package/dist/components/graph/graph.stories.js +0 -68
  303. package/dist/components/heatmap/heatmap.stories.d.ts +0 -27
  304. package/dist/components/heatmap/heatmap.stories.js +0 -179
  305. package/dist/components/map/map.stories.d.ts +0 -15
  306. package/dist/components/map/map.stories.js +0 -135
  307. package/dist/components/playback/playback.stories.d.ts +0 -7
  308. package/dist/components/playback/playback.stories.js +0 -16
  309. package/dist/components/select/select.stories.d.ts +0 -21
  310. package/dist/components/select/select.stories.js +0 -91
  311. package/dist/components/skeleton/skeleton.stories.d.ts +0 -5
  312. package/dist/components/skeleton/skeleton.stories.js +0 -16
  313. package/dist/components/sparkline/sparkline.stories.d.ts +0 -11
  314. package/dist/components/sparkline/sparkline.stories.js +0 -39
  315. package/dist/components/split/split.stories.d.ts +0 -7
  316. package/dist/components/split/split.stories.js +0 -36
  317. package/dist/components/stat/stat.stories.d.ts +0 -9
  318. package/dist/components/stat/stat.stories.js +0 -81
  319. package/dist/components/table/table.stories.d.ts +0 -12
  320. package/dist/components/table/table.stories.js +0 -67
  321. package/dist/components/time-range/time-range.stories.d.ts +0 -8
  322. package/dist/components/time-range/time-range.stories.js +0 -26
  323. package/dist/components/toast/toast.stories.d.ts +0 -8
  324. package/dist/components/toast/toast.stories.js +0 -72
  325. package/dist/components/tree/tree.stories.d.ts +0 -9
  326. package/dist/components/tree/tree.stories.js +0 -52
  327. package/dist/components/widget/widget.stories.d.ts +0 -9
  328. package/dist/components/widget/widget.stories.js +0 -74
  329. package/dist/components/word-cloud/word-cloud.stories.d.ts +0 -10
  330. package/dist/components/word-cloud/word-cloud.stories.js +0 -55
package/llms-full.txt CHANGED
@@ -23,6 +23,17 @@ here.
23
23
  - **Chart family** — `lyra-chart` (core), typed subclasses, `lyra-histogram`, `lyra-box-plot`,
24
24
  `lyra-lite-chart` (dependency-free bar/line alternative, no peer deps)
25
25
  - **Map & file-input** — `lyra-map`, `lyra-file-input`
26
+ - **Conversation & Agent UI** — `lyra-dialog`/`confirm()`, `lyra-tabs`, `lyra-checkbox`,
27
+ `lyra-switch`, `lyra-json-viewer`, `lyra-live-region`, `lyra-markdown`, `lyra-chat-message`,
28
+ `lyra-typing-indicator`, `lyra-tool-call-chip`, `lyra-tool-result-view` (+ its renderer registry),
29
+ `lyra-tool-result-dialog`, `lyra-chat-composer`, `lyra-attachment-chip`, `lyra-stream-status`,
30
+ `lyra-virtual-list`, `lyra-conversation-item`, `lyra-model-select`, `lyra-slider`,
31
+ `lyra-tool-select-dialog`, `lyra-citation-badge`, `lyra-source-list`/`lyra-source-card`,
32
+ `lyra-app-rail`, `lyra-responsive-panel`, `lyra-mention-popover`, `lyra-streaming-text`,
33
+ `lyra-thinking-panel`, `lyra-generation-status`, `lyra-code-block`, `lyra-tool-approval-dialog`,
34
+ `lyra-tool-param-form`, `lyra-menu`/`lyra-menu-item`, `lyra-chip`/`lyra-chip-group`,
35
+ `lyra-model-settings-panel`, `lyra-context-meter`, `lyra-dock-panel`, `lyra-document-preview`,
36
+ `lyra-media-card`, `lyra-attachment-trigger`, `lyra-kbd`, `lyra-result-card`/`lyra-result-field`
26
37
  - **Cross-cutting notes** — packaging and adoption caveats that apply across the whole library
27
38
 
28
39
  ---
@@ -90,8 +101,13 @@ Not a custom element itself — infrastructure every `lyra-*` component in the l
90
101
  scroll lock (used by `lyra-widget`'s fullscreen mode); safe to acquire/release more than once
91
102
  concurrently, restores the original `overflow` only once the last outstanding lock releases.
92
103
  - **`rtl.ts` → `isRtl(el): boolean`** — reads `getComputedStyle(el).direction === 'rtl'`, for the
93
- pointer/keyboard math (drag ratios, arrow-key direction) in `lyra-split` and `lyra-time-range`
94
- that reasons about physical left/right and can't rely on logical CSS properties alone.
104
+ pointer/keyboard math (drag ratios, arrow-key direction) in `lyra-split`, `lyra-time-range`, and
105
+ `lyra-dock-panel` that reasons about physical left/right and can't rely on logical CSS properties
106
+ alone. The same module also exports `rtlAwareSide(side, el)` and `rtlAwarePlacement(placement,
107
+ el)`, which swap a physical `'left'`/`'right'` value (or the `left`/`right` component of a
108
+ Floating UI `Placement` string) under RTL and pass it through unchanged under LTR — for a caller
109
+ that means "anchor to my trailing edge" but has to hand `place()` a physical placement. `lyra-menu`
110
+ resolves its `placement` property through `rtlAwarePlacement()` before positioning.
95
111
 
96
112
  **Known gotchas:**
97
113
  - `formResetCallback()`'s restore-to-constructed-default behavior (both here and in
@@ -245,6 +261,16 @@ combobox does, and the popup reuses `internal/positioner.ts` for placement.
245
261
  Single-select only, with no `filter`/`source`/`with-clear`/`max-options-visible`/`empty-text`/
246
262
  `max-render`/`multiple` surface — reach for `<lyra-combobox>` instead whenever any of those apply.
247
263
 
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.
273
+
248
274
  **Properties:**
249
275
  - `placeholder: string = ''`
250
276
  - `disabled: boolean = false` (reflected)
@@ -301,6 +327,10 @@ secondary line, when `sub` is set), `expand-icon`, `error`, `hint`
301
327
  - The trigger's accessible name now checks a host-level `aria-label` attribute first, before falling
302
328
  back to `label`/`placeholder`/`"Select"` — a plain `aria-label` on `<lyra-select>` is no longer
303
329
  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.
304
334
 
305
335
  ---
306
336
 
@@ -764,9 +794,12 @@ KPI/stat card — value + unit + label + optional icon/trend/caption.
764
794
  - `goodDirection: 'up'|'down' = 'up'` (attribute `good-direction`) — which trend direction counts
765
795
  as "good"; inverts arrow/color polarity for cost/latency/error-rate-style metrics where a
766
796
  *decrease* is the win.
767
- - `rows: StatRow[] = []` (attribute: false) — `StatRow { label: string; value: string }`; rendered as
768
- a simple label/value breakdown list (`[part="rows"]`/`[part="row"]`/`[part="row-label"]`/
769
- `[part="row-value"]`) beneath the caption, hidden entirely when empty
797
+ - `rows: StatRow[] = []` (attribute: false) — `StatRow { label: string; value: string; exactValue?:
798
+ string }`; rendered as a simple label/value breakdown list (`[part="rows"]`/`[part="row"]`/
799
+ `[part="row-label"]`/`[part="row-value"]`) beneath the caption, hidden entirely when empty. A row's
800
+ optional `exactValue` mirrors the headline `exactValue`/`exact-value` pattern: rendered as a `title`
801
+ tooltip on that row's `[part="row-value"]` and gives it `tabindex="0"`, independently per row —
802
+ unset rows are unaffected.
770
803
  - `emphasis: boolean = false` (reflected) — visual emphasis (e.g. for a "headline" stat in a group):
771
804
  a brand-colored accent edge, orthogonal to the status `variant`; status semantics still win over
772
805
  it — `emphasis` only additionally tints `[part="value"]` brand-colored when `variant` is still
@@ -826,9 +859,17 @@ of `rows` — this component only renders and emits intents.
826
859
  `priority` progressively hides that column via a `@container` query as `[part='base']` narrows
827
860
  (`'low'` hides first, under a ~900px container width; `'medium'` next, under ~640px; both
828
861
  breakpoints are fixed in `table.styles.ts`, not themeable tokens), reversible via
829
- `[part='reveal-columns-button']` (rendered whenever any column sets a `priority`); `sticky` pins
830
- that column's header cell/cells to the inline-start edge (`position: sticky`) while the table
831
- scrolls horizontally
862
+ `[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
864
+ - `columnsHidden: boolean = false` (attribute `columns-hidden`, reflected) — computed/read-only: true
865
+ when a `priority` column is *actually* hidden right now by the `@container` breakpoints above, or
866
+ `showAllColumns` force-visible mode is currently active. Measured via a `ResizeObserver` on
867
+ `[part='base']` plus a post-render DOM check, so it settles one render cycle after a `columns`/
868
+ `rows`/width change lands — poll for the settled value (e.g. `await el.updateComplete;` twice, or
869
+ `waitUntil()`) rather than assuming a single `updateComplete` covers it. Setting it directly has no
870
+ lasting effect; it's recomputed on the next render or resize. `[part='reveal-columns-button']` now
871
+ renders exactly when `columnsHidden` is true — no longer whenever any column merely *declares* a
872
+ `priority` regardless of whether anything is actually hidden
832
873
  - `rows: T[] = []` (attribute: false)
833
874
  - `sortKey: string = ''` (attribute `sort-key`)
834
875
  - `sortDir: 'asc'|'desc' = 'asc'` (attribute `sort-dir`)
@@ -846,13 +887,14 @@ of `rows` — this component only renders and emits intents.
846
887
  button's label once they've been revealed)
847
888
 
848
889
  **Events:** `lyra-sort` (`detail: { key }`, fired on sortable-header activation), `lyra-row-click`
849
- (`detail: { row }`), `lyra-load-more` (fired on the "load more" button)
890
+ (`detail: { row }`), `lyra-load-more` (fired on the "load more" button), `lyra-columns-hidden-change`
891
+ (`detail: { hidden: boolean }`, fired only on a real `columnsHidden` transition)
850
892
 
851
893
  **Slots:** none — content comes entirely from `columns`/`rows`.
852
894
 
853
895
  **CSS parts:** `base`, `table`, `head`, `header-cell`, `row`, `cell`, `more-button`, `sort-icon` (a
854
896
  chevron indicator shown on the active sortable header, rotated per `sortDir`), `reveal-columns-button`
855
- (shown only when at least one column sets `priority`)
897
+ (shown only when `columnsHidden` is true)
856
898
 
857
899
  **Themeable custom properties:** `--lyra-table-max-height` (default `none`; controls the scrollable
858
900
  body's `max-block-size`).
@@ -1027,13 +1069,35 @@ auto-inserted between each adjacent pair.
1027
1069
  regardless — only the effective drag/keyboard clamp bounds (and the rendered `flex-basis`, via a
1028
1070
  native CSS `clamp()` so a constrained panel stays pinned between its px bounds across container
1029
1071
  resizes with no extra `ResizeObserver`) change for a constrained panel.
1072
+ - `collapse: 'start'|'end'|'none' = 'none'` (reflected) — opt-in responsive collapse for one panel:
1073
+ `'start'`/`'end'` is a *logical* position (RTL-aware, matching CSS logical properties — the panel at
1074
+ the document's visual leading/trailing edge, not a raw array index). `lyra-split` only owns the
1075
+ width-collapse mechanics/state signaling below; it renders no icon-only UI itself — the collapsing
1076
+ panel's own slotted content is expected to adapt itself (e.g. via its own `@container` query reading
1077
+ the panel's clamped width or `data-collapse-state`, see below).
1078
+ - `railWidth: string = '3.5rem'` (attribute `rail-width`) — the fixed CSS length the collapsing panel
1079
+ clamps to in `'rail'` state.
1080
+ - `railBreakpoint: number = 640` (attribute `rail-breakpoint`, px) — below this container width (a
1081
+ `ResizeObserver` on `[part='base']`, active only while `collapse !== 'none'`), the collapsing panel
1082
+ switches from its normal drag-resizable percent width to the fixed `railWidth`.
1083
+ - `floatBreakpoint: number = 400` (attribute `float-breakpoint`, px) — below this narrower container
1084
+ width, the collapsing panel instead becomes an absolutely-positioned overlay ("floating card") on
1085
+ top of the other pane(s), removed from the normal flex flow; the sibling(s) take the full width.
1086
+
1087
+ `collapse`'s three resulting states — `'wide'` (default, today's plain layout) / `'rail'` / `'floating'`
1088
+ — are exposed as: a `data-collapse-state` attribute on both the host and the collapsing panel element
1089
+ itself (absent for `'wide'`/`collapse="none"`); and the `lyra-split-collapse-change` event below. The
1090
+ divider adjacent to the collapsed panel is drag/keyboard-disabled (`aria-disabled="true"`) while
1091
+ collapsed. `collapse="none"` (the default) is byte-for-byte identical to pre-collapse-feature behavior.
1030
1092
 
1031
1093
  **Events:** `lyra-resize` (`detail: { sizes }`, fired on every drag step/release **and** every
1032
- keyboard step)
1094
+ 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)
1033
1096
 
1034
1097
  **Slots:** default (each direct child element is one panel).
1035
1098
 
1036
- **CSS parts:** `base`, `divider`
1099
+ **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)
1037
1101
 
1038
1102
  **Themeable custom properties:** shared tokens only.
1039
1103
 
@@ -1370,6 +1434,12 @@ click, or Enter/Space on the focused cell, fires `lyra-cell-click`.
1370
1434
  position (`{ row, col }` in matrix mode, `{ week, weekday }` in calendar mode) and its value.
1371
1435
  Unset (the default) falls back to the built-in English "Row X, Col Y: value" / "Mon DD: value"
1372
1436
  template — additive, not breaking.
1437
+ - `columnX?: (index: number) => number` (attribute: false, calendar mode only) — overrides the
1438
+ internal week-column x-coordinate formula (`CAL_PAD_LEFT + week * (CAL_CELL + CAL_GAP)`) used
1439
+ consistently across drawing, hit-testing, the focus ring, and month-label positioning, so a
1440
+ consumer can pixel-align this calendar's week columns with a sibling `<lyra-lite-chart>`'s bars
1441
+ (see that component's own `barX`) by supplying the same coordinate function to both. Unset (the
1442
+ default) is the original formula, unchanged.
1373
1443
 
1374
1444
  **Events:** `lyra-cell-click` (fired on click, or Enter/Space on the keyboard-focused cell —
1375
1445
  `detail: { row, col, value }` in matrix mode, `detail: { date, value }` in calendar mode)
@@ -1442,7 +1512,10 @@ color) and `--lyra-space-xs`.
1442
1512
  longer leak a `MediaQueryList` listener per crossing — both previously-known issues are fixed.
1443
1513
  - calendar mode's date labels (used by the default `cellText` template and the tooltip/live-region
1444
1514
  text) now format via the runtime locale (`toLocaleString(undefined, ...)`) instead of a hardcoded
1445
- `'en'` — fixed.
1515
+ `'en'` — fixed. The canvas-drawn axis chrome is now locale-aware too: month labels use
1516
+ `toLocaleString(undefined, ...)` (previously hardcoded `'en'`) and weekday labels are derived via
1517
+ `Intl.DateTimeFormat(undefined, { weekday: 'short' })` (previously a literal English `['', 'Mon',
1518
+ '', 'Wed', '', 'Fri', '']` array) — same sparse every-other-day spacing, just locale-correct text.
1446
1519
 
1447
1520
  ---
1448
1521
 
@@ -1753,6 +1826,23 @@ passthrough). Not a subclass of `LyraChart`.
1753
1826
  - `tickFormat?: (value: number) => string` (attribute: false) — formats a y-axis tick value for
1754
1827
  display (e.g. `(v) => \`$${v.toFixed(2)}\`` for currency, or a duration formatter for `"42s"`).
1755
1828
  Falls back to the built-in "nice numbers" formatter when unset.
1829
+ - `layout: 'fit' | 'scroll' = 'fit'` (reflected) — `'fit'` (default) is the original squeeze-the-
1830
+ whole-plot-to-host-width behavior, unchanged. `'scroll'` gives bars a fixed `barWidth` instead: plot
1831
+ content width becomes `categoryCount * barWidth` (can exceed the host's measured width), and
1832
+ `[part='base']` becomes horizontally `overflow-x: auto` so the user scrolls to see every bar at a
1833
+ legible fixed width instead of them compressing as category count grows. Bar type only.
1834
+ - `barWidth: number = 32` (attribute `bar-width`, px) — each bar's fixed width in `layout="scroll"`
1835
+ mode; ignored in the default `'fit'` mode.
1836
+ - `maxLabels?: number` (attribute `max-labels`, type Number) — decimates which category axis labels
1837
+ actually render *text* when `labels.length > maxLabels` (bars themselves are never decimated, only
1838
+ their axis `<text>` labels): always shows the first and last label, and roughly evenly distributes
1839
+ the rest between them. Works in either `layout` mode. Unset (the default) renders every label,
1840
+ unchanged.
1841
+ - `barX?: (index: number) => number` (attribute: false, bar type only) — overrides the internal
1842
+ per-category x-origin formula (`plotX + i * slot`) used by both bars and their axis labels, so a
1843
+ consumer can pixel-align this chart's bars with a sibling `<lyra-heatmap>` calendar's week columns
1844
+ (see that component's own `columnX`) by supplying the same coordinate function to both. Unset (the
1845
+ default) is the original formula, unchanged.
1756
1846
 
1757
1847
  **Events:** `lyra-point-click` — fired when a bar/point is activated (click, or Enter/Space while
1758
1848
  focused). `detail: { datasetIndex: number, index: number, label: string | undefined, value: number
@@ -1761,10 +1851,11 @@ focused). `detail: { datasetIndex: number, index: number, label: string | undefi
1761
1851
  **Performance:** rendering is `IntersectionObserver`-gated and content-signature-memoized — `render()`
1762
1852
  skips recomputing the grid/marks (and reuses the previous `TemplateResult`) while the host is scrolled
1763
1853
  off-screen, or when none of the content-affecting properties (`type`, `labels`, `datasets`, `legend`,
1764
- `xLabel`, `yLabel`, `beginAtZero`, `stacked`, or the measured plot size) have changed since the last
1765
- render. A `tickFormat` identity change alone (with everything else unchanged) does not force a redraw —
1766
- in practice `tickFormat` is stable across a component's lifetime, and any data change that would make a
1767
- different `tickFormat` output visible already changes `datasets`/`labels` too.
1854
+ `xLabel`, `yLabel`, `beginAtZero`, `stacked`, `layout`, `barWidth`, `maxLabels`, or the measured plot
1855
+ size) have changed since the last render. A `tickFormat`/`barX` identity change alone (with everything
1856
+ else unchanged) does not force a redraw — in practice both are stable across a component's lifetime,
1857
+ and any data change that would make a different output visible already changes `datasets`/`labels`
1858
+ too.
1768
1859
 
1769
1860
  **Slots:** none.
1770
1861
 
@@ -2069,6 +2160,3734 @@ an extension-only `accept` list.
2069
2160
 
2070
2161
  ---
2071
2162
 
2163
+ ## `lyra-dialog` / `confirm()`
2164
+
2165
+ General-purpose modal/overlay plus a promise-based confirmation helper built on top of it.
2166
+
2167
+ ### `lyra-dialog`
2168
+
2169
+ 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.
2173
+
2174
+ **Properties:**
2175
+ - `open: boolean = false` (reflected) — there is no separate `show()`/`hide()` pair; set this (or
2176
+ call `close()`)
2177
+ - `label: string = ''` — accessible name used only when no heading is slotted (see below)
2178
+
2179
+ **Methods:** `close(reason: DialogCloseReason = 'api'): void` — closes the dialog, emits
2180
+ `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'`).
2184
+
2185
+ **Events:** `lyra-dialog-close` (`detail: DialogCloseReason`) — fired on every dismissal path
2186
+ (Escape, backdrop click, or any `close()` call).
2187
+
2188
+ **Slots:** default (the dialog body), `footer` (action buttons, rendered in a bottom row, hidden
2189
+ entirely when empty)
2190
+
2191
+ **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)
2194
+
2195
+ **Themeable custom properties:** `--lyra-dialog-overlay-color` (default `rgb(0 0 0 / 0.5)` — the
2196
+ 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`.
2199
+
2200
+ **Optional peer deps:** none.
2201
+
2202
+ ```html
2203
+ <lyra-dialog id="dlg">
2204
+ <h2>Delete item?</h2>
2205
+ <p>This cannot be undone.</p>
2206
+ <div slot="footer">
2207
+ <button id="cancel">Cancel</button>
2208
+ <button id="confirm">Delete</button>
2209
+ </div>
2210
+ </lyra-dialog>
2211
+ <script type="module">
2212
+ const dlg = document.getElementById('dlg');
2213
+ dlg.open = true;
2214
+ dlg.addEventListener('lyra-dialog-close', (e) => console.log('closed:', e.detail));
2215
+ document.getElementById('cancel').addEventListener('click', () => dlg.close('cancel'));
2216
+ </script>
2217
+ ```
2218
+
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.
2227
+
2228
+ **Known gotchas:**
2229
+ - `role="dialog"`/`aria-modal="true"` are only present on `[part="panel"]` while `open` is `true` —
2230
+ inspecting closed markup won't show them.
2231
+ - Heading detection only rescans on `slotchange`, not on every render — mutating an already-slotted
2232
+ heading's `textContent` in place (rather than replacing the node) won't retroactively update
2233
+ `aria-label`; set `label` instead for a title that needs to change live.
2234
+ - Only *direct* children are scanned for a heading — one nested several layers deep, or inside a
2235
+ 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
2238
+ move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
2239
+ `willUpdate()` alone wouldn't otherwise notice.
2240
+ - Tab-trap focus order follows the default (body) slot, then the `footer` slot — the same order the
2241
+ flattened tree already tabs through — and is resolved shadow-piercingly, so a slotted custom
2242
+ element's real focusable target inside its own shadow root is found even though the host tag
2243
+ itself isn't a native focusable element.
2244
+
2245
+ ### `confirm()`
2246
+
2247
+ A drop-in async replacement for `window.confirm()`, built on `<lyra-dialog>`.
2248
+
2249
+ ```ts
2250
+ import { confirm } from '@aceshooting/lyra-ui';
2251
+
2252
+ const ok = await confirm({
2253
+ title: 'Delete conversation?',
2254
+ description: 'This cannot be undone.',
2255
+ confirmLabel: 'Delete',
2256
+ tone: 'danger',
2257
+ });
2258
+ if (ok) deleteConversation();
2259
+ ```
2260
+
2261
+ `confirm(options: ConfirmOptions): Promise<boolean>` where
2262
+ `ConfirmOptions = { title: string; description?: string; confirmLabel?: string /* = 'Confirm' */; cancelLabel?: string /* = 'Cancel' */; tone?: 'neutral' | 'danger' /* = 'neutral' */ }`.
2263
+
2264
+ Resolves `true` only when the confirm button is pressed — Escape, a backdrop click, and the cancel
2265
+ button all resolve `false`. Mounts a transient `<lyra-dialog>` on `document.body` for the duration
2266
+ of the call and removes it once settled, rather than reusing a persistent page-level region
2267
+ (contrast `lyra-toast`'s `toaster.ts`): a confirmation modal has no stacking/queueing concerns —
2268
+ only one is ever meant to be open at a time — so a mount-and-remove per call keeps its lifetime
2269
+ trivially tied to the returned promise. `title` becomes a slotted `<h2>`, which per `<lyra-dialog>`'s
2270
+ own heading-detection also drives the dialog's accessible name; `description`, if provided, becomes
2271
+ a slotted `<p>`. `tone: 'danger'` fills the confirm button with `--lyra-color-danger` instead of
2272
+ `--lyra-color-brand`, for destructive actions. Confirm/cancel buttons are plain inline-styled
2273
+ `<button>` elements (no shared button component exists in this library yet), but every color value
2274
+ used is still a `--lyra-*` token reference, never a raw literal.
2275
+
2276
+ **Known gotchas:**
2277
+ - Every dismissal path (confirm button, cancel button, Escape, backdrop click) funnels through
2278
+ `<lyra-dialog>`'s own `close()`/`lyra-dialog-close` event, so there is exactly one place that
2279
+ resolves the promise and tears the dialog down — a consumer never needs to (and shouldn't) call
2280
+ `.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.
2284
+ - Importing `confirm` alone is enough to register `<lyra-dialog>` — `confirm.ts` imports
2285
+ `./dialog.js` for its side effect, so a consumer doesn't need a separate import for the dialog
2286
+ element.
2287
+
2288
+ ---
2289
+
2290
+ ## `lyra-tabs`
2291
+
2292
+ A tab strip whose panels are direct light-DOM children, each carrying `slot="<id>"` (the panel's
2293
+ stable id) and `label="<text>"` (the tab button's text). One named `<slot>` is rendered per distinct
2294
+ `slot` name found among the current children — a child with no `label`, or a name with no matching
2295
+ child, simply never produces a tab. Implements the WAI-ARIA APG tabs pattern with automatic
2296
+ activation: Left/Right (swapped under RTL) move focus *and* selection together, Home/End jump to the
2297
+ first/last enabled tab, and a roving `tabindex` follows whichever tab is currently selected.
2298
+
2299
+ **Properties:**
2300
+ - `active: string = ''` (reflected) — the active tab's `slot`/id; falls back to the first enabled
2301
+ tab whenever the current value doesn't resolve to one (including on every children/attribute
2302
+ change, tracked via a `MutationObserver`)
2303
+
2304
+ **Events:** `lyra-tabs-change` (`detail: { tabId: string }`) — fired when the active tab changes via
2305
+ click or keyboard. Not fired when `active` self-corrects to a valid tab (initial default, or a tab
2306
+ disappearing/becoming disabled underneath the current selection).
2307
+
2308
+ **Slots:** default — direct children with `slot="<id>" label="<text>"` (and optionally `disabled`);
2309
+ one becomes each tab's panel.
2310
+
2311
+ **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)
2314
+
2315
+ **Themeable custom properties:** shared tokens only — `--lyra-space-s/-m`,
2316
+ `--lyra-color-border/-text-quiet/-text/-brand`, `--lyra-transition-fast`, `--lyra-radius`,
2317
+ `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
2318
+
2319
+ **Optional peer deps:** none.
2320
+
2321
+ ```html
2322
+ <lyra-tabs active="general">
2323
+ <div slot="general" label="General">General settings…</div>
2324
+ <div slot="advanced" label="Advanced" disabled>Advanced settings…</div>
2325
+ </lyra-tabs>
2326
+ <script type="module">
2327
+ document.querySelector('lyra-tabs').addEventListener('lyra-tabs-change', (e) => console.log(e.detail.tabId));
2328
+ </script>
2329
+ ```
2330
+
2331
+ **Known gotchas:**
2332
+ - Tabs are rebuilt from direct children via a `MutationObserver` watching `childList` plus
2333
+ `attributeFilter: ['slot', 'label', 'disabled']` — not `slotchange` — because a brand-new tab's
2334
+ `slot` name has no matching `<slot>` to fire `slotchange` on until this component has already
2335
+ rendered one for it, and neither `slotchange` nor any Lit lifecycle hook observes a plain
2336
+ attribute edit on a light-DOM child at all.
2337
+ - If two children share the same `slot` name, the *first* one wins for the tab button's label
2338
+ (matches native slot assignment: both would render into the one panel, but only one label can back
2339
+ the button).
2340
+ - Left/Right are swapped under RTL (read via `internal/rtl.ts`'s `isRtl()`); Up/Down are not used —
2341
+ this is a horizontal strip only.
2342
+
2343
+ ---
2344
+
2345
+ ## `lyra-checkbox`
2346
+
2347
+ A boolean form control. `role="checkbox"` with an `aria-checked` that can also be `"mixed"`, and a
2348
+ visual box/checkmark. Structurally the same idea as `<lyra-switch>` (form-associated via
2349
+ `ElementInternals`, click and Space/Enter both toggle) but with checkbox semantics.
2350
+
2351
+ **Properties:**
2352
+ - `checked: boolean = false` (reflected)
2353
+ - `indeterminate: boolean = false` (reflected) — visual-only mixed state; does not affect `checked`,
2354
+ and is cleared back to `false` by any user interaction (click or keyboard), matching native
2355
+ `<input type="checkbox">`
2356
+ - `disabled: boolean = false` (reflected)
2357
+ - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
2358
+ - `name: string = ''`
2359
+ - `value: string = 'on'` — only contributed to form submission while `checked` (a native checkbox
2360
+ submits nothing at all, not even an empty string, while unchecked)
2361
+
2362
+ **Events:** `lyra-change` (`detail: { checked: boolean }`) — fired on a user toggle (click or
2363
+ Space/Enter); not fired for a programmatic `.checked` assignment.
2364
+
2365
+ **Slots:** default — label text, rendered next to the box. Clicking it toggles the checkbox, the
2366
+ same as clicking a native checkbox's associated `<label>`. If left empty, set `aria-label` on the
2367
+ host so the control still has an accessible name.
2368
+
2369
+ **CSS parts:** `base` (the whole interactive control, `role="checkbox"`), `box` (the small square
2370
+ showing the checkmark/indeterminate dash), `checkmark` (the checkmark or indeterminate-dash glyph),
2371
+ `label` (wrapper around the default slot)
2372
+
2373
+ **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`.
2376
+
2377
+ **Optional peer deps:** none.
2378
+
2379
+ ```html
2380
+ <lyra-checkbox name="terms" required>Accept the terms and conditions</lyra-checkbox>
2381
+ <script type="module">
2382
+ document
2383
+ .querySelector('lyra-checkbox')
2384
+ .addEventListener('lyra-change', (e) => console.log(e.detail.checked));
2385
+ </script>
2386
+ ```
2387
+
2388
+ Form-associated via a directly-attached `ElementInternals` (not the shared `FormAssociated` mixin,
2389
+ whose `value` accessor assumes a plain string default flow) with its own hand-rolled
2390
+ `updateValidity()` — same shape as `<lyra-combobox>`'s and `<lyra-switch>`'s direct-`ElementInternals`
2391
+ handling.
2392
+
2393
+ **Known gotchas:**
2394
+ - `formResetCallback()` restores `checked` to whatever the declarative `checked` attribute parsed to
2395
+ at first connect — captured once via a one-shot flag (not from `attributeChangedCallback` alone,
2396
+ since `checked` reflects and that would wrongly re-capture on every later user toggle). A later
2397
+ `el.checked = true` assignment never redefines the reset default.
2398
+ - `indeterminate` is visual-only and silently clears on any user click/keypress — a consumer relying
2399
+ on it staying `true` after a user interacts with the box will be surprised.
2400
+ - The rendered `aria-label` is copied from the host's own `aria-label` attribute at render time; if
2401
+ neither that nor slotted label text is present, the control has no accessible name.
2402
+
2403
+ ---
2404
+
2405
+ ## `lyra-switch`
2406
+
2407
+ A boolean toggle-switch form control. `role="switch"` with `aria-checked` read as an on/off state
2408
+ rather than checked/unchecked, and no indeterminate state. Structurally the same idea as
2409
+ `<lyra-checkbox>` (form-associated via `ElementInternals`, click and Space/Enter both toggle).
2410
+
2411
+ **Properties:**
2412
+ - `checked: boolean = false` (reflected)
2413
+ - `disabled: boolean = false` (reflected)
2414
+ - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
2415
+ - `name: string = ''`
2416
+ - `value: string = 'on'` — only contributed to form submission while `checked`
2417
+
2418
+ **Events:** `lyra-change` (`detail: { checked: boolean }`) — fired on a user toggle (click or
2419
+ Space/Enter); not fired for a programmatic `.checked` assignment.
2420
+
2421
+ **Slots:** default — label text, rendered next to the track. Clicking it toggles the switch, the
2422
+ same as clicking a checkbox's associated `<label>`. If left empty, set `aria-label` on the host so
2423
+ the control still has an accessible name.
2424
+
2425
+ **CSS parts:** `base` (the whole interactive control, `role="switch"`), `track` (the pill-shaped
2426
+ background), `thumb` (the circular knob that slides across the track), `label` (wrapper around the
2427
+ default slot)
2428
+
2429
+ **Themeable custom properties:** `--track-inline-size` (default `2.25rem`), `--track-block-size`
2430
+ (default `1.25rem`), `--thumb-offset` (default `2px`) — component-local geometry knobs, set on
2431
+ `:host`, since a fully-rounded pill/thumb needs a radius well past the shared `--lyra-radius`
2432
+ default — plus shared tokens `--lyra-space-s`, `--lyra-color-border/-brand/-surface/-text`,
2433
+ `--lyra-transition-fast`, `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
2434
+
2435
+ **Optional peer deps:** none.
2436
+
2437
+ ```html
2438
+ <lyra-switch name="notifications" checked>Enable notifications</lyra-switch>
2439
+ <script type="module">
2440
+ document
2441
+ .querySelector('lyra-switch')
2442
+ .addEventListener('lyra-change', (e) => console.log(e.detail.checked));
2443
+ </script>
2444
+ ```
2445
+
2446
+ Form-associated the same way as `<lyra-checkbox>`: a directly-attached `ElementInternals` with a
2447
+ hand-rolled `updateValidity()`, not the shared `FormAssociated` mixin. The thumb animates the
2448
+ logical `inset-inline-start` property (not a physical `transform: translateX()`), so the slide
2449
+ direction mirrors correctly under `dir="rtl"`.
2450
+
2451
+ **Known gotchas:**
2452
+ - `formResetCallback()` restores `checked` to the value captured from the declarative `checked`
2453
+ attribute at first connect (same one-shot-flag capture as `<lyra-checkbox>`) — a later `.checked =
2454
+ true` property assignment never redefines what `form.reset()` restores to.
2455
+ - The rendered `aria-label` is copied from the host's own `aria-label` attribute at render time; with
2456
+ neither that nor slotted label text, the control has no accessible name.
2457
+
2458
+ ---
2459
+
2460
+ ## `lyra-json-viewer`
2461
+
2462
+ A collapsible, copyable tree view for an arbitrary JSON-serializable value (object, array, string,
2463
+ number, boolean, null, or `undefined`). Serves as a fallback renderer wherever a raw payload needs
2464
+ inspecting without a bespoke view. Expand/collapse state is keyed by structural path (not object
2465
+ identity), so it survives a `data` reassignment that keeps the same shape — e.g. a streaming result
2466
+ being patched in place.
2467
+
2468
+ **Properties:**
2469
+ - `data: unknown` (attribute `false` — property-only, not settable via an HTML attribute)
2470
+ - `collapsedDepth?: number` (attribute `collapsed-depth`) — nodes at or beyond this nesting depth
2471
+ (root = `0`) start collapsed; omitted/`undefined` means nothing auto-collapses
2472
+ - `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"20rem"`); once set, the
2473
+ viewer scrolls internally past this height instead of growing the page
2474
+ - `copyable: boolean = false` (reflected) — shows copy-to-clipboard affordances: one for the whole
2475
+ value, plus one per node
2476
+ - `search: string = ''` — case-insensitive substring match against keys/values; matches are
2477
+ highlighted and their ancestors auto-expanded
2478
+
2479
+ **Events:** `lyra-copy` (`detail: { text: string }`) — fired by the top-level copy button or a
2480
+ per-node one. Fires even when `navigator.clipboard` silently failed or is unavailable (insecure
2481
+ context, older browser, a stubbed-out test environment), so a consumer can still observe copy
2482
+ *intent*.
2483
+
2484
+ **Slots:** none — the tree is rendered entirely from `data`.
2485
+
2486
+ **CSS parts:** `base` (root scroll container, respects `max-height`), `toolbar` (wrapper around the
2487
+ 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`)
2494
+
2495
+ **Themeable custom properties:** `--lyra-json-viewer-max-height` (default `none` — grows with content
2496
+ until `max-height` is set), `--lyra-json-viewer-font` (default `ui-monospace, SFMono-Regular, Menlo,
2497
+ Consolas, monospace` — component-specific since no shared monospace token exists), plus shared tokens
2498
+ `--lyra-color-border/-surface/-text/-text-quiet/-brand/-brand-quiet/-success/-warning/-warning-quiet`,
2499
+ `--lyra-radius`, `--lyra-space-xs/-s/-l`, `--lyra-focus-ring-width/-color/-offset`,
2500
+ `--lyra-transition-fast`.
2501
+
2502
+ **Optional peer deps:** none.
2503
+
2504
+ ```ts
2505
+ import { html } from 'lit';
2506
+ import '@aceshooting/lyra-ui/components/json-viewer/json-viewer.js';
2507
+
2508
+ html`<lyra-json-viewer .data=${apiResponse} copyable max-height="24rem" search=${query}></lyra-json-viewer>`;
2509
+ ```
2510
+
2511
+ ```html
2512
+ <lyra-json-viewer copyable max-height="24rem"></lyra-json-viewer>
2513
+ <script type="module">
2514
+ document.querySelector('lyra-json-viewer').data = { hello: 'world', items: [1, 2, 3] };
2515
+ </script>
2516
+ ```
2517
+
2518
+ **Known gotchas:**
2519
+ - `data` is property-only (`attribute: false`) — it must be set via `.data = ...` or a lit-html `.data=${...}`
2520
+ binding, never as a plain HTML attribute.
2521
+ - Search highlighting auto-expands only the *ancestors* of a match, not the whole tree — a
2522
+ non-matching sibling subtree elsewhere stays collapsed (or expanded) exactly as it already was.
2523
+ - An explicit per-node expand/collapse (from clicking a node's `toggle` button) permanently overrides
2524
+ both `collapsedDepth` and any search-driven auto-expand for that path, until `data` is reassigned
2525
+ with a different shape.
2526
+ - Per-node copy buttons call `stopPropagation()` on click so clicking one doesn't also toggle the
2527
+ row's expand/collapse state.
2528
+
2529
+ ---
2530
+
2531
+ ## `lyra-live-region` (+ internal `Announcer`)
2532
+
2533
+ A throttled screen-reader announcement helper, split into a DOM-free coalescing engine
2534
+ (`internal/announcer.ts`'s `Announcer` class) and a real custom element that wraps it.
2535
+
2536
+ ### Internal: `Announcer` (`internal/announcer.ts`)
2537
+
2538
+ Not a custom element — pure timing/coalescing logic with no DOM dependency, composed by
2539
+ `<lyra-live-region>` (below) and intended for reuse by any other component that needs throttled
2540
+ announcements (a stream-status indicator, a tool-call chip's status transitions, a chat message's
2541
+ streaming state).
2542
+
2543
+ Streaming UIs (token-by-token chat responses, progress ticks, etc.) naturally produce far more
2544
+ candidate announcements than a screen-reader user can usefully absorb — reading every incremental
2545
+ chunk aloud is spam, not information. `Announcer` collapses a burst of `announce()` calls arriving
2546
+ within `throttleMs` of the *first* call in that burst down to a single trailing-edge flush of the
2547
+ latest text: superseded intermediate text is dropped outright, never queued or concatenated.
2548
+
2549
+ - `new Announcer(options: AnnouncerOptions)` where
2550
+ `AnnouncerOptions = { throttleMs?: number /* = 500 */; onFlush: (text: string) => void }`.
2551
+ - `announce(text: string, options?: AnnounceOptions)` where `AnnounceOptions = { force?: boolean }` —
2552
+ queues `text`, overwriting whatever an earlier call in the same burst queued. Only the *first*
2553
+ call of a burst schedules the flush timer, so the deadline stays anchored to that first call
2554
+ rather than being pushed back by every subsequent call. `{ force: true }` bypasses any
2555
+ in-progress window and flushes immediately, so a terminal message (e.g. "response complete") is
2556
+ never swallowed mid-burst.
2557
+ - `cancel()` — drops any pending (not yet flushed) text without invoking `onFlush`.
2558
+ - `pendingText: string | undefined` — the latest text awaiting flush, if a burst is in progress.
2559
+ - `isPending: boolean` — whether a flush is currently scheduled.
2560
+ - `throttleMs` — a plain public field, safe to change between bursts; a flush already scheduled
2561
+ keeps the deadline it was scheduled with.
2562
+
2563
+ ### `lyra-live-region`
2564
+
2565
+ A visually-hidden ARIA live region that throttles and coalesces announcements instead of relaying
2566
+ every call verbatim, by composing an internal `Announcer`. A consumer typically mounts one
2567
+ `<lyra-live-region>` per page/surface (much like `<lyra-toast>` is one region per placement) and
2568
+ keeps a reference to call `announce()` from application code or a parent component.
2569
+
2570
+ **Properties:**
2571
+ - `mode: 'polite' | 'assertive' = 'polite'` (reflected) — `'polite'` renders `role="status"` +
2572
+ `aria-live="polite"` (waits for the user to be idle); `'assertive'` renders `role="alert"` +
2573
+ `aria-live="assertive"` (interrupts)
2574
+ - `throttleMs: number = 500` (attribute `throttle-ms`) — the coalescing window; see `Announcer`
2575
+ above
2576
+
2577
+ **Methods:** `announce(text: string, options?: AnnounceOptions): void` — queues `text` for
2578
+ announcement through the internal `Announcer`; `{ force: true }` bypasses the current throttle
2579
+ window and flushes immediately.
2580
+
2581
+ **Events:** none.
2582
+
2583
+ **Slots:** none.
2584
+
2585
+ **CSS parts:** `region` — the visually-hidden element carrying `role`/`aria-live`.
2586
+
2587
+ **Themeable custom properties:** none component-specific — the region is hidden via the shared
2588
+ `.sr-only` helper class (`internal/a11y.ts`), not tokenized CSS.
2589
+
2590
+ **Optional peer deps:** none.
2591
+
2592
+ ```html
2593
+ <!-- once, near the root of a page/surface -->
2594
+ <lyra-live-region id="live" mode="polite"></lyra-live-region>
2595
+ <script type="module">
2596
+ const live = document.getElementById('live');
2597
+ // streaming tokens: fine to call on every chunk, only the trailing state lands
2598
+ live.announce(`${partialText} …`);
2599
+ // stream finished: always announced, even mid-throttle-window
2600
+ live.announce('Response complete', { force: true });
2601
+ </script>
2602
+ ```
2603
+
2604
+ A parent Lit component would instead hold the reference via `@query('lyra-live-region')`.
2605
+
2606
+ **Known gotchas:**
2607
+ - Re-announcing text identical to what was last written is special-cased: screen readers announce a
2608
+ live region only on text-content *change*, so the component clears `textContent` first and
2609
+ 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.
2611
+ - The region's DOM is tracked outside Lit's own template bindings (`write()` mutates
2612
+ `regionEl.textContent` directly) — an `announce()`/flush landing before `firstUpdated()` has run
2613
+ (e.g. a consumer creates, appends, and calls `announce()` synchronously) is buffered and applied
2614
+ 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.
2618
+ - Changing `throttle-ms` updates the live `Announcer`'s window immediately, but a flush already
2619
+ scheduled under the old window keeps the deadline it was scheduled with.
2620
+
2621
+ ---
2622
+
2623
+ ## `lyra-markdown`
2624
+
2625
+ Sanitized Markdown-to-HTML rendering (GFM tables, fenced code blocks, links, blockquotes) built on
2626
+ two optional peer dependencies — `marked` (parsing) and `dompurify` (sanitizing) — both lazy-loaded
2627
+ independently via `markdown-loader.ts`'s `loadMarkdownDeps()` on first connect, cached per page the
2628
+ 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).
2632
+
2633
+ **Properties:**
2634
+ - `content: string = ''` — the Markdown source to render
2635
+ - `sanitize: boolean = true` — sanitize `marked`'s HTML output with DOMPurify before rendering
2636
+ - `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
2639
+ - `internalLinkPrefix: string = ''` (attribute `internal-link-prefix`) — when set, a rendered link
2640
+ whose `href` *attribute* (not the browser-resolved `.href` property) starts with this prefix is
2641
+ intercepted on click and reported via `lyra-link-click` instead of navigating; empty (the default)
2642
+ means every link is treated as external
2643
+ - `streaming: boolean = false` (reflected) — forward-compatible hint for a future streaming renderer
2644
+ expected to build on this component (coalescing partial tokens as they arrive); setting it has no
2645
+ rendering effect yet, it only lets a consumer already target `lyra-markdown[streaming]` in CSS
2646
+
2647
+ **Events:** `lyra-link-click` (`detail: { href: string, internal: true }`, fired — with the click
2648
+ prevented — when a rendered link's `href` starts with `internal-link-prefix`; ordinary external
2649
+ links navigate normally and never fire this), `lyra-render-error` (`detail: { error: unknown }`,
2650
+ fired whenever rendering falls back to plain text — see the fallback matrix below)
2651
+
2652
+ **Slots:** none — content comes from the `content` property, not light-DOM children.
2653
+
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>`)
2657
+
2658
+ **Themeable custom properties:** `--lyra-markdown-font-mono` (default `ui-monospace, SFMono-Regular,
2659
+ Menlo, Consolas, monospace` — the code/code-block font; component-specific since no shared
2660
+ `--wa-*`/`--lyra-*` monospace token exists), plus shared tokens `--lyra-space-xs/-s/-m/-l`,
2661
+ `--lyra-color-brand-quiet`, `--lyra-color-brand`, `--lyra-color-border`, `--lyra-color-text-quiet`,
2662
+ `--lyra-radius`.
2663
+
2664
+ **Optional peer deps:** `marked`, `dompurify` (both lazy-loaded via `markdown-loader.ts`'s
2665
+ `loadMarkdownDeps()`, mirroring `chart-loader.ts`'s two-independent-optional-peers shape). Each half
2666
+ is loaded and caught independently — a consumer who installs only `marked` and explicitly sets
2667
+ `sanitize="false"` (so `dompurify` is never needed) is a valid, supported combination.
2668
+
2669
+ ```html
2670
+ <lyra-markdown
2671
+ content="# Report&#10;&#10;See the [setup guide](/docs/setup) for details."
2672
+ internal-link-prefix="/docs/"
2673
+ ></lyra-markdown>
2674
+ <script>
2675
+ document.querySelector('lyra-markdown').addEventListener('lyra-link-click', (e) => {
2676
+ router.navigate(e.detail.href);
2677
+ });
2678
+ </script>
2679
+ ```
2680
+
2681
+ Rendering never ships unsanitized or broken markup silently. If `marked` fails to load, or throws
2682
+ while parsing malformed input, the component falls back to plain text (`white-space: pre-wrap`, no
2683
+ HTML parsing at all — the raw `content` string itself) and fires `lyra-render-error`. If `sanitize`
2684
+ is `true` (the default) and `dompurify` fails to load, the component *also* falls back to plain text
2685
+ + `lyra-render-error` — it never renders `marked`'s raw HTML output when sanitization was requested
2686
+ (or defaulted to) but is unavailable, even though `marked` itself loaded fine. If `sanitize` is
2687
+ explicitly `false`, `marked`'s raw output renders as-is regardless of whether `dompurify` is
2688
+ installed. While the optional peers are still resolving, the host carries `aria-busy="true"` (set/
2689
+ cleared in `updated()` based on whether the deps have loaded) and shows the same plain-text fallback
2690
+ rendering — there's no separate loading skeleton, since the un-rendered Markdown source is already
2691
+ legible text in the meantime.
2692
+
2693
+ **Known gotchas:**
2694
+ - a malformed percent-escape or lone UTF-16 surrogate in a link's raw `href` makes the internal
2695
+ `encodeURI`-based validity guard throw, silently dropping just that anchor (the link text still
2696
+ renders, with no `href`) — mirrors `marked`'s own default `link()` renderer's defensive behavior.
2697
+ - `target` is not in DOMPurify's default attribute allowlist (unlike `part`/`rel`/`class`, which
2698
+ already are), so sanitization is called with `ADD_ATTR: ['target']` — without that, every rendered
2699
+ link's `target` would be silently stripped by sanitization even though the anchor itself survives.
2700
+ - a fresh `marked.Marked()` instance (with a fresh renderer) is built on every single parse rather
2701
+ than cached, specifically so the renderer's `link()` override always closes over the *current*
2702
+ `linkTarget` — `marked`'s `.use()` otherwise persists whatever renderer it was given for the
2703
+ instance's lifetime, which would go stale if `linkTarget` changed after a cached instance's first use.
2704
+ - `internal-link-prefix` matching compares against the raw `href` *attribute*, not the resolved
2705
+ `.href` IDL property (always an absolute URL in the browser) — a prefix like `/docs/` matches a
2706
+ relative markdown link but would never match against the resolved property.
2707
+ - rendered output goes through `unsafeHTML`; with `sanitize="false"` the component renders whatever
2708
+ HTML `marked` produces from `content` completely unsanitized, so untrusted `content` must never be
2709
+ paired with `sanitize="false"`.
2710
+
2711
+ ---
2712
+
2713
+ ## `lyra-chat-message`
2714
+
2715
+ A role-based message bubble *shell* for a chat/agent conversation surface. It renders none of the
2716
+ message content itself — the default slot carries whatever a consumer wants to display (plain text,
2717
+ a `<lyra-markdown>`, a custom template, anything) and this component only supplies the surrounding
2718
+ chrome: alignment/coloring by `role`, an avatar/badges header row, an optional collapse toggle, an
2719
+ attachments strip, and a status-aware footer (a live-updating status dot + text, the formatted
2720
+ `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).
2724
+
2725
+ **Properties:**
2726
+ - `role: ChatMessageRole = 'assistant'` (`'user' | 'assistant' | 'system'`) — reflects to
2727
+ `data-role`, **not** the bare `role` attribute (those role strings aren't valid ARIA role tokens
2728
+ and reflecting to `role` would collide with the element's own ARIA role); a plain `role="..."`
2729
+ attribute set directly in markup is ignored entirely
2730
+ - `status: ChatMessageStatus = 'sent'` (`'sending' | 'sent' | 'failed' | 'streaming'`, reflected) —
2731
+ drives the footer's status dot/text, `status="failed"`'s danger treatment on the bubble, and the
2732
+ built-in retry button
2733
+ - `timestamp?: Date | string` (attribute: false) — accepts a `Date` or anything `new Date()` can
2734
+ parse; invalid input is treated the same as unset (no timestamp rendered)
2735
+ - `formatTimestamp?: (date: Date) => string` (attribute: false) — overrides the default
2736
+ `hour:minute` (`Intl.DateTimeFormat`, runtime locale) rendering of `timestamp`
2737
+ - `collapsible: boolean = false` (reflected) — shows the built-in collapse/expand toggle in the header
2738
+ - `collapsed: boolean = false` (reflected) — whether the message body is hidden; effective whenever
2739
+ set, independent of `collapsible` (which only controls whether the toggle button itself is
2740
+ rendered) — mirrors `lyra-widget`'s identical `collapsible`/`collapsed` pair
2741
+
2742
+ **Events:** `lyra-retry` (no detail payload; fired by the built-in retry button, only rendered when
2743
+ `status="failed"`), `lyra-collapse-toggle` (`detail: boolean`, the new `collapsed` state — fired when
2744
+ the user activates the built-in collapse button)
2745
+
2746
+ **Slots:** default (the message body), `avatar` (an avatar/icon for the message author), `badges`
2747
+ (small status/metric chips — e.g. token count, latency, model name — entirely app-supplied), `actions`
2748
+ (action controls such as copy/retry, rendered at the end of the footer), `attachments` (file/image
2749
+ attachment chips, rendered below the message body)
2750
+
2751
+ **CSS parts:** `bubble`, `header` (hidden entirely when nothing is in it), `avatar`, `badges`,
2752
+ `collapse-button` (only rendered when `collapsible`), `body` (hidden while `collapsed`),
2753
+ `attachments`, `footer` (hidden entirely when nothing is in it), `status-indicator` (a small
2754
+ decorative `aria-hidden` dot, absent while `status="sent"`), `status-text` (the visible text twin of
2755
+ `status-indicator`), `timestamp`, `retry-button` (only rendered when `status="failed"`), `actions`
2756
+
2757
+ **Themeable custom properties:** `--lyra-chat-message-max-width` (default `80%` — the bubble's max
2758
+ inline size; component-specific, no shared width token exists), plus shared tokens
2759
+ `--lyra-space-xs/-m`, `--lyra-color-border`, `--lyra-color-surface`, `--lyra-color-brand-quiet`,
2760
+ `--lyra-color-brand`, `--lyra-color-text-quiet`, `--lyra-color-danger`, `--lyra-color-danger-quiet`,
2761
+ `--lyra-radius`, `--lyra-icon-button-size`, `--lyra-focus-ring-*`, `--lyra-transition-fast`.
2762
+
2763
+ **Optional peer deps:** none. Internally renders a `<lyra-live-region>` (a first-party sibling
2764
+ component, auto-imported alongside this one, not an npm peer) for the status-transition
2765
+ announcements described below.
2766
+
2767
+ ```html
2768
+ <lyra-chat-message data-role="assistant" status="streaming">
2769
+ <span slot="avatar">🤖</span>
2770
+ <span slot="badges">gpt-5.4 · 1.2s</span>
2771
+ <lyra-markdown content="Here's what I found…"></lyra-markdown>
2772
+ <button slot="actions">Copy</button>
2773
+ </lyra-chat-message>
2774
+ <script>
2775
+ document.querySelector('lyra-chat-message').addEventListener('lyra-retry', () => resend());
2776
+ </script>
2777
+ ```
2778
+
2779
+ Accessibility of `status`: the current status is always available as plain visible text
2780
+ (`[part="status-text"]`), never color alone. A transition *to* `"failed"`, or *from* `"streaming"` to
2781
+ `"sent"` (a stream finishing), is additionally announced through the internal `<lyra-live-region>` —
2782
+ `"failed"` announces assertively (`"Message failed to send."`), a streaming→sent completion announces
2783
+ politely (`"Message complete."`) — so a screen-reader user not currently focused on this message
2784
+ still learns about it. No other status transition is announced (e.g. `streaming`→`sending`, or
2785
+ `sending`→`sent` without having passed through `streaming`, produce no announcement). This differs
2786
+ from `<lyra-typing-indicator>`'s deliberately simpler `role="status"` approach, appropriate there
2787
+ since that component only ever announces once (its own mount); this component's `status` can flip
2788
+ between several values across a single element's lifetime.
2789
+
2790
+ **Known gotchas:**
2791
+ - mounting a message with `status="failed"` (or any other non-`"sent"` status) already set does
2792
+ **not** announce anything — only a genuine *later* transition (`changed.get('status') !==
2793
+ undefined`, i.e. not the very first update) triggers the live-region announcement.
2794
+ - `lyra-retry` fires with no detail payload at all (`undefined`), not e.g. `{ status: 'failed' }`.
2795
+ - the header/footer/avatar/badges/attachments/actions wrappers are shown/hidden via the `hidden`
2796
+ attribute, not conditional templating. Whether each slot currently has content is checked once via
2797
+ a light-DOM children scan on the very first update (`willUpdate`, gated on `!this.hasUpdated`) and
2798
+ thereafter only via each slot's own `slotchange` listener — content added directly with
2799
+ `appendChild` after first paint still triggers native `slotchange`, so this works transparently,
2800
+ but any code that manually re-parents already-slotted nodes without a real slot-assignment change
2801
+ won't refresh the corresponding wrapper's visibility.
2802
+ - `role` intentionally reflects to `data-role`; CSS or selectors that key off role must target
2803
+ `[data-role="user"]` etc., not `[role="user"]`.
2804
+
2805
+ ---
2806
+
2807
+ ## `lyra-typing-indicator`
2808
+
2809
+ A purely presentational "assistant is responding" presence cue — no events, no interactivity. A
2810
+ consumer mounts it while a response is being generated and removes (or hides) it once real content
2811
+ arrives. Three visual variants share one component rather than three separate tags, since callers
2812
+ pick between them along a single axis (how the surrounding surface wants the cue to read) and
2813
+ nothing else about the component differs: `dots` (default, three dots with a staggered bounce — the
2814
+ classic "typing…" affordance for a standalone status line), `pulse` (a single breathing dot, a
2815
+ quieter cue for a tight space, e.g. next to an avatar), `cursor` (a blinking vertical bar, meant to
2816
+ sit inline at the tail end of streamed text still being appended to).
2817
+
2818
+ **Properties:**
2819
+ - `variant: TypingIndicatorVariant = 'dots'` (`'dots' | 'pulse' | 'cursor'`, reflected)
2820
+ - `label: string = 'Thinking…'` — the accessible name, exposed via `role="status"`; not re-announced
2821
+ on every animation frame, only on mount and on any later change to this property
2822
+ - `size: TypingIndicatorSize = 'md'` (`'sm' | 'md'`, reflected) — compact sizing for dense layouts
2823
+
2824
+ **Events:** none — purely presentational.
2825
+
2826
+ **Slots:** none.
2827
+
2828
+ **CSS parts:** `base` (the decorative, `aria-hidden`, wrapper around the animated shape), `dot`
2829
+ (each of the three dots in the `dots` variant), `pulse` (the single pulsing dot in the `pulse`
2830
+ variant), `cursor` (the blinking bar in the `cursor` variant)
2831
+
2832
+ **Themeable custom properties:** `--lyra-typing-dot-size` (default `0.5rem`, `0.375rem` at
2833
+ `size="sm"`), `--lyra-typing-gap` (default `0.25rem`, `0.1875rem` at `size="sm"`),
2834
+ `--lyra-typing-cursor-width` (default `0.125rem`, `0.09375rem` at `size="sm"`),
2835
+ `--lyra-typing-cursor-height` (default `1em`, unaffected by `size`) — all component-specific; plus
2836
+ the shared `--lyra-transition-base` token, used as each variant's full `animation:` shorthand
2837
+ (duration + timing-function together) since it drives a continuous ambient loop rather than a
2838
+ discrete state flip (which would instead use `--lyra-transition-fast`).
2839
+
2840
+ **Optional peer deps:** none.
2841
+
2842
+ ```html
2843
+ <lyra-typing-indicator label="Assistant is responding…"></lyra-typing-indicator>
2844
+ <lyra-typing-indicator variant="pulse" size="sm"></lyra-typing-indicator>
2845
+ <lyra-typing-indicator variant="cursor"></lyra-typing-indicator>
2846
+ ```
2847
+
2848
+ Accessibility: since this indicator typically mounts and unmounts around a real generation lifecycle
2849
+ (appears when a response starts, disappears once one arrives) rather than emitting a stream of
2850
+ updates of its own, it does **not** route through `<lyra-live-region>`/the internal `Announcer` —
2851
+ that machinery exists to coalesce many rapidly-changing announcements into one, and there is only
2852
+ ever a single announcement here: the mount itself. `role="status"` plus an accessible name derived
2853
+ from `label` is set both as `aria-label` on the host *and* as a visually-hidden text node
2854
+ (`.sr-only`) in the shadow tree, so the name survives even if only one of the two is picked up by a
2855
+ given assistive-tech/browser pairing. The animated shape itself is `aria-hidden="true"` — it's
2856
+ decorative; `label` is the entire accessible content, nothing narrates individual animation frames.
2857
+
2858
+ **Known gotchas:**
2859
+ - under `prefers-reduced-motion: reduce`, every variant collapses to its plain, fully-visible resting
2860
+ state (`opacity: 1`, no transform, `animation: none !important`) rather than freezing on whatever
2861
+ frame the animation happened to be on — notably relevant for `cursor`, which would otherwise risk
2862
+ freezing on its invisible ("off") blink half.
2863
+ - the two dot-bounce stagger delays (`60ms`, `120ms`) are fixed literals, not `calc()`-derived
2864
+ fractions of `--lyra-transition-base` — that token is a *compound* `duration timing-function` value
2865
+ (like every `transition:`/`animation:` shorthand token in this library), so it can't be decomposed
2866
+ arithmetically; the two delays just approximate a third and two-thirds of the token's own default
2867
+ duration.
2868
+ - `size="sm"` shrinks the dot size, gap, and cursor width, but **not** `--lyra-typing-cursor-height`
2869
+ (still `1em` at any size) — the cursor bar's height is meant to track surrounding text size via
2870
+ `1em`, not the component's own `size` property.
2871
+
2872
+ ---
2873
+
2874
+ ## `lyra-tool-call-chip`
2875
+
2876
+ A compact inline pill representing one tool/function call an agent made mid-conversation, e.g.
2877
+ `web_search: Searching web…` with a `running` spinner. First-party invention (no Web Awesome
2878
+ equivalent). It owns no detail surface of its own — activating it (click or Enter/Space while
2879
+ focused) only fires `lyra-tool-chip-select`; a consumer wires that to opening a
2880
+ `<lyra-tool-result-dialog>` (or anything else) at the call site, keeping the chip reusable wherever
2881
+ a compact call summary is useful, with or without a detail surface behind it.
2882
+
2883
+ **Properties:**
2884
+ - `name: string = ''` — the tool/function name, e.g. `web_search`
2885
+ - `category: string = ''` — optional grouping label, e.g. `research`
2886
+ - `status: 'pending'|'running'|'success'|'error'|'denied' = 'pending'` (reflected) — drives the
2887
+ 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
2889
+ - `summary: string = ''` — short human-readable status text, e.g. `Searching web…`
2890
+ - `durationMs?: number` (attribute `duration-ms`) — how long the call took, in milliseconds; the
2891
+ `duration` part is omitted entirely when unset
2892
+ - `icon: string = ''` — literal icon hint (e.g. an emoji) rendered when the `icon` slot is empty;
2893
+ ignored once anything is assigned to `slot="icon"`
2894
+ - `callId: string = ''` (attribute `call-id`) — unique identifier for this invocation, echoed back
2895
+ in `lyra-tool-chip-select`'s detail so a listener can correlate the click with the call it fired for
2896
+
2897
+ **Events:** `lyra-tool-chip-select` (`detail: { name: string; callId: string }`) — fired on click or
2898
+ Enter/Space activation of the pill.
2899
+
2900
+ **Slots:** default (rich tooltip/detail content — e.g. the tool's raw arguments or a short preview —
2901
+ shown in a floating tooltip on hover/focus; nothing renders at all, no hover affordance, when this
2902
+ slot is empty), `icon` (overrides the built-in per-status glyph entirely via native slot-fallback
2903
+ content — assigned content wins; otherwise the `icon` prop is rendered as a literal hint; otherwise
2904
+ the built-in glyph for the current `status` is used)
2905
+
2906
+ **CSS parts:** `base` (the clickable `<button>`), `icon`, `label` (wrapper around `category`, `name`,
2907
+ `summary`), `category`, `name`, `summary`, `meta` (wrapper around `status-text` and `duration`),
2908
+ `status-text`, `duration`, `tooltip` (the floating detail popup, only meaningful while open)
2909
+
2910
+ **Themeable custom properties:** no component-specific custom properties (per-status accent/
2911
+ background/border are internal `--lyra-tool-call-chip-*` variables swapped by `:host([status])`
2912
+ rules, not part of the public theming API); shared tokens referenced: `--lyra-color-text-quiet`,
2913
+ `--lyra-color-surface`, `--lyra-color-border`, `--lyra-color-brand`/`-brand-quiet`,
2914
+ `--lyra-color-success`/`-success-quiet`, `--lyra-color-danger`/`-danger-quiet`,
2915
+ `--lyra-color-warning`/`-warning-quiet`, `--lyra-color-text`, `--lyra-space-xs/-s/-m`, `--lyra-radius`,
2916
+ `--lyra-shadow`, `--lyra-focus-ring-*`, `--lyra-transition-fast`.
2917
+
2918
+ **Optional peer deps:** none.
2919
+
2920
+ ```html
2921
+ <lyra-tool-call-chip
2922
+ name="web_search"
2923
+ category="research"
2924
+ status="running"
2925
+ summary="Searching web…"
2926
+ duration-ms="820"
2927
+ call-id="call_123"
2928
+ @lyra-tool-chip-select=${(e) => openDetail(e.detail.callId)}
2929
+ >
2930
+ <pre slot="icon" style="display:none"></pre>
2931
+ <code>{"query": "lyra ui components"}</code>
2932
+ </lyra-tool-call-chip>
2933
+ ```
2934
+
2935
+ The default slot's tooltip is positioned with the same `internal/positioner.js` `place()` helper
2936
+ `<lyra-combobox>` uses for its listbox (`placement: 'top-start'`), and appears/disappears instantly
2937
+ on hover/focus/blur/mouseleave with no fade transition and no "pointer moved into the tooltip"
2938
+ tracking — it's documented as read-only preview content, not an interactive surface meant to retain
2939
+ focus of its own. `denied` gets its own warning-toned glyph and color (a policy rejection, not a
2940
+ runtime failure) distinct from `error`'s danger tone, matching `<lyra-tool-result-dialog>`'s
2941
+ identical status vocabulary so a call reads the same way in both places. Duration formatting is
2942
+ sub-1000ms `"820ms"`, else trimmed to at most one decimal of seconds (`"1.5s"`, `"2s"`).
2943
+
2944
+ **Known gotchas:**
2945
+ - the default slot is checked for emptiness by scanning `Array.from(this.children)` for elements
2946
+ once on first update, then kept in sync via `slotchange` — only *element* children count (a bare
2947
+ text node assigned to the default slot won't trigger the tooltip)
2948
+ - `aria-label` on the host element (if you set one) wins over the component's own generated
2949
+ accessible label (`"name — summary — Status — duration"`); otherwise that generated string is
2950
+ what's announced
2951
+ - Escape only dismisses the tooltip when it's open; it does not fire any event or otherwise affect
2952
+ `status`/`open` state, since the chip has no "open" state of its own beyond the tooltip
2953
+
2954
+ ---
2955
+
2956
+ ## `lyra-tool-result-view`
2957
+
2958
+ 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
2961
+ owns none of the actual visual weight of a populated tool result — that's entirely whatever the
2962
+ registered renderer returns; `<lyra-tool-result-view>` is just the dispatch + fallback + loading-state
2963
+ shell around it.
2964
+
2965
+ **Properties:**
2966
+ - `registry?: ToolRendererRegistry` (property only, no attribute) — a custom `Map<string,
2967
+ ToolRendererDefinition>` to dispatch against instead of the module-level default registry (see
2968
+ `registry.ts` below)
2969
+ - `toolName: string = ''` (attribute `tool-name`) — the tool's name; the primary dispatch key
2970
+ - `result: unknown` (property only, no attribute) — the tool call's result payload, handed to the
2971
+ matched renderer's `render()` (and to `matches()` for shape-based dispatch, and to the
2972
+ `<lyra-json-viewer>` fallback)
2973
+ - `args: unknown` (property only, no attribute) — the tool call's original arguments, if available,
2974
+ handed to the matched renderer's `render()` alongside `result`
2975
+ - `fallback: string = 'json'` (reflected) — forward-compatible fallback-kind selector; only `"json"`
2976
+ (an unconditional `<lyra-json-viewer>`) is implemented today, but the property/attribute is still
2977
+ accepted so markup that already sets `fallback="…"` for a future value doesn't need to change again
2978
+ once more fallback kinds land
2979
+
2980
+ **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.
2983
+
2984
+ **Slots:** none.
2985
+
2986
+ **CSS parts:** `base` — the root wrapper around the resolved renderer's output (or the loading/
2987
+ fallback view).
2988
+
2989
+ **Themeable custom properties:** none — the component's own styling is deliberately minimal (just
2990
+ enough to not collapse to a zero-height inline box); all visible styling comes from whatever
2991
+ renderer/`<lyra-skeleton>`/`<lyra-json-viewer>` child is currently mounted.
2992
+
2993
+ **Optional peer deps:** none required by the component itself — individual registered renderers may
2994
+ of course pull in whatever they need (a charting library, a markdown renderer), which is exactly what
2995
+ the lazy `load()` path in the registry exists for.
2996
+
2997
+ ```html
2998
+ <lyra-tool-result-view
2999
+ tool-name="get_weather"
3000
+ .result=${{ tempC: 21, condition: 'cloudy' }}
3001
+ .args=${{ city: 'Brussels' }}
3002
+ @lyra-render-error=${(e) => console.warn('renderer failed', e.detail)}
3003
+ ></lyra-tool-result-view>
3004
+ ```
3005
+
3006
+ ### `registerToolRenderer()` and the tool-renderer registry (`registry.ts`)
3007
+
3008
+ A type-keyed dispatch registry — a tiny plugin system so a host app can teach
3009
+ `<lyra-tool-result-view>` how to draw the result of e.g. a `get_weather` or `run_query` tool call
3010
+ without this library knowing anything about either. Every registered instance dispatches against
3011
+ this same module-level registry unless a given `<lyra-tool-result-view>`'s `registry` property is
3012
+ set to a different `Map` instance.
3013
+
3014
+ **`ToolRendererDefinition`** — the shape of one registered renderer:
3015
+ - `render?: (result: unknown, args: unknown) => unknown` — renders the result (and the args that
3016
+ produced it) as UI. Typed as `unknown` rather than Lit's `TemplateResult` so any lit-html-renderable
3017
+ value works (a plain string, a DOM node, an array of templates) — consumers already own their own
3018
+ Lit import and don't need this module to add one
3019
+ - `matches?: (payload: unknown) => boolean` — facade/shape-based dispatch predicate, consulted only
3020
+ when no exact `toolName` key matches (see dispatch order below); only ever consulted *before*
3021
+ `load` resolves when supplied inline at registration time — a definition that needs shape-based
3022
+ dispatch and also wants to lazy-load its `render` should register a lightweight synchronous
3023
+ `matches` up front alongside `load`
3024
+ - `load?: () => Promise<ToolRendererDefinition | { default: ToolRendererDefinition }>` — lazy loader
3025
+ for a code-split renderer, so a host app can defer the cost of a rarely-used or heavy renderer
3026
+ (e.g. one pulling in a charting library) instead of paying for it on every page that merely
3027
+ registers it. Resolves to either a definition directly, or a `{ default }`-shaped module namespace
3028
+ object, so `load: () => import('./my-renderer.js')` works unmodified when that module's default
3029
+ export is itself a `ToolRendererDefinition`
3030
+
3031
+ **Exports:**
3032
+ - `registerToolRenderer(name: string, def: ToolRendererDefinition): void` — registers (or
3033
+ overwrites) the renderer for `name` in the module-level default registry
3034
+ - `getDefaultToolRendererRegistry(): ToolRendererRegistry` — returns the default `Map` that
3035
+ `registerToolRenderer()` writes to and every `<lyra-tool-result-view>` reads from unless its own
3036
+ `registry` prop is set
3037
+ - `findToolRenderer(toolName: string, payload: unknown, registry?: ToolRendererRegistry):
3038
+ ToolRendererDefinition | undefined` — the dispatch function `<lyra-tool-result-view>` calls
3039
+ internally on every resolve; exposed for direct use/testing too
3040
+ - `loadToolRenderer(def: ToolRendererDefinition): Promise<ToolRendererDefinition>` — resolves `def`
3041
+ to a definition guaranteed to carry a real `render`, awaiting/unwrapping `def.load()` when present
3042
+ (or returning `def` unchanged otherwise)
3043
+ - `clearToolRenderers(): void` — test-only utility that empties the default registry and its
3044
+ `load()` cache, so one test's `registerToolRenderer()` calls can't leak into the next
3045
+
3046
+ **Dispatch order** (`findToolRenderer`), exactly as `<lyra-tool-result-view>`'s own `resolve()` uses
3047
+ it:
3048
+ 1. An exact `toolName` key match in the registry.
3049
+ 2. Failing that, the first entry — in registration order, since a `Map` already iterates that way —
3050
+ whose `matches(payload)` returns `true`. Useful when several tool names share one result shape
3051
+ (e.g. every `*_search` tool returning `{ results: [...] }`) or when the caller doesn't reliably
3052
+ know the tool name at all.
3053
+ 3. `undefined` if neither matches — `<lyra-tool-result-view>` falls back to `<lyra-json-viewer>` and
3054
+ fires `lyra-render-error`.
3055
+
3056
+ Once a definition is found, if it carries `load`, `<lyra-tool-result-view>` shows a
3057
+ `<lyra-skeleton variant="rect" height="4rem">` while `loadToolRenderer()` resolves it. The resolved
3058
+ `load()` promise is cached keyed by *definition object identity* (a `WeakMap`, not by tool-name
3059
+ string) — two different registries that happen to reuse the same tool-name string get independently
3060
+ cached loads, and any given lazy definition's `load()` runs at most once no matter how many times
3061
+ it's dispatched to, across every `<lyra-tool-result-view>` instance that resolves to it. A **rejected**
3062
+ `load()` is *not* cached — the definition stays registered, so a later resolution attempt (e.g. after
3063
+ a transient network failure) gets a fresh `load()` call rather than being stuck replaying one failed
3064
+ promise forever.
3065
+
3066
+ ```ts
3067
+ import { registerToolRenderer } from '@aceshooting/lyra-ui/tool-result-view/registry.js';
3068
+
3069
+ registerToolRenderer('get_weather', {
3070
+ render: (result, args) => html`<weather-card .data=${result} .city=${args?.city}></weather-card>`,
3071
+ });
3072
+
3073
+ // Lazily loaded, shape-based fallback for every *_search tool:
3074
+ registerToolRenderer('web_search', {
3075
+ matches: (payload) => typeof payload === 'object' && payload !== null && 'results' in payload,
3076
+ load: () => import('./search-result-renderer.js'), // default export is a ToolRendererDefinition
3077
+ });
3078
+ ```
3079
+
3080
+ **Known gotchas:**
3081
+ - `<lyra-tool-result-view>` re-resolves (re-runs the full dispatch → load → render pipeline)
3082
+ whenever `toolName`, `result`, `args`, or `registry` changes, or on first update — a stale
3083
+ in-flight `load()` superseded by a newer change is detected via an internal generation counter and
3084
+ its result is discarded rather than clobbering a more recent render
3085
+ - registering under the same `name` twice silently overwrites the earlier definition — there is no
3086
+ warning or error
3087
+ - `matches` is a linear scan over every registered definition's `matches` in registration order; it
3088
+ only runs when the exact-name lookup misses, so tool names with a direct registration never pay
3089
+ that scan cost
3090
+ - the `fallback` property currently only accepts the effective value `"json"` — setting it to
3091
+ anything else does not change fallback behavior, it's forward-compatible plumbing only
3092
+
3093
+ ---
3094
+
3095
+ ## `lyra-tool-result-dialog`
3096
+
3097
+ A full tool-call detail overlay: a status/duration header plus a `body` slot where a consumer
3098
+ typically places a `<lyra-tabs>` with Input/Preview/JSON/Raw panels. First-party invention (no Web
3099
+ 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.
3104
+
3105
+ **Properties:**
3106
+ - `open: boolean = false` (reflected) — whether the dialog is open; set this (or call `close()`) —
3107
+ there is no separate `show()`/`hide()` pair
3108
+ - `toolName: string = ''` (attribute `tool-name`) — the tool's name, rendered prominently in the
3109
+ header
3110
+ - `status: 'pending'|'running'|'success'|'error'|'denied' = 'pending'` (reflected) — drives the
3111
+ header's status badge; same status vocabulary as `<lyra-tool-call-chip>`
3112
+ - `durationMs?: number` (attribute `duration-ms`) — how long the call took, in milliseconds; omitted
3113
+ from the header entirely when unset
3114
+ - `maximized: boolean = false` (reflected) — near-fullscreen presentation of the same open dialog
3115
+
3116
+ **Methods:** `close(reason: ToolResultDialogCloseReason = 'api'): void` — closes the dialog (no-op if
3117
+ already closed), emits `lyra-dialog-close` with `reason`, and returns focus to whatever had it before
3118
+ the dialog opened. Built-in triggers call this with `'escape'`/`'backdrop'`/`'close-button'`; a
3119
+ consumer's own close affordance (e.g. a footer action button) should call it directly with its own
3120
+ reason string so every dismissal path funnels through the same event.
3121
+
3122
+ **Events:** `lyra-dialog-close` (`detail: ToolResultDialogCloseReason` — `'escape'|'backdrop'|
3123
+ 'close-button'|'api'|string`) fired exactly once per dismissal; `lyra-maximize-change` (`detail:
3124
+ boolean`, the new `maximized` state) fired when the header's maximize/restore toggle is clicked.
3125
+
3126
+ **Slots:** `body` (the dialog's main content — typically a `<lyra-tabs>` with Input/Preview/JSON/Raw
3127
+ panels, entirely consumer-assembled), `footer` (optional action buttons, rendered in a bottom row —
3128
+ the footer row itself is hidden via `[hidden]` when nothing is slotted)
3129
+
3130
+ **CSS parts:** `backdrop`, `panel` (`role="dialog"` while open), `header`, `title` (wrapper around
3131
+ tool name/status/duration), `tool-name`, `status`, `duration`, `header-actions`, `maximize-button`,
3132
+ `close-button`, `body`, `footer`
3133
+
3134
+ **Themeable custom properties:** `--lyra-tool-result-dialog-overlay-color` (default `rgb(0 0 0 /
3135
+ 0.5)` — the backdrop scrim color; component-specific since no shared overlay token exists),
3136
+ `--lyra-tool-result-dialog-maximized-inset` (default `var(--lyra-space-l)` — inset applied to the
3137
+ panel while `[maximized]`, overridable e.g. to leave a persistent app rail visible), plus shared
3138
+ tokens `--lyra-color-surface/-border/-text-quiet/-brand/-brand-quiet/-success/-success-quiet/
3139
+ -danger/-danger-quiet/-warning/-warning-quiet`, `--lyra-space-*`, `--lyra-radius`, `--lyra-shadow`,
3140
+ `--lyra-icon-button-size`, `--lyra-focus-ring-*`, `--lyra-transition-base`.
3141
+
3142
+ **Optional peer deps:** none.
3143
+
3144
+ ```html
3145
+ <lyra-tool-result-dialog
3146
+ tool-name="run_query"
3147
+ status="success"
3148
+ duration-ms="1240"
3149
+ ?open=${dialogOpen}
3150
+ @lyra-dialog-close=${(e) => (dialogOpen = false)}
3151
+ @lyra-maximize-change=${(e) => console.log('maximized:', e.detail)}
3152
+ >
3153
+ <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>
3156
+ </lyra-tabs>
3157
+ <button slot="footer">Rerun</button>
3158
+ </lyra-tool-result-dialog>
3159
+ ```
3160
+
3161
+ While open, `[part="panel"]` takes `role="dialog"` + `aria-modal="true"` with `aria-labelledby`
3162
+ pointing at the tool-name element, document scroll is locked, and Tab/Shift+Tab are bounded to the
3163
+ panel's own focusable content in header-buttons → `body` slot → `footer` slot order (resolved
3164
+ shadow-piercingly, so a slotted custom element's real focusable target inside its own shadow root is
3165
+ found too). On open, focus moves to the first focusable element (falling back to the panel itself);
3166
+ on close, focus returns to whatever element triggered the open (captured at open time via the active
3167
+ element, since the trigger typically lives entirely outside this component). `maximized` toggles
3168
+ between the constrained modal size and a near-fullscreen size within the same open dialog and
3169
+ open/close lifecycle — unlike `<lyra-widget>`'s fullscreen mode there's no separate non-modal resting
3170
+ state, so no additional scroll-lock/focus-trap bookkeeping is needed for that transition alone.
3171
+
3172
+ **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
3181
+ - `close()` is a no-op when `open` is already `false` — calling it twice in a row only fires
3182
+ `lyra-dialog-close` once
3183
+ - the `maximize`/`close` buttons are always the first elements in the Tab order while open,
3184
+ regardless of visual position, followed by `body` then `footer` content
3185
+
3186
+ ---
3187
+
3188
+ ## `lyra-chat-composer`
3189
+
3190
+ The message input for a chat/agent conversation surface: an auto-resizing `<textarea>` plus a
3191
+ built-in send/stop button. **Form-associated** via the shared `FormAssociated` mixin (same shape as
3192
+ `<lyra-date-input>`) — `name: string = ''`, `value: string = ''`, `disabled: boolean = false`
3193
+ (reflected), `required: boolean = false` (reflected) are all inherited, along with
3194
+ `checkValidity()`/`reportValidity()`, so it participates in native `<form>` submission/validation/
3195
+ reset like any other text control.
3196
+
3197
+ **Properties (own):**
3198
+ - `placeholder: string = ''`
3199
+ - `minRows: number = 1` (attribute `min-rows`) — floored to `1` at render time
3200
+ - `maxRows: number = 8` (attribute `max-rows`) — floored to at least `minRows`
3201
+ - `status: ChatComposerStatus = 'idle'` (reflected) — `'idle' | 'sending' | 'streaming'`; drives the
3202
+ built-in button's icon/label (send vs. stop) and whether Enter still submits
3203
+ - `submitOnEnter: boolean = true` (reflected, attribute `submit-on-enter`) — when `false`, Enter
3204
+ always inserts a newline instead of submitting
3205
+
3206
+ **Methods (own):** none beyond the inherited `checkValidity()`/`reportValidity()`.
3207
+
3208
+ **Events:**
3209
+ - `lyra-input` (`detail: { value }`) — fired on every user-driven edit of the textarea, not a
3210
+ programmatic `.value` assignment
3211
+ - `lyra-submit` (`detail: { value }`) — fired by Enter (per `submit-on-enter`) or the built-in
3212
+ button while `status="idle"`. `detail.value` is always the exact, untrimmed current value;
3213
+ trimming is left to the consumer. Submitting does **not** clear `value`
3214
+ - `lyra-stop` (no detail) — fired by the built-in button while `status` is `"sending"` or
3215
+ `"streaming"`
3216
+
3217
+ **Slots:** `leading` (content before the textarea, e.g. an attach-file trigger button), `chips` (an
3218
+ attachment tray rendered above the input row), `trailing` (overrides the built-in send/stop button
3219
+ entirely when it has assigned content)
3220
+
3221
+ **CSS parts:** `base`, `chips`, `row`, `leading`, `textarea`, `trailing`, `action-button`
3222
+
3223
+ **Themeable custom properties:** no component-specific custom properties; consumes shared tokens
3224
+ `--lyra-space-xs`, `--lyra-space-s`, `--lyra-color-border`, `--lyra-color-surface`,
3225
+ `--lyra-color-brand`, `--lyra-color-on-brand`, `--lyra-color-text-quiet`, `--lyra-radius`,
3226
+ `--lyra-icon-button-size`, `--lyra-transition-fast`, `--lyra-opacity-disabled`,
3227
+ `--lyra-focus-ring-width`, `--lyra-focus-ring-color`, `--lyra-focus-ring-offset`.
3228
+
3229
+ **Optional peer deps:** none.
3230
+
3231
+ ```html
3232
+ <lyra-chat-composer
3233
+ id="composer"
3234
+ name="message"
3235
+ placeholder="Message the assistant…"
3236
+ min-rows="1"
3237
+ max-rows="8"
3238
+ ></lyra-chat-composer>
3239
+ <script type="module">
3240
+ const composer = document.getElementById('composer');
3241
+ composer.addEventListener('lyra-submit', (e) => {
3242
+ sendMessage(e.detail.value);
3243
+ composer.value = ''; // the composer never clears itself
3244
+ composer.status = 'sending';
3245
+ });
3246
+ composer.addEventListener('lyra-stop', () => stopGeneration());
3247
+ </script>
3248
+ ```
3249
+
3250
+ Auto-resize (`resizeTextarea()`) reads the textarea's own *computed* line-height/padding/border at
3251
+ call time rather than assuming a fixed px-per-row constant, so it stays correct under a consumer's
3252
+ 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
3254
+ `submit-on-enter` is `true` (the default): plain Enter submits and prevents the default newline;
3255
+ Shift+Enter always inserts a newline regardless of `submit-on-enter`; an IME composition step
3256
+ (checked via `isComposing`, with `keyCode === 229` as a defense-in-depth fallback for browsers that
3257
+ report `isComposing` inconsistently) is never treated as a submit trigger; and while `status` isn't
3258
+ `"idle"`, Enter is left alone to insert a newline instead of resubmitting — the textarea itself is
3259
+ **not** auto-disabled during `sending`/`streaming`, so a user can keep composing their next message
3260
+ while a previous one is in flight.
3261
+
3262
+ **Known gotchas:**
3263
+ - `lyra-submit` never clears `value` — the consumer must clear it once a submission is actually
3264
+ accepted, so a failed send can leave the text in place for retry.
3265
+ - While `status !== 'idle'`, only the built-in button's behavior changes (it emits `lyra-stop`
3266
+ instead of `lyra-submit`); the textarea keeps accepting input and Enter keeps inserting newlines
3267
+ rather than being blocked.
3268
+ - Auto-resize requires a concrete, unitless `line-height` on the textarea (the component sets
3269
+ `line-height: 1.5` in its own styles) — the UA default of `normal` has no single resolved px
3270
+ figure to measure rows against, so overriding `line-height` to a keyword breaks row sizing.
3271
+ - The `trailing` slot fully replaces the built-in action button rather than rendering alongside it —
3272
+ once it has assigned content, the library's send/stop icon, its `aria-label`, and its
3273
+ `status`-driven busy styling all disappear, so a custom trailing control needs its own send/stop
3274
+ handling.
3275
+ - `[part="chips"]`/`[part="leading"]` are hidden via a JS-tracked `[hidden]` attribute rather than a
3276
+ CSS `:empty` selector, because each always contains a literal `<slot>` child regardless of
3277
+ assigned content.
3278
+
3279
+ ---
3280
+
3281
+ ## `lyra-attachment-chip`
3282
+
3283
+ A compact chip representing one file queued for (or already part of) a chat message — used in a
3284
+ composer's pre-send attachment tray or a sent message's attachments display. Two independent ways
3285
+ to populate it: set `file` to a real `File` (fresh from a picker/drop), from which `name`/`size`/
3286
+ `mime-type` and the image thumbnail are all auto-derived; or set the plain `name`/`size`/
3287
+ `mime-type`/`thumbnail-src` props instead, for reconstructing a chip from server-persisted
3288
+ attachment metadata after a page reload, when no real `File` object exists any more. `file` always
3289
+ wins when both are present.
3290
+
3291
+ **Properties:**
3292
+ - `file?: File` (attribute `false`, i.e. property-only) — when set, `name`/`size`/`mimeType`/the
3293
+ image thumbnail are all derived from it, taking precedence over the independent props below
3294
+ - `name: string = ''` — filename, used only while `file` is unset
3295
+ - `size: number = 0` — file size in bytes, used only while `file` is unset
3296
+ - `mimeType: string = ''` (attribute `mime-type`) — used only while `file` is unset
3297
+ - `thumbnailSrc: string = ''` (attribute `thumbnail-src`) — thumbnail image URL, used only while
3298
+ `file` is unset; rendered whenever present regardless of `mimeType` (no `file`-derived equivalent
3299
+ exists for a non-image file)
3300
+ - `status: AttachmentChipStatus = 'pending'` (reflected) — `'pending' | 'uploading' | 'error' |
3301
+ 'done'`; drives the accent tint and which of `progress`/`spinner`/`retry-button` renders
3302
+ - `progress: number = 0` — upload completion, 0-100; only meaningful while `status="uploading"`, a
3303
+ value of `0` or `NaN` falls back to the indeterminate spinner
3304
+ - `removable: boolean = true` (reflected) — shows the remove (×) button
3305
+
3306
+ The component identifies *which* attachment a `lyra-remove`/`lyra-retry` event is about via the
3307
+ platform's own `id` attribute/property rather than a second, differently-named prop. Set `id="..."`
3308
+ when you have a stable server-side attachment id; when unset and `file` is set, a stable id is
3309
+ derived from `` `${file.name}:${file.size}:${file.lastModified}` ``; when neither is available, a
3310
+ generated internal id is used as a last resort.
3311
+
3312
+ **Events:** `lyra-remove` (`detail: { id }`, only rendered while `removable`), `lyra-retry`
3313
+ (`detail: { id }`, only rendered while `status="error"`)
3314
+
3315
+ **Slots:** none.
3316
+
3317
+ **CSS parts:** `base`, `thumbnail`, `meta`, `name`, `size`, `status-text`, `progress`,
3318
+ `progress-fill`, `spinner`, `retry-button`, `remove-button`
3319
+
3320
+ **Themeable custom properties:** `--lyra-attachment-chip-accent` (default
3321
+ `var(--lyra-color-text-quiet)`), `--lyra-attachment-chip-bg` (default `var(--lyra-color-surface)`),
3322
+ `--lyra-attachment-chip-border` (default `var(--lyra-color-border)`) — this trio is swapped per
3323
+ `status` (`uploading` → brand/brand-quiet/transparent, `error` → danger/danger-quiet/transparent,
3324
+ `done` → success/success-quiet/transparent); plus shared tokens `--lyra-space-xs`, `--lyra-space-s`,
3325
+ `--lyra-radius`, `--lyra-color-text`, `--lyra-color-danger`, `--lyra-icon-button-size`,
3326
+ `--lyra-transition-fast`, `--lyra-transition-base`, `--lyra-focus-ring-width`,
3327
+ `--lyra-focus-ring-color`, `--lyra-focus-ring-offset`.
3328
+
3329
+ **Optional peer deps:** none.
3330
+
3331
+ Also exported from the package root: `formatFileSize(bytes: number): string` — `512` → `"512 B"`
3332
+ (whole bytes never get a decimal), `2415919` → `"2.3 MB"` (every unit past bytes gets exactly one
3333
+ decimal place), and a negative or non-finite input (`NaN`, `Infinity`) returns `""` so an unknown
3334
+ size renders nothing instead of `"NaN B"`.
3335
+
3336
+ ```html
3337
+ <lyra-attachment-chip name="report.pdf" size="245000" mime-type="application/pdf" status="done"></lyra-attachment-chip>
3338
+ <lyra-attachment-chip id="att-2" status="uploading" progress="42"></lyra-attachment-chip>
3339
+ <script type="module">
3340
+ import { formatFileSize } from '@aceshooting/lyra-ui';
3341
+
3342
+ const chip = document.createElement('lyra-attachment-chip');
3343
+ chip.file = pickedFile; // name/size/mime-type/thumbnail all derived from the File
3344
+ chip.addEventListener('lyra-remove', (e) => removeAttachment(e.detail.id));
3345
+ chip.addEventListener('lyra-retry', (e) => retryUpload(e.detail.id));
3346
+ console.log(formatFileSize(pickedFile.size));
3347
+ </script>
3348
+ ```
3349
+
3350
+ The image thumbnail for a real `File` is a lazily-created `URL.createObjectURL()` blob URL —
3351
+ created only from `render()`, i.e. only once a thumbnail is actually about to paint, never eagerly
3352
+ on `file` assignment — and revoked automatically once `file` changes to a different `File` (or to
3353
+ `undefined`) and on disconnect, so reassigning `file` several times before the next paint never
3354
+ leaks URLs that were created but never shown.
3355
+
3356
+ **Known gotchas:**
3357
+ - `file` always wins over `name`/`size`/`mimeType` when both are set — assigning those props while
3358
+ `file` is also set has no visible effect on the rendered chip.
3359
+ - A `0`-byte size and an unset size are indistinguishable (there's no separate flag for "genuinely
3360
+ empty file"); the `size` part is hidden entirely rather than showing a literal `"0 B"`.
3361
+ - `progress` only renders as a numeric bar when `status="uploading"` **and** `progress` is finite
3362
+ and `> 0`; otherwise it's either nothing (non-`uploading` status) or the indeterminate spinner
3363
+ (`uploading` with no known progress).
3364
+ - `thumbnailSrc` is used whenever present regardless of `mimeType` — pass a URL that's already known
3365
+ to be an image; there's no non-image fallback check for it the way there is for `file`.
3366
+
3367
+ ---
3368
+
3369
+ ## `lyra-stream-status`
3370
+
3371
+ A compact status indicator for a single streaming connection (SSE, WebSocket, long-poll, …), with
3372
+ built-in heartbeat-aware stall detection. First-party invention (no Web Awesome equivalent). The
3373
+ host drives `phase` directly for `idle`/`connecting`/`streaming`, and calls the imperative
3374
+ `recordActivity()` method on every *semantic* frame received while streaming — a real content
3375
+ chunk, never a transport-level keep-alive ping. This component has no payload-inspection logic of
3376
+ its own: "ignore heartbeats" is entirely call-site discipline, which is exactly why a connection
3377
+ that's only sending keep-alives (no real content) for longer than `stall-threshold-ms` correctly
3378
+ reads as stalled.
3379
+
3380
+ **Properties:**
3381
+ - `phase: 'idle' | 'connecting' | 'streaming' | 'stalled' = 'idle'` (reflected) — current
3382
+ connection phase. Fully public and directly settable by the host at any time, including a manual
3383
+ override to `'stalled'`; the component never fights a host-driven reassignment.
3384
+ - `stallThresholdMs: number = 10000` (attribute `stall-threshold-ms`) — how long `phase` may stay
3385
+ `'streaming'` with no `recordActivity()` call before the component auto-transitions to
3386
+ `'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).
3388
+
3389
+ **Methods:**
3390
+ - `recordActivity(): void` — call on every semantic (non-heartbeat) frame received while
3391
+ streaming.
3392
+ - While `phase === 'streaming'`: (re)arms the stall timer, pushing the stall deadline
3393
+ `stallThresholdMs` further out.
3394
+ - While `phase === 'stalled'`: recovers — `phase` becomes `'streaming'` again (firing
3395
+ `lyra-recover` and arming the timer fresh, via the same transition handling a direct host
3396
+ assignment would also go through).
3397
+ - While `phase` is `'idle'` or `'connecting'`: a no-op. Safe to call defensively before formally
3398
+ flipping to `'streaming'`; it never throws or starts a timer early.
3399
+
3400
+ **Events:** `lyra-stall` (no detail payload) — fires whenever `phase` transitions into `'stalled'`
3401
+ from any other phase, whether timer-driven or via a direct host assignment. `lyra-recover` (no
3402
+ detail payload) — fires whenever `phase` transitions out of `'stalled'` to any other phase,
3403
+ whether via `recordActivity()` or a direct host assignment. Neither fires for a same-value
3404
+ reassignment, and neither fires for whatever phase the element happens to *mount* with — only a
3405
+ later change counts as a transition.
3406
+
3407
+ **Slots:** default (custom copy shown only while `phase="stalled"`, e.g. "Taking longer than
3408
+ usual…" — falls back to a built-in default message when nothing is slotted), `actions` (a
3409
+ stop/retry button row; always present in the template regardless of `phase` — its wrapper's
3410
+ visibility is driven purely by whether anything is slotted into it, not by `phase`)
3411
+
3412
+ **CSS parts:** `base`, `indicator`, `message`, `actions`
3413
+
3414
+ **Themeable custom properties:** shared tokens only — `--lyra-color-text-quiet` (idle dot color),
3415
+ `--lyra-color-brand` (connecting/streaming dot color), `--lyra-color-warning` (stalled dot color,
3416
+ message text color, stalled border), `--lyra-color-warning-quiet` (stalled background tint),
3417
+ `--lyra-space-s` / `--lyra-space-xs` (base gap, stalled padding, actions gap), `--lyra-radius`
3418
+ (base corner radius), `--lyra-transition-base` (background/border-color transitions and the
3419
+ streaming pulse animation's cycle length). The component also sets two internal, phase-driven
3420
+ custom properties (`--lyra-stream-status-dot-color`, `--lyra-stream-status-dot-opacity`) on
3421
+ `:host`, but its own `:host([phase="..."])` rules outrank a page-level override for every phase
3422
+ except the default `idle`, so these aren't a practical external theming hook.
3423
+
3424
+ **Optional peer deps:** none.
3425
+
3426
+ ```html
3427
+ <lyra-stream-status phase="streaming" stall-threshold-ms="8000">
3428
+ <span slot="actions"><button>Stop</button></span>
3429
+ </lyra-stream-status>
3430
+ ```
3431
+ ```ts
3432
+ const status = document.querySelector('lyra-stream-status')!;
3433
+ status.addEventListener('lyra-stall', () => console.warn('stream stalled'));
3434
+ status.addEventListener('lyra-recover', () => console.info('stream recovered'));
3435
+
3436
+ // on every real content chunk from the transport (never on a keep-alive ping):
3437
+ status.recordActivity();
3438
+ ```
3439
+
3440
+ Internally, the inactivity timer runs only while `phase === 'streaming'`. It's (re)armed whenever
3441
+ the phase transitions to `'streaming'` (directly, or via `recordActivity()` recovering from
3442
+ `'stalled'`) and on every subsequent `recordActivity()` call while already streaming; it's disarmed
3443
+ the instant `phase` becomes anything else, including a host-driven reassignment away from
3444
+ `'streaming'` — so a stale timer can never fire a stall transition after the host has already moved
3445
+ on. Phase transitions into/out of `'stalled'` are announced through an internal
3446
+ `<lyra-live-region>` rather than a hand-rolled `aria-live` region: entering `'stalled'` announces
3447
+ "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
3450
+ matter how often the host calls it — only the phase *transition* announces, exactly once. The
3451
+ decorative indicator dot is `aria-hidden` (a color/motion cue only) and only pulses while
3452
+ `phase="streaming"`; `'stalled'` is styled as a warning tone, not danger, since a stall is usually
3453
+ recoverable — a host that wants to escalate after N stalls can scope its own CSS off
3454
+ `[phase="stalled"]`, or stop rendering this component and show its own danger-styled error state
3455
+ instead. The pulse animation is suppressed under `prefers-reduced-motion: reduce`.
3456
+
3457
+ **Known gotchas:**
3458
+ - `recordActivity()` is a plain instance method, not a reactive property — there's nothing to bind
3459
+ to in a template; call it directly from streaming/application code on every real chunk received.
3460
+ - Never call `recordActivity()` for a heartbeat/keep-alive ping. This component has no
3461
+ payload-inspection logic of its own, so a connection that's only sending pings (no real content)
3462
+ for longer than `stall-threshold-ms` is *supposed* to read as stalled — that's the entire
3463
+ point of the API.
3464
+ - Setting `stallThresholdMs` to `0`, a negative number, or a non-finite value disables the stall
3465
+ timer outright; the component will stay `'streaming'` forever until the host manually changes
3466
+ `phase`.
3467
+ - `phase` remains directly settable at all times; assigning `'stalled'` yourself fires `lyra-stall`
3468
+ and the assertive announcement exactly as if the timer had fired.
3469
+ - The `actions` slot's wrapper is always present in the DOM and toggled with the `hidden` attribute
3470
+ based on slotted content, not gated by `phase` — content placed there is visible regardless of
3471
+ the current phase.
3472
+
3473
+ ---
3474
+ </markdown>
3475
+
3476
+ ---
3477
+
3478
+ ## `lyra-virtual-list`
3479
+
3480
+ A generic windowed/virtualized list host. Renders only the items within the current viewport (plus
3481
+ `overscan` padding rows on each side) as real DOM, regardless of how large `items` is, so a
3482
+ multi-thousand-row chat-history sidebar (or a long message thread) stays cheap to scroll. Content is
3483
+ entirely caller-supplied: `renderItem(item, index)` returns whatever `lit-html` value should represent
3484
+ that row, and `keyFunction(item, index)` gives it a stable identity for DOM reconciliation. First-party
3485
+ invention (no Web Awesome equivalent).
3486
+
3487
+ **Properties:**
3488
+ - `items: unknown[] = []` (attribute: false) — the full, non-windowed item collection. JS-only; set via
3489
+ a property/lit-html binding (`.items=`), not an HTML attribute.
3490
+ - `renderItem: (item: unknown, index: number) => unknown = () => nothing` (attribute: false) — renders
3491
+ one row's content, typically returning a `lit-html` `TemplateResult`. JS-only.
3492
+ - `keyFunction?: (item: unknown, index: number) => string | number` (attribute: false) — derives a
3493
+ row's stable reconciliation key. JS-only. Falls back to the item's index in `items` when omitted,
3494
+ which is only a safe identity while `items` never reorders/inserts/removes — provide this whenever
3495
+ possible, or scroll position and per-row DOM state (e.g. an `<audio>` element's playback position)
3496
+ can attach to the wrong row across a mutation.
3497
+ - `groups?: VirtualListGroup[]` (attribute: false) — reserved for a future sticky-header sectioning
3498
+ feature; accepted but **not currently read or rendered** — every row still renders flat, in `items`
3499
+ order.
3500
+ - `rowHeight: string = 'auto'` (attribute `row-height`) — `'auto'` measures each row's real height via
3501
+ `ResizeObserver`; a numeric string (e.g. `"56"`) fixes every row to that many pixels. Anything else
3502
+ (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.
3504
+ - `activeId: string = ''` (attribute `active-id`) — when set and it matches a row's `keyFunction`
3505
+ result (compared as a string), that row is smoothly scrolled into view whenever this changes, and
3506
+ rendered with `aria-current="true"`.
3507
+ - `loading: boolean = false` (reflected) — sets `aria-busy` on the scroll container and a `cursor:
3508
+ progress` style; does not by itself gate `lyra-load-more` (see below).
3509
+ - `hasMore: boolean = false` (attribute `has-more`, reflected) — when true, scrolling near the bottom
3510
+ fires `lyra-load-more` (gated by `loading`).
3511
+
3512
+ **Exported types:** `VirtualListRange { start: number; end: number }` (the `lyra-visible-range-changed`
3513
+ detail shape); `VirtualListGroup { key: string | number; label?: string; startIndex: number }` — a
3514
+ placeholder shape for the not-yet-implemented grouping feature above. No exported helper functions ship
3515
+ in this pass (a paired `groupByRecency()` helper referenced in the source's own class doc doesn't exist
3516
+ yet).
3517
+
3518
+ **Events:** `lyra-load-more` (no detail — fired once per approach to the bottom of the list while
3519
+ `has-more` is true and `loading` is false; does not refire on every scroll tick while still near the
3520
+ bottom — scrolling back away from the bottom and returning, or `items` growing enough to move the
3521
+ window away from the end, re-arms it), `lyra-visible-range-changed` (`detail: VirtualListRange`, the
3522
+ current visible, non-overscanned item index range — fired only when it actually changes)
3523
+
3524
+ **Slots:** none — all content comes from `renderItem`.
3525
+
3526
+ **CSS parts:** `base` (the scrollable container, `role="list"`, `tabindex="0"`), `spacer` (the
3527
+ full-content-height inner element establishing true scroll extent), `row` (one rendered row's
3528
+ absolutely-positioned wrapper, `role="listitem"`)
3529
+
3530
+ **Themeable custom properties:** `--lyra-virtual-list-height` (default `24rem` — the host's bounded
3531
+ scroll extent; component-specific since a virtualized list is meaningless without a sized viewport),
3532
+ plus shared `--lyra-focus-ring-width/-color/-offset` (inward-offset ring on `[part="base"]`, negative
3533
+ so it isn't clipped by the container's own `overflow: auto`).
3534
+
3535
+ **Optional peer deps:** none.
3536
+
3537
+ ```html
3538
+ <lyra-virtual-list
3539
+ .items=${sessions}
3540
+ .renderItem=${(item, index) => html`
3541
+ <lyra-conversation-item
3542
+ id=${item.id}
3543
+ title=${item.title}
3544
+ .timestamp=${item.updatedAt}
3545
+ ?active=${item.id === currentId}
3546
+ ></lyra-conversation-item>
3547
+ `}
3548
+ .keyFunction=${(item) => item.id}
3549
+ active-id=${currentId}
3550
+ ?has-more=${hasMorePages}
3551
+ ?loading=${isLoadingMore}
3552
+ @lyra-load-more=${() => loadNextPage()}
3553
+ @lyra-visible-range-changed=${(e) => console.log('visible', e.detail.start, e.detail.end)}
3554
+ ></lyra-virtual-list>
3555
+ ```
3556
+
3557
+ 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.
3565
+
3566
+ **Known gotchas:**
3567
+ - `items`, `renderItem`, `keyFunction`, and `groups` are all `attribute: false` — they must be set as
3568
+ JS properties (`.items=`, `.renderItem=`, …), never as HTML attribute strings.
3569
+ - The container is `role="list"` with rows `role="listitem"`, deliberately not `listbox`/`option` —
3570
+ this component only provides windowing, not the roving-tabindex/`aria-activedescendant`
3571
+ keyboard-interaction contract a real `listbox` requires. `active-id` only scrolls a row into view and
3572
+ marks it `aria-current`; it is not a selection widget. Compose your own selection behavior on top if
3573
+ needed.
3574
+ - `[part="base"]` carries `tabindex="0"` unconditionally, since `renderItem`'s caller-supplied content
3575
+ isn't guaranteed to contain a focusable element and an otherwise-unreachable-by-keyboard scroll
3576
+ region would result.
3577
+ - `aria-setsize`/`aria-posinset` are computed from a row's real index in the full `items` array, not its
3578
+ position among the currently-rendered DOM window, so assistive tech still announces e.g. "item 12 of
3579
+ 340" correctly even though only a handful of rows exist in the DOM at a time.
3580
+ - `groups` is accepted today purely for forward API compatibility; setting it has no visible effect
3581
+ yet.
3582
+
3583
+ ---
3584
+
3585
+ ## `lyra-conversation-item`
3586
+
3587
+ A selectable row representing one chat session in a history sidebar list. Usable standalone or as the
3588
+ `renderItem()` payload of `<lyra-virtual-list>`; has no dependency on that (or any) other component.
3589
+ First-party invention (no Web Awesome equivalent).
3590
+
3591
+ **Properties:**
3592
+ - `title: string = ''` — the session's display title. Falls back to "Untitled conversation" when empty
3593
+ (display only — the property itself is never mutated by that fallback).
3594
+ - `excerpt: string = ''` — a short preview snippet of the last message. Omit for no excerpt line.
3595
+ - `timestamp?: Date | string` (attribute: false) — accepts a `Date` or anything `new Date()` can parse
3596
+ (e.g. an ISO 8601 string); invalid input is treated as unset (no `<time>` rendered).
3597
+ - `formatTimestamp?: (date: Date) => string` (attribute: false) — overrides the default absolute-time
3598
+ rendering (clock time for same-day timestamps, otherwise a calendar date). Not a fuzzy "2 hours ago"
3599
+ relative string — bucketed relative grouping is a list-level concern, not this row's job.
3600
+ - `active: boolean = false` (reflected) — whether this is the currently-selected/open session; drives
3601
+ the brand-quiet background treatment.
3602
+ - `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.
3604
+
3605
+ **Events:** `lyra-select` (no detail payload — identify the row via the platform `id` attribute on the
3606
+ event's `target`/`currentTarget`, the same convention `<lyra-attachment-chip>` uses; fires on a click on
3607
+ `[part="option"]` outside the rename button/`actions` slot, or Enter/Space while it's focused, only
3608
+ while not currently renaming), `lyra-rename` (`detail: { title: string }` — an in-place rename was
3609
+ committed via Enter or blur-while-editing; does not mutate `title` itself, this is a controlled
3610
+ component — not fired when the trimmed draft is empty or unchanged from the original `title`, treated
3611
+ as an implicit cancel)
3612
+
3613
+ **Slots:** `actions` — overflow/icon-button controls rendered at the trailing edge of the row (e.g. a
3614
+ future pin/delete button); only visually shown once it actually has assigned elements.
3615
+
3616
+ **CSS parts:** `base`, `option`, `content`, `title`, `title-input`, `rename-button`, `excerpt`,
3617
+ `timestamp`, `actions`
3618
+
3619
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs/-s/-m`, `--lyra-radius`,
3620
+ `--lyra-transition-fast`, `--lyra-color-text/-text-quiet/-brand/-brand-quiet/-surface`,
3621
+ `--lyra-focus-ring-width/-color/-offset`, `--lyra-icon-button-size`.
3622
+
3623
+ **Optional peer deps:** none.
3624
+
3625
+ ```html
3626
+ <lyra-conversation-item
3627
+ id="sess_123"
3628
+ title="Q3 roadmap planning"
3629
+ excerpt="Let's revisit the timeline for the launch…"
3630
+ .timestamp=${session.updatedAt}
3631
+ ?active=${session.id === currentSessionId}
3632
+ @lyra-select=${(e) => openSession(e.currentTarget.id)}
3633
+ @lyra-rename=${(e) => renameSession(e.currentTarget.id, e.detail.title)}
3634
+ >
3635
+ <button slot="actions" aria-label="Delete conversation">✕</button>
3636
+ </lyra-conversation-item>
3637
+ ```
3638
+
3639
+ `role="option"` (not `"button"`) lives on `[part="option"]`, since this is meant to sit inside a
3640
+ `role="listbox"` history list; used truly standalone it's still fully operable, just missing that ARIA
3641
+ parent context. Because `role="option"` forbids focusable descendants, the rename button and the
3642
+ `actions` slot are rendered as DOM *siblings* of `[part="option"]` (not nested inside it) — the same
3643
+ constraint the in-place rename `<input>` runs into one level deeper, which is why `[part="option"]`
3644
+ sheds its `role`/`tabindex`/`aria-selected`/`aria-label` entirely for the duration of an edit rather than
3645
+ just visually swapping content.
3646
+
3647
+ **Known gotchas:**
3648
+ - `lyra-select` carries no detail payload at all — read the session id off the event's own `target`/
3649
+ `currentTarget`, not a `detail` field.
3650
+ - Renaming is a controlled interaction: committing `lyra-rename` never updates `title` locally: the
3651
+ consumer must apply the new title once it's actually persisted.
3652
+ - An empty or unchanged (post-trim) rename draft is treated as an implicit cancel — no `lyra-rename`
3653
+ fires, and the row silently reverts to showing `title`.
3654
+ - Rename is triggered only by the dedicated pencil-icon button, never a double-click on the title —
3655
+ double-click has no keyboard/screen-reader equivalent and would also swallow the row's own
3656
+ single-click `lyra-select`.
3657
+ - While renaming, `[part="option"]` has no `role`/`tabindex`/`aria-selected`/`aria-label` at all — a
3658
+ screen reader briefly stops announcing it as a listbox option for the duration of the edit.
3659
+
3660
+ ---
3661
+
3662
+ ## `lyra-model-select`
3663
+
3664
+ A provider/model picker that renders as a closed dropdown when a fixed `catalog` is available, or as a
3665
+ filterable free-text combobox when it isn't (or when `allow-custom` explicitly permits typing something
3666
+ outside the catalog). Form-associated (hand-rolled internals via `attachInternals()` directly, not the
3667
+ shared `FormAssociated` mixin — same reasoning as `lyra-combobox`/`lyra-select`: see the shared-foundation
3668
+ notes). Built on the same trigger-button/`aria-activedescendant` listbox technique `<lyra-select>` uses
3669
+ and the filter-as-you-type suggestion-popup technique `<lyra-combobox>` uses, without composing either
3670
+ element. First-party invention (no Web Awesome equivalent).
3671
+
3672
+ **Exported types:**
3673
+ - `LyraModelCatalogEntry { id: string; label: string }` — one catalog row.
3674
+ - `LyraModelCatalog = string[] | LyraModelCatalogEntry[]` — either every entry is a plain string (used
3675
+ as both id and label) or every entry is a full `{ id, label }` row; the two shapes are not meant to be
3676
+ mixed within one array.
3677
+
3678
+ **Properties:**
3679
+ - `provider: string = ''` — informational only (e.g. `'ollama'`); rendered as a small leading badge.
3680
+ - `catalog?: LyraModelCatalog` (attribute: false) — the full model list. Omit (or leave empty) to fall
3681
+ back to plain free-text entry.
3682
+ - `allowCustom: boolean = false` (attribute `allow-custom`, reflected) — let the user type/commit a
3683
+ value that isn't in `catalog`, even when `catalog` is non-empty.
3684
+ - `placeholder: string = ''`
3685
+ - `name: string = ''`
3686
+ - `disabled: boolean = false` (reflected)
3687
+ - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
3688
+ - `open: boolean = false` (reflected)
3689
+ - `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.
3691
+
3692
+ **Mode switching:** `closedMode` (private) is `true` whenever `normalizedCatalog.length > 0 &&
3693
+ !allowCustom` — a non-empty `catalog` with `allowCustom` left `false` renders the closed dropdown
3694
+ trigger-button UI (`[part="trigger"]`, `role="combobox"` on a `<button>`, no typing). Any other
3695
+ combination (`catalog` empty/unset, or `allowCustom` true) renders the free-text `<input>` UI
3696
+ (`[part="combobox"]`/`[part="combobox-input"]`) with live substring filtering against the catalog (id or
3697
+ label, case-insensitive). The mode is re-evaluated on every render, so toggling `allowCustom` or clearing
3698
+ `catalog` at runtime switches modes live, repositioning the shared `[part="listbox"]` popover against
3699
+ whichever element is the active anchor.
3700
+
3701
+ A `value` that isn't present in `catalog` (e.g. a model id saved from a provider whose live catalog has
3702
+ since changed) is never silently dropped: it's appended to the rendered option list as a synthetic,
3703
+ visually-distinct row (dashed border, italic label, "not in catalog" badge) computed fresh from
3704
+ `catalog` + `value` on every access, without ever mutating the `catalog` property itself.
3705
+
3706
+ **Events:** `lyra-change` (`detail: { value: string; inCatalog: boolean }` — fired when a value is
3707
+ selected from the listbox or committed in free-text mode; `inCatalog` reflects whether that value was
3708
+ actually present in `normalizedCatalog`, so a consumer can flag a freshly-typed custom value distinctly
3709
+ from a real catalog pick)
3710
+
3711
+ **Slots:** none.
3712
+
3713
+ **CSS parts:** `trigger`, `combobox`, `combobox-input`, `provider-badge`, `listbox`, `option`,
3714
+ `option-label`, `option-badge`
3715
+
3716
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs/-s`,
3717
+ `--lyra-color-border/-surface/-brand/-brand-quiet/-text-quiet`, `--lyra-radius`, `--lyra-shadow`,
3718
+ `--lyra-focus-ring-width/-color/-offset`, `--lyra-icon-button-size`, `--lyra-transition-fast`,
3719
+ `--lyra-opacity-disabled`.
3720
+
3721
+ **Optional peer deps:** none.
3722
+
3723
+ ```html
3724
+ <lyra-model-select
3725
+ provider="openai"
3726
+ .catalog=${[
3727
+ { id: 'gpt-4o', label: 'GPT-4o' },
3728
+ { id: 'gpt-4o-mini', label: 'GPT-4o mini' },
3729
+ ]}
3730
+ value="gpt-4o"
3731
+ placeholder="Choose a model…"
3732
+ @lyra-change=${(e) => setModel(e.detail.value, e.detail.inCatalog)}
3733
+ ></lyra-model-select>
3734
+
3735
+ <!-- No fixed catalog yet: falls back to free-text entry -->
3736
+ <lyra-model-select provider="ollama" placeholder="Type a model id…" allow-custom></lyra-model-select>
3737
+ ```
3738
+
3739
+ **Known gotchas:**
3740
+ - `catalog` must be homogeneous — an array of plain strings, or an array of `{ id, label }` objects, not
3741
+ a mix; `LyraModelCatalog` is a union of two array *types*, not an array of a union item type.
3742
+ - The synthetic "not in catalog" row only ever appears when `catalog` is non-empty and `value` isn't one
3743
+ of its ids — with no `catalog` at all, there's no catalog list to diff `value` against, so no badge.
3744
+ - `value`/form-association here is hand-rolled via `attachInternals()` directly, not the shared
3745
+ `FormAssociated` mixin — mirrors `lyra-combobox`/`lyra-select`'s identical divergence.
3746
+ - `aria-invalid`/`data-invalid` only reflect once the control has been blurred (touched) at least once,
3747
+ matching `lyra-select`'s identical pattern — validity styling never flashes on first render.
3748
+ - In free-text mode, the input's displayed text is `query` only while `open`; while closed it shows the
3749
+ committed value's label — so setting `.value` programmatically doesn't require also touching the live
3750
+ typed text.
3751
+
3752
+ ---
3753
+
3754
+ ## `lyra-slider`
3755
+
3756
+ A numeric range control (e.g. an LLM "temperature" setting). **Form-associated** via the shared
3757
+ `FormAssociated` mixin (`name`, `value`, `disabled`, `required` all inherited). Mirrors native `<input
3758
+ type="range">` semantics: `value` is the string form-submitted via the mixin, `valueAsNumber` is the
3759
+ 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).
3761
+
3762
+ **Properties:**
3763
+ - `min: number = 0`
3764
+ - `max: number = 100`
3765
+ - `step: number = 1`
3766
+ - `label: string = ''` — accessible name set as `aria-label` on the `role="slider"` thumb; a plain
3767
+ `aria-label` attribute on the host itself is honored as a fallback when this is left unset (matching
3768
+ `<lyra-checkbox>`/`<lyra-switch>`).
3769
+ - `showValue: boolean = true` (attribute `show-value`) — whether to render the current numeric value as
3770
+ visible text next to the track. Not reflected; toggle it off via the `.showValue=${false}` property
3771
+ binding — a bare `show-value="false"` content attribute is still truthy, since presence is all Lit's
3772
+ default boolean converter checks.
3773
+ - Inherited from `FormAssociated`: `name: string = ''`, `value: string` (form-submitted string form),
3774
+ `disabled: boolean = false` (reflected), `required: boolean = false` (reflected).
3775
+
3776
+ **Accessor:** `valueAsNumber: number` — get/set. Reading always returns a finite, clamped, step-snapped
3777
+ number, even if `value` is momentarily `""` (e.g. right after `form.reset()`, before the mount-time
3778
+ default reseeds it), by falling back to the midpoint of `[min, max]`. Writing clamps/snaps the input and
3779
+ stringifies the result back into `value`.
3780
+
3781
+ **Events:** `lyra-input` (`detail: { value: number }` — fired continuously during an active drag or a
3782
+ keyboard step, including OS key-repeat while a key is held, mirroring native `<input type=range>`'s
3783
+ `input` event), `lyra-change` (`detail: { value: number }` — fired once an interaction commits: on
3784
+ pointerup for a drag, or on keyup for a keyboard step, so a single Arrow/Home/End/PageUp/PageDown press
3785
+ fires both `lyra-input` and `lyra-change`, mirroring native `<input type=range>`'s own `change`-on-every-
3786
+ committed-step behavior)
3787
+
3788
+ **Slots:** none.
3789
+
3790
+ **CSS parts:** `base` (row wrapping the track and optional value readout), `track`, `fill` (filled
3791
+ portion from `min` up to the current value), `thumb` (`role="slider"`), `value` (numeric readout, shown
3792
+ when `show-value` is true)
3793
+
3794
+ **Themeable custom properties:** shared tokens only — `--lyra-space-s`,
3795
+ `--lyra-color-border/-brand/-surface/-text-quiet`, `--lyra-shadow`,
3796
+ `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
3797
+
3798
+ **Optional peer deps:** none.
3799
+
3800
+ ```html
3801
+ <lyra-slider
3802
+ name="temperature"
3803
+ min="0"
3804
+ max="2"
3805
+ step="0.1"
3806
+ label="Temperature"
3807
+ .valueAsNumber=${0.7}
3808
+ @lyra-input=${(e) => setDraftTemperature(e.detail.value)}
3809
+ @lyra-change=${(e) => commitTemperature(e.detail.value)}
3810
+ ></lyra-slider>
3811
+ ```
3812
+
3813
+ An unset `value` is eagerly defaulted — on connect, and again after `form.reset()` — to the midpoint of
3814
+ `[min, max]` snapped to `step`, the same "range sanitization algorithm" default a native range input
3815
+ applies. A slider therefore always represents *some* number, so `required` only has a narrow window to
3816
+ block submission before that default lands, matching how `required` isn't a meaningful constraint on a
3817
+ native range input either.
3818
+
3819
+ **Known gotchas:**
3820
+ - `valueAsNumber` always returns a real, clamped number — never `NaN` or `""` — even reading it in the
3821
+ brief window right after a `form.reset()`.
3822
+ - Under `direction: rtl`, physical ArrowRight/ArrowLeft swap which one counts as "forward" (increasing
3823
+ value); ArrowUp/ArrowDown are never swapped, since direction only affects the horizontal inline axis.
3824
+ - Changing `min`/`max`/`step` after mount automatically re-clamps/re-snaps the current `value` in the
3825
+ next update — narrowing the domain can silently move the slider's value.
3826
+ - A pointer drag fires `lyra-input` continuously and a single `lyra-change` on release; a keyboard step
3827
+ fires exactly one of each per press, but OS key-repeat while a key is held re-fires `lyra-input` on
3828
+ every repeat while still only committing `lyra-change` once, on the eventual keyup.
3829
+
3830
+ ---
3831
+
3832
+ ## `lyra-tool-select-dialog`
3833
+
3834
+ 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).
3839
+
3840
+ **Exported types:**
3841
+ - `ToolSelectDialogTool { id: string; name: string; description?: string; category?: string; icon?:
3842
+ string; disabled?: boolean; disabledReason?: string }` — one selectable agent tool. `category` groups
3843
+ the row into a heading; tools with no `category` (or an empty/whitespace-only one) fall into a
3844
+ trailing "Other" bucket. `icon` is a literal glyph (e.g. an emoji) rendered next to `name` — an opaque
3845
+ string, not a registry lookup, the same convention `<lyra-tool-call-chip>`'s `icon` uses. `disabled`
3846
+ individually gates a tool regardless of `useDefaults`/`selected` (e.g. a tool requiring admin
3847
+ approval); `disabledReason` is supporting text shown under a disabled row, ignored when `disabled` is
3848
+ falsy.
3849
+ - `ToolSelectFilter = (tool: ToolSelectDialogTool, query: string) => boolean` — a predicate deciding
3850
+ whether `tool` matches an already-trimmed, already-lowercased `query`. Assign `filter` to replace the
3851
+ built-in case-insensitive name/description substring match entirely (mirrors `<lyra-combobox>`'s
3852
+ `OptionFilter` convention).
3853
+ - `ToolSelectionChangeDetail { selected: string[]; useDefaults: boolean }` — the `lyra-change` detail
3854
+ shape.
3855
+ - `ToolSelectDialogCloseReason = 'escape' | 'backdrop' | 'api' | string` — the `lyra-close` detail;
3856
+ `'escape'`/`'backdrop'` come from the dialog's own built-in dismiss triggers, any other string is
3857
+ whatever a caller passes to `close()` directly.
3858
+
3859
+ **Properties:**
3860
+ - `open: boolean = false` (reflected) — set this (or call `close()`) to dismiss; there is no separate
3861
+ `show()`/`hide()` pair.
3862
+ - `tools: ToolSelectDialogTool[] = []` (attribute: false) — the full set of tools a consumer offers,
3863
+ across all categories.
3864
+ - `selected: string[] = []` (attribute: false) — the currently-enabled tool ids.
3865
+ - `useDefaults: boolean = false` (attribute `use-defaults`, reflected) — whether the conversation is
3866
+ using the default tool set (`true`) or a custom selection (`false`).
3867
+ - `label: string = 'Select tools'` — the dialog's visible heading and accessible name.
3868
+ - `searchPlaceholder: string = 'Search tools…'` (attribute `search-placeholder`)
3869
+ - `filter: ToolSelectFilter | null = null` (attribute: false) — overrides the built-in
3870
+ case-insensitive name/description substring match.
3871
+
3872
+ **Methods:** `close(reason: ToolSelectDialogCloseReason = 'api'): void` — closes the dialog, emits
3873
+ `lyra-close` with `reason`, and returns focus to whatever had it before the dialog opened.
3874
+
3875
+ **Events:** `lyra-change` (`detail: ToolSelectionChangeDetail` — the enabled-tool selection or the
3876
+ `useDefaults` toggle changed), `lyra-close` (`detail: ToolSelectDialogCloseReason` — fired exactly once
3877
+ per dismissal, via Escape, a backdrop click, or a `close()` call)
3878
+
3879
+ **Slots:** `footer` — optional action buttons (e.g. a "Done" button), rendered in a bottom row. Changes
3880
+ already apply live via `lyra-change`, so this slot is purely optional; only visually shown once it has
3881
+ assigned elements.
3882
+
3883
+ **CSS parts:** `backdrop`, `panel`, `header`, `title`, `subtitle`, `search-row`, `search-input`,
3884
+ `defaults-row`, `defaults-toggle`, `defaults-hint`, `body`, `empty`, `category`, `category-heading`,
3885
+ `category-count`, `category-list`, `tool-row`, `tool-checkbox`, `tool-name`, `tool-icon`,
3886
+ `tool-description`, `tool-disabled-reason`, `footer`
3887
+
3888
+ **Themeable custom properties:** `--lyra-tool-select-dialog-overlay-color` (default `rgb(0 0 0 / 0.5)` —
3889
+ the backdrop scrim color; component-specific since no shared overlay token exists, mirrors
3890
+ `<lyra-dialog>`'s/`<lyra-tool-result-dialog>`'s identical pattern), plus shared `--lyra-space-*`,
3891
+ `--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`.
3893
+
3894
+ **Optional peer deps:** none — internally renders `<lyra-checkbox>` and `<lyra-switch>`, both bundled
3895
+ dependencies of this package imported directly, not optional peers.
3896
+
3897
+ ```html
3898
+ <lyra-tool-select-dialog
3899
+ label="Select tools"
3900
+ .tools=${[
3901
+ { id: 'search', name: 'Web search', category: 'Research' },
3902
+ { id: 'python', name: 'Python', category: 'Code', description: 'Run sandboxed Python' },
3903
+ { id: 'admin', name: 'Admin console', disabled: true, disabledReason: 'Requires admin approval' },
3904
+ ]}
3905
+ .selected=${enabledToolIds}
3906
+ ?use-defaults=${usingDefaults}
3907
+ ?open=${dialogOpen}
3908
+ @lyra-change=${(e) => updateTools(e.detail.selected, e.detail.useDefaults)}
3909
+ @lyra-close=${() => (dialogOpen = false)}
3910
+ >
3911
+ <button slot="footer" @click=${(e) => e.target.closest('lyra-tool-select-dialog').close('done')}>
3912
+ Done
3913
+ </button>
3914
+ </lyra-tool-select-dialog>
3915
+ ```
3916
+
3917
+ `useDefaults` is a single top-level switch: while `true`, every per-tool checkbox renders disabled
3918
+ (still reflecting whatever `selected` holds — populate that with the actual default tool set whenever
3919
+ `useDefaults` is true) alongside a hint explaining that turning the switch off is how to customize.
3920
+ Turning it off is the only control that both flips `useDefaults` to `false` *and* unlocks the per-tool
3921
+ checkboxes for editing.
3922
+
3923
+ **Known gotchas:**
3924
+ - No built-in footer/close button — dismissal happens via Escape, a backdrop click, or a consumer's own
3925
+ `footer`-slotted action calling `close()` directly.
3926
+ - A row is effectively disabled whenever *either* its own `tool.disabled` is true *or* the top-level
3927
+ `useDefaults` switch is on — a tool without `disabled` set can still render as a locked checkbox while
3928
+ `useDefaults` is true.
3929
+ - `disabledReason` text only renders when both `tool.disabled` and `tool.disabledReason` are set.
3930
+ - Categories are grouped in first-seen order across `tools`; an empty/whitespace-only `category` folds
3931
+ into a trailing "Other" bucket that's always rendered last. A category left with zero matches after
3932
+ filtering is dropped entirely, not rendered as an empty heading.
3933
+ - 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
3935
+ `disconnectedCallback`.
3936
+ - The search input is the first focusable element in the panel and receives focus automatically on open.
3937
+
3938
+ ---
3939
+
3940
+ ## `lyra-citation-badge`
3941
+
3942
+ An inline `[n]` citation marker with a hover/focus preview popover and confidence/verification-status
3943
+ coloring. First-party invention (no Web Awesome equivalent). Meant to sit inline in a chat message's
3944
+ text, each badge carrying a `source-id` that matches a corresponding `<lyra-source-card>` shown
3945
+ elsewhere on the page (a sibling component in this family) — this component never imports or knows
3946
+ anything about `<lyra-source-card>`, it only carries the id through its event details.
3947
+
3948
+ **Properties:**
3949
+ - `index: number = 1` — the citation number shown, e.g. `3` renders as `[3]`.
3950
+ - `status: CitationBadgeStatus = 'default'` (reflected) — one of `'default' | 'high' | 'medium' |
3951
+ 'low' | 'verified' | 'unverified'`; drives the badge's color and (unless `label` is set) part of
3952
+ its accessible name.
3953
+ - `sourceId: string = ''` (attribute `source-id`) — id of a corresponding `<lyra-source-card>`,
3954
+ echoed back verbatim in both events; never read or validated by this component.
3955
+ - `href: string = ''` — optional direct link target for the citation's source, carried into
3956
+ `lyra-citation-open`'s detail as-is; this component never navigates.
3957
+ - `label: string = ''` — overrides the computed accessible name (`"Citation {index}[, {status}]"`).
3958
+
3959
+ **Events:**
3960
+ - `lyra-citation-activate` (`detail: { sourceId: string; index: number }`) — fires on click, or on
3961
+ Enter while focused (native `<button>` behavior, no listener needed for the Enter case). The
3962
+ lightweight "jump to this source" signal.
3963
+ - `lyra-citation-open` (`detail: { sourceId: string; index: number; href?: string }`) — fires on
3964
+ dblclick, or on Space while focused. A distinct "full preview" signal; `href` is `undefined` when
3965
+ the `href` prop isn't set. A double-click also fires two `lyra-citation-activate` events (one per
3966
+ constituent click, standard browser `dblclick` behavior) in addition to the one `lyra-citation-open`.
3967
+
3968
+ **Slots:** default — rich preview/tooltip content (e.g. a filename + excerpt), shown in a floating
3969
+ popover on hover/focus. This is *not* the badge's visible content (the badge always renders
3970
+ `[index]`); nothing renders at all (no hover affordance) when this slot is empty.
3971
+
3972
+ **CSS parts:** `base` (the clickable `<button>`), `bracket` (each of the two literal `[`/`]` glyphs),
3973
+ `index` (the citation number), `popover` (the floating preview panel, only meaningful while open).
3974
+
3975
+ **Themeable custom properties:** `--lyra-citation-badge-accent` / `--lyra-citation-badge-bg` /
3976
+ `--lyra-citation-badge-border` (internal per-status accent variables, not typically overridden
3977
+ 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-*`.
3982
+
3983
+ **Optional peer deps:** none.
3984
+
3985
+ ```html
3986
+ <p>
3987
+ Revenue grew 12% year over year
3988
+ <lyra-citation-badge index="1" status="verified" source-id="doc-1">
3989
+ <strong>annual_report.pdf</strong> — "Revenue grew 12% year over year, driven primarily by..."
3990
+ </lyra-citation-badge>.
3991
+ </p>
3992
+ <script type="module">
3993
+ document.addEventListener('lyra-citation-activate', (e) => {
3994
+ document.querySelector(`lyra-source-card[source-id="${e.detail.sourceId}"]`)
3995
+ ?.scrollIntoView({ behavior: 'smooth', block: 'center' });
3996
+ });
3997
+ </script>
3998
+ ```
3999
+
4000
+ The popover is positioned with the same internal `place()` helper (`top-start` placement) that
4001
+ `<lyra-tool-call-chip>` uses for its own detail tooltip, and never traps focus — it's supplementary
4002
+ preview content, not a modal, so Tab continues past the badge normally even while the popover happens
4003
+ to be visible from a mouse hover. Hovering and focus are tracked as independent "keep it open"
4004
+ reasons (mirroring `<lyra-toast-item>`'s hovering/focused pair), so the pointer leaving while the
4005
+ badge still holds keyboard focus doesn't schedule a hide the focus is still holding open. There's a
4006
+ 200ms grace period before a hover/focus-out actually hides the popover, so moving the pointer from
4007
+ the badge into the popover itself (to select/copy its text) doesn't make it vanish mid-move; Escape
4008
+ and blur (Tab away) close it immediately instead, with no delay.
4009
+
4010
+ Status coloring follows a semantic scheme: `verified`/`high` use the success tones (a claim that's
4011
+ been checked, or the model is confident in); `medium`/`low` use warning tones; `unverified` uses the
4012
+ *danger* tone — deliberately distinct from `low`, since "hasn't been checked at all" is a different
4013
+ (arguably riskier) claim than "checked but uncertain". `default` renders as plain neutral text with
4014
+ no background tint, for citations that carry no confidence/verification signal at all.
4015
+
4016
+ **Known gotchas:**
4017
+ - Enter and Space are given distinct meanings (Enter = activate via native `<button>` click, Space =
4018
+ open) — Space's native click-on-keyup is pre-empted with `preventDefault()` on keydown so it fires
4019
+ `lyra-citation-open` instead of triggering a second `lyra-citation-activate`.
4020
+ - Escape closes the popover but calls `stopPropagation()`, so it won't also close a surrounding
4021
+ `<lyra-dialog>` that has its own Escape-to-close handler.
4022
+ - The preview slot's presence is tracked in JS (`hasPreviewSlot`), not via CSS `:empty` — the
4023
+ `[part="popover"]` always contains a literal `<slot>` child, so `:empty` would never match even
4024
+ with nothing assigned.
4025
+
4026
+ ---
4027
+
4028
+ ## `lyra-source-list` / `lyra-source-card`
4029
+
4030
+ A collapsible "Sources" panel for one chat message (`lyra-source-list`) that groups a set of
4031
+ `lyra-source-card` entries. First-party invention (no Web Awesome equivalent). Cards are meant to be
4032
+ direct light-DOM children of the list (plain composition — no `.items` array prop, the same shape
4033
+ `<lyra-split>`'s panels take), though `lyra-source-card` renders and functions fine standalone.
4034
+
4035
+ ### `lyra-source-list`
4036
+
4037
+ **Properties:**
4038
+ - `expanded: boolean = false` (reflected) — whether the card list is currently shown. Starts
4039
+ collapsed by default so a message's sources don't eat vertical space until asked for.
4040
+ - `label: string = ''` — header text used when `label-plural` isn't set, e.g. `"Sources"`.
4041
+ - `labelPlural: string = ''` (attribute `label-plural`) — fully consumer-built, already-pluralized
4042
+ header summary, e.g. `"3 sources"` or `"1 source"`; this component never counts or pluralizes on
4043
+ its own. Takes precedence over `label` when both are set. If neither is set, the header falls back
4044
+ to the literal word `"Sources"`.
4045
+
4046
+ **Getters:** `sourceCount: number` — read-only, live-updated count of the currently-slotted children,
4047
+ handy for building a `label-plural` string reactively, e.g. `` list.labelPlural = `${list.sourceCount} sources` ``.
4048
+
4049
+ **Events:** `lyra-toggle` (`detail: { expanded: boolean }`) — the header was activated, expanding or
4050
+ collapsing the list.
4051
+
4052
+ **Slots:** default — `<lyra-source-card>` elements (or any content, though the card pairing is the
4053
+ intended usage).
4054
+
4055
+ **CSS parts:** `base` (outer container), `header` (the clickable `<button>` toggling `expanded`),
4056
+ `toggle` (the chevron indicator inside the header), `list` (wrapper around the default slot, `hidden`
4057
+ while collapsed).
4058
+
4059
+ **Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-surface`,
4060
+ `--lyra-color-text`, `--lyra-color-brand` / `-brand-quiet`, `--lyra-radius`, `--lyra-space-xs`/`-s`/
4061
+ `-m`, `--lyra-transition-fast`, `--lyra-focus-ring-*`.
4062
+
4063
+ **Optional peer deps:** none.
4064
+
4065
+ ### `lyra-source-card`
4066
+
4067
+ **Properties:**
4068
+ - `sourceId: string = ''` (attribute `source-id`) — stable identifier matching a
4069
+ `<lyra-citation-badge>` elsewhere on the page.
4070
+ - `title: string = ''` — the source's display title, e.g. a filename. Falls back to `"Untitled
4071
+ source"` when empty.
4072
+ - `page?: string | number` — optional page reference, e.g. `12` or `"iv"`, rendered as-is (never
4073
+ parsed/validated as a number), appended to the title as `" — p. {page}"`.
4074
+ - `href?: string` — optional URL, echoed back (unopened) in `lyra-open`'s detail.
4075
+
4076
+ **Events:**
4077
+ - `lyra-expand` (`detail: { sourceId: string; expanded: boolean }`) — the per-card "Show
4078
+ more"/"Show less" toggle was activated. Unrelated to the parent `lyra-source-list`'s own
4079
+ expand/collapse, which only ever hides/shows the *set* of cards, never a single card's own content.
4080
+ - `lyra-open` (`detail: { sourceId: string; href?: string }`) — the title was activated. This
4081
+ component never navigates on its own (a controlled component, the same convention
4082
+ `<lyra-tool-call-chip>`'s `lyra-tool-chip-select` follows); a listener decides what "open" means.
4083
+
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).
4086
+
4087
+ **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).
4091
+
4092
+ **Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-surface`,
4093
+ `--lyra-color-text` / `-text-quiet`, `--lyra-color-brand`, `--lyra-radius`, `--lyra-space-xs`/`-s`,
4094
+ `--lyra-focus-ring-*`.
4095
+
4096
+ **Optional peer deps:** none.
4097
+
4098
+ ```html
4099
+ <lyra-source-list label-plural="2 sources">
4100
+ <lyra-source-card source-id="doc-1" title="annual_report.pdf" page="12">
4101
+ <span slot="excerpt">Revenue grew 12% year over year...</span>
4102
+ <span slot="full">Revenue grew 12% year over year, driven primarily by...</span>
4103
+ </lyra-source-card>
4104
+ <lyra-source-card source-id="doc-2" title="q3_notes.md">
4105
+ <span slot="excerpt">No matching full-text chunk for this source.</span>
4106
+ </lyra-source-card>
4107
+ </lyra-source-list>
4108
+ <script type="module">
4109
+ // Elsewhere, a <lyra-citation-badge>'s activation handler can scroll to and
4110
+ // highlight the matching card -- neither component needs extra API surface
4111
+ // for that, only source-id to be targeted by:
4112
+ document.addEventListener('lyra-citation-activate', (e) => {
4113
+ const card = document.querySelector(`lyra-source-card[source-id="${e.detail.sourceId}"]`);
4114
+ card?.scrollIntoView({ behavior: 'smooth', block: 'center' });
4115
+ });
4116
+ </script>
4117
+ ```
4118
+
4119
+ `lyra-source-list` removes the card list from the accessibility tree (not just visually hides it)
4120
+ while collapsed, via the native `hidden` attribute on `[part="list"]` — a screen reader user tabbing
4121
+ past the header never lands on off-screen source cards they can't currently see. Both components
4122
+ track slot presence in JS state (`slottedCount` on the list, `hasFullSlot` on the card) rather than
4123
+ relying on CSS `:empty`, since a `[part]` wrapper always contains a literal `<slot>` child regardless
4124
+ of assigned content; both also reconcile that state in `firstUpdated()` as a fallback for slot-
4125
+ forwarding scenarios or engines that don't fire `slotchange` for content already present at parse
4126
+ time.
4127
+
4128
+ **Known gotchas:**
4129
+ - This library has no built-in i18n/pluralization (the same stance `<lyra-empty>`'s plain
4130
+ `description` prop takes) — `lyra-source-list`'s header text is entirely consumer-supplied via
4131
+ `label`/`label-plural`; there's no automatic "N sources" string generation beyond the literal
4132
+ `"Sources"` fallback.
4133
+ - `lyra-source-card`'s own expand/collapse (`full` slot, `lyra-expand` event) is completely
4134
+ independent of the parent list's `expanded`/`lyra-toggle` — collapsing the list doesn't reset an
4135
+ individual card's `fullExpanded` state, and there is no cross-talk between the two components at
4136
+ all beyond DOM nesting.
4137
+
4138
+ ---
4139
+
4140
+ ## `lyra-app-rail`
4141
+
4142
+ A responsive navigation rail that adapts across three presentations as the *viewport* narrows (not
4143
+ this element's own inline size): `'full'` (nav items show icon + label, inline), `'icon-only'` (a
4144
+ narrower inline rail, icons only), and `'mobile'` (hidden behind a toggle button; opening it shows a
4145
+ focus-trapped floating overlay over the page). First-party invention (no Web Awesome equivalent).
4146
+ Breakpoints are viewport-width `matchMedia()` queries against `icon-only-breakpoint`/
4147
+ `mobile-breakpoint`, not a `ResizeObserver` on this element — presentation tracks the actual device/
4148
+ window width the way a native OS shell's navigation does, not however much horizontal space a
4149
+ particular layout happens to give it. `[part="base"]` (the inline `'full'`/`'icon-only'`
4150
+ presentation) and `[part="panel"]` (the mobile overlay) are the *same* element promoted in place
4151
+ across modes (mirrors `<lyra-widget>`'s fullscreen mode) — never both at once, and slotted nav
4152
+ content is never duplicated.
4153
+
4154
+ **Properties:**
4155
+ - `mode: AppRailMode` (custom accessor, reflected) — the getter always returns one of the three real
4156
+ modes (`'full'|'icon-only'|'mobile'`), never `'auto'`. The setter accepts
4157
+ `AppRailModeInput` (`AppRailMode | 'auto'`): assigning `'full'`/`'icon-only'`/`'mobile'` forces
4158
+ that mode and stops the element responding to breakpoint changes; assigning the write-only
4159
+ sentinel `'auto'` releases the force and immediately re-syncs to the current viewport width,
4160
+ resuming automatic tracking. Settable via the `mode` attribute too (`mode="icon-only"`,
4161
+ `mode="auto"`).
4162
+ - `iconOnlyBreakpoint: string = '960px'` (attribute `icon-only-breakpoint`) — any valid CSS length,
4163
+ used directly in a `(max-width: ...)` media query; below it the rail switches from `'full'` to
4164
+ `'icon-only'`.
4165
+ - `mobileBreakpoint: string = '600px'` (attribute `mobile-breakpoint`) — same mechanism; below it the
4166
+ rail switches from `'icon-only'` to `'mobile'`. Should be smaller than `iconOnlyBreakpoint` to
4167
+ produce all three states as the viewport narrows.
4168
+ - `open: boolean = false` (reflected) — whether the mobile floating overlay is shown. Only meaningful
4169
+ while `mode` is `'mobile'` — the value is preserved (not reset) while another mode is active, but
4170
+ no overlay chrome renders until `mode` is `'mobile'` again. Set this directly, or use the built-in
4171
+ toggle button — there is no separate `show()`/`hide()` pair.
4172
+ - `label: string = 'Navigation'` — accessible name for the rail's navigation landmark, and for its
4173
+ dialog role while the mobile overlay is open.
4174
+
4175
+ **Events:** `lyra-mode-change` (`detail: AppRailModeChangeDetail` = `{ mode: AppRailMode }`; the
4176
+ effective mode changed, whether from a breakpoint crossing or an explicit `mode` assignment — not
4177
+ fired for a redundant reassignment to the mode already in effect), `lyra-toggle`
4178
+ (`detail: AppRailToggleDetail` = `{ open: boolean }`; the mobile overlay opened or closed — via the
4179
+ built-in toggle button, Escape, a backdrop click, a nav-item click while open, or a breakpoint/forced
4180
+ mode change leaving `'mobile'` while open — not fired when a consumer sets `open` directly).
4181
+
4182
+ **Slots:** default (nav items — generic slotted content, e.g. `<a>`/`<button>` elements the consumer
4183
+ builds with its own icon+label structure; clicking anywhere in this slot closes the mobile overlay if
4184
+ open), `header` (logo/brand content, shown above the nav items in every mode), `footer` (a trailing
4185
+ user/settings trigger, shown below the nav items).
4186
+
4187
+ **CSS parts:** `base`, `header`, `nav`, `footer`, `toggle`, `backdrop`, `panel` (`base`/`panel` are
4188
+ mutually exclusive on the same underlying element — see above).
4189
+
4190
+ **Themeable custom properties:** `--lyra-app-rail-width` (default `15rem` — the inline rail width in
4191
+ `'full'` mode), `--lyra-app-rail-icon-width` (default `4rem` — the inline rail width in `'icon-only'`
4192
+ mode), `--lyra-app-rail-mobile-width` (default `18rem`, capped at `85vw` — the mobile overlay panel
4193
+ width), `--lyra-app-rail-overlay-color` (default `rgb(0 0 0 / 0.5)` — the mobile backdrop scrim
4194
+ color; component-specific since no shared token exists), plus shared tokens (`--lyra-color-border`,
4195
+ `--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`, `--lyra-color-brand-quiet`,
4196
+ `--lyra-space-*`, `--lyra-radius`, `--lyra-shadow`, `--lyra-icon-button-size`,
4197
+ `--lyra-focus-ring-*`, `--lyra-transition-base`).
4198
+
4199
+ **Optional peer deps:** none.
4200
+
4201
+ ```html
4202
+ <lyra-app-rail label="Main navigation" icon-only-breakpoint="960px" mobile-breakpoint="600px">
4203
+ <span slot="header"><img src="/logo.svg" alt="Acme" /></span>
4204
+ <a href="/inbox" aria-label="Inbox"><svg aria-hidden="true">...</svg><span>Inbox</span></a>
4205
+ <a href="/settings" aria-label="Settings"><svg aria-hidden="true">...</svg><span>Settings</span></a>
4206
+ <span slot="footer"><button>Profile</button></span>
4207
+ </lyra-app-rail>
4208
+ ```
4209
+ ```ts
4210
+ rail.mode = 'icon-only'; // force a presentation regardless of viewport width
4211
+ rail.mode = 'auto'; // release the force, resume live breakpoint tracking
4212
+ ```
4213
+
4214
+ The package root also exports a pure `computeAppRailMode(iconOnlyMatches: boolean, mobileMatches:
4215
+ boolean): AppRailMode` resolver (plus the `AppRailMode`/`AppRailModeInput`/`AppRailModeChangeDetail`/
4216
+ `AppRailToggleDetail` types) — the same logic the element's internal `matchMedia` listeners call,
4217
+ exposed standalone so a consumer can compute or unit-test the same three-state resolution without a
4218
+ real browser window. `mobileMatches` wins over `iconOnlyMatches` when both are true (the viewport is
4219
+ narrower than both breakpoints at once).
4220
+
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
4225
+ `<nav>`'s implicit role can't be overridden to `role="dialog"` without an `aria-allowed-role`
4226
+ violation. In `'icon-only'` mode, slotted nav items lose their visible text label — give each one a
4227
+ real accessible name (`aria-label`, visually hidden text, or `title`) regardless, since this
4228
+ component only lays out whatever is slotted and can't inspect or fix up a consumer's own markup.
4229
+
4230
+ **Known gotchas:**
4231
+ - `mode`'s setter accepts the wider `AppRailModeInput` (including the `'auto'` sentinel) but the
4232
+ getter's return type is the narrower `AppRailMode` — assigning `'auto'` is a one-way instruction,
4233
+ not a value read back later; there is no `isForced`-style property to check whether the rail is
4234
+ currently locked to a mode or tracking the viewport.
4235
+ - reassigning `icon-only-breakpoint`/`mobile-breakpoint` after first render tears down and rebuilds
4236
+ the `matchMedia` listeners, but does not itself un-force a previously-forced `mode` — if a consumer
4237
+ set `mode = 'icon-only'`, changing the breakpoints won't resume auto-tracking until `mode = 'auto'`
4238
+ is set explicitly.
4239
+ - leaving `'mobile'` mode while `open` (via a breakpoint crossing or a forced `mode` reassignment)
4240
+ auto-closes the overlay through the same path as the toggle button, so `lyra-toggle` still fires
4241
+ and the scroll lock/focus trap still release normally — a consumer listening only for explicit
4242
+ toggle-button clicks would miss this closure.
4243
+ - the mobile panel is also given `inert` whenever `mode === 'mobile'` and `open` is `false` — it's
4244
+ removed from the accessibility tree and tab order via `inert` at the same time it's hidden visually
4245
+ via `transform: translateX(-100%)`, both applied simultaneously rather than one implying the other.
4246
+ - the offscreen slide direction for the mobile panel is flipped for RTL via a `:dir(rtl)` CSS
4247
+ selector (`translateX(100%)`), not through the shared `internal/rtl.ts` JS helper used for pointer/
4248
+ keyboard math elsewhere in this library — a physical `transform` isn't expressible with logical
4249
+ 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.
4254
+
4255
+ ---
4256
+
4257
+ ## `lyra-responsive-panel`
4258
+
4259
+ The same slotted content either docked inline in the page's normal layout flow (desktop) or
4260
+ presented as a full-screen/bottom-sheet overlay (mobile), depending on viewport width. First-party
4261
+ invention (no Web Awesome equivalent). Typical uses: a settings panel or a conversation-history
4262
+ sidebar that's a permanent docked pane on a wide screen but a modal on a phone.
4263
+
4264
+ **Properties:**
4265
+ - `open: boolean = false` (reflected) — in the inline presentation this just means visible/mounted;
4266
+ in the overlay presentation this is the actual modal open/closed state.
4267
+ - `mode: ResponsivePanelMode = 'auto'` (reflected) — `'auto'` tracks `mobile-breakpoint` live;
4268
+ `'inline'`/`'overlay'` force that presentation regardless of viewport width.
4269
+ - `variant: ResponsivePanelVariant = 'fullscreen'` (reflected) — only affects the overlay
4270
+ presentation's visual treatment: `'fullscreen'` covers the whole viewport; `'bottom-sheet'` slides
4271
+ up from the bottom and doesn't cover the full height. Has no visual effect while the effective
4272
+ 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.
4275
+ - `mobileBreakpoint: string = '768px'` (attribute `mobile-breakpoint`) — CSS length passed to
4276
+ `matchMedia` as `(max-width: <this>)` to decide, in `mode="auto"`, whether the effective
4277
+ presentation is `'overlay'` (below/at this width) or `'inline'` (above it).
4278
+
4279
+ **Methods:** `close(reason: ResponsivePanelCloseReason = 'api'): void` — closes the panel (sets
4280
+ `open = false`), emits `lyra-close` with `reason`, and — only in the overlay presentation — returns
4281
+ focus to whichever element triggered the open. No-op if already closed. Built-in overlay triggers
4282
+ call this with `'escape'`/`'backdrop'`; a consumer's own close affordance (a footer button, a docked
4283
+ panel's own toggle) should call it directly with its own reason string.
4284
+
4285
+ **Events:** `lyra-close` (`detail: ResponsivePanelCloseReason` = `'escape'|'backdrop'|'api'|string`;
4286
+ fired by the overlay presentation's built-in dismiss triggers — Escape, backdrop click — and by any
4287
+ `close()` call, in either presentation; a plain `open = false` property write does **not** fire it,
4288
+ only going through `close()` counts as a dismissal), `lyra-mode-change`
4289
+ (`detail: ResponsivePanelModeChangeDetail` = `{ mode: ResponsivePanelEffectiveMode }`; fired whenever
4290
+ the *effective* mode — not the `mode` prop's possibly-`'auto'` literal value — changes between
4291
+ `'inline'` and `'overlay'`; never fired on the initial render, only for a live change thereafter).
4292
+
4293
+ **Slots:** default (the panel body), `header` (optional header content, rendered above the body),
4294
+ `footer` (optional footer content, e.g. action buttons, rendered below the body).
4295
+
4296
+ **CSS parts:** `base`, `backdrop`, `panel`, `header`, `body`, `footer` (`backdrop` is only rendered
4297
+ in the overlay presentation).
4298
+
4299
+ **Themeable custom properties:** `--lyra-responsive-panel-overlay-color` (default
4300
+ `rgb(0 0 0 / 0.5)` — the overlay presentation's backdrop scrim color; component-specific since no
4301
+ shared token exists), plus shared tokens (`--lyra-color-border`, `--lyra-color-surface`,
4302
+ `--lyra-space-*`, `--lyra-radius`, `--lyra-shadow`).
4303
+
4304
+ **Optional peer deps:** none.
4305
+
4306
+ ```html
4307
+ <lyra-responsive-panel id="settings-panel" label="Settings" variant="bottom-sheet" mobile-breakpoint="768px">
4308
+ <span slot="header"><h2>Settings</h2></span>
4309
+ <div>Panel body content — a form, a list, anything.</div>
4310
+ <span slot="footer"><button onclick="document.getElementById('settings-panel').close()">Done</button></span>
4311
+ </lyra-responsive-panel>
4312
+ ```
4313
+
4314
+ Breakpoint detection uses `matchMedia('(max-width: ' + mobileBreakpoint + ')')`, re-evaluated live
4315
+ 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.
4326
+
4327
+ The package root also exports the pure `resolveEffectiveMode(mode: ResponsivePanelMode,
4328
+ belowBreakpoint: boolean): ResponsivePanelEffectiveMode` resolver — renamed on export to
4329
+ `resolveResponsivePanelEffectiveMode` to avoid a name collision — alongside the
4330
+ `ResponsivePanelMode`/`ResponsivePanelEffectiveMode`/`ResponsivePanelVariant`/
4331
+ `ResponsivePanelCloseReason`/`ResponsivePanelModeChangeDetail` types. It's the same logic the
4332
+ element's internal `matchMedia` listener calls: `'inline'`/`'overlay'` pass straight through
4333
+ unchanged; `'auto'` resolves to `'overlay'` when `belowBreakpoint` is true, `'inline'` otherwise —
4334
+ exposed standalone so a consumer can compute or unit-test the same resolution without a real browser
4335
+ window.
4336
+
4337
+ **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.
4343
+ - `variant="bottom-sheet"` has no visible effect at all while the effective presentation is
4344
+ `'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.
4349
+ - the exported pure function is named `resolveEffectiveMode` in the component module but re-exported
4350
+ from the package root as `resolveResponsivePanelEffectiveMode` — importing the un-prefixed name
4351
+ from `@aceshooting/lyra-ui` will fail.
4352
+
4353
+ ---
4354
+
4355
+ ## `lyra-mention-popover`
4356
+
4357
+ A caret-anchored, keyboard-navigable popover for `@`-mention and `/`-slash-command autocomplete
4358
+ inside a plain-text `<textarea>`/`<input>` the host owns. First-party invention (no Web Awesome
4359
+ equivalent). It never takes DOM focus itself — the host's own input keeps focus, and this
4360
+ component conveys the active row via `activeDescendantId` for the host to apply as its own
4361
+ input's `aria-activedescendant`, the same pattern `<lyra-select>`/`<lyra-combobox>` use for their
4362
+ own listbox.
4363
+
4364
+ **Properties:**
4365
+ - `anchor?: HTMLElement` (attribute: false) — the element to position the popup relative to. A
4366
+ plain `<textarea>` or single-line text `<input type="text"|"search">` gets caret-precise
4367
+ positioning; any other element anchors the whole popup under that element's own box.
4368
+ - `items: MentionItem[] = []` (attribute: false) — the full candidate set, pre-`query`-filtering.
4369
+ - `query: string = ''` — the text typed since the trigger character; drives the built-in filtering
4370
+ (see `filter`).
4371
+ - `open: boolean = false` (reflected)
4372
+ - `filter: MentionFilter | null = null` (attribute: false) — overrides the built-in
4373
+ case-insensitive `label`/`description` substring match entirely.
4374
+ - `emptyText: string = 'No matches'` (attribute `empty-text`)
4375
+ - `label: string = 'Suggestions'` — accessible name for the `role="listbox"` popup.
4376
+ - `filteredItems: MentionItem[]` — read-only getter; `items` filtered by `query` via `filter` (or
4377
+ the built-in default). Empty `query` returns `items` unfiltered.
4378
+ - `activeDescendantId: string | null` — read-only getter; the `id` of the currently-highlighted
4379
+ row, or `null` while closed or when `filteredItems` is empty.
4380
+ - `listboxId: string` — read-only getter; the `id` of the `role="listbox"` element, for a host that
4381
+ also wants to wire `aria-controls`.
4382
+
4383
+ **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.
4388
+
4389
+ **Exported types:** `MentionItem { id: string; label: string; description?: string; icon?: string
4390
+ }`; `MentionFilter = (item: MentionItem, query: string) => boolean`; `MentionSelectDetail { id:
4391
+ string; label: string }`.
4392
+
4393
+ **Events:** `lyra-mention-select` (`detail: MentionSelectDetail`, `{ id, label }` of the row that
4394
+ was committed via Enter/Tab/click), `lyra-mention-close` (no detail payload —
4395
+ `this.emit('lyra-mention-close')` is called with no second argument, so `event.detail` is `null`,
4396
+ not `undefined`; fires on Escape or any other `open: true -> false` transition, but never for the
4397
+ close that immediately follows a `lyra-mention-select` commit, and never for markup that simply
4398
+ renders `open="false"` on first paint)
4399
+
4400
+ **Slots:** none.
4401
+
4402
+ **CSS parts:** `listbox`, `option`, `option-icon` (when `icon` is set), `option-label`,
4403
+ `option-description` (when `description` is set), `empty`
4404
+
4405
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs`/`-s`/`-m` (popup padding,
4406
+ row padding/gap), `--lyra-color-surface`/`-border` (popup background/border), `--lyra-radius`
4407
+ (popup and row corners), `--lyra-shadow` (popup elevation), `--lyra-transition-fast` (open/close
4408
+ transition), `--lyra-color-brand-quiet` (active-row background), `--lyra-color-brand` (selected-row
4409
+ text), `--lyra-color-text-quiet`/`--lyra-color-text` (description text, full-contrast on the active
4410
+ row).
4411
+
4412
+ **Optional peer deps:** none.
4413
+
4414
+ ```html
4415
+ <textarea id="composer"></textarea>
4416
+ <lyra-mention-popover id="mentions" label="People" empty-text="No matches"></lyra-mention-popover>
4417
+ <script type="module">
4418
+ const textarea = document.getElementById('composer');
4419
+ const popover = document.getElementById('mentions');
4420
+
4421
+ textarea.addEventListener('keydown', (e) => {
4422
+ if (popover.open && popover.handleKeyDown(e)) return; // consumed
4423
+ });
4424
+ textarea.addEventListener('input', () => {
4425
+ popover.anchor = textarea;
4426
+ popover.items = [
4427
+ { id: 'ada', label: 'Ada Lovelace', description: 'Engineering', icon: '👩‍💻' },
4428
+ { id: 'grace', label: 'Grace Hopper', description: 'Engineering' },
4429
+ ];
4430
+ popover.query = 'a'; // detected since the trigger character
4431
+ popover.open = true;
4432
+ });
4433
+
4434
+ popover.addEventListener('lyra-mention-select', (e) => {
4435
+ // splice `${e.detail.label}` into the textarea at the trigger offset
4436
+ });
4437
+ </script>
4438
+ ```
4439
+
4440
+ Integration is entirely the host's responsibility: detect a mention/command trigger in the host's
4441
+ own `input` handling, set `anchor`/`items`/`query` and flip `open = true`, forward every `keydown`
4442
+ through `handleKeyDown()` while open, and keep the host's own input's `aria-activedescendant` (and
4443
+ optionally `aria-controls`, via `listboxId`) in sync with `activeDescendantId`. Setting `open =
4444
+ false` whenever the query stops looking like an active mention context (a space typed, the trigger
4445
+ deleted, the input blurred, …) is also the host's job — `lyra-mention-close` fires automatically
4446
+ from that, there is no separate "tell it to close" call needed.
4447
+
4448
+ Positioning measures exactly where the caret currently paints via a hidden-mirror-element technique
4449
+ (`caretClientRect()`) and positions against that single point with `internal/positioner.js`'s
4450
+ `place()`, so the popup tracks the caret rather than sitting under the whole textarea. Re-measures
4451
+ automatically only on an `anchor` or `query` change while open (a keystroke moves the caret, so a
4452
+ fresh `query` is the proxy for "the caret may have moved").
4453
+
4454
+ **Known gotchas:**
4455
+ - The popover opens pre-highlighted on the top match (index 0), unlike `<lyra-combobox>`'s own
4456
+ listbox which opens with nothing highlighted (`-1`) — a bare Enter right after opening commits
4457
+ immediately.
4458
+ - Caret-precise positioning only applies to a plain `<textarea>` or single-line text
4459
+ `<input type="text"|"search">`; any other `anchor` element, or a text control whose caret rect
4460
+ can't be measured (e.g. `display: none`), silently falls back to whole-element anchoring against
4461
+ `anchor` itself.
4462
+ - A caret that moves for a reason other than typing (e.g. a mouse click elsewhere in the text while
4463
+ the popover happens to still be open) is not separately tracked — force a re-measure by toggling
4464
+ `open` or reassigning `anchor`.
4465
+ - `activeIndex` resets to `0` whenever `query` or `items` changes, but not when only `anchor`
4466
+ changes — reassigning `anchor` alone preserves whatever row was last highlighted.
4467
+
4468
+ ---
4469
+
4470
+ ## `lyra-streaming-text`
4471
+
4472
+ A token-coalescing incremental text renderer for streaming assistant output, with an optional
4473
+ blinking cursor and auto-detected Markdown rendering. First-party invention (no Web Awesome
4474
+ equivalent). The host is expected to assign the *entire* current text on every update to `content`,
4475
+ not a delta — this component does no accumulation or ordering of its own.
4476
+
4477
+ **Properties:**
4478
+ - `content: string = ''` — the full current text so far.
4479
+ - `streaming: boolean = false` (reflected) — shows the blinking cursor after the rendered text;
4480
+ reflects so a host can also target `lyra-streaming-text[streaming]` in CSS.
4481
+ - `coalesceMs: number = 50` (attribute `coalesce-ms`) — trailing-edge coalesce window, in ms, for
4482
+ `content` updates (see prose below).
4483
+ - `markdown?: boolean` (attribute `markdown`, tri-state via a custom `ComplexAttributeConverter`) —
4484
+ `undefined` (attribute absent, the default) auto-detects via `looksLikeMarkdown`; the attribute
4485
+ present with no value or `="true"` forces `true`; `markdown="false"` forces `false`. An explicit
4486
+ `true`/`false` always wins over the heuristic.
4487
+
4488
+ **Exported helper:** `looksLikeMarkdown(text: string): boolean` — runs a fixed, ordered list of
4489
+ lightweight regexes (ATX heading, fenced code block, `**bold**`, `_italic_`, inline code, bullet
4490
+ list item, numbered list item, `[text](url)` link, blockquote) against the whole string and returns
4491
+ `true` on the first match. Used internally whenever `markdown` is left unset; exported standalone
4492
+ so the heuristic is directly testable without going through the component's render cycle. None of
4493
+ the patterns need to be airtight — a false positive just routes ordinary prose harmlessly through
4494
+ `<lyra-markdown>`; a false negative just shows literal `**`/backticks/etc. as plain text until more
4495
+ of the stream arrives.
4496
+
4497
+ **Events:** none.
4498
+
4499
+ **Slots:** none — content renders from `content`, not a slot.
4500
+
4501
+ **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`)
4502
+
4503
+ **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).
4508
+
4509
+ **Optional peer deps:** none — internally imports and auto-registers `<lyra-markdown>` for
4510
+ Markdown-mode rendering (a side-effect import; the host never needs to import or register it
4511
+ itself).
4512
+
4513
+ ```html
4514
+ <lyra-streaming-text id="out" coalesce-ms="80" streaming></lyra-streaming-text>
4515
+ <script type="module">
4516
+ const out = document.getElementById('out');
4517
+ let text = '';
4518
+ for await (const token of tokenStream) {
4519
+ text += token;
4520
+ out.content = text; // always the full string so far, never a delta
4521
+ }
4522
+ out.streaming = false; // forces the final chunk through immediately; cursor disappears
4523
+ </script>
4524
+ ```
4525
+
4526
+ Token-by-token streaming can update `content` far faster than a human can usefully perceive a
4527
+ re-render, so updates funnel through `Announcer` (`../../internal/announcer.js`), reused here
4528
+ purely as a generic "coalesce rapid calls, flush the latest" timing primitive — with none of that
4529
+ class's usual DOM/ARIA plumbing. Within any `coalesce-ms` window, only the *last* `content` value
4530
+ assigned actually reaches the rendered DOM. Two cases always bypass the throttle and flush
4531
+ 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.
4534
+
4535
+ Rendering itself is never reimplemented here: Markdown mode composes `<lyra-markdown>` directly,
4536
+ forwarding this component's own `streaming` through as that component's `streaming` hint prop;
4537
+ plain-text mode renders into a `white-space: pre-wrap` span instead. The blinking cursor degrades
4538
+ to a static, always-visible bar under `prefers-reduced-motion: reduce`. In plain-text mode it sits
4539
+ inline at the tail of the final character; in Markdown mode it renders as its own trailing block
4540
+ below the rendered content instead of attempting to splice into whatever nested block Markdown
4541
+ happens to end with.
4542
+
4543
+ **Known gotchas:**
4544
+ - `content` must always be the complete string so far, never a delta — this component does no
4545
+ 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`.
4548
+ - `markdown="false"` (any string value other than exactly `"false"` is treated as `true` by the
4549
+ converter) forces plain-text mode even if the text obviously contains Markdown syntax.
4550
+ - Purely presentational: no events, and it does not announce anything to assistive tech itself — a
4551
+ host that needs streamed text announced needs `<lyra-live-region>` for that (e.g. composed inside
4552
+ `<lyra-chat-message>`).
4553
+
4554
+ ---
4555
+
4556
+ ## `lyra-thinking-panel`
4557
+
4558
+ A collapsible panel for an AI agent's intermediate reasoning/"thinking" transcript, kept visually
4559
+ and semantically distinct from its final response. First-party invention (no Web Awesome
4560
+ equivalent). Same collapsible header-button-plus-region shape as `<lyra-source-list>`; the default
4561
+ slot is entirely free-form (a consumer-composed `<lyra-streaming-text>`, `<lyra-markdown>`, or
4562
+ plain text) — this component has no dependency on either.
4563
+
4564
+ **Properties:**
4565
+ - `label: string = 'Thinking'`
4566
+ - `expanded: boolean = false` (reflected) — starts collapsed, matching `<lyra-source-list>`'s
4567
+ default.
4568
+ - `mode: 'live' | 'post-hoc' = 'live'` (reflected) — `'live'` while reasoning is actively streaming
4569
+ in; `'post-hoc'` once it's complete and being reviewed after the fact. Drives two concrete
4570
+ behavior differences, see prose below.
4571
+ - `durationMs?: number` (attribute `duration-ms`) — how long the reasoning took. Omitted entirely
4572
+ (nothing rendered in `'post-hoc'`, a pulsing placeholder in `'live'`) while unset.
4573
+
4574
+ **Methods:** `scrollToBottom(): void` — scrolls `[part="body"]` to its current bottom immediately
4575
+ (no smooth-scroll animation). Safe to call directly, e.g. from a host that wants to force a
4576
+ jump-to-latest action of its own.
4577
+
4578
+ **Events:** `lyra-toggle` (`detail: { expanded: boolean }`, same event name and shape as
4579
+ `<lyra-source-list>`'s own `lyra-toggle`) — fired whenever the header button is activated.
4580
+
4581
+ **Slots:** default (the reasoning/thinking content; entirely free-form)
4582
+
4583
+ **CSS parts:** `base`, `header`, `label`, `duration`, `toggle`, `body`
4584
+
4585
+ **Themeable custom properties:** `--lyra-thinking-panel-max-block-size` (default `16rem` —
4586
+ consumer-overridable cap on how tall `[part="body"]` grows before it scrolls internally; not
4587
+ exposed as a component property since it's a pure layout knob, not something a template branches
4588
+ on), plus shared `--lyra-color-border`/`-surface`/`-text`/`-text-quiet`/`-brand`/`-brand-quiet`,
4589
+ `--lyra-space-xs`/`-s`/`-m`, `--lyra-radius`, `--lyra-focus-ring-width`/`-color`/`-offset`,
4590
+ `--lyra-transition-fast`/`-base`.
4591
+
4592
+ **Optional peer deps:** none.
4593
+
4594
+ ```html
4595
+ <lyra-thinking-panel label="Reasoning" mode="live" expanded>
4596
+ <lyra-streaming-text content="Considering the user's constraints…" streaming></lyra-streaming-text>
4597
+ </lyra-thinking-panel>
4598
+
4599
+ <lyra-thinking-panel label="Reasoning" mode="post-hoc" duration-ms="4200">
4600
+ <p>Finished reasoning, collapsed by default.</p>
4601
+ </lyra-thinking-panel>
4602
+ ```
4603
+
4604
+ `mode` drives two concrete behavior differences, not just a styling hook. **Header hint:** while
4605
+ `duration-ms` is unset, `'live'` shows a pulsing "Thinking…" placeholder in `[part="duration"]`;
4606
+ `'post-hoc'` shows nothing there. Once `duration-ms` is set, both modes show the same static
4607
+ "Thought for …" text. **Auto-scroll:** only `'live'` mode auto-follows new content appended to the
4608
+ default slot while `expanded`; `'post-hoc'` never scrolls on its own.
4609
+
4610
+ Live-mode auto-scroll ("stick to bottom") is the classic chat-transcript convention: while
4611
+ `mode="live"` and `expanded`, new content keeps the panel scrolled to its latest line — unless the
4612
+ user has manually scrolled up to re-read earlier content (tracked via a `scroll` listener on
4613
+ `[part="body"]`: every user-driven scroll records whether the body was left within 48px of its own
4614
+ 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
4617
+ (`childList`+`subtree`+`characterData`), not `slotchange`, since streamed reasoning typically
4618
+ appends chunks to an existing node's `textContent` rather than re-slotting a whole new element per
4619
+ token; scroll-to-bottom calls are coalesced to at most one per animation frame under a fast token
4620
+ stream.
4621
+
4622
+ **Known gotchas:**
4623
+ - The `MutationObserver` only watches this element's own light-DOM subtree — it cannot see a
4624
+ mutation that happens entirely inside a slotted custom element's own shadow root (e.g. a
4625
+ `<lyra-markdown>` re-rendering its shadow tree after a `content` change). A slotted element whose
4626
+ own internal updates should drive auto-scroll needs to append/mutate visible light-DOM text
4627
+ 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.
4631
+ - The sticky-bottom flag starts `true` internally, so a panel that mounts already `expanded` and
4632
+ `mode="live"` follows its very first content mutation even before any `scroll` event has fired.
4633
+
4634
+ ---
4635
+
4636
+ ## `lyra-generation-status`
4637
+
4638
+ A compact, ticking status readout shown alongside an in-progress AI response: elapsed time, token
4639
+ count, and token-throughput, plus a built-in Stop button. First-party invention (no Web Awesome
4640
+ equivalent). Renders as e.g. `12.3s · 340 tokens · 27 tok/s [Stop]`.
4641
+
4642
+ **Properties:**
4643
+ - `active: boolean = false` (reflected) — whether generation is currently in progress. The
4644
+ elapsed-time ticker (a ~1s `setInterval`) runs only while this is `true`.
4645
+ - `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.
4648
+ - `tokenCount?: number` (attribute `token-count`) — running token count so far. Omitted from the
4649
+ readout entirely (no `tokens` segment) while unset.
4650
+ - `tokensPerSecond?: number` (attribute `tokens-per-second`) — host-computed tokens/sec figure,
4651
+ 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).
4657
+
4658
+ **Events:** `lyra-stop` (no detail payload — `this.emit('lyra-stop')` is called with no second
4659
+ argument, so `event.detail` is `null`, not `undefined`) — fired when the built-in Stop button is
4660
+ clicked.
4661
+
4662
+ **Slots:** none.
4663
+
4664
+ **CSS parts:** `base`, `elapsed` (always rendered, reads `"0.0s"` before the component has ever
4665
+ been active), `tokens` (only rendered when `token-count` is set), `throughput` (only rendered when
4666
+ a value is available, host-supplied or derived), `stop-button` (only rendered while `show-stop` is
4667
+ `true`)
4668
+
4669
+ **Themeable custom properties:** shared tokens only — `--lyra-color-text-quiet` (base readout and
4670
+ tokens/throughput text color), `--lyra-color-text` (the elapsed segment's higher-contrast color,
4671
+ and the stop-button's icon color), `--lyra-space-s` (stop-button margin), `--lyra-icon-button-size`
4672
+ (stop-button min sizing, capped at `1.75rem`), `--lyra-color-border`/`-surface`/`-brand`
4673
+ (stop-button border/background/hover), `--lyra-focus-ring-width`/`-color`/`-offset`,
4674
+ `--lyra-transition-fast`.
4675
+
4676
+ **Optional peer deps:** none.
4677
+
4678
+ ```html
4679
+ <lyra-generation-status active started-at="1732000000000" token-count="340" show-stop></lyra-generation-status>
4680
+ <script type="module">
4681
+ document.querySelector('lyra-generation-status').addEventListener('lyra-stop', () => {
4682
+ controller.abort(); // stop the host's own generation
4683
+ });
4684
+ </script>
4685
+ ```
4686
+
4687
+ This is deliberately a *different* concern than `<lyra-stream-status>`: that component is about
4688
+ transport/connection health (idle/connecting/streaming/stalled, heartbeat-aware stall detection),
4689
+ while this one is a user-facing metrics readout for a generation both components' hosts typically
4690
+ already know is healthily in progress. Neither imports or depends on the other; compose both side
4691
+ by side rather than picking one. `tokens-per-second`, when supplied directly, is always used as-is;
4692
+ when omitted, this component derives a live figure from `token-count` divided by elapsed seconds,
4693
+ but only once at least one full second of elapsed time has accumulated (dividing by a sub-second
4694
+ window can produce wildly-swinging early readings, e.g. 3 tokens in 40ms reading as "75 tok/s").
4695
+ The elapsed clock is frozen, not reset, once `active` goes `false` — a static "Generated in 12.3s"
4696
+ reads better as a completed-state summary than the readout blanking out the instant generation
4697
+ ends.
4698
+
4699
+ This readout ticks roughly once per second while active, which is exactly the kind of
4700
+ high-frequency update `<lyra-live-region>`/`Announcer` exists to *prevent* from being read aloud
4701
+ verbatim — this component therefore carries no `role="status"`/`aria-live` of its own and never
4702
+ announces anything. A host that wants generation-start/-end announced should pair this with
4703
+ something that announces state *transitions* instead. The Stop button gets a normal, always-present
4704
+ `aria-label="Stop generating"`, no different from any other icon-only button in this library.
4705
+
4706
+ **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.
4709
+ - The derived `tokens-per-second` figure only appears once `elapsedMs >= 1000`; before that, the
4710
+ `throughput` part simply doesn't render — supply `tokens-per-second` yourself for a stable figure
4711
+ from the very first tick.
4712
+ - The elapsed-time display is never reset to `"0.0s"` when `active` goes `false` — it freezes at
4713
+ its last value. A host that wants a blank readout between generations must reset `started-at`/
4714
+ `token-count` itself (or unmount/remount the element).
4715
+ - `started-at` only re-baselines the ticker at the moment it's read: mounting the component with
4716
+ `active` already `true` but no `started-at` captures `Date.now()` at that first update, not at
4717
+ whatever earlier instant generation may actually have begun.
4718
+
4719
+ ---
4720
+
4721
+ ## `lyra-code-block`
4722
+
4723
+ Fenced code display with optional lazy syntax highlighting and a copy button. First-party invention
4724
+ (no Web Awesome equivalent). No highlighting grammar ships in the component itself — it lazy-loads
4725
+ the optional peer dependency `shiki` (see `code-loader.ts`) for the actual tokenizing, and degrades
4726
+ to a plain `<pre><code>` when that peer isn't installed or `language` is unset/unrecognized. That
4727
+ fallback is the *default* rendering path, not a degraded one: unhighlighted code is perfectly usable,
4728
+ and it's what every instance renders at zero extra bytes until shiki resolves.
4729
+
4730
+ **Properties:**
4731
+ - `code: string = ''` — the raw source text
4732
+ - `language: string = ''` — a shiki-recognized language id or alias (e.g. `"javascript"`, `"python"`,
4733
+ `"json"`); when unset, or when shiki doesn't recognize it, the code renders as plain unhighlighted
4734
+ text regardless of whether shiki itself is available
4735
+ - `filename: string = ''` — shown in the header, when set
4736
+ - `collapsible: boolean = false` (reflected) — shows the collapse/expand chevron button
4737
+ - `collapsed: boolean = false` (reflected) — only has a visible effect while `collapsible` is also
4738
+ true
4739
+ - `copyable: boolean = true` (reflected) — shows the copy-to-clipboard button (assign `false` via a
4740
+ PROPERTY binding, e.g. `.copyable=${false}` — a `?copyable=${false}` boolean-attribute binding
4741
+ cannot override a true default)
4742
+ - `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"20rem"`); once set, the
4743
+ code scrolls internally past this height instead of growing the page
4744
+
4745
+ **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)
4747
+
4748
+ **Slots:** none.
4749
+
4750
+ **CSS parts:** `base`, `header`, `filename`, `language`, `copy-button`, `toggle`, `body`, `pre`,
4751
+ `code`
4752
+
4753
+ **Themeable custom properties:** `--lyra-code-block-max-height` (default `none` — the consumer-tunable
4754
+ scroll cap; only takes effect once `max-height` is set), `--lyra-code-block-font` (default
4755
+ `ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` — no shared `--wa-*`/`--lyra-*` monospace
4756
+ token exists to resolve through), plus shared tokens `--lyra-color-border`, `--lyra-radius`,
4757
+ `--lyra-color-surface`, `--lyra-space-xs/-s/-m`, `--lyra-font`, `--lyra-color-text-quiet`,
4758
+ `--lyra-color-text`, `--lyra-color-brand`/`-brand-quiet`, `--lyra-transition-fast`,
4759
+ `--lyra-focus-ring-width/-color/-offset`.
4760
+
4761
+ **Optional peer deps:** `shiki` (lazy-loaded and cached once per page by `code-loader.ts`'s
4762
+ `loadShikiHighlighter()`, which builds a single `Highlighter` seeded with the bundled `github-light`/
4763
+ `github-dark` "dual themes" and *zero* language grammars up front; each `language` a
4764
+ `<lyra-code-block>` actually requests is loaded incrementally on first use via
4765
+ `loadShikiLanguage()`, and a language id that fails to load once is remembered and never retried. If
4766
+ `shiki` isn't installed, `loadShikiHighlighter()` resolves to `null` with a one-time `console.warn`
4767
+ and every instance falls back to plain text — install it with `pnpm add shiki` to enable
4768
+ highlighting).
4769
+
4770
+ ```html
4771
+ <lyra-code-block
4772
+ language="typescript"
4773
+ filename="sum.ts"
4774
+ collapsible
4775
+ max-height="20rem"
4776
+ .code=${`export function sum(a: number, b: number) {\n return a + b;\n}`}
4777
+ @lyra-copy=${(e) => console.log('copied', e.detail.text)}
4778
+ ></lyra-code-block>
4779
+ ```
4780
+
4781
+ A `<lyra-skeleton variant="rect">` placeholder (with `aria-busy="true"` on the host) stands in only
4782
+ while shiki itself is loading for the very first time on the page and `language` is set — it is
4783
+ deliberately *not* shown again for a later per-language grammar fetch (that's typically fast, and the
4784
+ plain-text fallback already reads fine as a placeholder for it). Internally, a shiki `transformer`
4785
+ (`partTransformer`) rewrites shiki's generated `<pre>`/`<code>` nodes in a single pass to carry this
4786
+ component's own `part="pre"`/`part="code"` hooks and strips shiki's default `tabindex="0"` from
4787
+ `<pre>`, since `[part="body"]` is already the single scrollable/focusable region (`role="group"`,
4788
+ `tabindex="0"`) for the code area. Dark mode is handled via shiki's own "dual themes" feature: every
4789
+ token carries its light color as a plain inline `color`/`background-color` and its dark color in
4790
+ `--shiki-dark`/`--shiki-dark-bg` custom properties, which an `!important` media-query rule in
4791
+ `code-block.styles.ts` reassigns under `prefers-color-scheme: dark` — the one deliberate exception in
4792
+ this component to every other color being a `--lyra-*` token, since these values come from shiki's own
4793
+ theme data.
4794
+
4795
+ **Known gotchas:**
4796
+ - `copyable` defaults to `true` and reflects — see the property note above about overriding it with a
4797
+ property binding, not a boolean-attribute binding.
4798
+ - an in-flight per-language grammar load is guarded by an internal token so a `code`/`language` change
4799
+ that arrives before a previous load resolves never applies a stale result — only the load matching
4800
+ the *current* `language` is ever rendered.
4801
+ - a malformed `code`/`language` combination that makes shiki's `codeToHtml()` throw falls back to
4802
+ plain text silently, not a blank code block.
4803
+ - the "Copied!" label reverts to "Copy" after a fixed 1500ms, regardless of whether the clipboard
4804
+ write actually succeeded — `navigator.clipboard` is absent in insecure contexts/older browsers, and
4805
+ `lyra-copy` still fires either way.
4806
+
4807
+ ---
4808
+
4809
+ ## `lyra-tool-approval-dialog`
4810
+
4811
+ A human-in-the-loop gate: presents one proposed tool/function call (`toolName` + `args`) and blocks an
4812
+ 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.
4817
+
4818
+ Approve/Deny/Edit are built-in chrome, not a `footer` slot a consumer must assemble — there is exactly
4819
+ one correct action set for "approve this call". The `footer` slot is offered only for *supplementary*
4820
+ content alongside those buttons (e.g. a "remember this choice for this tool" checkbox); its content
4821
+ renders at the start of the action row, before Deny/Edit/Approve.
4822
+
4823
+ **Exported types:**
4824
+ - `ToolApprovalDialogCloseReason = 'escape' | 'backdrop' | 'approve' | 'deny' | 'api' | string` — the
4825
+ `lyra-close` detail; `'escape'`/`'backdrop'`/`'approve'`/`'deny'` come from the dialog's own built-in
4826
+ dismiss triggers, any other string is whatever a caller passes to `close()` directly.
4827
+
4828
+ **Properties:**
4829
+ - `open: boolean = false` (reflected) — set this (or call `close()`) to dismiss; there is no separate
4830
+ `show()`/`hide()` pair
4831
+ - `toolName: string = ''` (attribute `tool-name`) — the proposed call's name, e.g. `web_search`;
4832
+ drives the heading and the dialog's accessible name
4833
+ - `args: unknown = {}` (attribute: false) — the proposed call's arguments, rendered via
4834
+ `<lyra-json-viewer>` read-only, or stringified into a `<textarea>` while editing
4835
+ - `editable: boolean = true` (reflected) — whether an "Edit" affordance is offered at all (assign
4836
+ `false` via a PROPERTY binding, e.g. `.editable=${false}` — a `?editable=${false}`
4837
+ boolean-attribute binding cannot override a true default). When `false`, `args` is always shown
4838
+ read-only and can never be changed before approval.
4839
+
4840
+ **Methods:** `close(reason: ToolApprovalDialogCloseReason = 'api'): void` — closes the dialog, emits
4841
+ `lyra-close` with `reason`, and returns focus to whatever had it before the dialog opened; a no-op if
4842
+ already closed.
4843
+
4844
+ **Events:** `lyra-approve` (`detail: { args: unknown }` — the current, already-parsed arguments: the
4845
+ original `args` prop, or the user's edited-and-validated version if an edit was in progress; always
4846
+ followed by `lyra-close` with reason `'approve'`), `lyra-deny` (no detail — `this.emit('lyra-deny')` is
4847
+ called with no second argument, so per the DOM spec's `CustomEventInit` default, `event.detail` is
4848
+ `null`, not `undefined`; always followed by `lyra-close` with reason `'deny'`), `lyra-close`
4849
+ (`detail: ToolApprovalDialogCloseReason` — fired exactly once per dismissal, via Escape, a backdrop
4850
+ click, the Approve/Deny buttons, or a `close()` call)
4851
+
4852
+ **Slots:** `footer` — optional supplementary content (e.g. a "remember this choice" checkbox),
4853
+ rendered before the built-in Deny/Edit/Approve buttons.
4854
+
4855
+ **CSS parts:** `backdrop`, `panel`, `header`, `tool-name`, `body`, `args-view`, `args-editor`, `error`,
4856
+ `footer`, `deny-button`, `edit-button`, `approve-button`
4857
+
4858
+ **Themeable custom properties:** `--lyra-tool-approval-dialog-overlay-color` (default
4859
+ `rgb(0 0 0 / 0.5)` — the backdrop scrim color; component-specific since no shared overlay token
4860
+ exists), `--lyra-tool-approval-dialog-mono-font` (default `ui-monospace, SFMono-Regular, Menlo,
4861
+ Consolas, monospace` — used by both `tool-name` and the raw-JSON editor), plus shared tokens
4862
+ `--lyra-space-xs/-s/-m/-l`, `--lyra-color-surface`, `--lyra-color-border`, `--lyra-radius`,
4863
+ `--lyra-shadow`, `--lyra-color-brand`, `--lyra-color-on-brand`, `--lyra-color-danger`,
4864
+ `--lyra-color-text`, `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
4865
+
4866
+ **Optional peer deps:** none — internally renders `<lyra-json-viewer>`, a bundled dependency of this
4867
+ package, not an optional peer.
4868
+
4869
+ ```html
4870
+ <lyra-tool-approval-dialog
4871
+ tool-name="send_email"
4872
+ .args=${{ to: 'ops@example.com', subject: 'Deploy finished' }}
4873
+ @lyra-approve=${(e) => runTool(e.detail.args)}
4874
+ @lyra-deny=${() => console.log('denied')}
4875
+ @lyra-close=${(e) => console.log('closed:', e.detail)}
4876
+ ></lyra-tool-approval-dialog>
4877
+ <script type="module">
4878
+ document.querySelector('lyra-tool-approval-dialog').open = true;
4879
+ </script>
4880
+ ```
4881
+
4882
+ While `editable`, an Edit button swaps the read-only `<lyra-json-viewer>` for a plain `<textarea>`
4883
+ pre-filled with `JSON.stringify(args, null, 2)`. Every keystroke re-validates with `JSON.parse` — the
4884
+ Approve button is `disabled` for as long as the current textarea content fails to parse, so a
4885
+ malformed edit can never be silently approved as either the broken text or a stale copy of the
4886
+ original args. The same button relabels to "Cancel" while editing; clicking it discards the draft
4887
+ entirely and returns to the read-only view of the *original* `args` — there is no separate "save"
4888
+ step independent of Approve itself. Both `editing` and any in-progress draft reset back to the
4889
+ read-only view every time the dialog transitions from closed to open, so a reused instance never
4890
+ leaks one proposal's half-finished edit into the next.
4891
+
4892
+ Initial focus deliberately does *not* land on Approve: approving a tool call is a consequential,
4893
+ potentially irreversible action, so a user who opens the dialog and reflexively presses Enter/Space
4894
+ before reading anything should deny, not approve. Deny gets the initial focus instead — the same
4895
+ "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.
4900
+
4901
+ **Known gotchas:**
4902
+ - `editable` defaults to `true` and reflects — see the property note above about overriding it with a
4903
+ property binding, not a boolean-attribute binding.
4904
+ - `lyra-deny` has no detail payload: its `event.detail` is `null`, not `undefined`.
4905
+ - a consumer flipping `editable` off while an edit is already in progress automatically exits edit mode
4906
+ and discards the draft, so an unreachable "Cancel" affordance is never left stranded on screen.
4907
+ - 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.
4911
+ - 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.
4914
+
4915
+ ---
4916
+
4917
+ ## `lyra-tool-param-form`
4918
+
4919
+ Renders one form control per top-level property of a JSON Schema object, for ad hoc tool invocation or
4920
+ approval-editing UIs (e.g. "the agent wants to call `create_event(title, attendees, allDay)` — let the
4921
+ user tweak the arguments before running it"). First-party invention (no Web Awesome equivalent).
4922
+
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.
4928
+
4929
+ **Exported types:**
4930
+ - `ToolParamFormPropertyType = 'string' | 'number' | 'integer' | 'boolean'` — the four leaf property
4931
+ types this renderer understands
4932
+ - `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.
4937
+ - `ToolParamFormSchema { type: 'object'; properties: Record<string, ToolParamFormProperty>; required?:
4938
+ string[] }` — the (intentionally flat) schema shape this component can render.
4939
+
4940
+ **Properties:**
4941
+ - `schema: ToolParamFormSchema = { type: 'object', properties: {} }` (attribute: false)
4942
+ - `value: Record<string, unknown> = {}` (attribute: false) — exactly what the consumer last set it to.
4943
+ A field with no entry in `value` but a schema `default` *displays* (and is *emitted*, via
4944
+ `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`.
4946
+ - `name: string = ''` — submission key for optional native `<form>` participation
4947
+ - `disabled: boolean = false` (reflected)
4948
+
4949
+ **Getters:**
4950
+ - `effectiveValue: Record<string, unknown>` — `value` with every property missing from it filled in
4951
+ from `schema`'s own `default`; this is what actually renders and what `lyra-input`'s detail carries.
4952
+ A key the user has explicitly cleared (a real own property set to `undefined`) stays cleared rather
4953
+ than snapping back to its default — only a key genuinely absent from `value` falls back.
4954
+ - `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.
4957
+
4958
+ **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()`.
4964
+
4965
+ **Events:** `lyra-input` (`detail: { value: Record<string, unknown> }` — the full current value
4966
+ 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)
4970
+
4971
+ **Slots:** none.
4972
+
4973
+ **CSS parts:** `base`, `field`, `label`, `description`, `error`
4974
+
4975
+ **Themeable custom properties:** no component-specific custom properties; shared tokens only —
4976
+ `--lyra-space-l/-xs/-s`, `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`,
4977
+ `--lyra-color-danger`, `--lyra-color-text-quiet`, `--lyra-focus-ring-width/-color/-offset`,
4978
+ `--lyra-opacity-disabled`.
4979
+
4980
+ **Optional peer deps:** none — internally renders `<lyra-select>`, `<lyra-option>`, and
4981
+ `<lyra-checkbox>`, all bundled dependencies of this package imported directly, not optional peers.
4982
+
4983
+ ```html
4984
+ <lyra-tool-param-form
4985
+ .schema=${{
4986
+ type: 'object',
4987
+ properties: {
4988
+ title: { type: 'string', title: 'Title' },
4989
+ attendees: { type: 'string', enum: ['team', 'everyone'], default: 'team' },
4990
+ allDay: { type: 'boolean', title: 'All day' },
4991
+ },
4992
+ required: ['title'],
4993
+ }}
4994
+ .value=${draftArgs}
4995
+ @lyra-input=${(e) => (draftArgs = e.detail.value)}
4996
+ @lyra-validity-change=${(e) => (formIsValid = e.detail.valid)}
4997
+ ></lyra-tool-param-form>
4998
+ ```
4999
+
5000
+ This component owns no Submit/Cancel/Approve chrome — a consumer composes it inside their own dialog
5001
+ (e.g. `<lyra-tool-approval-dialog>`) and reads `.value`/`.errors`/`checkValidity()` (or calls
5002
+ `reportValidity()` right before acting). Fields render in `Object.keys(schema.properties)` order
5003
+ (insertion order). A `'string'` property with a non-empty `enum` renders as a `<lyra-select>` of
5004
+ `<lyra-option>`s; a plain `'string'` renders a text `<input>`; `'number'`/`'integer'` render a numeric
5005
+ `<input type="number">` (`step="1"` for integer, `step="any"` for number); `'boolean'` renders a
5006
+ `<lyra-checkbox>` with the field's label projected into its default slot — real slotted content, since
5007
+ that's `<lyra-checkbox>`'s documented way to give itself an accessible name, unlike `aria-describedby`
5008
+ (neither `<lyra-select>` nor `<lyra-checkbox>` forward a host-level `aria-describedby` to their
5009
+ 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.
5013
+
5014
+ Optional native `<form>` participation is implemented via `ElementInternals` attached directly in the
5015
+ constructor (`static formAssociated = true`) rather than a string-value mixin, since this component's
5016
+ 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.
5021
+
5022
+ **Known gotchas:**
5023
+ - a schema property whose `type` isn't `'string'`/`'number'`/`'integer'`/`'boolean'` renders an inline
5024
+ "Unsupported field type" message (a plain `.unsupported`-classed element, not one of the documented
5025
+ CSS parts) instead of throwing or being silently dropped.
5026
+ - inline per-field errors only render once a field has been visited (`focusout`) at least once, or
5027
+ after an explicit `reportValidity()` call — `checkValidity()` alone never reveals them, matching
5028
+ every other form control in this library (`<lyra-select>`/`<lyra-combobox>`/`<lyra-model-select>`
5029
+ all avoid flashing red before the user has touched anything).
5030
+ - `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.
5033
+ - `lyra-validity-change` fires once immediately at connect time even before any user interaction, so a
5034
+ form with an unmet required field announces `valid: false` on mount, not only after the first edit.
5035
+
5036
+ ---
5037
+
5038
+ ## `lyra-menu` / `lyra-menu-item`
5039
+
5040
+ An anchored dropdown built around a consumer-supplied trigger element (typically an icon button)
5041
+ assigned to the `trigger` slot. First-party invention (no Web Awesome equivalent) — a close, drop-
5042
+ in-shaped replacement for reaching outside this library for a third-party dropdown to build a gear
5043
+ menu, an avatar menu, or a history row's overflow menu. Uses the WAI-ARIA "menu button" pattern —
5044
+ `role="menu"`/`role="menuitem"` with real roving DOM focus moving between actual focusable
5045
+ `<lyra-menu-item>` rows — deliberately not a `role="listbox"`/`aria-activedescendant` shape (that's
5046
+ `<lyra-select>`'s pattern instead).
5047
+
5048
+ ### `lyra-menu`
5049
+
5050
+ **Properties:**
5051
+ - `open: boolean = false` (reflected)
5052
+ - `placement?: Placement` (reflected — resolved through `rtlAwarePlacement()` (`internal/rtl.ts`),
5053
+ then forwarded to `place()`; defaults to whatever `place()` itself defaults to. A `left`/`right`
5054
+ side is mirrored under `dir="rtl"`, so e.g. `placement="left-start"` still anchors to the menu's
5055
+ trailing edge instead of pinning to the physical left)
5056
+ - `label: string = 'Menu'` — accessible name for the `role="menu"` popup; override with something
5057
+ specific (e.g. "Row actions") when a page has more than one menu
5058
+
5059
+ **Events:** `lyra-show` (no detail — fires only when `open` transitions to `true`, not for markup
5060
+ that renders `open` true from the start), `lyra-hide` (same first-render guard, opposite
5061
+ transition), `lyra-menu-select` (`detail: { value }` — a consolidated re-fire of the activated
5062
+ `<lyra-menu-item>`'s own `lyra-menu-item-select`; always followed by the menu closing and focus
5063
+ returning to the trigger)
5064
+
5065
+ **Slots:** `trigger` (the consumer's own trigger element — first assigned element wins if several
5066
+ are assigned; enhanced imperatively with `aria-haspopup="menu"`/`aria-expanded`/`aria-controls`
5067
+ since those attributes belong on the actual interactive trigger, which lives outside this
5068
+ component's shadow root), default (`<lyra-menu-item>` elements, plus optionally plain `<hr>`
5069
+ dividers between groups — native `<hr>` already carries an implicit `separator` role)
5070
+
5071
+ **CSS parts:** `trigger` (wrapper around the `trigger` slot — the positioning anchor), `popup` (the
5072
+ positioned floating panel), `list` (the `role="menu"` container wrapping the default slot)
5073
+
5074
+ **Themeable custom properties:** shared tokens only (`--lyra-color-surface`, `--lyra-color-border`,
5075
+ `--lyra-radius`, `--lyra-shadow`, `--lyra-space-xs`, `--lyra-transition-fast`).
5076
+
5077
+ **Optional peer deps:** none.
5078
+
5079
+ ### `lyra-menu-item`
5080
+
5081
+ Not meaningful standalone — it exists purely as `<lyra-menu>`'s light-DOM child, the same
5082
+ relationship `<lyra-option>` has to `<lyra-combobox>`/`<lyra-select>`. `role="menuitem"` and the
5083
+ roving `tabindex` both live on the host element itself (mirroring `<lyra-tree-node>`), not an
5084
+ internal shadow-DOM button; `<lyra-menu>` is the sole owner of this element's `tabIndex`.
5085
+
5086
+ **Properties:**
5087
+ - `value: string = ''` — an id/value echoed back in the parent `<lyra-menu>`'s `lyra-menu-select`
5088
+ detail
5089
+ - `disabled: boolean = false` (reflected — disables selection and excludes this item from
5090
+ `<lyra-menu>`'s roving-tabindex navigation entirely)
5091
+ - `destructive: boolean = false` (reflected — tints the row with `--lyra-color-danger`, for a
5092
+ dangerous action like "Delete")
5093
+
5094
+ **Events:** `lyra-menu-item-select` (no detail payload — `this.emit('lyra-menu-item-select')` is
5095
+ called with no second argument, so `event.detail` is `null`, not `undefined`; fires on click, or
5096
+ when the parent `<lyra-menu>`'s own Enter/Space keydown handling calls `select()` on the currently
5097
+ roving-focused item)
5098
+
5099
+ **Slots:** default (the item's label content), `icon` (optional leading icon)
5100
+
5101
+ **CSS parts:** `base` (the row — `role` lives on the host, not this part), `icon` (wrapper around
5102
+ the `icon` slot; not rendered/hidden entirely while the slot is empty), `label` (wrapper around the
5103
+ default slot)
5104
+
5105
+ **Themeable custom properties:** shared tokens only (`--lyra-radius`, `--lyra-focus-ring-width`,
5106
+ `--lyra-focus-ring-color`, `--lyra-space-xs`, `--lyra-space-s`, `--lyra-color-brand-quiet`,
5107
+ `--lyra-opacity-disabled`, `--lyra-color-danger`, `--lyra-color-danger-quiet`).
5108
+
5109
+ **Optional peer deps:** none.
5110
+
5111
+ ```html
5112
+ <lyra-menu label="Row actions">
5113
+ <button slot="trigger" aria-label="More actions">⋮</button>
5114
+ <lyra-menu-item value="edit">Edit</lyra-menu-item>
5115
+ <lyra-menu-item value="duplicate">Duplicate</lyra-menu-item>
5116
+ <hr />
5117
+ <lyra-menu-item value="delete" destructive>Delete</lyra-menu-item>
5118
+ </lyra-menu>
5119
+ <script type="module">
5120
+ document.querySelector('lyra-menu').addEventListener('lyra-menu-select', (e) => console.log(e.detail.value));
5121
+ </script>
5122
+ ```
5123
+
5124
+ The popup is always rendered in the DOM (never `display:none`) so `.focus()` calls on its content
5125
+ work synchronously the instant it opens — closed state is conveyed via `visibility`/`opacity`
5126
+ instead. `visibility` is an inherited CSS property that pierces the `<slot>` projection boundary, so
5127
+ 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.
5133
+
5134
+ **Known gotchas:**
5135
+ - `<lyra-menu-item>`'s click handler lives on an inner shadow-DOM element (`[part="base"]`), not the
5136
+ 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"]')`.
5139
+ - `lyra-show`/`lyra-hide` are suppressed on the very first render even if `open` is already `true`
5140
+ in markup — only later `open` transitions fire them.
5141
+ - `lyra-menu-item-select` carries no detail payload (`event.detail === null`); read
5142
+ `event.target.value` instead. `<lyra-menu>`'s own re-fired `lyra-menu-select` is the one that
5143
+ carries `detail: { value }`.
5144
+ - Tab closes the menu without `preventDefault()` or focus trapping — the browser's own default Tab
5145
+ navigation proceeds untouched, only the (now-stale) open state is cleared.
5146
+ - Only Escape and a committed selection refocus the trigger on close; a click outside does not.
5147
+
5148
+ ---
5149
+
5150
+ ## `lyra-chip` / `lyra-chip-group`
5151
+
5152
+ A small, content-agnostic pill for a short label: a tag, an active-filter/scope indicator, etc.
5153
+ Distinct from `<lyra-attachment-chip>` (specifically file-shaped, with a thumbnail/size/upload-
5154
+ progress) — this pair carries no domain assumptions at all. `<lyra-chip>` is a controlled component:
5155
+ clicking its remove (×) button only fires `lyra-remove` — the chip never removes itself from the DOM
5156
+ on its own interaction, the same contract `<lyra-attachment-chip>`/`<lyra-conversation-item>`
5157
+ already follow.
5158
+
5159
+ ### `lyra-chip`
5160
+
5161
+ **Properties:**
5162
+ - `tone: 'neutral' | 'brand' | 'success' | 'warning' | 'danger' = 'neutral'` (reflected — tints the
5163
+ whole pill using a loud-color-on-quiet-tint convention; `neutral` has no dedicated token pair, so
5164
+ it falls back to a plain bordered-surface look)
5165
+ - `removable: boolean = false` (reflected — shows the remove (×) button)
5166
+ - `value?: string` — opaque consumer bookkeeping value, never read, validated, or rendered by this
5167
+ component itself, only ever echoed back verbatim (including `undefined` if never set) in
5168
+ `lyra-remove`'s detail
5169
+
5170
+ **Events:** `lyra-remove` (`detail: { value }` — the remove (×) button was activated via click or
5171
+ Enter/Space while focused; only rendered/reachable while `removable`)
5172
+
5173
+ **Slots:** default (the chip's label content), `icon` (optional leading icon or status dot; nothing
5174
+ reserved for it — no extra gap — when left empty)
5175
+
5176
+ **CSS parts:** `base` (the pill's root container), `icon` (wrapper around the `icon` slot; hidden
5177
+ entirely while empty), `label` (wrapper around the default slot), `remove-button` (the remove (×)
5178
+ affordance, only rendered while `removable`)
5179
+
5180
+ **Themeable custom properties:** `--lyra-chip-accent`, `--lyra-chip-bg`, `--lyra-chip-border`
5181
+ (component-local trio swapped per `tone` rather than repeating background/color/border per part per
5182
+ tone; default `var(--lyra-color-text)` / `var(--lyra-color-surface)` / `var(--lyra-color-border)` —
5183
+ mirrors the same accent/bg/border vocabulary `<lyra-tool-call-chip>`/`<lyra-attachment-chip>` use),
5184
+ plus shared tokens (`--lyra-space-xs`, `--lyra-space-s`, `--lyra-color-brand`/`-brand-quiet`,
5185
+ `--lyra-color-success`/`-success-quiet`, `--lyra-color-warning`/`-warning-quiet`,
5186
+ `--lyra-color-danger`/`-danger-quiet`, `--lyra-icon-button-size`, `--lyra-focus-ring-width`,
5187
+ `--lyra-focus-ring-color`, `--lyra-focus-ring-offset`, `--lyra-transition-fast`).
5188
+
5189
+ **Optional peer deps:** none.
5190
+
5191
+ ### `lyra-chip-group`
5192
+
5193
+ A flex-wrap container for a set of `<lyra-chip>` children — plain light-DOM composition, direct
5194
+ children are the chips (the same shape `<lyra-split>`'s panels / `<lyra-source-list>`'s cards take,
5195
+ no `.items` array prop).
5196
+
5197
+ **Properties:**
5198
+ - `maxVisible?: number` (attribute `max-visible`) — maximum number of direct children shown before
5199
+ the rest collapse behind a "+N" indicator; unset (the default) means no limit, every child is
5200
+ always shown
5201
+
5202
+ **Events:** `lyra-overflow-toggle` (`detail: { expanded }` — the overflow indicator was activated,
5203
+ revealing or re-collapsing the excess children; fires only from that click, i.e. only when
5204
+ `max-visible` is actually causing an overflow state — never as a side effect of `max-visible`/
5205
+ children changing on their own)
5206
+
5207
+ **Slots:** default (`<lyra-chip>` elements, or any content, though the chip pairing is the intended
5208
+ usage)
5209
+
5210
+ **CSS parts:** `base` (the flex-wrap container, holds both the slot and the overflow indicator),
5211
+ `overflow-indicator` (the "+N" / "Show less" toggle button; only rendered while `max-visible` is
5212
+ actively causing an overflow — a locally-styled pill, not an instantiated real `<lyra-chip>`)
5213
+
5214
+ **Themeable custom properties:** shared tokens only (`--lyra-space-xs`, `--lyra-space-s`,
5215
+ `--lyra-color-border`, `--lyra-color-surface`, `--lyra-color-text-quiet`, `--lyra-color-text`,
5216
+ `--lyra-color-brand`, `--lyra-focus-ring-width`, `--lyra-focus-ring-color`,
5217
+ `--lyra-focus-ring-offset`, `--lyra-transition-fast`).
5218
+
5219
+ **Optional peer deps:** none.
5220
+
5221
+ ```html
5222
+ <lyra-chip-group max-visible="3">
5223
+ <lyra-chip removable value="draft">Draft</lyra-chip>
5224
+ <lyra-chip tone="success" removable value="reviewed">Reviewed</lyra-chip>
5225
+ <lyra-chip tone="warning">Needs input</lyra-chip>
5226
+ <lyra-chip tone="danger">Blocked</lyra-chip>
5227
+ </lyra-chip-group>
5228
+ <script type="module">
5229
+ const group = document.querySelector('lyra-chip-group');
5230
+ group.addEventListener('lyra-overflow-toggle', (e) => console.log(e.detail.expanded));
5231
+ group.querySelectorAll('lyra-chip').forEach((chip) => chip.addEventListener('lyra-remove', (e) => console.log(e.detail.value)));
5232
+ </script>
5233
+ ```
5234
+
5235
+ Since CSS alone can't parameterize `:nth-child` on a runtime prop, `<lyra-chip-group>` reaches
5236
+ directly into the light DOM and sets each excess child's own `hidden` property once `max-visible` is
5237
+ exceeded — the same approach `<lyra-split>` uses to set each panel's inline `flex`/`order`, rather
5238
+ than a stylesheet-only solution.
5239
+
5240
+ **Known gotchas:**
5241
+ - `<lyra-chip>`'s accessible remove-button label ("Remove {text}") is computed only from the default
5242
+ slot's own text content — text living inside the (decorative) `icon` slot doesn't leak into it.
5243
+ - `<lyra-chip-group>` silently un-expands (`expanded` resets to `false`, with no event firing) if a
5244
+ consumer raises `max-visible` past the current child count while already expanded — only an actual
5245
+ click on the overflow indicator fires `lyra-overflow-toggle`.
5246
+ - `<lyra-chip-group>`'s overflow indicator is its own locally-styled pill, not an instantiated
5247
+ `<lyra-chip>` in its shadow DOM — don't expect `<lyra-chip>`'s CSS parts or custom properties to
5248
+ reach it.
5249
+
5250
+ ---
5251
+
5252
+ ## `lyra-model-settings-panel`
5253
+
5254
+ A fixed composition of `<lyra-model-select>` and `<lyra-slider>` into one agent-configuration card:
5255
+ pick a provider's model, then tune its sampling temperature. First-party invention (no Web Awesome
5256
+ equivalent). Not a generic layout shell — it exists so a consumer doesn't have to re-wire the same
5257
+ two child `lyra-change` events into one combined settings object by hand every time this pairing
5258
+ comes up.
5259
+
5260
+ Every prop here is a plain pass-through to (or mirror of) the matching child control's own prop of
5261
+ the same/similar name — see `lyra-model-select` and `lyra-slider` themselves for the exact semantics
5262
+ of `catalog`/`allowCustom` and `temperatureMin`/`temperatureMax`/`temperatureStep`.
5263
+
5264
+ **Properties:**
5265
+ - `provider: string = ''` — informational provider badge, passed straight through to the internal
5266
+ `lyra-model-select`.
5267
+ - `catalog?: LyraModelCatalog` (attribute: false, JS-only) — `string[] | { id: string; label: string
5268
+ }[]` (every entry must be one shape or the other, never mixed); passed straight through to the
5269
+ internal `lyra-model-select`.
5270
+ - `modelValue: string = ''` (attribute `model-value`) — the current model id.
5271
+ - `allowCustom: boolean = false` (attribute `allow-custom`) — lets the model control accept a value
5272
+ outside `catalog`; passed straight through.
5273
+ - `temperature: number = 1` — the current sampling temperature. `1` is the midpoint of the default
5274
+ `[0, 2]` range and matches both OpenAI's and Anthropic's own provider default; reassign it yourself
5275
+ if your provider differs.
5276
+ - `temperatureMin: number = 0` (attribute `temperature-min`)
5277
+ - `temperatureMax: number = 2` (attribute `temperature-max`)
5278
+ - `temperatureStep: number = 0.1` (attribute `temperature-step`)
5279
+ - `layout: 'vertical' | 'compact' = 'vertical'` (reflected) — `vertical` stacks full-width rows with
5280
+ visible labels; `compact` runs the same two rows side by side with a smaller, uppercase temperature
5281
+ caption, for toolbars/sidebars where the vertical layout's height doesn't fit.
5282
+
5283
+ **Events:** `lyra-change` — `detail: { modelValue: string; inCatalog: boolean; temperature: number }`.
5284
+ Fires whenever *either* child control's own `lyra-change` fires, and always carries the full current
5285
+ settings snapshot, not just whichever field actually changed. `inCatalog` is recomputed fresh from
5286
+ `catalog`/`modelValue` on every emission (mirroring `lyra-model-select`'s own `effectiveEntries` logic)
5287
+ rather than cached from the last child event, so it's still correct even when `modelValue` was just
5288
+ assigned directly instead of via the child's own event.
5289
+
5290
+ **Slots:** none — this is a fixed two-control composition, not a generic layout shell.
5291
+
5292
+ **CSS parts:** `base`, `model-row`, `temperature-row`, `temperature-label`, `temperature-value`
5293
+
5294
+ **Themeable custom properties:** no component-specific custom properties; consumes shared tokens
5295
+ `--lyra-space-l/-m/-s/-xs`, `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`,
5296
+ `--lyra-color-text`, `--lyra-color-text-quiet`.
5297
+
5298
+ **Optional peer deps:** none — it composes the library's own `<lyra-model-select>` and `<lyra-slider>`
5299
+ internally (both imported unconditionally as side effects, not optional).
5300
+
5301
+ ```html
5302
+ <lyra-model-settings-panel
5303
+ provider="OpenAI"
5304
+ .catalog=${['gpt-4o', 'gpt-4o-mini', 'gpt-4.1']}
5305
+ model-value="gpt-4o"
5306
+ temperature="0.7"
5307
+ @lyra-change=${(e) => console.log(e.detail)}
5308
+ ></lyra-model-settings-panel>
5309
+
5310
+ <lyra-model-settings-panel layout="compact" .catalog=${catalog}></lyra-model-settings-panel>
5311
+ ```
5312
+
5313
+ The internal `lyra-slider` renders with its own value readout suppressed (`.showValue=${false}`);
5314
+ the current temperature is instead shown via this component's own `[part="temperature-value"]` span,
5315
+ 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.
5317
+
5318
+ **Known gotchas:**
5319
+ - `catalog` is JS-only (`attribute: false`) — set it via a property binding (`.catalog=`), never as
5320
+ an HTML attribute, same requirement as the underlying `lyra-model-select`.
5321
+ - `layout="compact"` removes the host's own `max-inline-size` cap (`28rem` in `vertical` layout)
5322
+ entirely, so a compact panel can grow as wide as its container/flex context allows.
5323
+ - The nested `lyra-model-select`'s own `max-inline-size` (sized for a standalone dropdown) is
5324
+ overridden to `100%`/`none` inside `[part="model-row"]` so it fills the card's full width — a
5325
+ detail only worth knowing if you're targeting `lyra-model-select` internals with your own CSS
5326
+ through this component.
5327
+
5328
+ ---
5329
+
5330
+ ## `lyra-context-meter`
5331
+
5332
+ A segmented occupancy meter (bar or ring) for showing how a fixed capacity — a model's context
5333
+ window, a token budget, any consumable quota — is divided across labeled categories. First-party
5334
+ invention (no Web Awesome equivalent). Pure data visualization: it renders `segments`/`total` exactly
5335
+ as given and never computes token counts, costs, or any other domain-specific estimate itself — the
5336
+ one exception is the plain arithmetic sum of segment values used to build the accessible "X of Y
5337
+ used" summary.
5338
+
5339
+ **Properties:**
5340
+ - `segments: ContextMeterSegment[] = []` (attribute: false, JS-only) — `{ label: string; value:
5341
+ number; tone?: 'brand' | 'success' | 'warning' | 'danger' | 'neutral' }[]`. `value` is an *absolute*
5342
+ quantity measured against `total`, never a pre-computed percentage.
5343
+ - `total: number = 0` — the full capacity segments are measured against (e.g. a model's context
5344
+ window size).
5345
+ - `variant: 'ring' | 'bar' = 'bar'` (reflected)
5346
+ - `label: string = ''` — overall accessible caption, e.g. `"128K context window"`. Also rendered
5347
+ visually (`[part="label"]`) when set.
5348
+
5349
+ **Events:** none.
5350
+
5351
+ **Slots:** none.
5352
+
5353
+ **CSS parts:** `base` (a `<div>` for `bar`, an `<svg>` for `ring`), `track` (the unfilled/empty
5354
+ capacity), `segment` (one occupied segment — carries `data-tone` for styling), `label`
5355
+
5356
+ **Themeable custom properties:** no component-specific custom properties; consumes shared tokens
5357
+ `--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`.
5360
+
5361
+ **Optional peer deps:** none.
5362
+
5363
+ ```html
5364
+ <lyra-context-meter
5365
+ label="128K context window"
5366
+ total="128000"
5367
+ .segments=${[
5368
+ { label: 'System prompt', value: 2200, tone: 'neutral' },
5369
+ { label: 'Conversation history', value: 61000, tone: 'brand' },
5370
+ { label: 'Retrieved context', value: 30800, tone: 'warning' },
5371
+ ]}
5372
+ ></lyra-context-meter>
5373
+
5374
+ <lyra-context-meter variant="ring" total="128000" .segments=${segments}></lyra-context-meter>
5375
+ ```
5376
+
5377
+ `role="img"` and a computed `aria-label` are set imperatively on the *host* element itself in
5378
+ `willUpdate` (mirroring `lyra-gauge`'s "meter" role convention) — every internal node (`track`,
5379
+ `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.
5389
+
5390
+ **Known gotchas:**
5391
+ - The ring variant's per-segment `<title>` and the bar variant's per-segment `title=` attribute are
5392
+ native mouse-hover tooltips only — they sit inside `aria-hidden` markup, so screen readers never
5393
+ read them; only the host's own `role="img"`/`aria-label` carries accessible information.
5394
+ - `variant="ring"` fixes the host at `8em × 8em` (`:host([variant='ring'])`) — the bar variant's
5395
+ `inline-size: 100%` does not apply in ring mode; resize it via `font-size` or an explicit
5396
+ width/height override on the host instead.
5397
+ - Segment order is significant for the ring's cumulative `stroke-dashoffset` — later entries in
5398
+ `segments` render further around the circle (starting at 12 o'clock, going clockwise); there's no
5399
+ independent sort/z-order control.
5400
+
5401
+ ---
5402
+
5403
+ ## `lyra-dock-panel`
5404
+
5405
+ A single panel docked to one edge of whatever contains it, resizable by dragging its inner edge.
5406
+ First-party invention (no Web Awesome equivalent). Unlike `lyra-split` (which owns and lays out N
5407
+ sibling panels, and requires restructuring a layout so every panel becomes its direct child), this is
5408
+ one self-contained element you drop next to your existing content — typically as an absolutely-
5409
+ positioned child of a `position: relative` parent, or as a flex item alongside a main-content sibling.
5410
+ It deliberately imposes no `position`/`inset` of its own: it only manages its own size along the
5411
+ resize axis (`inline-size` for `start`/`end`, `block-size` for `top`/`bottom`) and fills 100% of the
5412
+ cross axis, leaving where it sits in the page entirely up to the consumer's own layout. `lyra-split`
5413
+ stays the right primitive for the multi-sibling-panel case; this is the primitive for the single-edge-
5414
+ docked case.
5415
+
5416
+ **Properties:**
5417
+ - `edge: 'start' | 'end' | 'top' | 'bottom' = 'end'` (reflected) — which edge of the panel's own
5418
+ container it's docked to. `start`/`end` are logical-inline (mirror left/right depending on writing
5419
+ direction); `top`/`bottom` are block-direction and unaffected by RTL.
5420
+ - `size: string = '280px'` — the current docked size along the resize axis, as a CSS length.
5421
+ - `minSize: string = '160px'` (attribute `min-size`) — minimum resize bound, as a CSS length.
5422
+ - `maxSize: string = ''` (attribute `max-size`) — maximum resize bound. Empty means "no explicit
5423
+ cap": the live extent of the containing element is used instead (falling back to the viewport if
5424
+ there's no parent, e.g. not yet connected), so the panel still can't be dragged wider/taller than
5425
+ its container.
5426
+ - `collapsible: boolean = false` (reflected)
5427
+ - `collapsed: boolean = false` (reflected)
5428
+ - `resizable: boolean = true` (reflected) — when `false`, no drag handle renders at all and the panel
5429
+ is a fixed size. (Assign `false` via a **property** binding, e.g. `.resizable=${false}` — a
5430
+ `?resizable=${false}` boolean-attribute binding cannot override a `true` default; see AGENTS.md.)
5431
+
5432
+ **Exported helper:** `parseLengthPx(length: string, containerPx: number, fontSizeEl: Element =
5433
+ document.documentElement): number | undefined` — resolves an arbitrary CSS length (`px`, `rem`, `em`,
5434
+ `vw`, `vh`, `%`, or a bare/unitless number treated as `px`) to a live pixel value without a DOM-probe
5435
+ measurement, since `min-size`/`max-size` are pure constraints that are never themselves rendered
5436
+ anywhere. `rem` resolves against the document root's font size; `em` resolves against `fontSizeEl`'s
5437
+ own computed font size; `%` resolves against `containerPx`. Returns `undefined` for an
5438
+ empty/unparseable string. Used internally to resolve `min-size`/`max-size`; the panel's *current* size
5439
+ is instead always read back live from `getBoundingClientRect()`, which handles any unit for free.
5440
+
5441
+ **Events:**
5442
+ - `lyra-resize` — `detail: { size }` (a `px` CSS length string), fired on every drag step, drag
5443
+ release, and keyboard step.
5444
+ - `lyra-collapse-change` — `detail: { collapsed }`, fired whenever the collapse toggle flips
5445
+ `collapsed`.
5446
+
5447
+ **Slots:** default — the panel's own content.
5448
+
5449
+ **CSS parts:** `base` (the panel root), `content` (wraps the default slot; hidden while `collapsed`),
5450
+ `handle` (the draggable resize edge; only rendered when `resizable` and not `collapsed`),
5451
+ `collapse-toggle` (only rendered when `collapsible`)
5452
+
5453
+ **Themeable custom properties:** `--lyra-dock-panel-collapsed-size` (default
5454
+ `var(--lyra-icon-button-size)`) — the persistent "rail" width/height the panel holds at while
5455
+ `collapsed`, rather than collapsing to zero (a zero-size collapsed panel would have nowhere to host
5456
+ the re-expand toggle); component-specific since collapse never zeroes the box. Plus shared tokens
5457
+ `--lyra-color-surface`, `--lyra-color-border`, `--lyra-color-brand`, `--lyra-color-brand-quiet`,
5458
+ `--lyra-color-text`, `--lyra-radius`, `--lyra-space-xs`, `--lyra-focus-ring-width/-color/-offset`,
5459
+ `--lyra-transition-fast`, `--lyra-icon-button-size`.
5460
+
5461
+ **Optional peer deps:** none.
5462
+
5463
+ ```html
5464
+ <div style="position: relative; block-size: 100vh;">
5465
+ <lyra-dock-panel
5466
+ edge="end"
5467
+ size="320px"
5468
+ min-size="200px"
5469
+ max-size="480px"
5470
+ collapsible
5471
+ @lyra-resize=${(e) => console.log(e.detail.size)}
5472
+ @lyra-collapse-change=${(e) => console.log(e.detail.collapsed)}
5473
+ >
5474
+ <div>Sidebar content — a chat thread list, an inspector, anything.</div>
5475
+ </lyra-dock-panel>
5476
+ </div>
5477
+ ```
5478
+
5479
+ Pointer-drag-resize mirrors `lyra-split`'s pointer-capture technique (`pointerdown` captures the
5480
+ pointer on the handle; `pointermove` computes a new size; `pointerup`/`pointercancel`/
5481
+ `lostpointercapture` all release it, since a drag can end without a clean `pointerup`) but reasons in
5482
+ raw pixels throughout rather than percent. Every resize — drag step, drag release, or a keyboard step
5483
+ (<kbd>ArrowLeft</kbd>/<kbd>ArrowRight</kbd> for the inline axis, <kbd>ArrowUp</kbd>/<kbd>ArrowDown</kbd>
5484
+ for the block axis, 16px per step) — always commits `size` as a rounded `px` string regardless of
5485
+ what unit `size`/`min-size`/`max-size` were originally expressed in.
5486
+
5487
+ **Known gotchas:**
5488
+ - `collapsed` doesn't zero the panel's box — it shrinks to the persistent rail size
5489
+ (`--lyra-dock-panel-collapsed-size`). `size` itself is left untouched while collapsed, so
5490
+ re-expanding restores exactly what it was.
5491
+ - `handle` only renders while `resizable && !collapsed`; `collapse-toggle` only renders while
5492
+ `collapsible` — a panel with both `false` renders neither control, just fixed-size slotted content.
5493
+ `resizable` and `collapsed` interact: dragging is disabled whenever `collapsed` is `true`, even if
5494
+ `resizable` is also `true`.
5495
+ - The collapse-toggle's chevron rotates to point toward the panel's pinned edge when expanded (the
5496
+ direction clicking it will shrink toward) and away from it when collapsed — this is folded through
5497
+ both `edge` and, for `start`/`end`, current RTL-ness, so the same markup visually flips correctly
5498
+ under `dir="rtl"` with no extra author work.
5499
+
5500
+ ---
5501
+
5502
+ ## `lyra-document-preview`
5503
+
5504
+ A format-dispatching viewer for one document/attachment, plus the visual state machine for an async
5505
+ server-side conversion a host app runs in front of it. First-party invention (no Web Awesome
5506
+ equivalent).
5507
+
5508
+ Format dispatch is intentionally minimal: only `text/*`/`application/json` (a plain, scrollable
5509
+ `<pre>` — no syntax highlighting; compose `<lyra-code-block>` yourself via the `unsupported` slot for
5510
+ that) and `image/*` (a contained `<img>`) render inline. Everything else — PDF, office documents,
5511
+ video, audio, or any unrecognized MIME type — falls back to a generic "can't preview this" state: a
5512
+ file glyph, a short message, and (when `src` is set) a native `<a download>` link. This is a
5513
+ deliberate ceiling, not a gap: the component ships a dispatch *shell*, not a format registry. The
5514
+ `unsupported` slot is the escape hatch for every format left out of the built-in three.
5515
+
5516
+ `status="converting"` is a second, independent axis from format dispatch. This component doesn't know
5517
+ your backend's conversion API shape and owns none of the actual polling/fetch — a host converting a
5518
+ non-natively-previewable format server-side (e.g. `.docx` → `.pdf`) polls its own backend and updates
5519
+ `status`/`progress`/`src` here as that proceeds; this component only *visualizes* that state (an
5520
+ indeterminate spinner, or a determinate one once `progress` is supplied). The one piece of async work
5521
+ this component *does* own is fetching a `text/*`/`application/json` `src` itself — there's no other
5522
+ way to get a `<pre>`'s text content from a URL — gated behind a generation-counter guard
5523
+ (`lyra-tool-result-view`'s `resolve()` uses the identical pattern) so a `src` reassigned mid-fetch
5524
+ can't have a stale response clobber a newer one.
5525
+
5526
+ **Properties:**
5527
+ - `src: string = ''` — URL to fetch (for `text`/`application/json`) or display (`image`, or as the
5528
+ generic fallback's download `href`). Optional — gracefully absent while, e.g., a conversion is
5529
+ still in progress.
5530
+ - `mimeType: string = ''` (attribute `mime-type`) — drives format dispatch (see above).
5531
+ - `filename: string = ''` — shown in the header and used as the download link's suggested filename.
5532
+ - `status: 'idle' | 'converting' | 'ready' | 'error' = 'idle'` (reflected) — host-owned lifecycle
5533
+ state. `"converting"` shows the spinner regardless of `mimeType`/`src`; `"error"` shows
5534
+ `errorMessage` regardless of either. `"idle"`/`"ready"` both resume normal format dispatch — a host
5535
+ with no conversion step never has to explicitly set `"ready"`.
5536
+ - `progress?: number` (type `Number`) — 0-100. Only consulted while `status="converting"`. Unset (the
5537
+ default) renders an indeterminate spinner instead of a determinate progress bar.
5538
+ - `errorMessage: string = ''` (attribute `error-message`) — shown via `[part="error"]` while
5539
+ `status="error"`.
5540
+ - `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"24rem"`); once set,
5541
+ `[part="body"]` scrolls internally past this height instead of growing the page — same contract as
5542
+ `lyra-json-viewer`'s identically-named prop.
5543
+
5544
+ **Events:**
5545
+ - `lyra-download` — `detail: { src, filename }` — fired when the generic-download fallback's link is
5546
+ activated. The browser download itself needs no JS (a plain `<a download>` handles it); this is
5547
+ purely for a host that wants to observe/log the download.
5548
+ - `lyra-render-error` — `detail: { error }` — fired when this component's own `text/*`/
5549
+ `application/json` `fetch(src)` fails (network error or non-2xx response). Distinct from
5550
+ `status="error"`, which is entirely host-driven.
5551
+
5552
+ **Slots:** `unsupported` — escape hatch: when populated, its content renders *instead of* the generic
5553
+ download fallback for any `mime-type` this component doesn't natively support. Ignored while
5554
+ `mime-type` resolves to `text`/`image` dispatch, or while `status` is `"converting"`/`"error"`.
5555
+
5556
+ **CSS parts:** `base`, `header` (hidden entirely when `filename` is unset), `filename`, `body`,
5557
+ `spinner` (indeterminate `role="status"`, or `role="progressbar"` once numeric progress is known —
5558
+ used both for `status="converting"` and this component's own in-flight text fetch), `error`
5559
+ (`role="alert"` — used both for `status="error"` and a failed text fetch), `download-link` (only
5560
+ rendered when `src` is set)
5561
+
5562
+ **Themeable custom properties:** `--lyra-document-preview-max-height` (default `none`) — the
5563
+ consumer-tunable scroll cap on `[part="body"]`, set from `max-height`; `none` means the preview grows
5564
+ with its content until a caller opts in. `--lyra-document-preview-font` (default `ui-monospace,
5565
+ SFMono-Regular, Menlo, Consolas, monospace`) — no shared `--wa-*`/`--lyra-*` monospace token exists yet
5566
+ (the same gap `lyra-json-viewer`'s own `--lyra-json-viewer-font` documents). Plus shared tokens
5567
+ `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`, `--lyra-space-s/-m/-l/-xs`,
5568
+ `--lyra-color-text`, `--lyra-color-text-quiet`, `--lyra-color-danger`, `--lyra-color-brand`,
5569
+ `--lyra-color-on-brand`, `--lyra-focus-ring-width/-color/-offset`, `--lyra-transition-fast`.
5570
+
5571
+ **Optional peer deps:** none directly — the `unsupported` slot is commonly used to compose
5572
+ `<lyra-code-block>` (which has its own optional `shiki` peer dependency; see that component's own
5573
+ entry) or a third-party PDF/office-doc viewer, but neither is a dependency of this component itself.
5574
+
5575
+ ```html
5576
+ <lyra-document-preview
5577
+ filename="board-notes.txt"
5578
+ mime-type="text/plain"
5579
+ src="/files/board-notes.txt"
5580
+ max-height="24rem"
5581
+ @lyra-render-error=${(e) => console.error(e.detail.error)}
5582
+ ></lyra-document-preview>
5583
+
5584
+ <!-- A host driving its own server-side conversion -->
5585
+ <lyra-document-preview filename="deck.pptx" status="converting" progress="42"></lyra-document-preview>
5586
+
5587
+ <!-- Escape hatch for an unsupported format -->
5588
+ <lyra-document-preview filename="deck.pptx" mime-type="application/vnd.ms-powerpoint" src="/files/deck.pptx">
5589
+ <lyra-code-block slot="unsupported" language="text">Open in PowerPoint to preview.</lyra-code-block>
5590
+ </lyra-document-preview>
5591
+ ```
5592
+
5593
+ Accessibility: the `"converting"` state without numeric `progress` is a `role="status"` region
5594
+ wrapping a visually-hidden "Converting document…" string — a *plain* static region, not routed
5595
+ through `<lyra-live-region>`/`Announcer`, since (like `<lyra-typing-indicator>`) it only ever has one
5596
+ thing to announce (entering the state), not a rapidly-repeating stream. Once real `progress` is
5597
+ available, the region becomes a standard `role="progressbar"` instead, self-describing via
5598
+ `aria-valuenow` with no extra live-region wiring. `status="error"` renders `[part="error"]` as
5599
+ `role="alert"` — a one-shot assertive notice, without needing the announcer machinery either.
5600
+
5601
+ **Known gotchas:**
5602
+ - `status="converting"`/`status="error"` always win over format dispatch, regardless of
5603
+ `mimeType`/`src` — a `text`/`image` source is not shown until `status` returns to `"idle"`/`"ready"`.
5604
+ - The component's own text/JSON `fetch(src)` is a *different* async operation from the host-driven
5605
+ `status="converting"` conversion. A failed fetch fires `lyra-render-error` and renders
5606
+ `[part="error"]` on its own; it never sets `status="error"` itself.
5607
+ - `progress` is only ever consulted for the host-driven `"converting"` state — this component's own
5608
+ in-flight text fetch always renders the indeterminate spinner, with no numeric-progress path.
5609
+ - The `unsupported` slot's initial presence is detected once, before the first render, by scanning
5610
+ light-DOM children directly (not the slot's `assignedElements()`); every later slot-content change
5611
+ is instead picked up via the slot's own `slotchange` listener. Both paths keep detection correct,
5612
+ just via two different mechanisms depending on timing.
5613
+ - `download-link` (and thus `lyra-download`) only renders/fires when `src` is set — a generic-fallback
5614
+ state with no `src` shows only the file glyph and message, with no download affordance at all.
5615
+
5616
+ ---
5617
+
5618
+ ## `lyra-media-card`
5619
+
5620
+ A lightweight inline preview for one already-sent, already-available attachment inside a rendered
5621
+ chat message body — distinct from `lyra-document-preview` (a fuller viewer with an async
5622
+ server-side-conversion state machine) and `lyra-attachment-chip` (a pre-send queued-file chip with
5623
+ upload progress). This component has neither concern; it only ever shows a `src` that's already
5624
+ final.
5625
+
5626
+ **Properties:**
5627
+ - `src: string = ''` — the media URL. Always re-validated against a safe-scheme allowlist before
5628
+ use (see below) — never trust it unsanitized even though it's typed as a plain string.
5629
+ - `kind?: 'image' | 'video' | 'file'` (reflected) — explicit format dispatch. Leave unset to
5630
+ auto-detect from `mimeType`.
5631
+ - `mimeType: string = ''` (attribute `mime-type`) — drives auto-detection when `kind` is unset.
5632
+ - `filename: string = ''` — shown in the file-chip fallback, used as the download link's suggested
5633
+ filename, and folded into the accessible name.
5634
+ - `alt: string = ''` — alt text for the image case (and reused as a video label fallback). Falls
5635
+ back to `filename`, then a generic per-kind description.
5636
+
5637
+ **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
5643
+ handling.
5644
+
5645
+ **Slots:** none.
5646
+
5647
+ **CSS parts:** `base` (a `<button>` for `kind="image"`, a plain wrapper `<div>` for `kind="video"`,
5648
+ an `<a>` or `<span>` for the file-chip fallback depending on href safety), `media` (the `<img>`/
5649
+ `<video>`), `file-icon`, `filename` (file-chip fallback only), `open-button` (video only — see
5650
+ below).
5651
+
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-*`.
5654
+
5655
+ **Optional peer deps:** none.
5656
+
5657
+ ```html
5658
+ <lyra-media-card kind="image" src="https://example.com/photo.jpg" alt="Screenshot" filename="photo.jpg"
5659
+ @lyra-open=${(e) => openLightbox(e.detail.src)}
5660
+ ></lyra-media-card>
5661
+ <lyra-media-card kind="file" src="https://example.com/report.pdf" filename="report.pdf"></lyra-media-card>
5662
+ ```
5663
+
5664
+ **Safe-URL checking.** `src` is validated (exported as `safeMediaSrc()`/`safeLinkHref()`) before it's
5665
+ ever assigned to an `<img>`/`<video>` `src` or an `<a href>` — only `http:`/`https:`/`blob:` (plus
5666
+ `data:` for a *media* `src` only) or a scheme-relative/relative URL with no scheme at all pass;
5667
+ anything else (`javascript:`, `vbscript:`, and similarly suspicious schemes) is rejected. `data:` is
5668
+ allowed for `safeMediaSrc()` (a browser never executes script from a media element's `src`) but
5669
+ rejected by the stricter `safeLinkHref()` (a `data:text/html` URI navigated to via a clicked `<a
5670
+ href>` runs as a full document and can execute script) — the same scheme gets a different verdict
5671
+ depending on which DOM sink it's headed for. Both functions delegate to the platform's own `new
5672
+ URL()` parser rather than a hand-rolled scheme regex, specifically because `new URL()` already
5673
+ implements the WHATWG URL Standard's input normalization (stripping tab/newline/leading-trailing
5674
+ space before looking for a scheme) — a naive regex is vulnerable to exactly the kind of
5675
+ tab-injected-into-a-scheme bypass a browser attribute sink still normalizes and executes. An
5676
+ `image`/`video` `kind` whose `src` fails the media-src check falls back to the generic file-chip
5677
+ rendering, which then separately re-validates `src` against the stricter href allowlist for its own
5678
+ download affordance.
5679
+
5680
+ `kind="video"` renders its open affordance as a separate `[part="open-button"]` next to
5681
+ `[part="media"]` rather than wrapping the whole card in one `<button>`/`<a>` (the pattern
5682
+ `image`/`file` use) — a `<video controls>` element is itself interactive content, and HTML forbids
5683
+ nesting interactive content inside a `<button>`/`<a>`; doing so anyway would also make every click on
5684
+ the video's own native controls bubble up and spuriously fire `lyra-open`.
5685
+
5686
+ **Known gotchas:**
5687
+ - Calling the real `.click()` (or dispatching a `click`/`MouseEvent`) on the file-chip's `<a href>`
5688
+ in a test genuinely triggers real browser navigation — always `preventDefault()` on a `click`
5689
+ listener registered before triggering it, the same precaution `lyra-document-preview`'s own
5690
+ download-link tests already take. A synthetic `dispatchEvent(new MouseEvent('click', {cancelable:
5691
+ true}))` still invokes the anchor's native activation behavior if nothing calls
5692
+ `preventDefault()` during dispatch — it is not a safe no-op.
5693
+ - `kind` only reflects to the host attribute when explicitly set — CSS keying off the
5694
+ auto-detected resolved kind should target the rendered `[part]`/element (e.g. `video[part="media"]`),
5695
+ not `:host([kind=...])`, since the latter won't see an auto-detected kind.
5696
+
5697
+ ---
5698
+
5699
+ ## `lyra-attachment-trigger`
5700
+
5701
+ A compact attach affordance designed for a chat composer's leading slot (see `lyra-chat-composer`'s
5702
+ own `leading` slot, which this drops straight into, though it has no code dependency on it). First-
5703
+ party invention (no Web Awesome equivalent). Its shape adapts to how many attachment `capabilities`
5704
+ are configured: exactly one renders a single plain icon button; more than one renders a small
5705
+ anchored menu (composed from the already-landed `lyra-menu`/`lyra-menu-item`) listing each
5706
+ capability as a row.
5707
+
5708
+ **Properties:**
5709
+ - `capabilities: AttachmentCapability[] = ['files']` (property only, no attribute) — which
5710
+ capabilities to offer, in display order. `AttachmentCapability = 'files' | 'image' | 'camera'`.
5711
+ - `accept: string = ''` — a native-file-input-style accept string (e.g. `'image/*'` or
5712
+ `'.pdf,.docx'`), forwarded to the hidden file input for the `files`/`image` capabilities. `image`
5713
+ defaults it to `'image/*'` unless this prop overrides it; `files` always uses it as-is (empty
5714
+ means "any file type").
5715
+ - `multiple: boolean = true` (reflected) — forwarded to the hidden file input's own `multiple`
5716
+ attribute.
5717
+ - `disabled: boolean = false` (reflected)
5718
+
5719
+ **Events:** `lyra-pick` (`detail: { capability: 'files' | 'image'; files: FileList }`) — fired once a
5720
+ file-backed capability's hidden input produces a real selection. The `FileList` is an independent
5721
+ snapshot (rehomed into a fresh `DataTransfer`), not a live reference to the input's own `.files` —
5722
+ see the gotcha below for why that distinction matters. `lyra-camera-request` (no detail — `detail`
5723
+ is `null`, not `undefined`, per the DOM spec's `CustomEventInit` default) — fired when the `camera`
5724
+ capability is activated; this component implements no capture UI of its own, the host owns
5725
+ everything from here (there's no single right answer for `getUserMedia` vs. `<input capture>` vs. a
5726
+ native wrapper's own camera API).
5727
+
5728
+ **Slots:** none — capabilities are configured entirely via the `capabilities` prop.
5729
+
5730
+ **CSS parts:** `trigger` (the single-capability button, only rendered when
5731
+ `capabilities.length === 1`), `menu` (the `lyra-menu` wrapper, only rendered when
5732
+ `capabilities.length > 1`).
5733
+
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`.
5736
+
5737
+ **Optional peer deps:** none.
5738
+
5739
+ ```html
5740
+ <lyra-attachment-trigger .capabilities=${['files', 'image', 'camera']} accept=".pdf,.docx"
5741
+ @lyra-pick=${(e) => queueFiles(e.detail.capability, e.detail.files)}
5742
+ @lyra-camera-request=${openCameraFlow}
5743
+ ></lyra-attachment-trigger>
5744
+ ```
5745
+
5746
+ **Known gotchas:**
5747
+ - `HTMLInputElement.files` is a *live* view in most browsers — clearing `input.value` after reading
5748
+ `.files` (needed so re-picking the exact same file still fires another `change` event next time)
5749
+ mutates that exact `FileList` object back to empty in place, not just detaches a stale reference.
5750
+ A consumer reading `lyra-pick`'s `detail.files` even one microtask later (an `async` handler, a
5751
+ queued upload) would otherwise observe an empty list — this component avoids that by rehoming the
5752
+ selection into a fresh `DataTransfer` before emitting, but any other file-input-adjacent code
5753
+ emitting `input.files` directly without that rehoming step has the same latent bug.
5754
+ - The `camera` capability never touches the hidden `<input type="file">` at all — it's scope-
5755
+ limited by design to firing `lyra-camera-request` and nothing else.
5756
+
5757
+ ---
5758
+
5759
+ ## `lyra-kbd`
5760
+
5761
+ A small chip representing a keyboard shortcut, rendering the platform-appropriate glyph for
5762
+ cross-platform modifier keys (⌘ on macOS, "Ctrl" elsewhere) from a single platform-neutral `keys`
5763
+ string. First-party invention (no Web Awesome equivalent).
5764
+
5765
+ **Properties:**
5766
+ - `keys: string = ''` — a `+`-separated sequence of tokens, e.g. `"mod+k"` or `"mod+shift+p"`.
5767
+ Recognized modifier tokens: `mod` (platform-neutral primary modifier — ⌘/"Command" on macOS,
5768
+ "Ctrl"/"Control" elsewhere), `alt` (⌥/"Option" on macOS, "Alt" elsewhere), `shift` (⇧/"Shift"
5769
+ always), `ctrl`/`control` (always the literal Control key, distinct from `mod`, for a shortcut
5770
+ that's specifically Ctrl even on macOS). Anything else falls through to a small built-in map of
5771
+ friendly labels (`enter` → `↵`/"Enter", `esc`/`escape` → "Esc"/"Escape", `tab`, `space`,
5772
+ `backspace` → `⌫`/"Backspace", `delete` → "Del"/"Delete", `home`, `end`, `pageup` → "PgUp"/"Page
5773
+ Up", `pagedown` → "PgDn"/"Page Down", the four `arrowup`/`arrowdown`/`arrowleft`/`arrowright` →
5774
+ arrow glyphs, `plus`/`minus` → literal "+"/"−" as an escape hatch since `+` is the token
5775
+ delimiter and can't appear as a literal token itself), or, failing that, renders as typed
5776
+ (single letters/digits upper-cased).
5777
+
5778
+ **Exported types/functions (also directly usable standalone):** `KbdKeyLabel { visual: string;
5779
+ word: string }` — one resolved token's rendered glyph and spelled-out word.
5780
+ `shortcutTokenLabel(rawToken: string, isMac: boolean): KbdKeyLabel` — resolves a single token,
5781
+ parameterized on `isMac` so both platform branches are unit-testable without spoofing `navigator`.
5782
+ `parseShortcut(keys: string, isMac: boolean): KbdKeyLabel[]` — splits and resolves a full `keys`
5783
+ string.
5784
+
5785
+ **Events:** none — purely presentational.
5786
+
5787
+ **Slots:** default — an escape hatch for fully custom key-cap content (e.g. an icon instead of a
5788
+ 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.
5791
+
5792
+ **CSS parts:** `base` (the chip root), `key` (one per rendered token).
5793
+
5794
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-surface`/
5795
+ `-border`/`-text-quiet`, `--lyra-radius`, `--lyra-font`.
5796
+
5797
+ **Optional peer deps:** none.
5798
+
5799
+ ```html
5800
+ <lyra-kbd keys="mod+k"></lyra-kbd>
5801
+ <lyra-kbd keys="mod+shift+p"></lyra-kbd>
5802
+ <lyra-kbd keys="esc"></lyra-kbd>
5803
+ ```
5804
+
5805
+ Platform detection (`IS_MAC`, computed once at module scope, not per-instance/per-render, since a
5806
+ page's platform never changes mid-session) prefers `navigator.userAgentData` (Client Hints, so far
5807
+ Chromium-only) when available, falling back through `navigator.platform` (long-deprecated) and
5808
+ finally a `navigator.userAgent` substring check — all three are deprecated/non-standard to varying
5809
+ degrees but remain, in combination, the practical cross-browser way to answer "is this macOS" today.
5810
+ The rendered chip carries `role="img"` with a single spelled-out `aria-label` (e.g. "Command+K")
5811
+ rather than exposing each glyph/`+`-separator as separate accessible-tree text, since the individual
5812
+ pieces aren't real words and would read worse piecemeal than as one label — glyphs like ⌘/⇧/⌥ are
5813
+ not reliably announced by every screen reader/platform combination, which is exactly why the
5814
+ spelled-out word form exists at all.
5815
+
5816
+ ---
5817
+
5818
+ ## `lyra-result-card` / `lyra-result-field`
5819
+
5820
+ A small, tightly-coupled pair giving any custom `lyra-tool-result-view` renderer (registered via
5821
+ `registerToolRenderer()` in `../tool-result-view/registry.js`) a consistent "small bordered card +
5822
+ label/value row" visual language, without each one hand-rolling its own box. Neither component has
5823
+ any code dependency on the tool-result-view registry itself — they're generically usable anywhere a
5824
+ small card/field shell is useful.
5825
+
5826
+ ### `lyra-result-card`
5827
+
5828
+ A small bordered card shell. Purely visual, with no state of its own beyond slot-presence tracking.
5829
+
5830
+ **Properties:**
5831
+ - `title: string = ''` — small heading for the card. Leave unset for an untitled card (e.g. a bare
5832
+ block of `lyra-result-field` rows with no natural heading).
5833
+
5834
+ **Events:** none.
5835
+
5836
+ **Slots:** default (the card body — typically one or more `lyra-result-field` rows, though any
5837
+ content is accepted), `actions` (small header controls, e.g. a copy button, rendered alongside the
5838
+ title).
5839
+
5840
+ **CSS parts:** `base` (outer bordered container), `header` (present in the DOM at all times so a
5841
+ 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`.
5843
+
5844
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs`/`-s`, `--lyra-color-border`/
5845
+ `-surface`/`-text`, `--lyra-radius`.
5846
+
5847
+ ### `lyra-result-field`
5848
+
5849
+ A single label/value row — e.g. "Status: 200 OK" or "Duration: 340ms" — rendered as a dense
5850
+ "label: value" line by default, matching the compact, small-card presentation this pair exists for.
5851
+
5852
+ **Properties:**
5853
+ - `label: string = ''` — the field name, e.g. "Status". Leave unset to render a value with no label.
5854
+ - `value: string = ''` — plain-text value, e.g. "200 OK". Ignored once the default slot carries real
5855
+ content.
5856
+
5857
+ **Events:** none.
5858
+
5859
+ **Slots:** default — rich value content (e.g. a `lyra-chip` status badge, or a plain text override),
5860
+ taking precedence over `value` whenever it has any assigned content. "Real content" means any
5861
+ assigned *element* (even one with no text of its own, like an attribute-driven status badge) or any
5862
+ non-whitespace text node — both a rich slotted badge and a plain-text override are caught.
5863
+
5864
+ **CSS parts:** `base` (row container), `label` (including its trailing colon), `value` (wrapper
5865
+ around either the slotted content or the plain `value` text).
5866
+
5867
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-text`/
5868
+ `-text-quiet`, `--lyra-font`.
5869
+
5870
+ **Optional peer deps:** none (either component).
5871
+
5872
+ ```html
5873
+ <lyra-result-card title="Weather">
5874
+ <lyra-result-field label="Status" value="200 OK"></lyra-result-field>
5875
+ <lyra-result-field label="Duration" value="340ms"></lyra-result-field>
5876
+ <lyra-result-field label="Provider">
5877
+ <lyra-chip tone="success">OpenWeather</lyra-chip>
5878
+ </lyra-result-field>
5879
+ </lyra-result-card>
5880
+ ```
5881
+
5882
+ **Known gotchas:**
5883
+ - `HTMLElement.textContent` read on a shadow-DOM wrapper containing a `<slot>` does NOT include the
5884
+ slot's assigned/projected light-DOM content — only literal fallback children of the `<slot>` tag
5885
+ itself (there are none here). Asserting against `[part="value"]`'s own `.textContent` to check
5886
+ rendered slotted content will read as empty even when the component is rendering correctly;
5887
+ assert against the slot's `assignedNodes()`/`assignedElements()` instead.
5888
+
5889
+ ---
5890
+
2072
5891
  ## Cross-cutting notes
2073
5892
 
2074
5893
  These apply across the whole library rather than to any one component: