@semiont/react-ui 0.5.9 → 0.5.11

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 (209) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-U4EVBZEV.js → PdfAnnotationCanvas.client-KWQ7XDWT.js} +5 -2
  3. package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +1 -0
  4. package/dist/{ar-WA47UUWA.js → ar-ZHKO4XDR.js} +45 -4
  5. package/dist/ar-ZHKO4XDR.js.map +1 -0
  6. package/dist/{bn-5ANDRIU6.js → bn-3BUDKAK5.js} +45 -4
  7. package/dist/bn-3BUDKAK5.js.map +1 -0
  8. package/dist/{chunk-YBGN4ACY.js → chunk-O23TLLXW.js} +1 -6
  9. package/dist/{chunk-WHUGODTB.js → chunk-PFUXQPWJ.js} +127 -127
  10. package/dist/{chunk-WHUGODTB.js.map → chunk-PFUXQPWJ.js.map} +1 -1
  11. package/dist/{chunk-GERSK2DY.js → chunk-R52YAGHK.js} +42 -1
  12. package/dist/chunk-R52YAGHK.js.map +1 -0
  13. package/dist/{cs-3RU7F4JX.js → cs-2EFEUKTW.js} +45 -4
  14. package/dist/cs-2EFEUKTW.js.map +1 -0
  15. package/dist/{da-GSW5P5HG.js → da-SUD3Q44Q.js} +45 -4
  16. package/dist/da-SUD3Q44Q.js.map +1 -0
  17. package/dist/{de-JUAUYF4Z.js → de-6IC5QRX7.js} +45 -4
  18. package/dist/de-6IC5QRX7.js.map +1 -0
  19. package/dist/{el-JNLWCKEC.js → el-Q4NAXFRC.js} +45 -4
  20. package/dist/el-Q4NAXFRC.js.map +1 -0
  21. package/dist/{en-RBN2GUHF.js → en-P2L4X27N.js} +5 -3
  22. package/dist/{es-WPOX225H.js → es-YUKZBD4W.js} +45 -4
  23. package/dist/es-YUKZBD4W.js.map +1 -0
  24. package/dist/{fa-TAEP6N77.js → fa-OJ47LILE.js} +45 -4
  25. package/dist/fa-OJ47LILE.js.map +1 -0
  26. package/dist/{fi-ZVZHANSP.js → fi-QFFL5CUB.js} +45 -4
  27. package/dist/fi-QFFL5CUB.js.map +1 -0
  28. package/dist/{fr-VLZW7M4N.js → fr-BOQZZWP4.js} +45 -4
  29. package/dist/fr-BOQZZWP4.js.map +1 -0
  30. package/dist/{he-QFAFYA77.js → he-LXDY3TFA.js} +45 -4
  31. package/dist/he-LXDY3TFA.js.map +1 -0
  32. package/dist/{hi-AO3DQPCV.js → hi-HOJER77V.js} +45 -4
  33. package/dist/hi-HOJER77V.js.map +1 -0
  34. package/dist/{id-GTXF42WM.js → id-I5WQ6EWD.js} +45 -4
  35. package/dist/id-I5WQ6EWD.js.map +1 -0
  36. package/dist/index.css +150 -112
  37. package/dist/index.css.map +1 -1
  38. package/dist/index.d.ts +403 -45
  39. package/dist/index.js +4660 -18860
  40. package/dist/index.js.map +1 -1
  41. package/dist/integrations/css-modules-helper.js +1 -1
  42. package/dist/integrations/styled-components-theme.js +1 -1
  43. package/dist/{it-AS5GM232.js → it-UYH642IJ.js} +45 -4
  44. package/dist/it-UYH642IJ.js.map +1 -0
  45. package/dist/{ja-GZ4HLUOF.js → ja-JTVWAC4L.js} +45 -4
  46. package/dist/ja-JTVWAC4L.js.map +1 -0
  47. package/dist/{ko-A4XUXFGJ.js → ko-IHXFMOIZ.js} +45 -4
  48. package/dist/ko-IHXFMOIZ.js.map +1 -0
  49. package/dist/{ms-YBAO3S6K.js → ms-JH25VFLO.js} +45 -4
  50. package/dist/ms-JH25VFLO.js.map +1 -0
  51. package/dist/{nl-3TJGIIIU.js → nl-SURXW7IQ.js} +45 -4
  52. package/dist/nl-SURXW7IQ.js.map +1 -0
  53. package/dist/{no-4AXIQPRQ.js → no-HXIKQFDZ.js} +45 -4
  54. package/dist/no-HXIKQFDZ.js.map +1 -0
  55. package/dist/{pl-5GP6GKXO.js → pl-7A4KU6XK.js} +45 -4
  56. package/dist/pl-7A4KU6XK.js.map +1 -0
  57. package/dist/{pt-26Y6JFG5.js → pt-6MGYP7Q5.js} +45 -4
  58. package/dist/pt-6MGYP7Q5.js.map +1 -0
  59. package/dist/{ro-C7UXFRWJ.js → ro-IROCHNIA.js} +45 -4
  60. package/dist/ro-IROCHNIA.js.map +1 -0
  61. package/dist/{sv-44DVD76T.js → sv-WDJD75CV.js} +45 -4
  62. package/dist/sv-WDJD75CV.js.map +1 -0
  63. package/dist/test-utils.js +9 -4
  64. package/dist/test-utils.js.map +1 -1
  65. package/dist/{th-GIQRTBOY.js → th-RIO2E6B3.js} +45 -4
  66. package/dist/th-RIO2E6B3.js.map +1 -0
  67. package/dist/{tr-WMQWO4D6.js → tr-TZ7ERCIN.js} +45 -4
  68. package/dist/tr-TZ7ERCIN.js.map +1 -0
  69. package/dist/{uk-I7ML6RGG.js → uk-ZFWDKLUE.js} +45 -4
  70. package/dist/uk-ZFWDKLUE.js.map +1 -0
  71. package/dist/{vi-FGWECASQ.js → vi-UIOSP2JG.js} +45 -4
  72. package/dist/vi-UIOSP2JG.js.map +1 -0
  73. package/dist/{zh-L5FN73XC.js → zh-6R6YUY46.js} +45 -4
  74. package/dist/zh-6R6YUY46.js.map +1 -0
  75. package/package.json +18 -9
  76. package/src/components/AnnotateReferencesProgressWidget.tsx +35 -16
  77. package/src/components/ResourceTagsInline.tsx +94 -11
  78. package/src/components/StatusDisplay.css +36 -0
  79. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +41 -91
  80. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  81. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  82. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  83. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  84. package/src/components/image-annotation/SvgDrawingCanvas.tsx +5 -1
  85. package/src/components/modals/ConfigureGatherStep.tsx +102 -0
  86. package/src/components/modals/ContextSummary.tsx +15 -6
  87. package/src/components/modals/GatherContextStep.tsx +104 -59
  88. package/src/components/modals/ResourceGenerateModal.tsx +258 -0
  89. package/src/components/modals/__tests__/ConfigureGatherStep.test.tsx +82 -0
  90. package/src/components/modals/__tests__/GatherContextStep.resource.test.tsx +107 -0
  91. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +197 -0
  92. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +5 -0
  93. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +6 -6
  94. package/src/components/resource/AnnotateView.tsx +59 -38
  95. package/src/components/resource/BrowseView.tsx +159 -133
  96. package/src/components/resource/ResourceViewer.tsx +119 -128
  97. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  98. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  99. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  100. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  101. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  102. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  103. package/src/components/resource/__tests__/BrowseView.test.tsx +35 -22
  104. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +63 -0
  105. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  106. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  107. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  108. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  109. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  110. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  111. package/src/components/resource/browse-renderers.tsx +63 -0
  112. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  113. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  114. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  115. package/src/components/resource/panels/ReferencesPanel.tsx +14 -0
  116. package/src/components/resource/panels/ResourceInfoPanel.tsx +22 -0
  117. package/src/components/resource/panels/TaggingPanel.tsx +4 -0
  118. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +5 -2
  119. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -0
  120. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -0
  121. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +16 -16
  122. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  123. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +1 -0
  124. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +26 -0
  125. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -0
  126. package/src/components/toolbar/Toolbar.css +14 -0
  127. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +14 -0
  128. package/src/features/admin-exchange/state/exchange-state-unit.ts +4 -2
  129. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +14 -0
  130. package/src/features/admin-security/state/admin-security-state-unit.ts +4 -2
  131. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +12 -5
  132. package/src/features/admin-users/state/admin-users-state-unit.ts +4 -2
  133. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +11 -0
  134. package/src/features/auth-welcome/state/welcome-state-unit.ts +1 -1
  135. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +14 -0
  136. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +4 -2
  137. package/src/features/resource-compose/components/ResourceComposePage.tsx +13 -7
  138. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +13 -0
  139. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -2
  140. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +15 -9
  141. package/src/features/resource-discovery/state/discover-state-unit.ts +5 -2
  142. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  143. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +119 -17
  144. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +11 -0
  145. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +30 -0
  146. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +1 -1
  147. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +7 -16
  148. package/src/styles/features/resource-viewer.css +19 -0
  149. package/translations/ar.json +41 -2
  150. package/translations/bn.json +41 -2
  151. package/translations/cs.json +41 -2
  152. package/translations/da.json +41 -2
  153. package/translations/de.json +41 -2
  154. package/translations/el.json +41 -2
  155. package/translations/en.json +39 -0
  156. package/translations/es.json +41 -2
  157. package/translations/fa.json +41 -2
  158. package/translations/fi.json +41 -2
  159. package/translations/fr.json +41 -2
  160. package/translations/he.json +41 -2
  161. package/translations/hi.json +41 -2
  162. package/translations/id.json +41 -2
  163. package/translations/it.json +41 -2
  164. package/translations/ja.json +41 -2
  165. package/translations/ko.json +41 -2
  166. package/translations/ms.json +41 -2
  167. package/translations/nl.json +41 -2
  168. package/translations/no.json +41 -2
  169. package/translations/pl.json +41 -2
  170. package/translations/pt.json +41 -2
  171. package/translations/ro.json +41 -2
  172. package/translations/sv.json +41 -2
  173. package/translations/th.json +41 -2
  174. package/translations/tr.json +41 -2
  175. package/translations/uk.json +41 -2
  176. package/translations/vi.json +41 -2
  177. package/translations/zh.json +41 -2
  178. package/dist/PdfAnnotationCanvas.client-U4EVBZEV.js.map +0 -1
  179. package/dist/ar-WA47UUWA.js.map +0 -1
  180. package/dist/bn-5ANDRIU6.js.map +0 -1
  181. package/dist/chunk-GERSK2DY.js.map +0 -1
  182. package/dist/cs-3RU7F4JX.js.map +0 -1
  183. package/dist/da-GSW5P5HG.js.map +0 -1
  184. package/dist/de-JUAUYF4Z.js.map +0 -1
  185. package/dist/el-JNLWCKEC.js.map +0 -1
  186. package/dist/es-WPOX225H.js.map +0 -1
  187. package/dist/fa-TAEP6N77.js.map +0 -1
  188. package/dist/fi-ZVZHANSP.js.map +0 -1
  189. package/dist/fr-VLZW7M4N.js.map +0 -1
  190. package/dist/he-QFAFYA77.js.map +0 -1
  191. package/dist/hi-AO3DQPCV.js.map +0 -1
  192. package/dist/id-GTXF42WM.js.map +0 -1
  193. package/dist/it-AS5GM232.js.map +0 -1
  194. package/dist/ja-GZ4HLUOF.js.map +0 -1
  195. package/dist/ko-A4XUXFGJ.js.map +0 -1
  196. package/dist/ms-YBAO3S6K.js.map +0 -1
  197. package/dist/nl-3TJGIIIU.js.map +0 -1
  198. package/dist/no-4AXIQPRQ.js.map +0 -1
  199. package/dist/pl-5GP6GKXO.js.map +0 -1
  200. package/dist/pt-26Y6JFG5.js.map +0 -1
  201. package/dist/ro-C7UXFRWJ.js.map +0 -1
  202. package/dist/sv-44DVD76T.js.map +0 -1
  203. package/dist/th-GIQRTBOY.js.map +0 -1
  204. package/dist/tr-WMQWO4D6.js.map +0 -1
  205. package/dist/uk-I7ML6RGG.js.map +0 -1
  206. package/dist/vi-FGWECASQ.js.map +0 -1
  207. package/dist/zh-L5FN73XC.js.map +0 -1
  208. /package/dist/{chunk-YBGN4ACY.js.map → chunk-O23TLLXW.js.map} +0 -0
  209. /package/dist/{en-RBN2GUHF.js.map → en-P2L4X27N.js.map} +0 -0
