@semiont/react-ui 0.2.33-build.81 → 0.2.33-build.83

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 (37) hide show
  1. package/dist/{PdfAnnotationCanvas.client-RAJRPQLU.mjs → PdfAnnotationCanvas.client-FGV33CWN.mjs} +9 -14
  2. package/dist/PdfAnnotationCanvas.client-FGV33CWN.mjs.map +1 -0
  3. package/dist/chunk-FC6SGLLT.mjs +141 -0
  4. package/dist/chunk-FC6SGLLT.mjs.map +1 -0
  5. package/dist/chunk-XS27QKGP.mjs +55 -0
  6. package/dist/chunk-XS27QKGP.mjs.map +1 -0
  7. package/dist/{chunk-QB52Q7EQ.mjs → chunk-YPYLOBA2.mjs} +31 -81
  8. package/dist/chunk-YPYLOBA2.mjs.map +1 -0
  9. package/dist/index.css +16 -0
  10. package/dist/index.css.map +1 -1
  11. package/dist/index.d.mts +70 -28
  12. package/dist/index.mjs +564 -621
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/test-utils.mjs +5 -3
  15. package/dist/test-utils.mjs.map +1 -1
  16. package/package.json +1 -1
  17. package/src/components/CodeMirrorRenderer.tsx +8 -8
  18. package/src/components/annotation/AnnotateToolbar.tsx +4 -1
  19. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -17
  20. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +6 -17
  21. package/src/components/resource/BrowseView.tsx +8 -8
  22. package/src/components/resource/__tests__/BrowseView.test.tsx +20 -12
  23. package/src/components/resource/panels/AssessmentEntry.tsx +3 -6
  24. package/src/components/resource/panels/CommentEntry.tsx +3 -6
  25. package/src/components/resource/panels/HighlightEntry.tsx +3 -6
  26. package/src/components/resource/panels/ReferenceEntry.tsx +3 -6
  27. package/src/components/resource/panels/TagEntry.tsx +3 -6
  28. package/src/components/resource/panels/TaggingPanel.tsx +5 -0
  29. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +42 -4
  30. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +44 -0
  31. package/src/components/toolbar/Toolbar.css +20 -0
  32. package/src/features/resource-viewer/__tests__/AnnotationCreationPending.test.tsx +312 -0
  33. package/src/features/resource-viewer/__tests__/GenerationFlowIntegration.test.tsx +4 -8
  34. package/src/features/resource-viewer/__tests__/ResolutionFlowIntegration.test.tsx +266 -0
  35. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +5 -3
  36. package/dist/PdfAnnotationCanvas.client-RAJRPQLU.mjs.map +0 -1
  37. package/dist/chunk-QB52Q7EQ.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.2.33-build.81",
3
+ "version": "0.2.33-build.83",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -9,6 +9,7 @@ import { ReferenceResolutionWidget } from '../lib/codemirror-widgets';
9
9
  import { isHighlight, isReference, isResolvedReference, isComment, isAssessment, isTag, getBodySource } from '@semiont/api-client';
10
10
  import type { components } from '@semiont/api-client';
11
11
  import type { EventBus } from '../contexts/EventBusContext';
12
+ import { createHoverHandlers } from '../hooks/useAttentionFlow';
12
13
 
13
14
  type Annotation = components['schemas']['Annotation'];
14
15
 
