@djangocfg/ui-tools 2.1.407 → 2.1.409

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 (297) hide show
  1. package/README.md +9 -10
  2. package/dist/file-icon/index.cjs +449 -61
  3. package/dist/file-icon/index.cjs.map +1 -1
  4. package/dist/file-icon/index.d.cts +56 -18
  5. package/dist/file-icon/index.d.ts +56 -18
  6. package/dist/file-icon/index.mjs +448 -62
  7. package/dist/file-icon/index.mjs.map +1 -1
  8. package/dist/tree/index.cjs +49 -22
  9. package/dist/tree/index.cjs.map +1 -1
  10. package/dist/tree/index.d.cts +9 -3
  11. package/dist/tree/index.d.ts +9 -3
  12. package/dist/tree/index.mjs +49 -22
  13. package/dist/tree/index.mjs.map +1 -1
  14. package/dist/{types-B_zhyAqR.d.cts → types-eEu8SeiQ.d.cts} +4 -0
  15. package/dist/{types-B_zhyAqR.d.ts → types-eEu8SeiQ.d.ts} +4 -0
  16. package/package.json +8 -13
  17. package/src/components/FloatingToolbar/index.tsx +37 -3
  18. package/src/lib/page-snapshot/__tests__/capture-integration.test.ts +85 -0
  19. package/src/lib/page-snapshot/__tests__/engine.test.ts +36 -0
  20. package/src/lib/page-snapshot/__tests__/redaction-integration.test.ts +99 -0
  21. package/src/lib/page-snapshot/__tests__/tokens.test.ts +17 -0
  22. package/src/lib/page-snapshot/capture/__tests__/budget.test.ts +49 -0
  23. package/src/lib/page-snapshot/capture/__tests__/chrome-filter.test.ts +47 -0
  24. package/src/lib/page-snapshot/capture/__tests__/fold.test.ts +66 -0
  25. package/src/lib/page-snapshot/capture/__tests__/scope.test.ts +74 -0
  26. package/src/lib/page-snapshot/capture/__tests__/walk.test.ts +129 -0
  27. package/src/lib/page-snapshot/capture/accessible-name.ts +73 -0
  28. package/src/lib/page-snapshot/capture/budget.ts +95 -0
  29. package/src/lib/page-snapshot/capture/chrome-filter.ts +81 -0
  30. package/src/lib/page-snapshot/capture/classify.ts +111 -0
  31. package/src/lib/page-snapshot/capture/dom-utils.ts +111 -0
  32. package/src/lib/page-snapshot/capture/fold.ts +96 -0
  33. package/src/lib/page-snapshot/capture/scope.ts +169 -0
  34. package/src/lib/page-snapshot/capture/walk.ts +250 -0
  35. package/src/lib/page-snapshot/cst/__tests__/serialize.test.ts +50 -0
  36. package/src/lib/page-snapshot/cst/directives.ts +47 -0
  37. package/src/lib/page-snapshot/cst/payload.ts +50 -0
  38. package/src/lib/page-snapshot/cst/serialize.ts +84 -0
  39. package/src/lib/page-snapshot/cst/types.ts +115 -0
  40. package/src/lib/page-snapshot/engine.ts +176 -0
  41. package/src/lib/page-snapshot/index.ts +93 -0
  42. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +72 -0
  43. package/src/lib/page-snapshot/react/PageSnapshotPreview.tsx +78 -0
  44. package/src/lib/page-snapshot/react/__tests__/PageSnapshotChip.test.tsx +54 -0
  45. package/src/lib/page-snapshot/react/__tests__/provider.test.tsx +103 -0
  46. package/src/lib/page-snapshot/react/__tests__/use-page-snapshot-toggle.test.tsx +62 -0
  47. package/src/lib/page-snapshot/react/provider.tsx +162 -0
  48. package/src/lib/page-snapshot/react/use-page-snapshot-toggle.ts +47 -0
  49. package/src/lib/page-snapshot/react/use-page-snapshot.ts +67 -0
  50. package/src/lib/page-snapshot/redaction/__tests__/audit.test.ts +25 -0
  51. package/src/lib/page-snapshot/redaction/__tests__/heuristics.test.ts +73 -0
  52. package/src/lib/page-snapshot/redaction/__tests__/luhn.test.ts +26 -0
  53. package/src/lib/page-snapshot/redaction/__tests__/patterns.test.ts +60 -0
  54. package/src/lib/page-snapshot/redaction/audit.ts +58 -0
  55. package/src/lib/page-snapshot/redaction/heuristics.ts +75 -0
  56. package/src/lib/page-snapshot/redaction/index.ts +75 -0
  57. package/src/lib/page-snapshot/redaction/luhn.ts +25 -0
  58. package/src/lib/page-snapshot/redaction/patterns.ts +111 -0
  59. package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +24 -0
  60. package/src/lib/page-snapshot/refs/registry.ts +46 -0
  61. package/src/lib/page-snapshot/staleness/__tests__/hash.test.ts +34 -0
  62. package/src/lib/page-snapshot/staleness/hash.ts +20 -0
  63. package/src/lib/page-snapshot/tokens.ts +15 -0
  64. package/src/tools/AudioPlayer/context/PlayerProvider.tsx +13 -14
  65. package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +55 -6
  66. package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +2 -5
  67. package/src/tools/Chat/README.md +277 -39
  68. package/src/tools/Chat/composer/Composer.tsx +471 -0
  69. package/src/tools/Chat/composer/ComposerActionBar.tsx +65 -0
  70. package/src/tools/Chat/composer/ComposerBanner.tsx +128 -0
  71. package/src/tools/Chat/composer/ComposerButton.tsx +64 -0
  72. package/src/tools/Chat/composer/ComposerFooter.tsx +90 -0
  73. package/src/tools/Chat/composer/ComposerMenuButton.tsx +62 -0
  74. package/src/tools/Chat/composer/ComposerModelPicker.tsx +104 -0
  75. package/src/tools/Chat/composer/ComposerRichTextarea.tsx +88 -0
  76. package/src/tools/Chat/composer/ComposerToolPill.tsx +95 -0
  77. package/src/tools/Chat/composer/index.ts +45 -0
  78. package/src/tools/Chat/composer/size-context.tsx +26 -0
  79. package/src/tools/Chat/composer/types.ts +143 -0
  80. package/src/tools/Chat/composer/useComposerActions.tsx +164 -0
  81. package/src/tools/Chat/context/ChatProvider.tsx +54 -3
  82. package/src/tools/Chat/core/__tests__/metadata.test.ts +69 -0
  83. package/src/tools/Chat/core/index.ts +23 -1
  84. package/src/tools/Chat/core/markdown.ts +1 -1
  85. package/src/tools/Chat/core/metadata.ts +47 -0
  86. package/src/tools/Chat/core/payload-dispatch.ts +1 -1
  87. package/src/tools/Chat/core/transport/http.ts +71 -32
  88. package/src/tools/Chat/core/transport/sse.ts +18 -10
  89. package/src/tools/Chat/highlight/HighlightOverlay.tsx +101 -0
  90. package/src/tools/Chat/highlight/README.md +103 -0
  91. package/src/tools/Chat/highlight/SpotlightCanvas.tsx +153 -0
  92. package/src/tools/Chat/highlight/__tests__/HighlightOverlay.test.tsx +112 -0
  93. package/src/tools/Chat/highlight/__tests__/resolveRef.test.ts +55 -0
  94. package/src/tools/Chat/highlight/index.ts +21 -0
  95. package/src/tools/Chat/highlight/resolveRef.ts +42 -0
  96. package/src/tools/Chat/highlight/types.ts +49 -0
  97. package/src/tools/Chat/highlight/useHighlightTargets.ts +128 -0
  98. package/src/tools/Chat/hooks/index.ts +0 -5
  99. package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +28 -47
  100. package/src/tools/Chat/hooks/useChat.ts +47 -14
  101. package/src/tools/Chat/hooks/useChatComposer.ts +2 -2
  102. package/src/tools/Chat/hooks/useChatLayout.ts +1 -1
  103. package/src/tools/Chat/hooks/useStreamEndFocus.ts +54 -0
  104. package/src/tools/Chat/index.ts +25 -219
  105. package/src/tools/Chat/launcher/ChatDock.tsx +1 -1
  106. package/src/tools/Chat/launcher/ChatLauncher.tsx +1 -1
  107. package/src/tools/Chat/launcher/{ChatHeader.tsx → header/ChatHeader.tsx} +24 -11
  108. package/src/tools/Chat/launcher/{ChatHeaderActionButton.tsx → header/ChatHeaderActionButton.tsx} +34 -3
  109. package/src/tools/Chat/launcher/{ChatHeaderLanguageButton.tsx → header/ChatHeaderLanguageButton.tsx} +2 -2
  110. package/src/tools/Chat/launcher/{ChatHeaderModeToggle.tsx → header/ChatHeaderModeToggle.tsx} +1 -1
  111. package/src/tools/Chat/launcher/{ChatHeaderResetButton.tsx → header/ChatHeaderResetButton.tsx} +2 -1
  112. package/src/tools/Chat/launcher/{HeaderSlots.tsx → header/HeaderSlots.tsx} +3 -3
  113. package/src/tools/Chat/launcher/header/index.ts +26 -0
  114. package/src/tools/Chat/launcher/index.ts +3 -10
  115. package/src/tools/Chat/lazy.tsx +38 -284
  116. package/src/tools/Chat/{components → messages}/MessageBubble.tsx +58 -5
  117. package/src/tools/Chat/{components → messages}/MessageList.tsx +8 -25
  118. package/src/tools/Chat/messages/blocks/MessageBlocks.tsx +131 -0
  119. package/src/tools/Chat/messages/blocks/builtin.tsx +91 -0
  120. package/src/tools/Chat/messages/blocks/index.ts +12 -0
  121. package/src/tools/Chat/messages/blocks/registry.tsx +42 -0
  122. package/src/tools/Chat/messages/blocks/renderers/AudioBlock.tsx +20 -0
  123. package/src/tools/Chat/messages/blocks/renderers/CodeBlock.tsx +19 -0
  124. package/src/tools/Chat/messages/blocks/renderers/GalleryBlock.tsx +26 -0
  125. package/src/tools/Chat/messages/blocks/renderers/ImageBlock.tsx +27 -0
  126. package/src/tools/Chat/messages/blocks/renderers/JsonBlock.tsx +12 -0
  127. package/src/tools/Chat/messages/blocks/renderers/LottieBlock.tsx +11 -0
  128. package/src/tools/Chat/messages/blocks/renderers/MapBlock.tsx +36 -0
  129. package/src/tools/Chat/messages/blocks/renderers/MermaidBlock.tsx +11 -0
  130. package/src/tools/Chat/messages/blocks/renderers/VideoBlock.tsx +24 -0
  131. package/src/tools/Chat/messages/blocks/renderers/types.ts +8 -0
  132. package/src/tools/Chat/{components → messages}/index.ts +11 -5
  133. package/src/tools/Chat/public.ts +212 -0
  134. package/src/tools/Chat/shell/ChatRoot.tsx +345 -0
  135. package/src/tools/Chat/{components → shell}/EmptyState.tsx +4 -2
  136. package/src/tools/Chat/shell/index.ts +15 -0
  137. package/src/tools/Chat/types/block.ts +120 -0
  138. package/src/tools/Chat/types/config.ts +0 -5
  139. package/src/tools/Chat/types/index.ts +17 -0
  140. package/src/tools/Chat/types/message.ts +3 -0
  141. package/src/tools/Chat/utils/index.ts +4 -0
  142. package/src/tools/CodeEditor/README.md +4 -6
  143. package/src/tools/CodeEditor/components/DiffEditor.tsx +48 -13
  144. package/src/tools/CodeEditor/components/Editor.tsx +96 -44
  145. package/src/tools/CodeEditor/context/EditorProvider.tsx +34 -17
  146. package/src/tools/CodeEditor/hooks/useEditorTheme.ts +92 -99
  147. package/src/tools/CodeEditor/hooks/useMonaco.ts +37 -22
  148. package/src/tools/CodeEditor/lazy.tsx +6 -0
  149. package/src/tools/CodeEditor/lib/index.ts +1 -1
  150. package/src/tools/CodeEditor/lib/themes.ts +3 -39
  151. package/src/tools/CronScheduler/CronScheduler.client.tsx +230 -61
  152. package/src/tools/CronScheduler/components/CustomInput.tsx +21 -4
  153. package/src/tools/CronScheduler/components/DayChips.tsx +13 -11
  154. package/src/tools/CronScheduler/components/MonthDayGrid.tsx +4 -4
  155. package/src/tools/CronScheduler/components/SchedulePreview.tsx +7 -3
  156. package/src/tools/CronScheduler/components/TimeSelector.tsx +1 -1
  157. package/src/tools/CronScheduler/index.tsx +1 -1
  158. package/src/tools/CronScheduler/types/index.ts +8 -3
  159. package/src/tools/CronScheduler/utils/cron-humanize.ts +61 -16
  160. package/src/tools/CronScheduler/utils/cron-parser.ts +13 -4
  161. package/src/tools/FileIcon/FileIcon.tsx +24 -39
  162. package/src/tools/FileIcon/get-file-icon.ts +73 -0
  163. package/src/tools/FileIcon/icons/icon-data.ts +399 -0
  164. package/src/tools/FileIcon/index.ts +4 -0
  165. package/src/tools/FileIcon/loader.ts +17 -35
  166. package/src/tools/FileIcon/specialFolders.ts +18 -0
  167. package/src/tools/Gallery/components/lightbox/GalleryLightbox.tsx +112 -35
  168. package/src/tools/Gallery/components/media/GalleryVideo.tsx +21 -2
  169. package/src/tools/Gallery/components/preview/GalleryCarousel.tsx +11 -1
  170. package/src/tools/Gallery/hooks/usePreloadImages.ts +54 -7
  171. package/src/tools/ImageViewer/components/ImageInfo.tsx +12 -1
  172. package/src/tools/ImageViewer/components/ImageToolbar.tsx +51 -43
  173. package/src/tools/ImageViewer/components/ImageViewer.tsx +96 -24
  174. package/src/tools/ImageViewer/hooks/useImageLoading.ts +13 -0
  175. package/src/tools/ImageViewer/utils/constants.ts +3 -0
  176. package/src/tools/ImageViewer/utils/index.ts +1 -0
  177. package/src/tools/JsonForm/JsonSchemaForm.tsx +4 -1
  178. package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +5 -3
  179. package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +7 -4
  180. package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +3 -1
  181. package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +23 -3
  182. package/src/tools/JsonForm/widgets/ColorWidget.tsx +20 -12
  183. package/src/tools/JsonForm/widgets/NumberWidget.tsx +14 -9
  184. package/src/tools/JsonForm/widgets/RadioWidget.tsx +78 -0
  185. package/src/tools/JsonForm/widgets/SelectWidget.tsx +1 -0
  186. package/src/tools/JsonForm/widgets/SliderWidget.tsx +7 -4
  187. package/src/tools/JsonForm/widgets/TextWidget.tsx +41 -17
  188. package/src/tools/JsonForm/widgets/index.ts +1 -0
  189. package/src/tools/JsonTree/components/JsonContent.tsx +115 -40
  190. package/src/tools/LottiePlayer/LottiePlayer.client.tsx +177 -72
  191. package/src/tools/LottiePlayer/index.tsx +14 -4
  192. package/src/tools/LottiePlayer/lazy.tsx +11 -3
  193. package/src/tools/LottiePlayer/types.ts +31 -1
  194. package/src/tools/LottiePlayer/useLottie.ts +32 -9
  195. package/src/tools/LottiePlayer/usePrefersReducedMotion.ts +46 -0
  196. package/src/tools/Map/components/LayerSwitcher.tsx +54 -21
  197. package/src/tools/Map/components/MapCluster.tsx +28 -21
  198. package/src/tools/Map/components/MapContainer.tsx +11 -4
  199. package/src/tools/Map/components/MapLegend.tsx +46 -15
  200. package/src/tools/Map/components/MapMarker.tsx +31 -2
  201. package/src/tools/Map/hooks/useMapEvents.ts +64 -105
  202. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +61 -6
  203. package/src/tools/MarkdownEditor/MentionList.tsx +37 -4
  204. package/src/tools/MarkdownEditor/createMentionSuggestion.ts +11 -0
  205. package/src/tools/MarkdownEditor/lazy.tsx +32 -7
  206. package/src/tools/MarkdownEditor/styles.css +13 -0
  207. package/src/tools/MarkdownMessage/CodeBlock.tsx +40 -17
  208. package/src/tools/MarkdownMessage/MarkdownMessage.tsx +26 -6
  209. package/src/tools/MarkdownMessage/components.tsx +22 -9
  210. package/src/tools/MarkdownMessage/types.ts +24 -1
  211. package/src/tools/Mermaid/Mermaid.client.tsx +27 -5
  212. package/src/tools/Mermaid/components/MermaidErrorPanel.tsx +31 -0
  213. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +14 -17
  214. package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +264 -168
  215. package/src/tools/Mermaid/hooks/useMermaidValidation.ts +76 -10
  216. package/src/tools/Mermaid/index.tsx +6 -0
  217. package/src/tools/Mermaid/utils/mermaid-helpers.ts +141 -18
  218. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +11 -1
  219. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +49 -20
  220. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +7 -0
  221. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +7 -4
  222. package/src/tools/OpenapiViewer/constants.ts +3 -0
  223. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +73 -11
  224. package/src/tools/OpenapiViewer/utils/schemaExport.ts +26 -6
  225. package/src/tools/PrettyCode/PrettyCode.client.tsx +23 -16
  226. package/src/tools/PrettyCode/lazy.tsx +1 -1
  227. package/src/tools/SpeechRecognition/README.md +1 -1
  228. package/src/tools/SpeechRecognition/__tests__/language.test.ts +9 -3
  229. package/src/tools/SpeechRecognition/components/RecordingPulse.tsx +59 -0
  230. package/src/tools/SpeechRecognition/components/index.ts +2 -0
  231. package/src/tools/SpeechRecognition/core/engine/external.ts +24 -7
  232. package/src/tools/SpeechRecognition/core/language.ts +23 -6
  233. package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +36 -5
  234. package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +18 -11
  235. package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +94 -26
  236. package/src/tools/SpeechRecognition/widgets/index.ts +1 -1
  237. package/src/tools/Tree/README.md +4 -8
  238. package/src/tools/Tree/TreeRoot.tsx +22 -10
  239. package/src/tools/Tree/components/TreeContent.tsx +24 -4
  240. package/src/tools/Tree/components/TreeLabel.tsx +8 -2
  241. package/src/tools/Tree/components/TreeRow.tsx +16 -6
  242. package/src/tools/Tree/data/flatten.ts +10 -4
  243. package/src/tools/Tree/types.ts +4 -0
  244. package/src/tools/Uploader/components/UploadAddButton.tsx +29 -6
  245. package/src/tools/Uploader/components/UploadDropzone.tsx +63 -7
  246. package/src/tools/Uploader/components/UploadPageDropOverlay.tsx +19 -5
  247. package/src/tools/Uploader/components/UploadPreviewItem.tsx +47 -17
  248. package/src/tools/Uploader/components/UploadPreviewList.tsx +24 -12
  249. package/src/tools/Uploader/utils/formatters.ts +8 -3
  250. package/src/tools/VideoPlayer/canvas/hls-canvas.tsx +1 -0
  251. package/src/tools/VideoPlayer/canvas/{jsx.d.ts → jsx-augmentation.ts} +12 -19
  252. package/src/tools/VideoPlayer/canvas/vimeo-canvas.tsx +1 -0
  253. package/src/tools/VideoPlayer/canvas/youtube-canvas.tsx +1 -0
  254. package/src/tools/VideoPlayer/parts/fullscreen.tsx +1 -1
  255. package/src/tools/VideoPlayer/parts/pip.tsx +1 -1
  256. package/src/tools/VideoPlayer/parts/playback-rate.tsx +1 -1
  257. package/src/tools/VideoPlayer/parts/seek-bar.tsx +2 -2
  258. package/src/tools/VideoPlayer/parts/volume.tsx +2 -2
  259. package/src/tools/index.ts +2 -1
  260. package/src/tools/Chat/components/AudioToggle.tsx +0 -78
  261. package/src/tools/Chat/components/ChatRoot.tsx +0 -305
  262. package/src/tools/Chat/components/Composer.tsx +0 -216
  263. package/src/tools/Chat/hooks/useChatScroll.ts +0 -145
  264. package/src/tools/Chat/types.ts +0 -9
  265. package/src/tools/JsonTree/components/JsonToolbar.tsx +0 -95
  266. package/src/tools/JsonTree/hooks/useElementCorner.ts +0 -84
  267. package/src/tools/JsonTree/hooks/useNavbarHeight.ts +0 -83
  268. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +0 -121
  269. package/src/tools/Tour/README.md +0 -373
  270. package/src/tools/Tour/components/Tour.tsx +0 -12
  271. package/src/tools/Tour/components/TourContent.tsx +0 -171
  272. package/src/tools/Tour/components/TourNavigation.tsx +0 -77
  273. package/src/tools/Tour/components/TourProgress.tsx +0 -88
  274. package/src/tools/Tour/components/TourSpotlight.tsx +0 -199
  275. package/src/tools/Tour/components/index.ts +0 -5
  276. package/src/tools/Tour/context/TourContext.ts +0 -19
  277. package/src/tools/Tour/context/TourProvider.tsx +0 -292
  278. package/src/tools/Tour/context/index.ts +0 -2
  279. package/src/tools/Tour/hooks/index.ts +0 -3
  280. package/src/tools/Tour/hooks/useKeyboardNavigation.ts +0 -59
  281. package/src/tools/Tour/hooks/useStepTarget.ts +0 -121
  282. package/src/tools/Tour/hooks/useTour.ts +0 -42
  283. package/src/tools/Tour/index.ts +0 -38
  284. package/src/tools/Tour/types/index.ts +0 -224
  285. package/src/tools/Tour/utils/dom.ts +0 -98
  286. package/src/tools/Tour/utils/index.ts +0 -3
  287. package/src/tools/Tour/utils/logger.ts +0 -3
  288. package/src/tools/Tour/utils/scrollIntoView.ts +0 -24
  289. /package/src/tools/Chat/{config.ts → constants.ts} +0 -0
  290. /package/src/tools/Chat/launcher/{ChatHeaderAudioToggle.tsx → header/ChatHeaderAudioToggle.tsx} +0 -0
  291. /package/src/tools/Chat/{components → messages}/Attachments.tsx +0 -0
  292. /package/src/tools/Chat/{components → messages}/JumpToLatest.tsx +0 -0
  293. /package/src/tools/Chat/{components → messages}/MessageActions.tsx +0 -0
  294. /package/src/tools/Chat/{components → messages}/Sources.tsx +0 -0
  295. /package/src/tools/Chat/{components → messages}/StreamingIndicator.tsx +0 -0
  296. /package/src/tools/Chat/{components → messages}/ToolCalls.tsx +0 -0
  297. /package/src/tools/Chat/{components → shell}/ErrorBanner.tsx +0 -0