@@ -1,20 +1,17 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
3
+ import React, { useState, useCallback, useRef, useMemo } from 'react';
4
4
  import { useTranslations } from '../../contexts/TranslationContext';
5
5
  import { AnnotateView, type SelectionMotivation, type ClickAction, type ShapeType } from './AnnotateView';
6
- import { BrowseView } from './BrowseView';
6
+ import { BrowseView, type ReferenceHover } from './BrowseView';
7
7
  import { PopupContainer } from '../annotation-popups/SharedPopupElements';
8
8
  import { JsonLdView } from '../annotation-popups/JsonLdView';
9
- import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components } from '@semiont/core';
9
+ import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components, EventMap } from '@semiont/core';
10
10
  import { getExactText, getTargetSelector, isHighlight, isAssessment, isReference, isComment, isTag, getBodySource } from '@semiont/core';
11
- import { useEventSubscriptions } from '../../contexts/useEventSubscription';
12
- import { useSemiont } from '../../session/SemiontProvider';
13
- import { useObservable } from '../../hooks/useObservable';
14
- import { useObservableExternalNavigation } from '../../hooks/useObservableBrowse';
11
+ import type { SemiontSession } from '@semiont/sdk';
12
+ import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
15
13
  import { ANNOTATORS } from '../../lib/annotation-registry';