@@ -409,23 +410,21 @@ export function CodeMirrorRenderer({
409
410
  // Attach hover event listeners using native DOM events with delegation
410
411
  const container = view.dom;
411
412
 
413
+ const { handleMouseEnter, handleMouseLeave, cleanup: cleanupHover } = createHoverHandlers(
414
+ (annotationId) => eventBusRef.current?.emit('annotation:hover', { annotationId })
415
+ );
416
+
412
417
  const handleMouseOver = (e: MouseEvent) => {
413
418
  const target = e.target as HTMLElement;
414
419
  const annotationElement = target.closest('[data-annotation-id]');
415
420
  const annotationId = annotationElement?.getAttribute('data-annotation-id');
416
-
417
- if (annotationId && eventBusRef.current) {
418
- eventBusRef.current.emit('annotation:hover', { annotationId });
419
- }
421
+ if (annotationId) handleMouseEnter(annotationId);
420
422
  };
421
423
 
422
424
  const handleMouseOut = (e: MouseEvent) => {
423
425
  const target = e.target as HTMLElement;
424
426
  const annotationElement = target.closest('[data-annotation-id]');
425
-
426
- if (annotationElement && eventBusRef.current) {
427
- eventBusRef.current.emit('annotation:hover', { annotationId: null });
428
- }
427
+ if (annotationElement) handleMouseLeave();
429
428
  };
430
429
 
431
430
  container.addEventListener('mouseover', handleMouseOver);
@@ -434,6 +433,7 @@ export function CodeMirrorRenderer({
434
433
  return () => {
435
434
  container.removeEventListener('mouseover', handleMouseOver);
436
435
  container.removeEventListener('mouseout', handleMouseOut);
436
+ cleanupHover();
437
437
  view.destroy();
438
438
  viewRef.current = null;
439
439
  };
@@ -299,7 +299,10 @@ export function AnnotateToolbar({
299
299
  onPin={() => setClickPinned(!clickPinned)}
300
300
  containerRef={clickRef}
301
301
  collapsedContent={
302
- <div className="semiont-dropdown-display">
302
+ <div
303
+ className="semiont-dropdown-display"
304
+ data-delete={clickActions.find(a => a.action === selectedClick)?.isDelete ? 'true' : 'false'}
305
+ >
303
306
  <span className="semiont-dropdown-icon">
304
307
  {clickActions.find(a => a.action === selectedClick)?.icon}
305
308
  </span>
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { useRef } from 'react';
3
+ import { useMemo } from 'react';
4
4
  import type { components } from '@semiont/api-client';
5
+ import { createHoverHandlers } from '../../hooks/useAttentionFlow';
5
6
  import { getSvgSelector, isHighlight, isReference, isAssessment, isComment, isTag, isBodyResolved, isResolvedReference } from '@semiont/api-client';
6
7
  import { parseSvgSelector } from '@semiont/api-client';
7
8
  import type { EventBus } from '../../contexts/EventBusContext';
@@ -77,22 +78,10 @@ export function AnnotationOverlay({
77
78
  const scaleX = displayWidth / imageWidth;
78
79
  const scaleY = displayHeight / imageHeight;
79
80
 
80
- // Track current hover state to prevent redundant emissions
81
- const currentHover = useRef<string | null>(null);
82
-
83
- const handleMouseEnter = (annotationId: string) => {
84
- if (currentHover.current !== annotationId) {
85
- currentHover.current = annotationId;
86
- eventBus?.emit('annotation:hover', { annotationId });
87
- }
88
- };
89
-
90
- const handleMouseLeave = () => {
91
- if (currentHover.current !== null) {
92
- currentHover.current = null;
93
- eventBus?.emit('annotation:hover', { annotationId: null });
94
- }
95
- };
81
+ const { handleMouseEnter, handleMouseLeave } = useMemo(
82
+ () => createHoverHandlers((annotationId) => eventBus?.emit('annotation:hover', { annotationId })),
83
+ [eventBus]
84
+ );
96
85
 
97
86
  return (
98
87
  <svg
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
4
+ import { createHoverHandlers } from '../../hooks/useAttentionFlow';
4
5
  import type { components, ResourceUri } from '@semiont/api-client';
5
6
  import { getTargetSelector } from '@semiont/api-client';
6
7
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
@@ -97,9 +98,6 @@ export function PdfAnnotationCanvas({
97
98
  const containerRef = useRef<HTMLDivElement>(null);
98
99
  const imageRef = useRef<HTMLImageElement>(null);
99
100
 
100
- // Track current hover state to prevent redundant emissions
101
- const currentHover = useRef<string | null>(null);
102
-
103
101
  // Load PDF document on mount
104
102
  useEffect(() => {
105
103
  let cancelled = false;
@@ -362,20 +360,11 @@ export function PdfAnnotationCanvas({
362
360
  return page === pageNumber;
363
361
  });
364
362
 
365
- // Hover handlers with state tracking
366
- const handleMouseEnter = (annotationId: string) => {
367
- if (currentHover.current !== annotationId) {
368
- currentHover.current = annotationId;
369
- eventBus?.emit('annotation:hover', { annotationId });
370
- }
371
- };
372
-
373
- const handleMouseLeave = () => {
374
- if (currentHover.current !== null) {
375
- currentHover.current = null;
376
- eventBus?.emit('annotation:hover', { annotationId: null });
377
- }
378
- };
363
+ // Hover handlers with currentHover guard and dwell delay
364
+ const { handleMouseEnter, handleMouseLeave } = useMemo(
365
+ () => createHoverHandlers((annotationId) => eventBus?.emit('annotation:hover', { annotationId })),
366
+ [eventBus]
367
+ );
379
368
 
380
369
  // Calculate motivation color
381
370
  const { stroke, fill } = getMotivationColor(selectedMotivation ?? null);
@@ -8,6 +8,7 @@ import { rehypeRenderAnnotations } from '../../lib/rehype-render-annotations';
8
8
  import type { components } from '@semiont/api-client';
9
9
  import { getExactText, getTextPositionSelector, getTargetSelector, getBodySource, getMimeCategory, isPdfMimeType, resourceUri as toResourceUri } from '@semiont/api-client';
10
10
  import { ANNOTATORS } from '../../lib/annotation-registry';
11
+ import { createHoverHandlers } from '../../hooks/useAttentionFlow';
11
12
  import { ImageViewer } from '../viewers';
12
13
  import { AnnotateToolbar, type ClickAction } from '../annotation/AnnotateToolbar';
13
14
  import type { AnnotationsCollection } from '../../types/annotation-props';
@@ -115,25 +116,23 @@ export function BrowseView({
115
116
  }
116
117
  };
117
118
 
119
+ const { handleMouseEnter, handleMouseLeave, cleanup: cleanupHover } = createHoverHandlers(
120
+ (annotationId) => eventBus.emit('annotation:hover', { annotationId })
121
+ );
122
+
118
123
  // Single mouseover handler for the container - fires once on enter
119
124
  const handleMouseOver = (e: MouseEvent) => {
120
125
  const target = e.target as HTMLElement;
121
126
  const annotationElement = target.closest('[data-annotation-id]');
122
127
  const annotationId = annotationElement?.getAttribute('data-annotation-id');
123
-
124
- if (annotationId) {
125
- eventBus.emit('annotation:hover', { annotationId });
126
- }
128
+ if (annotationId) handleMouseEnter(annotationId);
127
129
  };
128
130
 
129
131
  // Single mouseout handler for the container - fires once on exit
130
132
  const handleMouseOut = (e: MouseEvent) => {
131
133
  const target = e.target as HTMLElement;
132
134
  const annotationElement = target.closest('[data-annotation-id]');
133
-
134
- if (annotationElement) {
135
- eventBus.emit('annotation:hover', { annotationId: null });
136
- }
135
+ if (annotationElement) handleMouseLeave();
137
136
  };
138
137
 
139
138
  // Apply animation classes to new annotations
@@ -155,6 +154,7 @@ export function BrowseView({
155
154
  container.removeEventListener('click', handleClick);
156
155
  container.removeEventListener('mouseover', handleMouseOver);
157
156
  container.removeEventListener('mouseout', handleMouseOut);
157
+ cleanupHover();
158
158
  };
159
159
  }, [content, allAnnotations, newAnnotationIds]);
160
160
 
@@ -320,30 +320,38 @@ describe('BrowseView Component', () => {
320
320
  });
321
321
  });
322
322
 
323
- it('should emit annotation:hover with null when mouse exits annotation', async () => {
323
+ it('should emit annotation:hover with null when mouse exits after dwell', async () => {
324
+ vi.useFakeTimers();
324
325
  const tracker = createEventTracker();
325
- const { container } = renderWithEventTracking(<BrowseView {...defaultProps} />, tracker);
326
+ const annotations = {
327
+ ...defaultProps.annotations,
328
+ references: [createMockAnnotation('linking', 'ref-1')],
329
+ };
330
+ const { container } = renderWithEventTracking(
331
+ <BrowseView {...defaultProps} annotations={annotations} />,
332
+ tracker
333
+ );
326
334
 
327
335
  const browseContainer = container.querySelector('.semiont-browse-view__content');
328
336
 
329
- // Create annotation element
330
337
  const mockAnnotationElement = document.createElement('span');
331
338
  mockAnnotationElement.setAttribute('data-annotation-id', 'ref-1');
339
+ const mockTarget = { closest: vi.fn(() => mockAnnotationElement) } as any;
332
340
 
333
- const mockTarget = {
334
- closest: vi.fn(() => mockAnnotationElement),
335
- } as any;
341
+ // Enter and let dwell timer fire
342
+ fireEvent.mouseOver(browseContainer!, { target: mockTarget });
343
+ vi.advanceTimersByTime(200);
336
344
 
337
345
  tracker.clear();
338
346
 
339
- // Simulate mouseout event (fires once on exit)
347
+ // Now exit should emit null
340
348
  fireEvent.mouseOut(browseContainer!, { target: mockTarget });
341
349
 
342
- await waitFor(() => {
343
- expect(tracker.events.some(e =>
344
- e.event === 'annotation:hover' && e.payload?.annotationId === null
345
- )).toBe(true);
346
- });
350
+ expect(tracker.events.some(e =>
351
+ e.event === 'annotation:hover' && e.payload?.annotationId === null
352
+ )).toBe(true);
353
+
354
+ vi.useRealTimers();
347
355
  });
348
356
 
349
357
  it('should not emit on mouseover when not over annotation', async () => {
@@ -4,6 +4,7 @@ import { forwardRef } from 'react';
4
4
  import type { components } from '@semiont/api-client';
5
5
  import { getAnnotationExactText } from '@semiont/api-client';
6
6
  import { useEventBus } from '../../../contexts/EventBusContext';
7
+ import { useHoverEmitter } from '../../../hooks/useAttentionFlow';
7
8
 
8
9
  type Annotation = components['schemas']['Annotation'];
9
10
 
@@ -76,6 +77,7 @@ export const AssessmentEntry = forwardRef<HTMLDivElement, AssessmentEntryProps>(
76
77
  ref
77
78
  ) {
78
79
  const eventBus = useEventBus();
80
+ const hoverProps = useHoverEmitter(assessment.id);
79
81
 
80
82
  const selectedText = getAnnotationExactText(assessment);
81
83
  const assessmentText = getAssessmentText(assessment);
@@ -89,12 +91,7 @@ export const AssessmentEntry = forwardRef<HTMLDivElement, AssessmentEntryProps>(
89
91
  onClick={() => {
90
92
  eventBus.emit('annotation:click', { annotationId: assessment.id, motivation: assessment.motivation });
91
93
  }}
92
- onMouseEnter={() => {
93
- eventBus.emit('annotation:hover', { annotationId: assessment.id });
94
- }}
95
- onMouseLeave={() => {
96
- eventBus.emit('annotation:hover', { annotationId: null });
97
- }}
94
+ {...hoverProps}
98
95
  >
99
96
  {/* Selected text quote */}
100
97
  {selectedText && (
@@ -5,6 +5,7 @@ import { useTranslations } from '../../../contexts/TranslationContext';
5
5
  import type { components } from '@semiont/api-client';
6
6
  import { getAnnotationExactText, getCommentText } from '@semiont/api-client';
7
7
  import { useEventBus } from '../../../contexts/EventBusContext';
8
+ import { useHoverEmitter } from '../../../hooks/useAttentionFlow';
8
9
 
9
10
  type Annotation = components['schemas']['Annotation'];
10
11
 
@@ -44,6 +45,7 @@ export const CommentEntry = forwardRef<HTMLDivElement, CommentEntryProps>(
44
45
  ) {
45
46
  const t = useTranslations('CommentsPanel');
46
47
  const eventBus = useEventBus();
48
+ const hoverProps = useHoverEmitter(comment.id);
47
49
  const [isEditing, setIsEditing] = useState(false);
48
50
  const [editText, setEditText] = useState('');
49
51
  const internalRef = useRef<HTMLDivElement>(null);
@@ -88,12 +90,7 @@ export const CommentEntry = forwardRef<HTMLDivElement, CommentEntryProps>(
88
90
  onClick={() => {
89
91
  eventBus.emit('annotation:click', { annotationId: comment.id, motivation: comment.motivation });
90
92
  }}
91
- onMouseEnter={() => {
92
- eventBus.emit('annotation:hover', { annotationId: comment.id });
93
- }}
94
- onMouseLeave={() => {
95
- eventBus.emit('annotation:hover', { annotationId: null });
96
- }}
93
+ {...hoverProps}
97
94
  >
98
95
  {/* Selected text quote - only for text annotations */}
99
96
  {selectedText && (
@@ -4,6 +4,7 @@ import { forwardRef } from 'react';
4
4
  import type { components } from '@semiont/api-client';
5
5
  import { getAnnotationExactText } from '@semiont/api-client';
6
6
  import { useEventBus } from '../../../contexts/EventBusContext';
7
+ import { useHoverEmitter } from '../../../hooks/useAttentionFlow';
7
8
 
8
9
  type Annotation = components['schemas']['Annotation'];
9
10
 
@@ -40,6 +41,7 @@ export const HighlightEntry = forwardRef<HTMLDivElement, HighlightEntryProps>(
40
41
  ref
41
42
  ) {
42
43
  const eventBus = useEventBus();
44
+ const hoverProps = useHoverEmitter(highlight.id);
43
45
 
44
46
  const selectedText = getAnnotationExactText(highlight);
45
47
 
@@ -52,12 +54,7 @@ export const HighlightEntry = forwardRef<HTMLDivElement, HighlightEntryProps>(
52
54
  onClick={() => {
53
55
  eventBus.emit('annotation:click', { annotationId: highlight.id, motivation: highlight.motivation });
54
56
  }}
55
- onMouseEnter={() => {
56
- eventBus.emit('annotation:hover', { annotationId: highlight.id });
57
- }}
58
- onMouseLeave={() => {
59
- eventBus.emit('annotation:hover', { annotationId: null });
60
- }}
57
+ {...hoverProps}
61
58
  >
62
59
  {/* Highlighted text */}
63
60
  {selectedText && (
@@ -9,6 +9,7 @@ import { getEntityTypes } from '@semiont/ontology';
9
9
  import { getResourceIcon } from '../../../lib/resource-utils';
10
10
  import { useEventBus } from '../../../contexts/EventBusContext';
11
11
  import { useObservableExternalNavigation } from '../../../hooks/useObservableNavigation';
12
+ import { useHoverEmitter } from '../../../hooks/useAttentionFlow';
12
13
 
13
14
  type Annotation = components['schemas']['Annotation'];
14
15
 
@@ -42,6 +43,7 @@ export const ReferenceEntry = forwardRef<HTMLDivElement, ReferenceEntryProps>(
42
43
  const t = useTranslations('ReferencesPanel');
43
44
  const eventBus = useEventBus();
44
45
  const navigate = useObservableExternalNavigation();
46
+ const hoverProps = useHoverEmitter(reference.id);
45
47
 
46
48
  const selectedText = getAnnotationExactText(reference) || '';
47
49
  const isResolved = isBodyResolved(reference.body);
@@ -121,12 +123,7 @@ export const ReferenceEntry = forwardRef<HTMLDivElement, ReferenceEntryProps>(
121
123
  onClick={() => {
122
124
  eventBus.emit('annotation:click', { annotationId: reference.id, motivation: reference.motivation });
123
125
  }}
124
- onMouseEnter={() => {
125
- eventBus.emit('annotation:hover', { annotationId: reference.id });
126
- }}
127
- onMouseLeave={() => {
128
- eventBus.emit('annotation:hover', { annotationId: null });
129
- }}
126
+ {...hoverProps}
130
127
  >
131
128
  {/* Status indicator and text quote */}
132
129
  <div className="semiont-annotation-entry__header">
@@ -6,6 +6,7 @@ import { getAnnotationExactText } from '@semiont/api-client';
6
6
  import { getTagCategory, getTagSchemaId } from '@semiont/ontology';
7
7
  import { getTagSchema } from '../../../lib/tag-schemas';
8
8
  import { useEventBus } from '../../../contexts/EventBusContext';
9
+ import { useHoverEmitter } from '../../../hooks/useAttentionFlow';
9
10
 
10
11
  type Annotation = components['schemas']['Annotation'];
11
12
 
@@ -25,6 +26,7 @@ export const TagEntry = forwardRef<HTMLDivElement, TagEntryProps>(
25
26
  ref
26
27
  ) {
27
28
  const eventBus = useEventBus();
29
+ const hoverProps = useHoverEmitter(tag.id);
28
30
 
29
31
  const selectedText = getAnnotationExactText(tag);
30
32
  const category = getTagCategory(tag);
@@ -37,12 +39,7 @@ export const TagEntry = forwardRef<HTMLDivElement, TagEntryProps>(
37
39
  onClick={() => {
38
40
  eventBus.emit('annotation:click', { annotationId: tag.id, motivation: tag.motivation });
39
41
  }}
40
- onMouseEnter={() => {
41
- eventBus.emit('annotation:hover', { annotationId: tag.id });
42
- }}
43
- onMouseLeave={() => {
44
- eventBus.emit('annotation:hover', { annotationId: null });
45
- }}
42
+ {...hoverProps}
46
43
  className={`semiont-annotation-entry${isHovered ? ' semiont-annotation-pulse' : ''}`}
47
44
  data-type="tag"
48
45
  data-focused={isFocused ? 'true' : 'false'}
@@ -283,6 +283,11 @@ export function TaggingPanel({
283
283
  value: e.target.value,
284
284
  purpose: 'tagging' as const,
285
285
  },
286
+ {
287
+ type: 'TextualBody' as const,
288
+ value: selectedSchemaId,
289
+ purpose: 'classifying' as const,
290
+ },
286
291
  ],
287
292
  });
288
293
  }
@@ -273,7 +273,8 @@ describe('CommentEntry Component', () => {
273
273
  });
274
274
 
275
275
  describe('Hover Interactions', () => {
276
- it('should emit annotation:hover event with annotation id on mouse enter', () => {
276
+ it('should emit annotation:hover event with annotation id after dwell delay', () => {
277
+ vi.useFakeTimers();
277
278
  const hoverHandler = vi.fn();
278
279
 
279
280
  const { container, eventBus } = renderWithProviders(
@@ -287,13 +288,21 @@ describe('CommentEntry Component', () => {
287
288
  const commentDiv = container.firstChild as HTMLElement;
288
289
  fireEvent.mouseEnter(commentDiv);
289
290
 
291
+ // Not emitted immediately — dwell timer pending
292
+ expect(hoverHandler).not.toHaveBeenCalled();
293
+
294
+ // Advance past HOVER_DELAY_MS
295
+ vi.advanceTimersByTime(200);
296
+
290
297
  expect(hoverHandler).toHaveBeenCalledWith({ annotationId: 'comment-1' });
291
298
 
292
299
  // Clean up
293
300
  eventBus!.off('annotation:hover', hoverHandler);
301
+ vi.useRealTimers();
294
302
  });
295
303
 
296
- it('should emit annotation:hover event with null on mouse leave', () => {
304
+ it('should NOT emit annotation:hover when mouse leaves before dwell delay', () => {
305
+ vi.useFakeTimers();
297
306
  const hoverHandler = vi.fn();
298
307
 
299
308
  const { container, eventBus } = renderWithProviders(
@@ -305,12 +314,41 @@ describe('CommentEntry Component', () => {
305
314
  eventBus!.on('annotation:hover', hoverHandler);
306
315
 
307
316
  const commentDiv = container.firstChild as HTMLElement;
308
- fireEvent.mouseLeave(commentDiv);
317
+ fireEvent.mouseEnter(commentDiv);
318
+ fireEvent.mouseLeave(commentDiv); // leaves before timer fires
319
+
320
+ vi.advanceTimersByTime(200);
309
321
 
310
- expect(hoverHandler).toHaveBeenCalledWith({ annotationId: null });
322
+ // Timer was cancelled — no event emitted at all
323
+ expect(hoverHandler).not.toHaveBeenCalled();
311
324
 
312
325
  // Clean up
313
326
  eventBus!.off('annotation:hover', hoverHandler);
327
+ vi.useRealTimers();
328
+ });
329
+
330
+ it('should emit annotation:hover null after dwell then leave', () => {
331
+ vi.useFakeTimers();
332
+ const hoverHandler = vi.fn();
333
+
334
+ const { container, eventBus } = renderWithProviders(
335
+ <CommentEntry {...defaultProps} />,
336
+ { returnEventBus: true }
337
+ );
338
+
339
+ eventBus!.on('annotation:hover', hoverHandler);
340
+
341
+ const commentDiv = container.firstChild as HTMLElement;
342
+ fireEvent.mouseEnter(commentDiv);
343
+ vi.advanceTimersByTime(200); // hover committed
344
+
345
+ fireEvent.mouseLeave(commentDiv);
346
+
347
+ expect(hoverHandler).toHaveBeenNthCalledWith(1, { annotationId: 'comment-1' });
348
+ expect(hoverHandler).toHaveBeenNthCalledWith(2, { annotationId: null });
349
+
350
+ eventBus!.off('annotation:hover', hoverHandler);
351
+ vi.useRealTimers();
314
352
  });
315
353
 
316
354
  it('should handle hover events without errors', () => {
@@ -386,6 +386,50 @@ describe('TaggingPanel Component', () => {
386
386
  });
387
387
  });
388
388
 
389
+ it('should include schema id as classifying body alongside tagging body', async () => {
390
+ // Regression: manual tags were missing the classifying body, so getTagSchemaId()
391
+ // returned undefined and TagEntry never showed the schema name.
392
+ const tracker = createEventTracker();
393
+ const pendingAnnotation = createPendingAnnotation('Selected text');
394
+
395
+ renderWithEventBus(
396
+ <TaggingPanel
397
+ {...defaultProps}
398
+ pendingAnnotation={pendingAnnotation}
399
+ />,
400
+ tracker
401
+ );
402
+
403
+ const categorySelects = screen.getAllByRole('combobox');
404
+ const categorySelect = categorySelects.find(select =>
405
+ select.querySelector('option[value=""]')?.textContent === 'Choose a category'
406
+ );
407
+ await userEvent.selectOptions(categorySelect!, 'Rule');
408
+
409
+ await waitFor(() => {
410
+ const createEvent = tracker.events.find(e => e.event === 'annotation:create');
411
+ expect(createEvent).toBeDefined();
412
+ const body: any[] = createEvent!.payload.body;
413
+
414
+ // Must have exactly two body elements
415
+ expect(body).toHaveLength(2);
416
+
417
+ // First: the category
418
+ expect(body[0]).toMatchObject({
419
+ type: 'TextualBody',
420
+ value: 'Rule',
421
+ purpose: 'tagging',
422
+ });
423
+
424
+ // Second: the schema id — this is what was missing before the fix
425
+ expect(body[1]).toMatchObject({
426
+ type: 'TextualBody',
427
+ value: 'legal-irac',
428
+ purpose: 'classifying',
429
+ });
430
+ });
431
+ });
432
+
389
433
  it('should have proper styling for tag creation form', () => {
390
434
  const pendingAnnotation = createPendingAnnotation('Selected text');
391
435
 
@@ -79,6 +79,26 @@
79
79
  color: var(--semiont-color-gray-300);
80
80
  }
81
81
 
82
+ .semiont-dropdown-display[data-delete="true"] {
83
+ background: var(--semiont-color-error);
84
+ color: var(--semiont-color-white);
85
+ border-radius: var(--semiont-radius-sm);
86
+ padding: 0.125rem 0.375rem;
87
+ }
88
+
89
+ .semiont-dropdown-display[data-delete="true"] .semiont-dropdown-label {
90
+ color: var(--semiont-color-white);
91
+ }
92
+
93
+ [data-theme="dark"] .semiont-dropdown-display[data-delete="true"] {
94
+ background: var(--semiont-color-error-dark);
95
+ color: var(--semiont-color-error-light);
96
+ }
97
+
98
+ [data-theme="dark"] .semiont-dropdown-display[data-delete="true"] .semiont-dropdown-label {
99
+ color: var(--semiont-color-error-light);
100
+ }
101
+
82
102
  .semiont-dropdown-menu {
83
103
  position: absolute;
84
104
  top: 100%;