@@ -11,9 +11,15 @@ describe('toBCP47', () => {
11
11
  expect(toBCP47('no')).toBe('nb-NO');
12
12
  });
13
13
 
14
- it('falls back to <code>-<UPPER(code)> for unmapped ISO codes', () => {
15
- expect(toBCP47('uk')).toBe('uk-UK');
16
- expect(toBCP47('cs')).toBe('cs-CS');
14
+ it('resolves catalogue ISO codes to their canonical dialect', () => {
15
+ // Not in the built-in table, but present in the Web Speech
16
+ // catalogue — must resolve to a real region, never `uk-UK`/`cs-CS`.
17
+ expect(toBCP47('uk')).toBe('uk-UA');
18
+ expect(toBCP47('cs')).toBe('cs-CZ');
19
+ });
20
+
21
+ it('falls back to <code>-<UPPER(code)> for fully unknown ISO codes', () => {
22
+ expect(toBCP47('xx')).toBe('xx-XX');
17
23
  });
18
24
 
19
25
  it('passes BCP-47 input through unchanged', () => {
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+
3
+ import type * as React from 'react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ export interface RecordingPulseProps {
8
+ /** When false the overlay is fully hidden (no DOM cost, no animation). */
9
+ active: boolean;
10
+ /**
11
+ * Live RMS mic level 0..1 (from `useMicLevel` / `useSpeechRecognition`).
12
+ * Drives the pulse amplitude so the ring "breathes" with the voice.
13
+ * Omit for a steady ambient pulse.
14
+ */
15
+ level?: number;
16
+ /** Tailwind colour token for the ring. @default 'bg-destructive' */
17
+ tone?: string;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Pulsing circle overlay that signals an active recording — Gemini-style
23
+ * `circle-overlay`. Sits absolutely over a round mic button (`inset-0`),
24
+ * so the parent must be `relative`.
25
+ *
26
+ * Two layers: a steady `animate-ping` halo for the "still recording"
27
+ * baseline, plus a level-driven ring that scales with mic amplitude so
28
+ * loud speech visibly bumps the pulse. Pointer-events are off — the
29
+ * button underneath stays fully clickable.
30
+ */
31
+ export function RecordingPulse({
32
+ active,
33
+ level = 0,
34
+ tone = 'bg-destructive',
35
+ className,
36
+ }: RecordingPulseProps): React.ReactElement | null {
37
+ if (!active) return null;
38
+
39
+ // Map RMS 0..1 → a calm 1.0–1.45 scale. Speech rarely exceeds ~0.5
40
+ // RMS, so we amplify the low end for a lively-but-not-jittery ring.
41
+ const amp = Math.min(1, Math.max(0, level));
42
+ const scale = 1 + Math.min(0.45, amp * 0.9);
43
+ const glowOpacity = 0.25 + Math.min(0.35, amp * 0.7);
44
+
45
+ return (
46
+ <span aria-hidden className={cn('pointer-events-none absolute inset-0', className)}>
47
+ {/* Steady halo — guarantees visible feedback even in silence. */}
48
+ <span className={cn('absolute inset-0 rounded-full opacity-30 animate-ping', tone)} />
49
+ {/* Amplitude ring — scales smoothly with the live mic level. */}
50
+ <span
51
+ className={cn(
52
+ 'absolute inset-0 rounded-full transition-transform duration-100 ease-out',
53
+ tone,
54
+ )}
55
+ style={{ transform: `scale(${scale})`, opacity: glowOpacity }}
56
+ />
57
+ </span>
58
+ );
59
+ }
@@ -14,3 +14,5 @@ export { ErrorBanner } from './ErrorBanner';
14
14
  export type { ErrorBannerProps } from './ErrorBanner';
15
15
  export { PushToTalkHint } from './PushToTalkHint';
16
16
  export type { PushToTalkHintProps } from './PushToTalkHint';
17
+ export { RecordingPulse } from './RecordingPulse';
18
+ export type { RecordingPulseProps } from './RecordingPulse';
@@ -37,8 +37,8 @@ export interface ExternalEngineHandle {
37
37
  */
38
38
  emitPartial(text: string): void;
39
39
  /**
40
- * Push the final transcript. Closes the current segment; the engine
41
- * emits `final` then transitions to `closed`.
40
+ * Push the final transcript. Closes the current segment. With the
41
+ * default `closeOnFinal`, also closes the whole session.
42
42
  */
43
43
  emitFinal(text: string, confidence?: number): void;
44
44
  /** Surface a backend error. Engine transitions to `closed`. */
@@ -85,6 +85,17 @@ export interface ExternalEngineOptions {
85
85
  * native side to confirm the capture session opened.
86
86
  */
87
87
  autoMarkListening?: boolean;
88
+ /**
89
+ * If `true` (default), the engine closes the whole session right
90
+ * after the first `emitFinal` — the common single-shot case (one
91
+ * recording → one transcript, e.g. cmdop Wails push-to-talk).
92
+ *
93
+ * Set `false` for rolling backends that emit many partial/final
94
+ * pairs within a single capture session (Deepgram-style streaming).
95
+ * The session then stays `listening` after each `emitFinal` and only
96
+ * closes on an explicit `stop()` / `abort()` or `emitError`.
97
+ */
98
+ closeOnFinal?: boolean;
88
99
  }
89
100
 
90
101
  /**
@@ -124,6 +135,7 @@ export function createExternalEngine(
124
135
  opts: ExternalEngineOptions,
125
136
  ): RecognitionEngine {
126
137
  const bus = createEngineBus();
138
+ const closeOnFinal = opts.closeOnFinal ?? true;
127
139
  let currentSegmentId: string | null = null;
128
140
  let unsubscribe: Unsub | null = null;
129
141
  let running = false;
@@ -145,11 +157,16 @@ export function createExternalEngine(
145
157
  if (!running) return;
146
158
  const id = currentSegmentId ?? newSegmentId();
147
159
  bus.emit('final', text, id, confidence);
148
- // External engines almost always go idle right after their
149
- // final — close the session so consumers' `onStop` fires
150
- // without requiring a separate `stop()` call.
151
- bus.emit('state', 'closed');
152
- teardown();
160
+ // Single-shot engines (default) go idle right after their final —
161
+ // close the session so consumers' `onStop` fires without a
162
+ // separate `stop()` call. Rolling engines keep the session open
163
+ // and just reset the segment so the next partial starts fresh.
164
+ if (closeOnFinal) {
165
+ bus.emit('state', 'closed');
166
+ teardown();
167
+ } else {
168
+ currentSegmentId = null;
169
+ }
153
170
  },
154
171
  emitError(err: RecognitionError): void {
155
172
  bus.emit('error', err);
@@ -5,11 +5,14 @@
5
5
  * STT services expect.
6
6
  *
7
7
  * We keep a small built-in table for the locales we ship translations
8
- * for; everything else falls through to `<code>-<UPPER(code)>`, which
9
- * works for the majority of regions. The mapping is also re-exported
10
- * so consumers can extend it.
8
+ * for; codes outside it are looked up in the full Web Speech catalogue
9
+ * (so `uk` `uk-UA`, not the invalid `uk-UK`), and only fall through
10
+ * to `<code>-<UPPER(code)>` as a last resort. The mapping is also
11
+ * re-exported so consumers can extend it.
11
12
  */
12
13
 
14
+ import { WEB_SPEECH_LANGUAGES } from './languages-catalog';
15
+
13
16
  const ISO_TO_BCP47: Record<string, string> = {
14
17
  en: 'en-US',
15
18
  ru: 'ru-RU',
@@ -32,11 +35,23 @@ const ISO_TO_BCP47: Record<string, string> = {
32
35
 
33
36
  export const DEFAULT_ISO_TO_BCP47 = ISO_TO_BCP47;
34
37
 
38
+ /** ISO-639 primary subtag → default catalogue dialect (e.g. `uk` → `uk-UA`). */
39
+ const CATALOG_ISO_TO_TAG: Record<string, string> = (() => {
40
+ const map: Record<string, string> = {};
41
+ for (const lang of WEB_SPEECH_LANGUAGES) {
42
+ const first = lang.dialects[0];
43
+ if (first) map[lang.iso.toLowerCase()] = first.code;
44
+ }
45
+ return map;
46
+ })();
47
+
35
48
  /**
36
49
  * Normalise any of:
37
50
  * - BCP-47 ("en-US", "ru-RU") — passed through.
38
- * - ISO 639-1 ("en", "ru") — mapped via the table above, or
39
- * falls back to `<code>-<UPPER(code)>`.
51
+ * - ISO 639-1 ("en", "ru") — mapped via the built-in table, then
52
+ * the full Web Speech catalogue, then
53
+ * `<code>-<UPPER(code)>` as a last
54
+ * resort.
40
55
  * - `null`/`undefined`/empty — returns `undefined`.
41
56
  */
42
57
  export function toBCP47(
@@ -48,7 +63,9 @@ export function toBCP47(
48
63
  if (!trimmed) return undefined;
49
64
  if (trimmed.includes('-')) return trimmed; // already BCP-47
50
65
  const lower = trimmed.toLowerCase();
51
- return table[lower] ?? `${lower}-${lower.toUpperCase()}`;
66
+ return (
67
+ table[lower] ?? CATALOG_ISO_TO_TAG[lower] ?? `${lower}-${lower.toUpperCase()}`
68
+ );
52
69
  }
53
70
 
54
71
  /**
@@ -13,17 +13,27 @@ export interface UsePushToTalkOptions {
13
13
 
14
14
  const MOD_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta', 'mod']);
15
15
 
16
+ /** `mod` resolves to ⌘ on Apple platforms, Ctrl elsewhere. */
17
+ function isApplePlatform(): boolean {
18
+ if (typeof navigator === 'undefined') return false;
19
+ const p =
20
+ (navigator as { userAgentData?: { platform?: string } }).userAgentData
21
+ ?.platform ?? navigator.platform;
22
+ return /mac|iphone|ipad|ipod/i.test(p ?? '');
23
+ }
24
+
16
25
  function parseChord(chord: string): { mods: Set<string>; main: string | null } {
17
26
  const parts = chord
18
27
  .toLowerCase()
19
28
  .split('+')
20
29
  .map((s) => s.trim());
30
+ const modAlias = isApplePlatform() ? 'meta' : 'ctrl';
21
31
  const mods = new Set<string>();
22
32
  let main: string | null = null;
23
33
  for (const part of parts) {
24
34
  if (MOD_KEYS.has(part)) {
25
- mods.add(part === 'mod' ? 'meta' : part);
26
- } else {
35
+ mods.add(part === 'mod' ? modAlias : part);
36
+ } else if (part) {
27
37
  main = part;
28
38
  }
29
39
  }
@@ -34,12 +44,33 @@ function matches(e: KeyboardEvent, mods: Set<string>, main: string | null): bool
34
44
  if (mods.has('shift') !== e.shiftKey) return false;
35
45
  if (mods.has('ctrl') !== e.ctrlKey) return false;
36
46
  if (mods.has('alt') !== e.altKey) return false;
37
- // 'mod' meta on mac / ctrl elsewhere; we already normalised to 'meta'.
38
- if (mods.has('meta') !== (e.metaKey || (!e.metaKey && false))) return false;
47
+ if (mods.has('meta') !== e.metaKey) return false;
39
48
  if (main && e.key.toLowerCase() !== main) return false;
40
49
  return true;
41
50
  }
42
51
 
52
+ /**
53
+ * Key-up matcher — modifier state is unreliable on `keyup` (releasing
54
+ * the modifier itself drops the flag before the main key's keyup), so
55
+ * we only check the main key here. Pure-modifier chords match when the
56
+ * released key is one of the chord's modifiers.
57
+ */
58
+ function matchesRelease(
59
+ e: KeyboardEvent,
60
+ mods: Set<string>,
61
+ main: string | null,
62
+ ): boolean {
63
+ const key = e.key.toLowerCase();
64
+ if (main) return key === main;
65
+ // Pure-modifier chord: stop when any chord modifier is released.
66
+ return (
67
+ (mods.has('shift') && key === 'shift') ||
68
+ (mods.has('ctrl') && key === 'control') ||
69
+ (mods.has('alt') && key === 'alt') ||
70
+ (mods.has('meta') && key === 'meta')
71
+ );
72
+ }
73
+
43
74
  /**
44
75
  * Hold-to-talk wiring. Press → `start()`, release → `stop()`. Ignores
45
76
  * repeats and skips keydown inside `<input>` / `<textarea>` unless a
@@ -70,7 +101,7 @@ export function usePushToTalk(
70
101
  void recognition.start();
71
102
  };
72
103
  const onUp = (e: KeyboardEvent): void => {
73
- if (!matches(e, mods, main) && main && e.key.toLowerCase() !== main) return;
104
+ if (!matchesRelease(e, mods, main)) return;
74
105
  if (recognition.status !== 'listening' && recognition.status !== 'starting') return;
75
106
  void recognition.stop();
76
107
  };
@@ -98,12 +98,22 @@ export function useSpeechRecognition(
98
98
  ];
99
99
  return () => {
100
100
  offs.forEach((off) => off());
101
+ // Release the mic / socket if the consumer unmounts (or swaps
102
+ // the engine) mid-session. Without this the MediaRecorder and
103
+ // getUserMedia stream keep running headless after unmount.
104
+ engine.abort();
101
105
  };
102
106
  }, [engine]);
103
107
 
104
- // AutoStop driven by silence + maxMs caps.
108
+ // AutoStop driven by silence + maxMs caps. `level` updates ~60fps,
109
+ // so it must NOT live in the effect deps — otherwise the silence
110
+ // interval is torn down and recreated every animation frame, which
111
+ // both wastes CPU and resets the silence timer before it can fire.
112
+ // The interval reads the freshest level through a ref instead.
105
113
  const silenceTimer = useRef<number | null>(null);
106
114
  const maxTimer = useRef<number | null>(null);
115
+ const levelRef = useRef(level);
116
+ levelRef.current = level;
107
117
  useEffect(() => {
108
118
  if (state.status !== 'listening') return undefined;
109
119
  const { silenceMs, maxMs, silenceThreshold = 0.02 } = config.autoStop ?? {};
@@ -113,9 +123,10 @@ export function useSpeechRecognition(
113
123
  void engine.stop();
114
124
  }, maxMs);
115
125
  }
126
+ let checkInterval: number | null = null;
116
127
  if (silenceMs) {
117
- const checkInterval = window.setInterval(() => {
118
- if (level < silenceThreshold) {
128
+ checkInterval = window.setInterval(() => {
129
+ if (levelRef.current < silenceThreshold) {
119
130
  if (silenceTimer.current == null) {
120
131
  silenceTimer.current = window.setTimeout(() => {
121
132
  log.engine.debug('autoStop silence detected');
@@ -127,19 +138,15 @@ export function useSpeechRecognition(
127
138
  silenceTimer.current = null;
128
139
  }
129
140
  }, 200);
130
- return () => {
131
- clearInterval(checkInterval);
132
- if (silenceTimer.current != null) clearTimeout(silenceTimer.current);
133
- silenceTimer.current = null;
134
- if (maxTimer.current != null) clearTimeout(maxTimer.current);
135
- maxTimer.current = null;
136
- };
137
141
  }
138
142
  return () => {
143
+ if (checkInterval != null) clearInterval(checkInterval);
144
+ if (silenceTimer.current != null) clearTimeout(silenceTimer.current);
145
+ silenceTimer.current = null;
139
146
  if (maxTimer.current != null) clearTimeout(maxTimer.current);
140
147
  maxTimer.current = null;
141
148
  };
142
- }, [state.status, config.autoStop, level, engine]);
149
+ }, [state.status, config.autoStop, engine]);
143
150
 
144
151
  const start = useCallback(async () => {
145
152
  if (state.status === 'listening' || state.status === 'starting') return;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type * as React from 'react';
4
4
  import { useCallback, useEffect, useRef } from 'react';
5
- import { Loader2, Mic } from 'lucide-react';
5
+ import { AlertCircle, Loader2, Mic } from 'lucide-react';
6
6
 
7
7
  import { useCountdownFromSeconds, useNotificationSounds } from '@djangocfg/ui-core/hooks';
8
8
  import { cn } from '@djangocfg/ui-core/lib';
@@ -13,8 +13,12 @@ import { useVoiceSupport } from '../hooks/useVoiceSupport';
13
13
  import { getSpeechLogger } from '../core/logger';
14
14
  import { normaliseFinal } from '../core/transcript';
15
15
  import { DEFAULT_VOICE_SOUNDS, type VoiceSoundEvent } from '../core/audio/defaults';
16
+ import { RecordingPulse } from '../components/RecordingPulse';
16
17
  import type { RecognitionEngine } from '../types';
17
18
 
19
+ /** High-level visual state the composer mic button reflects. */
20
+ export type VoiceSlotState = 'idle' | 'listening' | 'processing' | 'error';
21
+
18
22
  const log = getSpeechLogger();
19
23
 
20
24
  export interface VoiceComposerSlotProps {
@@ -51,18 +55,26 @@ export interface VoiceComposerSlotProps {
51
55
  * — users can silence everything via their own UI.
52
56
  */
53
57
  sounds?: boolean | { start?: string; stop?: string };
58
+ /** Notified whenever the high-level visual state changes. */
59
+ onStateChange?: (state: VoiceSlotState) => void;
54
60
  }
55
61
 
62
+ /**
63
+ * Per-size geometry. Mirrors the chat composer action-bar buttons
64
+ * (`composer-kit/ComposerButton` `BUTTON_SIZE`) so the slot drops into
65
+ * `composerSlots.inlineEnd` round and correctly sized with no host CSS.
66
+ */
56
67
  const SIZE_CLS: Record<NonNullable<VoiceComposerSlotProps['size']>, string> = {
57
- sm: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4',
58
- md: 'h-9 w-9 [&_svg]:h-4 [&_svg]:w-4',
59
- lg: 'h-12 w-12 [&_svg]:h-5 [&_svg]:w-5',
68
+ sm: 'h-7 w-7 [&_svg]:size-4',
69
+ md: 'h-9 w-9 [&_svg]:size-[1.125rem]',
70
+ lg: 'h-11 w-11 [&_svg]:size-5',
60
71
  };
61
72
 
62
73
  const STORAGE_KEY = 'djangocfg-stt:voice-sounds';
63
74
 
64
75
  /**
65
- * Drop-in slot for the `<Composer toolbarEnd>` (or `toolbarStart`) prop.
76
+ * Drop-in microphone slot for `<Composer>` pass it as a raw node via
77
+ * `composerSlots.inlineEnd` (inline layout) or `composerSlots.blockStart`.
66
78
  *
67
79
  * Renders a microphone button — but only when the browser + device
68
80
  * combination can actually do speech recognition. Firefox, in-app
@@ -89,6 +101,7 @@ export function VoiceComposerSlot({
89
101
  className,
90
102
  onFinish,
91
103
  sounds = true,
104
+ onStateChange,
92
105
  }: VoiceComposerSlotProps): React.ReactElement | null {
93
106
  const support = useVoiceSupport(engine);
94
107
 
@@ -113,7 +126,7 @@ export function VoiceComposerSlot({
113
126
 
114
127
  // Resolve value/onChange: prop wins; otherwise pull from the
115
128
  // registered composer handle. The slot can therefore be dropped into
116
- // `composerToolbarEnd` of `ChatRoot` with zero props.
129
+ // `composerBlockStart` of `ChatRoot` with zero props.
117
130
  const resolvedGetValue = useCallback((): string => {
118
131
  if (value !== undefined) return value;
119
132
  return composerHandleRef.current?.getValue?.() ?? '';
@@ -289,51 +302,106 @@ export function VoiceComposerSlot({
289
302
  void rec.start();
290
303
  }, [rec, resolvedGetValue]);
291
304
 
305
+ // Derive the high-level visual state from the engine status. The
306
+ // engine owns the truth — `processing` is the post-speech tail while
307
+ // the final result settles (`stopping`), `error` is a recoverable
308
+ // failure that resets to `idle` on the next press.
309
+ const stopping = rec.status === 'stopping';
310
+ const slotState: VoiceSlotState = listening
311
+ ? 'listening'
312
+ : stopping
313
+ ? 'processing'
314
+ : rec.status === 'error'
315
+ ? 'error'
316
+ : 'idle';
317
+
318
+ // Notify the host on every transition (after render, ref-guarded so
319
+ // identical states never fire twice).
320
+ const lastStateRef = useRef<VoiceSlotState | null>(null);
321
+ const onStateChangeRef = useRef(onStateChange);
322
+ onStateChangeRef.current = onStateChange;
323
+ useEffect(() => {
324
+ if (lastStateRef.current === slotState) return;
325
+ lastStateRef.current = slotState;
326
+ onStateChangeRef.current?.(slotState);
327
+ }, [slotState]);
328
+
292
329
  if (!support.supported) return null;
293
330
  if (hideOnMobile && support.isMobile) return null;
294
331
 
295
- const stopping = rec.status === 'stopping';
332
+ // Tooltip: countdown + hotkey hint while listening, error copy on
333
+ // failure, plain prompt otherwise.
334
+ const tooltip =
335
+ slotState === 'listening'
336
+ ? `Listening — ${countdown.label || `${maxSeconds}s left`} · Enter to finish · Esc to cancel`
337
+ : slotState === 'processing'
338
+ ? 'Transcribing…'
339
+ : slotState === 'error'
340
+ ? rec.error?.message || 'Dictation failed — tap to retry'
341
+ : 'Dictate message';
296
342
 
297
- // Tooltip: countdown + hotkey hint while listening, plain copy
298
- // otherwise. Avoids the absolutely-positioned label that clipped
299
- // against the composer bottom edge in the previous design.
300
- const tooltip = listening
301
- ? `Listening${countdown.label || `${maxSeconds}s left`} · Enter to finish · Esc to cancel`
302
- : 'Dictate message';
343
+ const ariaLabel =
344
+ slotState === 'listening'
345
+ ? 'Stop dictation'
346
+ : slotState === 'error'
347
+ ? 'Dictation failed retry'
348
+ : 'Dictate message';
303
349
 
304
350
  return (
305
351
  <span className="inline-flex items-center gap-1.5 !h-auto">
306
- {listening && countdown.label ? (
352
+ {slotState === 'listening' && countdown.label ? (
307
353
  <span
308
354
  aria-hidden
309
- className="rounded-full bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] leading-none text-destructive tabular-nums"
355
+ className="rounded-full bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] leading-none text-destructive tabular-nums animate-in fade-in duration-200"
310
356
  >
311
357
  {countdown.label}
312
358
  </span>
313
359
  ) : null}
360
+ {slotState === 'error' ? (
361
+ <span
362
+ aria-hidden
363
+ className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] leading-none text-destructive animate-in fade-in duration-200"
364
+ >
365
+ Failed
366
+ </span>
367
+ ) : null}
314
368
  <button
315
369
  type="button"
316
370
  onClick={toggle}
317
- aria-pressed={listening}
318
- aria-label={listening ? 'Stop dictation' : 'Dictate message'}
371
+ aria-pressed={slotState === 'listening'}
372
+ aria-label={ariaLabel}
319
373
  title={tooltip}
374
+ data-state={slotState}
320
375
  className={cn(
321
- 'relative inline-flex items-center justify-center rounded-full transition-colors',
376
+ 'relative inline-flex items-center justify-center rounded-full transition-all duration-200',
322
377
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
323
378
  SIZE_CLS[size],
324
- listening
325
- ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
326
- : 'text-muted-foreground hover:bg-muted hover:text-foreground',
379
+ slotState === 'listening' &&
380
+ 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
381
+ slotState === 'processing' &&
382
+ 'bg-primary/10 text-primary hover:bg-primary/15',
383
+ slotState === 'error' &&
384
+ 'bg-destructive/10 text-destructive hover:bg-destructive/15',
385
+ slotState === 'idle' &&
386
+ 'text-muted-foreground hover:bg-muted hover:text-foreground',
327
387
  className,
328
388
  )}
329
389
  >
330
- {listening && (
331
- <span
332
- aria-hidden
333
- className="absolute inset-0 rounded-full bg-destructive/30 animate-ping"
390
+ {/* Recording feedback — pulsing circle overlay driven by the
391
+ live mic level. Hidden in every non-listening state. */}
392
+ <RecordingPulse active={slotState === 'listening'} level={rec.level} />
393
+ {slotState === 'processing' ? (
394
+ <Loader2 className="animate-spin" />
395
+ ) : slotState === 'error' ? (
396
+ <AlertCircle />
397
+ ) : (
398
+ <Mic
399
+ className={cn(
400
+ 'transition-transform duration-200',
401
+ slotState === 'listening' && 'scale-110',
402
+ )}
334
403
  />
335
404
  )}
336
- {stopping ? <Loader2 className="animate-spin" /> : <Mic />}
337
405
  </button>
338
406
  </span>
339
407
  );
@@ -3,4 +3,4 @@ export type { DictationFieldProps } from './DictationField';
3
3
  export { VoiceMessageRecorder } from './VoiceMessageRecorder';
4
4
  export type { VoiceMessageRecorderProps } from './VoiceMessageRecorder';
5
5
  export { VoiceComposerSlot } from './VoiceComposerSlot';
6
- export type { VoiceComposerSlotProps } from './VoiceComposerSlot';
6
+ export type { VoiceComposerSlotProps, VoiceSlotState } from './VoiceComposerSlot';
@@ -275,11 +275,7 @@ The active mode is also exposed on each row as
275
275
  ## VSCode-style file icons
276
276
 
277
277
  Tree is generic over `T` — it has no opinion on whether nodes are files. For a
278
- ready-made VSCode-style icon set, install the optional companion subpath:
279
-
280
- ```bash
281
- pnpm add material-file-icons
282
- ```
278
+ ready-made VSCode-style icon set, use the `file-icon` companion subpath:
283
279
 
284
280
  ```tsx
285
281
  import { TreeRoot } from '@djangocfg/ui-tools/tree';
@@ -292,9 +288,9 @@ import { createFileIconSlot } from '@djangocfg/ui-tools/file-icon';
292
288
  />
293
289
  ```
294
290
 
295
- `material-file-icons` is declared in `optionalDependencies`. If it's not
296
- installed, `<FileIcon>` falls back to a Lucide `File` icon — no warnings, no
297
- runtime errors.
291
+ The icon SVGs are vendored statically from `material-file-icons` (MIT) no
292
+ runtime dependency, no install step, and resolution is synchronous in any
293
+ bundler.
298
294
 
299
295
  Folders use a small built-in mapping (`src` → `FolderCode`,
300
296
  `node_modules` → `Package`, `.git` → `FolderGit2`, `dist`/`build`/`.next`
@@ -1,10 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useRef } from 'react';
3
+ import { useCallback, useEffect, useRef } from 'react';
4
4
  import { cn } from '@djangocfg/ui-core/lib';
5
5
 
6
6
  import { TreeProvider, useTreeContext } from './context/TreeContext';
7
- import { TreeContent } from './components/TreeContent';
7
+ import { TreeContent, treeRowDomId } from './components/TreeContent';
8
8
  import { TreeSearchInput } from './components/TreeSearchInput';
9
9
  import { appearanceToStyle } from './data/appearance';
10
10
  import { useTreeKeyboard } from './hooks/useTreeKeyboard';
@@ -125,15 +125,22 @@ function TreeRootShell<T>({
125
125
  [keyboardRef],
126
126
  );
127
127
 
128
- // Type-ahead jump.
128
+ // Keep the focused row scrolled into view whenever focus moves (keyboard
129
+ // nav, type-ahead, programmatic). Centralised so every focus source gets
130
+ // consistent scrolling — previously only type-ahead scrolled.
131
+ const focusedId = ctx.focused;
132
+ useEffect(() => {
133
+ if (!focusedId) return;
134
+ const el = containerRef.current?.querySelector<HTMLElement>(
135
+ `[data-tree-row][data-id="${CSS.escape(focusedId)}"]`,
136
+ );
137
+ el?.scrollIntoView({ block: 'nearest' });
138
+ }, [focusedId]);
139
+
140
+ // Type-ahead jump — focus update; scrolling handled by the effect above.
129
141
  const onTypeAheadMatch = useCallback(
130
142
  (id: string) => {
131
143
  ctx.setFocus(id);
132
- // Scroll the row into view if it has rendered.
133
- const el = containerRef.current?.querySelector<HTMLElement>(
134
- `[data-tree-row][data-id="${CSS.escape(id)}"]`,
135
- );
136
- el?.scrollIntoView({ block: 'nearest' });
137
144
  },
138
145
  [ctx],
139
146
  );
@@ -150,8 +157,13 @@ function TreeRootShell<T>({
150
157
  <div
151
158
  ref={setContainerRef}
152
159
  tabIndex={0}
160
+ role="tree"
161
+ aria-label={ctx.labels.ariaLabel}
162
+ aria-multiselectable={ctx.selectionMode === 'multiple' || undefined}
163
+ aria-activedescendant={focusedId ? treeRowDomId(focusedId) : undefined}
153
164
  className={cn(
154
- 'group/tree flex h-full w-full flex-col gap-2 outline-none',
165
+ 'group/tree flex h-full w-full flex-col gap-2 rounded-sm outline-none',
166
+ 'focus-visible:ring-1 focus-visible:ring-ring/50',
155
167
  className,
156
168
  )}
157
169
  style={{ ...appearanceToStyle(ctx.appearance), ...style }}
@@ -159,7 +171,7 @@ function TreeRootShell<T>({
159
171
  >
160
172
  {enableSearch ? <TreeSearchInput className="mx-2 mt-2" /> : null}
161
173
  <div className="min-h-0 flex-1 overflow-auto px-1">
162
- <TreeContent<T>>{renderRow}</TreeContent>
174
+ <TreeContent<T> role="group">{renderRow}</TreeContent>
163
175
  </div>
164
176
  </div>
165
177
  );