16
14
  import type { AnnotationsCollection } from '../../types/annotation-props';
17
- import { getSelectorType, getSelectedShapeForSelectorType, saveSelectedShapeForSelectorType } from '../../lib/media-shapes';
18
15
 
19
16
  /**
20
17
  * ResourceViewer - Display and interact with resource content and annotations
@@ -24,9 +21,11 @@ import { getSelectorType, getSelectedShapeForSelectorType, saveSelectedShapeForS
24
21
  * - Automatically invalidates cache when annotations change
25
22
  * - No manual refetch needed - events handle cache invalidation
26
23
  *
27
- * Requirements:
28
- * - Must be wrapped in SemiontProvider (which owns the session's event bus)
29
- * - Must be wrapped in CacheContext (provides cache manager)
24
+ * Bring-your-own-session: the `session` (SemiontSession) and the host's
25
+ * navigation / panel callbacks come in as props no SemiontProvider required.
26
+ * Every event it subscribes to is session-scoped (mark:*, browse:click) and
27
+ * reaches it via `session.subscribe`. Translations fall back to built-in English
28
+ * when no TranslationProvider is mounted; caching flows through `session.client.browse.*`.
30
29
  *
31
30
  * Event flow:
32
31
  * make-meaning → EventLog → SSE → EventBus → ResourceViewer → Cache invalidation
@@ -37,42 +36,86 @@ import { getSelectorType, getSelectedShapeForSelectorType, saveSelectedShapeForS
37
36
  interface Props {
38
37
  resource: SemiontResource & { content: string };
39
38
  annotations: AnnotationsCollection;
39
+ /** Session for the shown resource — its client mutates/invalidates, its bus feeds annotation events. */
40
+ session: SemiontSession | null;
41
+ /** Host-owned navigation: a resolved reference was followed. Omit for a view with no follow behavior. */
42
+ onOpenResource?: (resourceId: string) => void;
43
+ /** Host-owned panel control (annotation clicks open a panel). Omit for hosts without side panels. */
44
+ onOpenPanel?: (event: EventMap['panel:open']) => void;
45
+ /** A content link in the rendered content was clicked — the viewer preventDefaults and delegates; it never navigates on its own. */
46
+ onLinkClick?: (link: { href: string; event: React.MouseEvent }) => void;
47
+ /** A resolved reference span is hovered (after dwell + referent descriptor resolve); `null` on leave. Host renders its own preview. */
48
+ onReferenceHover?: (hover: ReferenceHover | null) => void;
49
+ /** Inline display variant: auto-height, no inner scroll, no pane chrome (browse path). Default: fill-the-pane. */
50
+ inline?: boolean;
51
+ /** Recently-created annotation ids to sparkle (threaded to the browse/annotate subtree). */
52
+ newAnnotationIds?: Set<string>;
40
53
  generatingReferenceId?: string | null;
