@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
@@ -61,12 +61,28 @@ function mapStatusToCode(status: number): string {
61
61
  return 'http_error';
62
62
  }
63
63
 
64
- function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
64
+ /**
65
+ * Derive an AbortSignal that fires after `timeoutMs` or when the caller's
66
+ * `signal` aborts. The returned `clear()` MUST be called once the request
67
+ * settles — otherwise the timer keeps the AbortController (and its abort
68
+ * listener on the parent signal) alive until the full timeout elapses,
69
+ * leaking one timer + one listener per request.
70
+ */
71
+ function withTimeout(
72
+ signal: AbortSignal | undefined,
73
+ timeoutMs: number,
74
+ ): { signal: AbortSignal; clear: () => void } {
65
75
  const ctrl = new AbortController();
66
76
  const onAbort = () => ctrl.abort();
67
77
  signal?.addEventListener('abort', onAbort, { once: true });
68
- setTimeout(() => ctrl.abort(), timeoutMs);
69
- return ctrl.signal;
78
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
79
+ return {
80
+ signal: ctrl.signal,
81
+ clear: () => {
82
+ clearTimeout(timer);
83
+ signal?.removeEventListener('abort', onAbort);
84
+ },
85
+ };
70
86
  }
71
87
 
72
88
  export function createHttpTransport(config: HttpTransportConfig): ChatTransport {
@@ -85,13 +101,18 @@ export function createHttpTransport(config: HttpTransportConfig): ChatTransport
85
101
 
86
102
  return {
87
103
  async createSession(opts?: CreateSessionOptions): Promise<SessionInfo> {
88
- const res = await fetchImpl(`${base}/sessions`, {
89
- method: 'POST',
90
- headers: await buildHeaders(),
91
- body: JSON.stringify({ slug: config.slug, metadata: opts?.metadata ?? {} }),
92
- signal: withTimeout(undefined, timeout),
93
- });
94
- return jsonOrThrow<SessionInfo>(res, 'createSession');
104
+ const t = withTimeout(undefined, timeout);
105
+ try {
106
+ const res = await fetchImpl(`${base}/sessions`, {
107
+ method: 'POST',
108
+ headers: await buildHeaders(),
109
+ body: JSON.stringify({ slug: config.slug, metadata: opts?.metadata ?? {} }),
110
+ signal: t.signal,
111
+ });
112
+ return await jsonOrThrow<SessionInfo>(res, 'createSession');
113
+ } finally {
114
+ t.clear();
115
+ }
95
116
  },
96
117
 
97
118
  async loadHistory(sessionId, cursor, limit): Promise<HistoryPage> {
@@ -101,12 +122,17 @@ export function createHttpTransport(config: HttpTransportConfig): ChatTransport
101
122
  const url = `${base}/sessions/${encodeURIComponent(sessionId)}/history${
102
123
  params.toString() ? `?${params.toString()}` : ''
103
124
  }`;
104
- const res = await fetchImpl(url, {
105
- method: 'GET',
106
- headers: await buildHeaders(),
107
- signal: withTimeout(undefined, timeout),
108
- });
109
- return jsonOrThrow<HistoryPage>(res, 'loadHistory');
125
+ const t = withTimeout(undefined, timeout);
126
+ try {
127
+ const res = await fetchImpl(url, {
128
+ method: 'GET',
129
+ headers: await buildHeaders(),
130
+ signal: t.signal,
131
+ });
132
+ return await jsonOrThrow<HistoryPage>(res, 'loadHistory');
133
+ } finally {
134
+ t.clear();
135
+ }
110
136
  },
111
137
 
112
138
  async *stream(
@@ -139,26 +165,39 @@ export function createHttpTransport(config: HttpTransportConfig): ChatTransport
139
165
 
140
166
  async send(sessionId, content, options?: SendOptions): Promise<ChatMessage> {
141
167
  const url = `${base}/sessions/${encodeURIComponent(sessionId)}/messages/buffered`;
142
- const res = await fetchImpl(url, {
143
- method: 'POST',
144
- headers: await buildHeaders(),
145
- body: JSON.stringify({
146
- content,
147
- attachments: options?.attachments ?? [],
148
- metadata: options?.metadata ?? {},
149
- }),
150
- signal: options?.signal ?? withTimeout(undefined, timeout),
151
- });
152
- return jsonOrThrow<ChatMessage>(res, 'send');
168
+ // Honour a caller-supplied signal as-is; otherwise apply our own
169
+ // timeout (and clean it up in `finally`).
170
+ const t = options?.signal ? null : withTimeout(undefined, timeout);
171
+ try {
172
+ const res = await fetchImpl(url, {
173
+ method: 'POST',
174
+ headers: await buildHeaders(),
175
+ body: JSON.stringify({
176
+ content,
177
+ attachments: options?.attachments ?? [],
178
+ metadata: options?.metadata ?? {},
179
+ }),
180
+ signal: options?.signal ?? t!.signal,
181
+ });
182
+ return await jsonOrThrow<ChatMessage>(res, 'send');
183
+ } finally {
184
+ t?.clear();
185
+ }
153
186
  },
154
187
 
155
188
  async closeSession(sessionId: string): Promise<void> {
156
189
  const url = `${base}/sessions/${encodeURIComponent(sessionId)}`;
157
- const res = await fetchImpl(url, {
158
- method: 'DELETE',
159
- headers: await buildHeaders(),
160
- signal: withTimeout(undefined, timeout),
161
- });
190
+ const t = withTimeout(undefined, timeout);
191
+ let res: Response;
192
+ try {
193
+ res = await fetchImpl(url, {
194
+ method: 'DELETE',
195
+ headers: await buildHeaders(),
196
+ signal: t.signal,
197
+ });
198
+ } finally {
199
+ t.clear();
200
+ }
162
201
  if (!res.ok && res.status !== 404) {
163
202
  throw new TransportError(`closeSession failed (${res.status})`, mapStatusToCode(res.status));
164
203
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ChatStreamEvent } from '../../types';
10
- import { LIMITS } from '../../config';
10
+ import { LIMITS } from '../../constants';
11
11
 
12
12
  interface RawEvent {
13
13
  event?: string;
@@ -49,12 +49,23 @@ export async function* parseSSE(
49
49
  const reader = response.body.getReader();
50
50
  const decoder = new TextDecoder();
51
51
  let buffer = '';
52
- let lastChunkAt = Date.now();
53
52
 
54
- const idleCheck = () => {
55
- if (Date.now() - lastChunkAt > idleMs) {
56
- throw new Error(`SSE idle timeout (${idleMs}ms)`);
57
- }
53
+ // Race `reader.read()` against an idle timer so a fully stalled
54
+ // connection one that accepts the request but never sends a byte —
55
+ // is surfaced as an error instead of hanging the generator forever.
56
+ // The previous check only ran *after* a successful read, so it could
57
+ // never fire while `reader.read()` itself was blocked.
58
+ const readWithIdleTimeout = (): Promise<ReadableStreamReadResult<Uint8Array>> => {
59
+ let timer: ReturnType<typeof setTimeout> | undefined;
60
+ const idle = new Promise<never>((_, reject) => {
61
+ timer = setTimeout(
62
+ () => reject(new Error(`SSE idle timeout (${idleMs}ms)`)),
63
+ idleMs,
64
+ );
65
+ });
66
+ return Promise.race([reader.read(), idle]).finally(() => {
67
+ if (timer) clearTimeout(timer);
68
+ }) as Promise<ReadableStreamReadResult<Uint8Array>>;
58
69
  };
59
70
 
60
71
  try {
@@ -62,10 +73,9 @@ export async function* parseSSE(
62
73
  if (options.signal?.aborted) {
63
74
  return;
64
75
  }
65
- const { value, done } = await reader.read();
76
+ const { value, done } = await readWithIdleTimeout();
66
77
  if (done) break;
67
78
 
68
- lastChunkAt = Date.now();
69
79
  buffer += decoder.decode(value, { stream: true });
70
80
 
71
81
  // Split on blank line which delimits SSE events.
@@ -86,8 +96,6 @@ export async function* parseSSE(
86
96
 
87
97
  separator = buffer.indexOf('\n\n');
88
98
  }
89
-
90
- idleCheck();
91
99
  }
92
100
 
93
101
  // Flush any trailing partial event.
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ import { SpotlightCanvas } from './SpotlightCanvas';
7
+ import type { PointDirective, SpotlightRect } from './types';
8
+ import { useHighlightTargets } from './useHighlightTargets';
9
+ import { type RefResolver } from './resolveRef';
10
+
11
+ /** Padding (px) drawn around each highlighted element. */
12
+ const HIGHLIGHT_PADDING = 6;
13
+ /** Corner radius (px) of the spotlight cut-out. */
14
+ const HIGHLIGHT_RADIUS = 6;
15
+
16
+ /** Props for `HighlightOverlay`. */
17
+ export interface HighlightOverlayProps {
18
+ /** `point` directives from the AI's reply. Empty hides the overlay. */
19
+ directives: PointDirective[];
20
+ /** Resolves a CST ref to a live element (the snapshot's registry). */
21
+ resolver: RefResolver | null;
22
+ /** Auto-dismiss after N ms; 0 keeps it until directives clear. */
23
+ ttlMs?: number;
24
+ /** Called when the overlay dismisses (scrim click, Esc, or TTL). */
25
+ onDismiss?: () => void;
26
+ }
27
+
28
+ /**
29
+ * Draws an AI-driven spotlight over elements the assistant pointed at.
30
+ *
31
+ * Read-only: it highlights and optionally focuses elements — it never
32
+ * changes the user's data. The page stays interactive underneath; the
33
+ * scrim is purely visual and dismissable.
34
+ */
35
+ export function HighlightOverlay({
36
+ directives,
37
+ resolver,
38
+ ttlMs = 6000,
39
+ onDismiss,
40
+ }: HighlightOverlayProps) {
41
+ const targets = useHighlightTargets(directives, resolver);
42
+
43
+ // Auto-dismiss after the TTL once something is showing.
44
+ useEffect(() => {
45
+ if (targets.length === 0 || ttlMs <= 0) return;
46
+ const timer = window.setTimeout(() => onDismiss?.(), ttlMs);
47
+ return () => window.clearTimeout(timer);
48
+ }, [targets.length, ttlMs, onDismiss]);
49
+
50
+ // Dismiss on Escape.
51
+ useEffect(() => {
52
+ if (targets.length === 0) return;
53
+ const onKey = (e: KeyboardEvent) => {
54
+ if (e.key === 'Escape') onDismiss?.();
55
+ };
56
+ window.addEventListener('keydown', onKey);
57
+ return () => window.removeEventListener('keydown', onKey);
58
+ }, [targets.length, onDismiss]);
59
+
60
+ if (targets.length === 0 || typeof document === 'undefined') return null;
61
+
62
+ const rects: SpotlightRect[] = targets.map((t) => ({
63
+ rect: t.rect,
64
+ padding: HIGHLIGHT_PADDING,
65
+ radius: HIGHLIGHT_RADIUS,
66
+ }));
67
+
68
+ return createPortal(
69
+ <div
70
+ className="pointer-events-none fixed inset-0 z-[60]"
71
+ data-chat-highlight-overlay=""
72
+ >
73
+ {/* The scrim is click-dismissable; it does not block the page. */}
74
+ <div className="pointer-events-auto absolute inset-0">
75
+ <SpotlightCanvas
76
+ rects={rects}
77
+ pulseRing
78
+ onClick={() => onDismiss?.()}
79
+ />
80
+ </div>
81
+
82
+ {/* Captions beside each highlighted element. */}
83
+ {targets.map((t, i) =>
84
+ t.label ? (
85
+ <div
86
+ key={i}
87
+ className="bg-primary text-primary-foreground absolute rounded-md px-2 py-1 text-xs shadow-md"
88
+ style={{
89
+ left: t.rect.x,
90
+ top: t.rect.y + t.rect.height + HIGHLIGHT_PADDING + 6,
91
+ maxWidth: 240,
92
+ }}
93
+ >
94
+ {t.label}
95
+ </div>
96
+ ) : null,
97
+ )}
98
+ </div>,
99
+ document.body,
100
+ );
101
+ }
@@ -0,0 +1,103 @@
1
+ # Chat highlight — AI-driven `point` directives
2
+
3
+ When the assistant answers a question about the page, it can also
4
+ **point at the screen** — highlight and optionally focus the relevant
5
+ elements. This module renders that.
6
+
7
+ ---
8
+
9
+ ## The idea
10
+
11
+ The chat sends the backend a snapshot of the user's page (the
12
+ `page-snapshot` engine). Every interactive element in that snapshot got
13
+ a short ref id — `@e4`. The assistant, knowing those refs, can reply:
14
+
15
+ > "Click **Save** to apply your changes."
16
+
17
+ …and attach a directive: `point at @e4`. This module resolves `@e4`
18
+ back to the live DOM element and draws a spotlight on it.
19
+
20
+ This is the intelligent successor to the old scripted product Tour —
21
+ the spotlight is the same, but *what* it points at is decided by the AI
22
+ in context, not a hard-coded step list. The SVG renderer here is in
23
+ fact adapted from that deleted Tour component.
24
+
25
+ **Read-only.** A `point` directive highlights / focuses an element. It
26
+ never types, clicks, or changes the user's data.
27
+
28
+ ---
29
+
30
+ ## The round trip
31
+
32
+ ```
33
+ capture: page-snapshot engine assigns @e1, @e2, … to interactive nodes
34
+ and keeps a ref → element registry
35
+
36
+ ▼ snapshot sent with the chat message (request body metadata)
37
+ backend: the LLM, given the snapshot, may append a directive marker:
38
+ <<directives>[{"type":"point","ref":"@e4","focus":true}]</directives>>
39
+ the marker is parsed + validated, stripped from the reply,
40
+ and emitted as a `directive` SSE event
41
+
42
+ ▼ directive SSE event arrives on the client
43
+ this module: resolve @e4 → element via the snapshot registry,
44
+ draw the spotlight, optionally focus
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Files
50
+
51
+ | File | Responsibility |
52
+ |------|----------------|
53
+ | `types.ts` | `PointDirective`, `HighlightTarget`, `SpotlightRect`, `CSTRefId`. |
54
+ | `resolveRef.ts` | `resolveRefs()` — CST ref → live DOM element via a `RefResolver`. Drops stale / detached refs. |
55
+ | `useHighlightTargets.ts` | Hook: directives → geometry-tracked targets. Re-measures on scroll/resize, scrolls the first off-screen target in, focuses on request, drops a target when its element leaves the DOM. |
56
+ | `SpotlightCanvas.tsx` | Pure SVG-mask renderer — dimmed scrim with rounded cut-outs, border, pulse ring. Takes geometry, not elements. |
57
+ | `HighlightOverlay.tsx` | The component: hook + canvas + captions, portalled to `body`, auto-dismiss (TTL / Esc / scrim click). |
58
+ | `index.ts` | Public barrel. |
59
+
60
+ ---
61
+
62
+ ## Usage
63
+
64
+ ```tsx
65
+ import { HighlightOverlay } from '@djangocfg/ui-tools/.../Chat/highlight';
66
+
67
+ <HighlightOverlay
68
+ directives={directivesFromLastDirectiveEvent}
69
+ resolver={snapshotRefRegistry} // the page-snapshot ref registry
70
+ ttlMs={6000}
71
+ onDismiss={() => clearDirectives()}
72
+ />
73
+ ```
74
+
75
+ - `directives` — the `directives` array from the latest `directive` SSE
76
+ event. Empty → the overlay renders nothing.
77
+ - `resolver` — anything with `resolve(ref) => HTMLElement | null`. The
78
+ `page-snapshot` engine's `RefRegistry` satisfies this. Structurally
79
+ typed on purpose, so this module does not import the capture engine.
80
+
81
+ ---
82
+
83
+ ## Staleness
84
+
85
+ A ref resolves against the snapshot that produced it. If the user has
86
+ since navigated or the element was removed, `resolve()` returns null (or
87
+ the element is detached) — that target is silently dropped. The overlay
88
+ only ever shows live elements.
89
+
90
+ ---
91
+
92
+ ## Decoupling notes
93
+
94
+ - This module lives under `Chat/` because `point` is a chat feature and
95
+ it has exactly one consumer. If a second consumer ever appears,
96
+ promote it to a shared location — not before.
97
+ - It does not import the `page-snapshot` capture engine. It depends only
98
+ on the structural `RefResolver` interface, so the two stay
99
+ independent.
100
+ - The directive type is `point` only. The shape is a discriminated
101
+ union on `type`, so a future write-style directive could be added —
102
+ but that is a different risk class (it would change user data) and is
103
+ deliberately out of scope here.
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ import type { SpotlightRect } from './types';
8
+
9
+ /**
10
+ * SVG-mask spotlight renderer — adapted from the former Tour component.
11
+ *
12
+ * Draws a dimmed full-screen overlay with rounded cut-outs over the
13
+ * target rects, plus a brand-coloured border and an attention pulse.
14
+ * Pure presentation: it takes geometry, not DOM elements.
15
+ */
16
+
17
+ // Injected once — keyframes + transition classes for the SVG rects.
18
+ const SPOTLIGHT_STYLES = `
19
+ @keyframes chat-spotlight-pulse {
20
+ 0%, 100% { opacity: 1; }
21
+ 50% { opacity: 0.5; }
22
+ }
23
+ @keyframes chat-spotlight-ring {
24
+ 0% { transform: scale(1); opacity: 0.8; }
25
+ 100% { transform: scale(1.15); opacity: 0; }
26
+ }
27
+ .chat-spotlight-rect {
28
+ transition: x 300ms ease-out, y 300ms ease-out,
29
+ width 300ms ease-out, height 300ms ease-out;
30
+ }
31
+ `;
32
+
33
+ /** Props for the spotlight canvas. */
34
+ export interface SpotlightCanvasProps {
35
+ /** Geometry of every element to spotlight. */
36
+ rects: SpotlightRect[];
37
+ /** Dim-overlay opacity, 0–1 (default: 0.5). */
38
+ opacity?: number;
39
+ /** Show an expanding attention ring (default: true). */
40
+ pulseRing?: boolean;
41
+ /** Dismiss the overlay when the scrim is clicked. */
42
+ onClick?: () => void;
43
+ className?: string;
44
+ }
45
+
46
+ export function SpotlightCanvas({
47
+ rects,
48
+ opacity = 0.5,
49
+ pulseRing = true,
50
+ onClick,
51
+ className,
52
+ }: SpotlightCanvasProps) {
53
+ // Expand each rect by its padding and pre-compute the rounded box.
54
+ // Hooks run unconditionally — no early return before this.
55
+ const boxes = useMemo(
56
+ () =>
57
+ rects.map((target, index) => {
58
+ const x = target.rect.x - target.padding;
59
+ const y = target.rect.y - target.padding;
60
+ const width = target.rect.width + target.padding * 2;
61
+ const height = target.rect.height + target.padding * 2;
62
+ const rx = target.radius + 2;
63
+ const centerX = target.rect.x + target.rect.width / 2;
64
+ const centerY = target.rect.y + target.rect.height / 2;
65
+ return {
66
+ key: index,
67
+ x,
68
+ y,
69
+ width,
70
+ height,
71
+ rx,
72
+ transformOrigin: `${centerX}px ${centerY}px`,
73
+ };
74
+ }),
75
+ [rects],
76
+ );
77
+
78
+ if (rects.length === 0) return null;
79
+
80
+ return (
81
+ <>
82
+ <style dangerouslySetInnerHTML={{ __html: SPOTLIGHT_STYLES }} />
83
+ <svg
84
+ className={cn('fixed inset-0 h-full w-full', className)}
85
+ aria-hidden="true"
86
+ onClick={onClick}
87
+ >
88
+ <defs>
89
+ <mask id="chat-spotlight-mask">
90
+ {/* White = visible dim; black cut-outs = transparent targets. */}
91
+ <rect fill="white" width="100%" height="100%" />
92
+ {boxes.map((b) => (
93
+ <rect
94
+ key={b.key}
95
+ fill="black"
96
+ className="chat-spotlight-rect"
97
+ x={b.x}
98
+ y={b.y}
99
+ width={b.width}
100
+ height={b.height}
101
+ rx={b.rx}
102
+ ry={b.rx}
103
+ />
104
+ ))}
105
+ </mask>
106
+ </defs>
107
+
108
+ {/* Dimmed scrim with the target cut-outs. */}
109
+ <rect
110
+ fill="black"
111
+ fillOpacity={opacity}
112
+ width="100%"
113
+ height="100%"
114
+ mask="url(#chat-spotlight-mask)"
115
+ className="transition-opacity duration-300"
116
+ />
117
+
118
+ {/* Attention pulse. */}
119
+ {pulseRing &&
120
+ boxes.map((b) => (
121
+ <rect
122
+ key={`pulse-${b.key}`}
123
+ className="fill-none stroke-primary stroke-2"
124
+ style={{
125
+ transformOrigin: b.transformOrigin,
126
+ animation: 'chat-spotlight-ring 1.5s ease-out infinite',
127
+ }}
128
+ x={b.x}
129
+ y={b.y}
130
+ width={b.width}
131
+ height={b.height}
132
+ rx={b.rx}
133
+ ry={b.rx}
134
+ />
135
+ ))}
136
+
137
+ {/* Solid border around each target. */}
138
+ {boxes.map((b) => (
139
+ <rect
140
+ key={`border-${b.key}`}
141
+ className="chat-spotlight-rect fill-none stroke-primary stroke-2"
142
+ x={b.x}
143
+ y={b.y}
144
+ width={b.width}
145
+ height={b.height}
146
+ rx={b.rx}
147
+ ry={b.rx}
148
+ />
149
+ ))}
150
+ </svg>
151
+ </>
152
+ );
153
+ }
@@ -0,0 +1,112 @@
1
+ // @vitest-environment jsdom
2
+ import { act, createElement } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import { HighlightOverlay } from '../HighlightOverlay';
7
+ import type { RefResolver } from '../resolveRef';
8
+ import type { CSTRefId, PointDirective } from '../types';
9
+
10
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ // jsdom has no ResizeObserver — the overlay observes its targets, so
13
+ // provide a no-op stub for the test environment.
14
+ if (typeof globalThis.ResizeObserver === 'undefined') {
15
+ (globalThis as Record<string, unknown>).ResizeObserver = class {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ };
20
+ }
21
+
22
+ let container: HTMLDivElement;
23
+ let root: Root;
24
+
25
+ beforeEach(() => {
26
+ container = document.createElement('div');
27
+ document.body.appendChild(container);
28
+ root = createRoot(container);
29
+ });
30
+
31
+ afterEach(() => {
32
+ act(() => root.unmount());
33
+ container.remove();
34
+ document.body.innerHTML = '';
35
+ });
36
+
37
+ /** Resolver over a fixed map. */
38
+ function makeResolver(map: Record<string, HTMLElement | null>): RefResolver {
39
+ return { resolve: (ref: CSTRefId) => map[ref] ?? null };
40
+ }
41
+
42
+ async function render(
43
+ directives: PointDirective[],
44
+ resolver: RefResolver | null,
45
+ ) {
46
+ await act(async () => {
47
+ root.render(
48
+ createElement(HighlightOverlay, { directives, resolver, ttlMs: 0 }),
49
+ );
50
+ });
51
+ }
52
+
53
+ describe('HighlightOverlay', () => {
54
+ it('renders nothing with no directives', async () => {
55
+ await render([], null);
56
+ expect(document.querySelector('[data-chat-highlight-overlay]')).toBeNull();
57
+ });
58
+
59
+ it('renders nothing when refs do not resolve', async () => {
60
+ await render([{ type: 'point', ref: '@e9' }], makeResolver({}));
61
+ expect(document.querySelector('[data-chat-highlight-overlay]')).toBeNull();
62
+ });
63
+
64
+ it('draws the overlay for a resolved ref', async () => {
65
+ const el = document.createElement('button');
66
+ el.textContent = 'Save';
67
+ document.body.appendChild(el);
68
+
69
+ await render(
70
+ [{ type: 'point', ref: '@e1' }],
71
+ makeResolver({ '@e1': el }),
72
+ );
73
+ expect(
74
+ document.querySelector('[data-chat-highlight-overlay]'),
75
+ ).not.toBeNull();
76
+ // The SVG spotlight canvas is mounted.
77
+ expect(document.querySelector('svg')).not.toBeNull();
78
+ });
79
+
80
+ it('renders a caption when the directive has a label', async () => {
81
+ const el = document.createElement('input');
82
+ document.body.appendChild(el);
83
+
84
+ await render(
85
+ [{ type: 'point', ref: '@e1', label: 'Type your key here' }],
86
+ makeResolver({ '@e1': el }),
87
+ );
88
+ expect(document.body.textContent).toContain('Type your key here');
89
+ });
90
+
91
+ it('focuses the element when the directive asks for focus', async () => {
92
+ const el = document.createElement('input');
93
+ document.body.appendChild(el);
94
+
95
+ await render(
96
+ [{ type: 'point', ref: '@e1', focus: true }],
97
+ makeResolver({ '@e1': el }),
98
+ );
99
+ expect(document.activeElement).toBe(el);
100
+ });
101
+
102
+ it('does not focus when focus is not requested', async () => {
103
+ const el = document.createElement('input');
104
+ document.body.appendChild(el);
105
+
106
+ await render(
107
+ [{ type: 'point', ref: '@e1' }],
108
+ makeResolver({ '@e1': el }),
109
+ );
110
+ expect(document.activeElement).not.toBe(el);
111
+ });
112
+ });