41
54
  showLineNumbers?: boolean;
42
55
  hoverDelayMs?: number;
43
56
  hoveredAnnotationId?: string | null;
57
+ /**
58
+ * Toolbar preferences as controlled props (TOOLBAR-PREFS-AS-PROPS). Supply a value
59
+ * to own that pref: the instance renders it and reports intents via the callback —
60
+ * it never self-mutates, never persists, never listens to other instances. Omit for
61
+ * a plain uncontrolled default (false / 'detail' / 'linking' / 'rectangle').
62
+ * Hosts wanting the shared+persisted Browser UX compose `useToolbarPrefs()`.
63
+ */
64
+ annotateMode?: boolean;
65
+ onAnnotateModeChange?: (mode: boolean) => void;
66
+ clickAction?: ClickAction;
67
+ onClickActionChange?: (action: ClickAction) => void;
68
+ selectionMotivation?: SelectionMotivation | null;
69
+ onSelectionMotivationChange?: (motivation: SelectionMotivation | null) => void;
70
+ shape?: ShapeType;
71
+ onShapeChange?: (shape: ShapeType) => void;
72
+ /**
73
+ * Render the built-in AnnotateToolbar (default true — today's behavior,
74
+ * byte-identical). `false` → no bar at any internal site (browse + every
75
+ * annotate render mode), while every seam stays live: annotate-mode selection
76
+ * capture, image/pdf drawing, `mark.request` emission, and the controlled
77
+ * props. Tier-2 hosts compose their own controls from the controlled props;
78
+ * this makes that opt-out a contract instead of a CSS override.
79
+ */
80
+ showToolbar?: boolean;
44
81
  }
45
82
 
46
83
  /**
47
84
  * @emits mark:delete - User requested to delete annotation. Payload: { annotationId: string }
48
85
  * @emits panel:open - Request to open panel with annotation. Payload: { panel: string, scrollToAnnotationId?: string, motivation?: Motivation }
49
86
  *
50
- * @subscribes mark:mode-toggled - Toggles between browse and annotate mode. Payload: { mode: 'browse' | 'annotate' }
51
87
  * @subscribes mark:added - New annotation was added. Payload: { annotation: Annotation }
52
88
  * @subscribes mark:removed - Annotation was removed. Payload: { annotationId: string }
53
89
  * @subscribes mark:body-updated - Annotation was updated. Payload: { annotation: Annotation }
54
- * @subscribes mark:selection-changed - Text selection tool changed. Payload: { selection: boolean }
55
- * @subscribes mark:click-changed - Click annotation tool changed. Payload: { click: 'detail' | 'scroll' | null }
56
- * @subscribes mark:shape-changed - Drawing shape changed. Payload: { shape: string }
57
90
  * @subscribes browse:click - User clicked on annotation. Payload: { annotationId: string }
58
91
  */
59
92
  export function ResourceViewer({
60
93
  resource,
61
94
  annotations,
95
+ session,
96
+ onOpenResource,
97
+ onOpenPanel,
98
+ onLinkClick,
99
+ onReferenceHover,
100
+ inline = false,
101
+ newAnnotationIds,
62
102
  generatingReferenceId,
63
103
  showLineNumbers = false,
64
104
  hoverDelayMs,
65
- hoveredAnnotationId: hoveredAnnotationIdProp
105
+ hoveredAnnotationId: hoveredAnnotationIdProp,
106
+ annotateMode: annotateModeProp,
107
+ onAnnotateModeChange,
108
+ clickAction: clickActionProp,
109
+ onClickActionChange,
110
+ selectionMotivation: selectionMotivationProp,
111
+ onSelectionMotivationChange,
112
+ shape: shapeProp,
113
+ onShapeChange,
114
+ showToolbar = true,
66
115
  }: Props) {
67
116
  const t = useTranslations('ResourceViewer');
68
117
  const documentViewerRef = useRef<HTMLDivElement>(null);
69
118
 
70
- const browser = useSemiont();
71
- const session = useObservable(browser.activeSession$);
72
-
73
- // Get observable navigation for event-driven routing
74
- const navigate = useObservableExternalNavigation();
75
-
76
119
  const { highlights, references, assessments, comments, tags } = annotations;
77
120
 
78
121
  // Extract resource URI once at the top - required for all annotation operations
@@ -93,25 +136,16 @@ export function ResourceViewer({
93
136
 
94
137
  const mimeType = getMimeType();
95
138
 
96
- // Annotate mode state - persisted in localStorage
97
- const [annotateMode, setAnnotateMode] = useState<boolean>(() => {
98
- if (typeof window !== 'undefined') {
99
- return localStorage.getItem('annotateMode') === 'true';
100
- }
101
- return false;
102
- });
103
-
104
- // Persist annotateMode to localStorage
105
- useEffect(() => {
106
- if (typeof window !== 'undefined') {
107
- localStorage.setItem('annotateMode', annotateMode.toString());
108
- }
109
- }, [annotateMode]);
110
-
111
- // Event handlers (extracted to avoid inline arrow functions)
112
- const handleViewModeToggle = useCallback(() => {
113
- setAnnotateMode(prev => !prev);
114
- }, []);
139
+ // Toolbar preferences (TOOLBAR-PREFS-AS-PROPS): controlled (prop supplied) or a
140
+ // plain uncontrolled default. Preferences are state, not events no localStorage
141
+ // and no preference bus channels here; hosts wanting the shared+persisted Browser
142
+ // UX compose useToolbarPrefs() (the policy layer) and pass the values down.
143
+ const [internalAnnotateMode, setInternalAnnotateMode] = useState(false);
144
+ const annotateMode = annotateModeProp ?? internalAnnotateMode;
145
+ const changeAnnotateMode = useCallback((mode: boolean) => {
146
+ if (annotateModeProp === undefined) setInternalAnnotateMode(mode);
147
+ onAnnotateModeChange?.(mode);
148
+ }, [annotateModeProp, onAnnotateModeChange]);
115
149
 
116
150
  // Determine active view based on annotate mode
117
151
  const activeView = annotateMode ? 'annotate' : 'browse';
@@ -130,74 +164,27 @@ export function ResourceViewer({
130
164
  semiont?.browse.invalidateAnnotationList(rUri);
131
165
  }, [semiont, rUri]);
132
166
 
133
- // Annotation toolbar state - persisted in localStorage
134
- const [selectedMotivation, setSelectedMotivation] = useState<SelectionMotivation | null>(() => {
135
- if (typeof window !== 'undefined') {
136
- const stored = localStorage.getItem('semiont-toolbar-selection');
137
- if (stored === 'null') return null;
138
- if (stored && ['linking', 'highlighting', 'assessing', 'commenting', 'tagging'].includes(stored)) {
139
- return stored as SelectionMotivation;
140
- }
141
- }
142
- return 'linking';
143
- });
144
-
145
- const [selectedClick, setSelectedClick] = useState<ClickAction>(() => {
146
- if (typeof window !== 'undefined') {
147
- const stored = localStorage.getItem('semiont-toolbar-click');
148
- if (stored && ['detail', 'follow', 'jsonld', 'deleting'].includes(stored)) {
149
- return stored as ClickAction;
150
- }
151
- }
152
- return 'detail';
153
- });
154
-
155
- // Get selector type for current media type
156
- const selectorType = getSelectorType(mimeType);
157
-
158
- // Get selected shape for this selector type
159
- const [selectedShape, setSelectedShape] = useState<ShapeType>(() => {
160
- return getSelectedShapeForSelectorType(selectorType);
161
- });
162
-
163
- // Toolbar event handlers (extracted to avoid inline arrow functions)
164
- const handleToolbarSelectionChanged = useCallback(({ motivation }: { motivation: string | null }) => {
165
- setSelectedMotivation(motivation as SelectionMotivation | null);
166
- }, []);
167
-
168
- const handleToolbarClickChanged = useCallback(({ action }: { action: string }) => {
169
- setSelectedClick(action as ClickAction);
170
- }, []);
171
-
172
- const handleToolbarShapeChanged = useCallback(({ shape }: { shape: string }) => {
173
- setSelectedShape(shape as ShapeType);
174
- }, []);
175
-
176
- // Persist toolbar state to localStorage
177
- useEffect(() => {
178
- if (selectedMotivation === null) {
179
- localStorage.setItem('semiont-toolbar-selection', 'null');
180
- } else {
181
- localStorage.setItem('semiont-toolbar-selection', selectedMotivation);
182
- }
183
- }, [selectedMotivation]);
184
-
185
- useEffect(() => {
186
- localStorage.setItem('semiont-toolbar-click', selectedClick);
187
- }, [selectedClick]);
188
-
189
- // Persist shape selection per selector type
190
- useEffect(() => {
191
- saveSelectedShapeForSelectorType(selectorType, selectedShape);
192
- }, [selectorType, selectedShape]);
193
-
194
- // Update selected shape when selector type changes (e.g., switching between PDF and image)
195
- useEffect(() => {
196
- const shapeForType = getSelectedShapeForSelectorType(selectorType);
197
- if (shapeForType !== selectedShape) {
198
- setSelectedShape(shapeForType);
199
- }
200
- }, [selectorType]);
167
+ // Remaining toolbar preferences same controlled/uncontrolled split as mode.
168
+ const [internalSelectionMotivation, setInternalSelectionMotivation] = useState<SelectionMotivation | null>('linking');
169
+ const selectedMotivation = selectionMotivationProp !== undefined ? selectionMotivationProp : internalSelectionMotivation;
170
+ const changeSelectionMotivation = useCallback((motivation: SelectionMotivation | null) => {
171
+ if (selectionMotivationProp === undefined) setInternalSelectionMotivation(motivation);
172
+ onSelectionMotivationChange?.(motivation);
173
+ }, [selectionMotivationProp, onSelectionMotivationChange]);
174
+
175
+ const [internalClickAction, setInternalClickAction] = useState<ClickAction>('detail');
176
+ const selectedClick = clickActionProp ?? internalClickAction;
177
+ const changeClickAction = useCallback((action: ClickAction) => {
178
+ if (clickActionProp === undefined) setInternalClickAction(action);
179
+ onClickActionChange?.(action);
180
+ }, [clickActionProp, onClickActionChange]);
181
+
182
+ const [internalShape, setInternalShape] = useState<ShapeType>('rectangle');
183
+ const selectedShape = shapeProp ?? internalShape;
184
+ const changeShape = useCallback((shape: ShapeType) => {
185
+ if (shapeProp === undefined) setInternalShape(shape);
186
+ onShapeChange?.(shape);
187
+ }, [shapeProp, onShapeChange]);
201
188
 
202
189
  // JSON-LD view state
203
190
  const [showJsonLdView, setShowJsonLdView] = useState(false);
@@ -267,8 +254,8 @@ export function ResourceViewer({
267
254
  if (selectedClick === 'follow' && isReference(annotation)) {
268
255
  const bodySource = getBodySource(annotation.body);
269
256
  if (bodySource) {
270
- // bodySource is already a bare resource ID
271
- navigate(`/know/resource/${bodySource}`, { resourceId: bodySource });
257
+ // bodySource is already a bare resource ID — the host owns navigation
258
+ onOpenResource?.(bodySource);
272
259
  }
273
260
  return;
274
261
  }
@@ -293,7 +280,7 @@ export function ResourceViewer({
293
280
  setDeleteConfirmation({ annotation, position });
294
281
  return;
295
282
  }
296
- }, [annotateMode, selectedClick, focusAnnotation]);
283
+ }, [annotateMode, selectedClick, focusAnnotation, onOpenResource]);
297
284
 
298
285
  // Annotation click coordinator - handles panel opening and scrolling
299
286
  const handleAnnotationClickEvent = useCallback(({ annotationId, motivation }: {
@@ -323,27 +310,19 @@ export function ResourceViewer({
323
310
  return;
324
311
  }
325
312
 
326
- // All annotations open the unified annotations panel
313
+ // All annotations open the unified annotations panel — the host owns the panel.
327
314
  // The panel internally switches tabs based on the motivation → tab mapping in UnifiedAnnotationsPanel
328
- browser.emit('panel:open', { panel: 'annotations', scrollToAnnotationId: annotationId, motivation });
329
- }, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick, session]);
315
+ onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation });
316
+ }, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick, onOpenPanel]);
330
317
 
331
318
  // Event subscriptions - Combined into single useEventSubscriptions call to prevent hook ordering issues
332
319
  // IMPORTANT: All event subscriptions MUST be in a single call to maintain consistent hook order between renders
333
- useEventSubscriptions({
334
- // View mode
335
- 'mark:mode-toggled': handleViewModeToggle,
336
-
320
+ useSessionEventSubscriptions(session, {
337
321
  // Annotation cache invalidation
338
322
  'mark:added': handleAnnotateAdded,
339
323
  'mark:removed': handleAnnotateRemoved,
340
324
  'mark:body-updated': handleAnnotateBodyUpdated,
341
325
 
342
- // Toolbar state
343
- 'mark:selection-changed': handleToolbarSelectionChanged,
344
- 'mark:click-changed': handleToolbarClickChanged,
345
- 'mark:shape-changed': handleToolbarShapeChanged,
346
-
347
326
  // Annotation clicks
348
327
  'browse:click': handleAnnotationClickEvent,
349
328
  });
@@ -370,7 +349,7 @@ export function ResourceViewer({
370
349
  }, [references]);
371
350
 
372
351
  return (
373
- <div ref={documentViewerRef} className="semiont-resource-viewer">
352
+ <div ref={documentViewerRef} className={`semiont-resource-viewer${inline ? ' semiont-resource-viewer--inline' : ''}`}>
374
353
  {/* Content */}
375
354
  {activeView === 'annotate' ? (
376
355
  <AnnotateView
@@ -380,9 +359,9 @@ export function ResourceViewer({
380
359
  annotations={annotationsCollection}
381
360
  uiState={uiState}
382
361
  onUIStateChange={(updates) => {
383
- if ('selectedMotivation' in updates) setSelectedMotivation(updates.selectedMotivation!);
384
- if ('selectedClick' in updates) setSelectedClick(updates.selectedClick!);
385
- if ('selectedShape' in updates) setSelectedShape(updates.selectedShape!);
362
+ if ('selectedMotivation' in updates) changeSelectionMotivation(updates.selectedMotivation ?? null);
363
+ if ('selectedClick' in updates) changeClickAction(updates.selectedClick!);
364
+ if ('selectedShape' in updates) changeShape(updates.selectedShape!);
386
365
  }}
387
366
  enableWidgets={true}
388
367
  getTargetResourceName={getTargetResourceName}
@@ -390,6 +369,10 @@ export function ResourceViewer({
390
369
  showLineNumbers={showLineNumbers}
391
370
  hoverDelayMs={hoverDelayMs}
392
371
  annotateMode={annotateMode}
372
+ onModeChange={changeAnnotateMode}
373
+ showToolbar={showToolbar}
374
+ session={session}
375
+ newAnnotationIds={newAnnotationIds}
393
376
  />
394
377
  ) : (
395
378
  <BrowseView
@@ -400,6 +383,14 @@ export function ResourceViewer({
400
383
  selectedClick={selectedClick}
401
384
  hoverDelayMs={hoverDelayMs}
402
385
  annotateMode={annotateMode}
386
+ onModeChange={changeAnnotateMode}
387
+ onClickActionChange={changeClickAction}
388
+ showToolbar={showToolbar}
389
+ session={session}
390
+ newAnnotationIds={newAnnotationIds}
391
+ onLinkClick={onLinkClick}
392
+ onReferenceHover={onReferenceHover}
393
+ inline={inline}
403
394
  />
404
395
  )}
405
396
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * EMBEDDABLE-RESOURCE-VIEWER step 1b — AnnotateView + AnnotateToolbar provider-free.
3
+ *
4
+ * AnnotateView takes `session` + `newAnnotationIds` as props; the REAL
5
+ * AnnotateToolbar (not mocked — its decoupling is the crux of 1b) takes `session`
6
+ * as a prop. CodeMirrorRenderer is mocked (heavy; already prop-based).
7
+ *
8
+ * Started RED (tsc: no `session` prop) and GREEN once step 1b lands.
9
+ */
10
+ import { describe, it, expect, vi } from 'vitest';
11
+ import { render, screen } from '@testing-library/react';
12
+ import '@testing-library/jest-dom';
13
+ import type { SemiontSession } from '@semiont/sdk';
14
+ import type { AnnotationUIState } from '../../../types/annotation-props';
15
+ import { AnnotateView } from '../AnnotateView';
16
+
17
+ vi.mock('../../CodeMirrorRenderer', () => ({ CodeMirrorRenderer: () => <div>cm-mock</div> }));
18
+
19
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
20
+ const uiState: AnnotationUIState = {
21
+ selectedMotivation: 'linking',
22
+ selectedClick: 'detail',
23
+ selectedShape: 'rectangle',
24
+ hoveredAnnotationId: null,
25
+ scrollToAnnotationId: null,
26
+ };
27
+
28
+ function fakeSession(): SemiontSession {
29
+ return {
30
+ client: {
31
+ mark: {
32
+ changeSelection: vi.fn(), changeClick: vi.fn(), changeShape: vi.fn(),
33
+ toggleMode: vi.fn(), request: vi.fn(),
34
+ },
35
+ },
36
+ subscribe: () => () => {},
37
+ } as unknown as SemiontSession;
38
+ }
39
+
40
+ describe('AnnotateView — embeddable (session prop, no providers)', () => {
41
+ it('renders (incl. the real AnnotateToolbar) fed only a session, no providers', () => {
42
+ render(
43
+ <AnnotateView resourceUri="res-1"
44
+ content="hello"
45
+ mimeType="text/plain"
46
+ annotations={emptyAnnotations}
47
+ uiState={uiState}
48
+ annotateMode={true}
49
+ session={fakeSession()}
50
+ />,
51
+ );
52
+ // The tree (real AnnotateToolbar + mocked CodeMirror) rendered without a provider.
53
+ expect(screen.getByText('cm-mock')).toBeInTheDocument();
54
+ });
55
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * EMBEDDABLE-RESOURCE-VIEWER step 1a — BrowseView renders provider-free.
3
+ *
4
+ * BrowseView takes its `session` + `newAnnotationIds` as props (not
5
+ * `useSemiont()` / `useResourceAnnotations()`), and subscribes to session-scoped
6
+ * beckon events via `session.subscribe`. `AnnotateToolbar` is mocked here — its
7
+ * own provider decoupling is step 1b — so this spec isolates BrowseView's body.
8
+ *
9
+ * Started RED (tsc: no `session` prop) and GREEN once step 1a lands.
10
+ */
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { render, screen } from '@testing-library/react';
13
+ import '@testing-library/jest-dom';
14
+ import type { SemiontSession } from '@semiont/sdk';
15
+ import { BrowseView } from '../BrowseView';
16
+
17
+ // AnnotateToolbar still calls useSemiont() (step 1b) — stub it out so this spec
18
+ // exercises only BrowseView's own provider-freedom.
19
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
20
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
21
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
22
+
23
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
24
+
25
+ function fakeSession(): SemiontSession {
26
+ return {
27
+ client: {
28
+ browse: { click: vi.fn() },
29
+ beckon: { hover: vi.fn() },
30
+ },
31
+ subscribe: () => () => {},
32
+ } as unknown as SemiontSession;
33
+ }
34
+
35
+ describe('BrowseView — embeddable (session prop, no providers)', () => {
36
+ it('renders content fed only a session, with NO providers mounted', () => {
37
+ render(
38
+ <BrowseView
39
+ content="Embeddable browse content."
40
+ mimeType="text/plain"
41
+ resourceUri="res-1"
42
+ annotations={emptyAnnotations}
43
+ annotateMode={false}
44
+ session={fakeSession()}
45
+ />,
46
+ );
47
+ expect(screen.getByText('Embeddable browse content.')).toBeInTheDocument();
48
+ });
49
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * EMBEDDABLE-VIEWER-COMPLETION Phase 2 — content-link delegation.
3
+ *
4
+ * A content link (`<a href>` rendered inside the resource content) must be
5
+ * intercepted: the viewer `preventDefault`s (never navigates on its own — an
6
+ * embedded/Electron security requirement) and delegates to `onLinkClick({href,
7
+ * event})`. With no handler, the click is still cancelled (nothing happens).
8
+ *
9
+ * Started RED (no `onLinkClick` prop) and GREEN once Phase 2 lands. Real
10
+ * react-markdown (not the mock) so the `<a>` is a genuine rendered content link.
11
+ */
12
+ import { describe, it, expect, vi } from 'vitest';
13
+ import { render, screen, fireEvent } from '@testing-library/react';
14
+ import '@testing-library/jest-dom';
15
+ import type { SemiontSession } from '@semiont/sdk';
16
+ import { BrowseView } from '../BrowseView';
17
+
18
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
19
+
20
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
21
+
22
+ function fakeSession(): SemiontSession {
23
+ return {
24
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
25
+ subscribe: () => () => {},
26
+ } as unknown as SemiontSession;
27
+ }
28
+
29
+ const MD = 'see [the link](https://example.test/x) here';
30
+
31
+ describe('BrowseView — content link delegation (Phase 2)', () => {
32
+ it('intercepts a content <a> click: preventDefault + onLinkClick(href)', () => {
33
+ const onLinkClick = vi.fn();
34
+ render(
35
+ <BrowseView
36
+ content={MD} mimeType="text/markdown" resourceUri="res-1"
37
+ annotations={emptyAnnotations} annotateMode={false}
38
+ session={fakeSession()} onLinkClick={onLinkClick}
39
+ />,
40
+ );
41
+ const anchor = screen.getByText('the link').closest('a')!;
42
+ // fireEvent.click returns false when the event was cancelled (preventDefault).
43
+ const notCancelled = fireEvent.click(anchor);
44
+ expect(notCancelled).toBe(false); // navigation blocked
45
+ expect(onLinkClick).toHaveBeenCalledTimes(1);
46
+ expect(onLinkClick.mock.calls[0]![0]).toMatchObject({ href: 'https://example.test/x' });
47
+ });
48
+
49
+ it('blocks navigation even with no handler (never navigates on its own)', () => {
50
+ render(
51
+ <BrowseView
52
+ content={MD} mimeType="text/markdown" resourceUri="res-1"
53
+ annotations={emptyAnnotations} annotateMode={false}
54
+ session={fakeSession()}
55
+ />,
56
+ );
57
+ const anchor = screen.getByText('the link').closest('a')!;
58
+ expect(fireEvent.click(anchor)).toBe(false); // still cancelled — nothing happens, but no navigation
59
+ });
60
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * BUG: browse-view-overlay-misses-async-content — the overlay effect must be
3
+ * keyed on everything it reads: the rendered content DOM AND the annotations.
4
+ *
5
+ * When annotations arrive BEFORE content (any host loading content async —
6
+ * useResourceContent), the overlay resolved ranges against the empty document
7
+ * and never re-ran when content landed: rendered text, ZERO spans, healed only
8
+ * by a remount. When content arrives first (sync-content hosts), it worked by
9
+ * accident of ordering. Both orders are pinned here, plus shrink-to-zero.
10
+ *
11
+ * REAL overlay pipeline (no annotation-overlay mocks) — react-markdown is
12
+ * mocked to identity, so the source→rendered offset map is 1:1 and spans
13
+ * paint in jsdom via the actual Range/surroundContents path.
14
+ *
15
+ * Started RED (async order → 0 spans) → GREEN with the single keyed effect.
16
+ */
17
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
18
+ import { render } from '@testing-library/react';
19
+ import '@testing-library/jest-dom';
20
+ import type { SemiontSession } from '@semiont/sdk';
21
+ import type { Annotation, AnnotationId } from '@semiont/core';
22
+ import { BrowseView } from '../BrowseView';
23
+
24
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
25
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
26
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
27
+
28
+ const CONTENT = 'hello world selectme end';
29
+
30
+ function fakeSession(): SemiontSession {
31
+ return {
32
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
33
+ subscribe: () => () => {},
34
+ } as unknown as SemiontSession;
35
+ }
36
+
37
+ /** A resolved reference over "world" (offsets 6..11) — real W3C shape. */
38
+ function referenceOnWorld(): Annotation {
39
+ return {
40
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
41
+ id: 'ref-world' as AnnotationId,
42
+ type: 'Annotation',
43
+ motivation: 'linking',
44
+ creator: { '@type': 'Person', name: 'user@example.com' },
45
+ created: '2026-07-10T00:00:00Z',
46
+ target: {
47
+ source: 'res-1',
48
+ selector: { type: 'TextPositionSelector', start: 6, end: 11 },
49
+ },
50
+ } as unknown as Annotation;
51
+ }
52
+
53
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
54
+ const withReference = { ...emptyAnnotations, references: [referenceOnWorld()] };
55
+
56
+ const baseProps = {
57
+ mimeType: 'text/plain',
58
+ resourceUri: 'res-1',
59
+ annotateMode: false,
60
+ };
61
+
62
+ const spans = (c: HTMLElement) => c.querySelectorAll('[data-annotation-id]');
63
+
64
+ describe('BrowseView — overlay vs async content arrival', () => {
65
+ beforeEach(() => vi.clearAllMocks());
66
+
67
+ it('paints spans when content arrives AFTER annotations (the async-content bug)', () => {
68
+ const session = fakeSession();
69
+ const { container, rerender } = render(
70
+ <BrowseView {...baseProps} content="" annotations={withReference} session={session} />,
71
+ );
72
+ expect(spans(container)).toHaveLength(0); // nothing to anchor on yet
73
+
74
+ // Content lands (useResourceContent resolved) — annotations unchanged.
75
+ rerender(
76
+ <BrowseView {...baseProps} content={CONTENT} annotations={withReference} session={session} />,
77
+ );
78
+
79
+ expect(spans(container)).toHaveLength(1);
80
+ expect(spans(container)[0]!.textContent).toBe('world');
81
+ expect(spans(container)[0]!.getAttribute('data-annotation-type')).toBe('reference');
82
+ });
83
+
84
+ it('paints spans when annotations arrive AFTER content (the always-working order — pinned)', () => {
85
+ const session = fakeSession();
86
+ const { container, rerender } = render(
87
+ <BrowseView {...baseProps} content={CONTENT} annotations={emptyAnnotations} session={session} />,
88
+ );
89
+ expect(spans(container)).toHaveLength(0);
90
+
91
+ rerender(
92
+ <BrowseView {...baseProps} content={CONTENT} annotations={withReference} session={session} />,
93
+ );
94
+
95
+ expect(spans(container)).toHaveLength(1);
96
+ expect(spans(container)[0]!.textContent).toBe('world');
97
+ });
98
+
99
+ it('clears spans when annotations shrink to zero', () => {
100
+ const session = fakeSession();
101
+ const { container, rerender } = render(
102
+ <BrowseView {...baseProps} content={CONTENT} annotations={withReference} session={session} />,
103
+ );
104
+ expect(spans(container)).toHaveLength(1);
105
+
106
+ rerender(
107
+ <BrowseView {...baseProps} content={CONTENT} annotations={emptyAnnotations} session={session} />,
108
+ );
109
+
110
+ expect(spans(container)).toHaveLength(0);
111
+ expect(container.textContent).toContain(CONTENT); // text restored intact
112
+ });
113
+ });