@semiont/react-ui 0.5.22 → 0.5.24

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 (197) hide show
  1. package/dist/{ar-ZHKO4XDR.js → ar-JRTKCMTF.js} +11 -4
  2. package/dist/ar-JRTKCMTF.js.map +1 -0
  3. package/dist/{bn-3BUDKAK5.js → bn-LO6EDSCC.js} +11 -4
  4. package/dist/bn-LO6EDSCC.js.map +1 -0
  5. package/dist/{chunk-PFUXQPWJ.js → chunk-4QMTEPQ3.js} +92 -60
  6. package/dist/chunk-4QMTEPQ3.js.map +1 -0
  7. package/dist/{chunk-R52YAGHK.js → chunk-5Y5JDCKC.js} +11 -4
  8. package/dist/chunk-5Y5JDCKC.js.map +1 -0
  9. package/dist/{cs-2EFEUKTW.js → cs-LCHHOR4O.js} +11 -4
  10. package/dist/cs-LCHHOR4O.js.map +1 -0
  11. package/dist/{da-SUD3Q44Q.js → da-TARGPMJR.js} +11 -4
  12. package/dist/da-TARGPMJR.js.map +1 -0
  13. package/dist/{de-6IC5QRX7.js → de-SBH4QBPJ.js} +11 -4
  14. package/dist/de-SBH4QBPJ.js.map +1 -0
  15. package/dist/{el-Q4NAXFRC.js → el-2D2OLGWB.js} +11 -4
  16. package/dist/el-2D2OLGWB.js.map +1 -0
  17. package/dist/{en-P2L4X27N.js → en-QFIEG3CA.js} +2 -2
  18. package/dist/{es-YUKZBD4W.js → es-U6YK2LX6.js} +11 -4
  19. package/dist/es-U6YK2LX6.js.map +1 -0
  20. package/dist/{fa-OJ47LILE.js → fa-S7SZE37M.js} +11 -4
  21. package/dist/fa-S7SZE37M.js.map +1 -0
  22. package/dist/{fi-QFFL5CUB.js → fi-CBL3NIXN.js} +11 -4
  23. package/dist/fi-CBL3NIXN.js.map +1 -0
  24. package/dist/{fr-BOQZZWP4.js → fr-EABUHV6S.js} +11 -4
  25. package/dist/fr-EABUHV6S.js.map +1 -0
  26. package/dist/{he-LXDY3TFA.js → he-THWD244Y.js} +11 -4
  27. package/dist/he-THWD244Y.js.map +1 -0
  28. package/dist/{hi-HOJER77V.js → hi-GHGFZJ44.js} +11 -4
  29. package/dist/hi-GHGFZJ44.js.map +1 -0
  30. package/dist/{id-I5WQ6EWD.js → id-POZOUMDW.js} +11 -4
  31. package/dist/id-POZOUMDW.js.map +1 -0
  32. package/dist/index.d.ts +269 -69
  33. package/dist/index.js +2080 -1861
  34. package/dist/index.js.map +1 -1
  35. package/dist/{it-UYH642IJ.js → it-KDYJADSH.js} +11 -4
  36. package/dist/it-KDYJADSH.js.map +1 -0
  37. package/dist/{ja-JTVWAC4L.js → ja-Z5Z6ZF3P.js} +11 -4
  38. package/dist/ja-Z5Z6ZF3P.js.map +1 -0
  39. package/dist/{ko-IHXFMOIZ.js → ko-7X3XUXRN.js} +11 -4
  40. package/dist/ko-7X3XUXRN.js.map +1 -0
  41. package/dist/{ms-JH25VFLO.js → ms-BTWEIW43.js} +11 -4
  42. package/dist/ms-BTWEIW43.js.map +1 -0
  43. package/dist/{nl-SURXW7IQ.js → nl-IS2CGF5I.js} +11 -4
  44. package/dist/nl-IS2CGF5I.js.map +1 -0
  45. package/dist/{no-HXIKQFDZ.js → no-AAMEVA26.js} +11 -4
  46. package/dist/no-AAMEVA26.js.map +1 -0
  47. package/dist/{pl-7A4KU6XK.js → pl-PVXUQYFT.js} +11 -4
  48. package/dist/pl-PVXUQYFT.js.map +1 -0
  49. package/dist/{pt-6MGYP7Q5.js → pt-3ITQ76TL.js} +11 -4
  50. package/dist/pt-3ITQ76TL.js.map +1 -0
  51. package/dist/{ro-IROCHNIA.js → ro-FRB4IIVS.js} +11 -4
  52. package/dist/ro-FRB4IIVS.js.map +1 -0
  53. package/dist/{sv-WDJD75CV.js → sv-DS3RO24M.js} +11 -4
  54. package/dist/sv-DS3RO24M.js.map +1 -0
  55. package/dist/test-utils.js +9 -3
  56. package/dist/test-utils.js.map +1 -1
  57. package/dist/{th-RIO2E6B3.js → th-AX66MWWB.js} +11 -4
  58. package/dist/th-AX66MWWB.js.map +1 -0
  59. package/dist/{tr-TZ7ERCIN.js → tr-7KTB67TT.js} +11 -4
  60. package/dist/tr-7KTB67TT.js.map +1 -0
  61. package/dist/{uk-ZFWDKLUE.js → uk-SMQS2MAQ.js} +11 -4
  62. package/dist/uk-SMQS2MAQ.js.map +1 -0
  63. package/dist/{vi-UIOSP2JG.js → vi-D3JXL2EZ.js} +11 -4
  64. package/dist/vi-D3JXL2EZ.js.map +1 -0
  65. package/dist/{zh-6R6YUY46.js → zh-S754AWFS.js} +11 -4
  66. package/dist/zh-S754AWFS.js.map +1 -0
  67. package/package.json +4 -4
  68. package/src/components/annotation-popups/JsonLdView.tsx +1 -1
  69. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +8 -2
  70. package/src/components/modals/ReferenceWizardModal.tsx +2 -1
  71. package/src/components/modals/ResourceGenerateModal.tsx +10 -4
  72. package/src/components/modals/ResourceSearchModal.tsx +3 -1
  73. package/src/components/modals/SearchModal.tsx +3 -1
  74. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +5 -0
  75. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +2 -1
  76. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +2 -1
  77. package/src/components/resource/AnnotateView.tsx +70 -138
  78. package/src/components/resource/AnnotationHistory.tsx +53 -17
  79. package/src/components/resource/BrowseView.tsx +3 -2
  80. package/src/components/resource/ResourceViewer.tsx +39 -22
  81. package/src/components/resource/__tests__/AnnotateView.registry.test.tsx +114 -0
  82. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +63 -41
  83. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +2 -1
  84. package/src/components/resource/__tests__/ResourceViewer.multi-mount-invalidation.test.tsx +140 -0
  85. package/src/components/resource/__tests__/ResourceViewer.registry.test.tsx +89 -0
  86. package/src/components/resource/annotate-renderers.tsx +155 -0
  87. package/src/components/resource/panels/JsonLdPanel.tsx +1 -1
  88. package/src/components/resource/panels/ReferencesPanel.tsx +38 -3
  89. package/src/components/resource/panels/TagEntry.tsx +5 -1
  90. package/src/components/resource/panels/TaggingPanel.tsx +6 -1
  91. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +41 -0
  92. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +8 -2
  93. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +90 -0
  94. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +2 -2
  95. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +2 -2
  96. package/src/components/resource/panels/__tests__/UnifiedAnnotationsPanel.failure.test.tsx +78 -0
  97. package/src/components/settings/SettingsPanel.tsx +6 -2
  98. package/src/components/settings/__tests__/SettingsPanel.test.tsx +28 -3
  99. package/src/features/admin-devops/__tests__/AdminDevOpsPage.test.tsx +3 -11
  100. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +0 -3
  101. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -1
  102. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -3
  103. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +1 -1
  104. package/src/features/admin-security/__tests__/AdminSecurityPage.test.tsx +0 -8
  105. package/src/features/admin-security/components/AdminSecurityPage.tsx +0 -3
  106. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +10 -9
  107. package/src/features/admin-security/state/admin-security-state-unit.ts +3 -2
  108. package/src/features/admin-users/__tests__/AdminUsersPage.test.tsx +0 -1
  109. package/src/features/admin-users/components/AdminUsersPage.tsx +0 -3
  110. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +7 -6
  111. package/src/features/admin-users/state/admin-users-state-unit.ts +3 -2
  112. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +36 -26
  113. package/src/features/auth-welcome/state/welcome-state-unit.ts +7 -2
  114. package/src/features/moderate-entity-tags/__tests__/EntityTagsPage.test.tsx +0 -2
  115. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +0 -3
  116. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +37 -12
  117. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +9 -10
  118. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +3 -5
  119. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +0 -3
  120. package/src/features/moderate-tag-schemas/__tests__/TagSchemasPage.test.tsx +3 -5
  121. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +0 -3
  122. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -1
  123. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -3
  124. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +17 -7
  125. package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -3
  126. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +33 -12
  127. package/src/features/resource-compose/state/compose-page-state-unit.ts +9 -8
  128. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +3 -2
  129. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +25 -3
  130. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +90 -25
  131. package/src/features/resource-discovery/state/discover-state-unit.ts +30 -30
  132. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +9 -5
  133. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +70 -40
  134. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +111 -8
  135. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +167 -27
  136. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +74 -9
  137. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +29 -28
  138. package/translations/ar.json +10 -3
  139. package/translations/bn.json +10 -3
  140. package/translations/cs.json +10 -3
  141. package/translations/da.json +10 -3
  142. package/translations/de.json +10 -3
  143. package/translations/el.json +10 -3
  144. package/translations/en.json +10 -3
  145. package/translations/es.json +10 -3
  146. package/translations/fa.json +10 -3
  147. package/translations/fi.json +10 -3
  148. package/translations/fr.json +10 -3
  149. package/translations/he.json +10 -3
  150. package/translations/hi.json +10 -3
  151. package/translations/id.json +10 -3
  152. package/translations/it.json +10 -3
  153. package/translations/ja.json +10 -3
  154. package/translations/ko.json +10 -3
  155. package/translations/ms.json +10 -3
  156. package/translations/nl.json +10 -3
  157. package/translations/no.json +10 -3
  158. package/translations/pl.json +10 -3
  159. package/translations/pt.json +10 -3
  160. package/translations/ro.json +10 -3
  161. package/translations/sv.json +10 -3
  162. package/translations/th.json +10 -3
  163. package/translations/tr.json +10 -3
  164. package/translations/uk.json +10 -3
  165. package/translations/vi.json +10 -3
  166. package/translations/zh.json +10 -3
  167. package/dist/ar-ZHKO4XDR.js.map +0 -1
  168. package/dist/bn-3BUDKAK5.js.map +0 -1
  169. package/dist/chunk-PFUXQPWJ.js.map +0 -1
  170. package/dist/chunk-R52YAGHK.js.map +0 -1
  171. package/dist/cs-2EFEUKTW.js.map +0 -1
  172. package/dist/da-SUD3Q44Q.js.map +0 -1
  173. package/dist/de-6IC5QRX7.js.map +0 -1
  174. package/dist/el-Q4NAXFRC.js.map +0 -1
  175. package/dist/es-YUKZBD4W.js.map +0 -1
  176. package/dist/fa-OJ47LILE.js.map +0 -1
  177. package/dist/fi-QFFL5CUB.js.map +0 -1
  178. package/dist/fr-BOQZZWP4.js.map +0 -1
  179. package/dist/he-LXDY3TFA.js.map +0 -1
  180. package/dist/hi-HOJER77V.js.map +0 -1
  181. package/dist/id-I5WQ6EWD.js.map +0 -1
  182. package/dist/it-UYH642IJ.js.map +0 -1
  183. package/dist/ja-JTVWAC4L.js.map +0 -1
  184. package/dist/ko-IHXFMOIZ.js.map +0 -1
  185. package/dist/ms-JH25VFLO.js.map +0 -1
  186. package/dist/nl-SURXW7IQ.js.map +0 -1
  187. package/dist/no-HXIKQFDZ.js.map +0 -1
  188. package/dist/pl-7A4KU6XK.js.map +0 -1
  189. package/dist/pt-6MGYP7Q5.js.map +0 -1
  190. package/dist/ro-IROCHNIA.js.map +0 -1
  191. package/dist/sv-WDJD75CV.js.map +0 -1
  192. package/dist/th-RIO2E6B3.js.map +0 -1
  193. package/dist/tr-TZ7ERCIN.js.map +0 -1
  194. package/dist/uk-ZFWDKLUE.js.map +0 -1
  195. package/dist/vi-UIOSP2JG.js.map +0 -1
  196. package/dist/zh-6R6YUY46.js.map +0 -1
  197. /package/dist/{en-P2L4X27N.js.map → en-QFIEG3CA.js.map} +0 -0
@@ -3,7 +3,9 @@
3
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 type { AnnotateMediaRenderers } from './annotate-renderers';
6
7
  import { BrowseView, type ReferenceHover } from './BrowseView';
8
+ import type { BrowseMediaRenderers } from './browse-renderers';
7
9
  import { PopupContainer } from '../annotation-popups/SharedPopupElements';
8
10
  import { JsonLdView } from '../annotation-popups/JsonLdView';
9
11
  import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components, EventMap, AnchorRect } from '@semiont/core';
@@ -16,19 +18,21 @@ import type { AnnotationsCollection } from '../../types/annotation-props';
16
18
  /**
17
19
  * ResourceViewer - Display and interact with resource content and annotations
18
20
  *
19
- * This component uses event-driven architecture for real-time updates:
20
- * - Subscribes to make-meaning events (mark:added, mark:removed, mark:body-updated)
21
- * - Automatically invalidates cache when annotations change
22
- * - No manual refetch needed - events handle cache invalidation
21
+ * Real-time updates arrive through the sdk, not through this component:
22
+ * the client's BrowseNamespace invalidates the right resource's annotation
23
+ * list on mark:added/mark:removed (payload-keyed) and patches
24
+ * mark:body-updated in place, so the live queries feeding `annotations`
25
+ * refresh on their own. The viewer subscribes only to `browse:click`
26
+ * (annotation-click routing).
23
27
  *
24
28
  * Bring-your-own-session: the `session` (SemiontSession) and the host's
25
29
  * 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
+ * Every event it subscribes to is session-scoped and reaches it via
31
+ * `session.subscribe`. Translations fall back to built-in English when no
32
+ * TranslationProvider is mounted; caching flows through `session.client.browse.*`.
29
33
  *
30
34
  * Event flow:
31
- * make-meaning → EventLog → SSE → EventBus → ResourceViewerCache invalidation
35
+ * make-meaning → EventLog → SSE → EventBus → sdk cache invalidation live queries
32
36
  */
33
37
  interface Props {
34
38
  resource: SemiontResource & { content: string };
@@ -75,6 +79,22 @@ interface Props {
75
79
  * this makes that opt-out a contract instead of a CSS override.
76
80
  */
77
81
  showToolbar?: boolean;
82
+ /**
83
+ * Override the read-only / annotating media renderers respectively; each is
84
+ * merged over its defaults by the view below.
85
+ *
86
+ * Two flat props rather than one nested object: the registries are
87
+ * separately typed (`MediaRendererProps` vs `AnnotateMediaRendererProps`)
88
+ * and nesting them would imply an interchangeability that does not exist.
89
+ *
90
+ * Forwarding them is what makes the registries reachable at all for a host
91
+ * that imports `ResourceViewer` — the documented entry point. Before this,
92
+ * reaching `BrowseView`'s `renderers` meant dropping to `BrowseView`
93
+ * directly and reimplementing the browse/annotate switching this component
94
+ * exists to provide. See .plans/ANNOTATE-RENDERER-REGISTRY.md (D5)
95
+ */
96
+ browseRenderers?: BrowseMediaRenderers;
97
+ annotateRenderers?: AnnotateMediaRenderers;
78
98
  }
79
99
 
80
100
  /**
@@ -82,9 +102,6 @@ interface Props {
82
102
  * panel opening invokes the host's `onOpenPanel` callback — the host owns
83
103
  * the panel and any `panel:open` emission.
84
104
  *
85
- * @subscribes mark:added - New annotation was added. Payload: { annotation: Annotation }
86
- * @subscribes mark:removed - Annotation was removed. Payload: { annotationId: string }
87
- * @subscribes mark:body-updated - Annotation was updated. Payload: { annotation: Annotation }
88
105
  * @subscribes browse:click - User clicked on annotation. Payload: { annotationId: string, motivation, anchorRect? }
89
106
  */
90
107
  export function ResourceViewer({
@@ -110,6 +127,8 @@ export function ResourceViewer({
110
127
  shape: shapeProp,
111
128
  onShapeChange,
112
129
  showToolbar = true,
130
+ browseRenderers,
131
+ annotateRenderers,
113
132
  }: Props) {
114
133
  const t = useTranslations('ResourceViewer');
115
134
  const documentViewerRef = useRef<HTMLDivElement>(null);
@@ -140,12 +159,13 @@ export function ResourceViewer({
140
159
  // Determine active view based on annotate mode
141
160
  const activeView = annotateMode ? 'annotate' : 'browse';
142
161
 
143
- const semiont = session?.client;
144
-
145
- // One invalidation for every annotation mutation event (added/removed/body-updated).
146
- const handleAnnotationsChanged = useCallback(() => {
147
- semiont?.browse.invalidateAnnotationList(rUri);
148
- }, [semiont, rUri]);
162
+ // NOTE deliberately NO mark:* invalidation here (MULTI-RESOURCE-SCOPE
163
+ // Step 5). The sdk's BrowseNamespace already invalidates the RIGHT
164
+ // resource's annotation list on mark:added/mark:removed (payload-keyed,
165
+ // once per event) and patches mark:body-updated in place. A viewer-side
166
+ // handler on these session-wide channels was pure duplication — and with
167
+ // N viewers on one session (the embeddable resource-per-chat-message
168
+ // pattern), an O(N) refetch amplification on every mark event.
149
169
 
150
170
  // Remaining toolbar preferences — same controlled/uncontrolled split as mode.
151
171
  const [internalSelectionMotivation, setInternalSelectionMotivation] = useState<SelectionMotivation | null>('linking');
@@ -286,11 +306,6 @@ export function ResourceViewer({
286
306
 
287
307
  // Single subscription call per file (see scripts/compliance/audit-hooks-ordering.ts).
288
308
  useSessionEventSubscriptions(session, {
289
- // Annotation cache invalidation
290
- 'mark:added': handleAnnotationsChanged,
291
- 'mark:removed': handleAnnotationsChanged,
292
- 'mark:body-updated': handleAnnotationsChanged,
293
-
294
309
  // Annotation clicks
295
310
  'browse:click': handleAnnotationClickEvent,
296
311
  });
@@ -343,6 +358,7 @@ export function ResourceViewer({
343
358
  showToolbar={showToolbar}
344
359
  session={session}
345
360
  newAnnotationIds={newAnnotationIds}
361
+ {...(annotateRenderers && { renderers: annotateRenderers })}
346
362
  />
347
363
  ) : (
348
364
  <BrowseView
@@ -361,6 +377,7 @@ export function ResourceViewer({
361
377
  onLinkClick={onLinkClick}
362
378
  onReferenceHover={onReferenceHover}
363
379
  inline={inline}
380
+ {...(browseRenderers && { renderers: browseRenderers })}
364
381
  />
365
382
  )}
366
383
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * ANNOTATE-RENDERER-REGISTRY — the annotate path's overridable media registry,
3
+ * symmetric to EMBEDDABLE-RESOURCE-VIEWER step 3's browse registry.
4
+ *
5
+ * A host that brings its own PDF stack can already replace the read-only
6
+ * renderer via `BrowseView`'s `renderers` prop; without the same seam here it
7
+ * ends up running two PDF engines depending on which mode the user is in.
8
+ *
9
+ * Started RED (tsc: no `renderers` prop on AnnotateView).
10
+ * See .plans/ANNOTATE-RENDERER-REGISTRY.md
11
+ */
12
+ import React from 'react';
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import { render, screen } from '@testing-library/react';
15
+ import '@testing-library/jest-dom';
16
+ import type { SemiontSession } from '@semiont/sdk';
17
+ import type { AnnotationUIState } from '../../../types/annotation-props';
18
+ import { AnnotateView } from '../AnnotateView';
19
+ import type { AnnotateMediaRendererProps } from '../annotate-renderers';
20
+
21
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
22
+ vi.mock('../../CodeMirrorRenderer', () => ({
23
+ CodeMirrorRenderer: () => <div data-testid="default-text">default-cm</div>,
24
+ }));
25
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({
26
+ SvgDrawingCanvas: () => <div data-testid="default-image">default-svg</div>,
27
+ }));
28
+
29
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
30
+ const uiState: AnnotationUIState = {
31
+ selectedMotivation: 'linking',
32
+ selectedClick: 'detail',
33
+ selectedShape: 'rectangle',
34
+ hoveredAnnotationId: null,
35
+ scrollToAnnotationId: null,
36
+ };
37
+
38
+ function fakeSession(): SemiontSession {
39
+ return {
40
+ client: {
41
+ mark: {
42
+ changeSelection: vi.fn(), changeClick: vi.fn(), changeShape: vi.fn(),
43
+ toggleMode: vi.fn(), request: vi.fn(),
44
+ },
45
+ },
46
+ subscribe: () => () => {},
47
+ } as unknown as SemiontSession;
48
+ }
49
+
50
+ type ViewProps = React.ComponentProps<typeof AnnotateView>;
51
+
52
+ const base = (mimeType: string): ViewProps => ({
53
+ resourceUri: 'res-1',
54
+ content: 'the-body',
55
+ mimeType,
56
+ annotations: emptyAnnotations,
57
+ uiState,
58
+ annotateMode: true,
59
+ session: fakeSession(),
60
+ });
61
+
62
+ describe('AnnotateView — media-renderer registry', () => {
63
+ it('uses a custom text renderer from the `renderers` override', () => {
64
+ const CustomText = ({ content }: AnnotateMediaRendererProps) => (
65
+ <div data-testid="custom-text">custom: {content}</div>
66
+ );
67
+
68
+ render(<AnnotateView {...base('text/plain')} renderers={{ text: CustomText }} />);
69
+
70
+ expect(screen.getByTestId('custom-text')).toHaveTextContent('custom: the-body');
71
+ expect(screen.queryByTestId('default-text')).not.toBeInTheDocument();
72
+ });
73
+
74
+ it('uses a custom pdf renderer — the realistic override, a host bringing its own viewer', () => {
75
+ const CustomPdf = ({ content }: AnnotateMediaRendererProps) => (
76
+ <div data-testid="custom-pdf">{content}</div>
77
+ );
78
+
79
+ render(<AnnotateView {...base('application/pdf')} renderers={{ pdf: CustomPdf }} />);
80
+
81
+ expect(screen.getByTestId('custom-pdf')).toBeInTheDocument();
82
+ });
83
+
84
+ it('merges over the defaults rather than replacing them: overriding text leaves image alone', () => {
85
+ const CustomText = () => <div data-testid="custom-text" />;
86
+
87
+ render(<AnnotateView {...base('image/png')} renderers={{ text: CustomText }} />);
88
+
89
+ expect(screen.getByTestId('default-image')).toBeInTheDocument();
90
+ expect(screen.queryByTestId('custom-text')).not.toBeInTheDocument();
91
+ });
92
+
93
+ it('a mode in neither the defaults nor the override falls back to unsupported, download intact', () => {
94
+ // Deliberately unlike the browse side, where a registry miss falls through
95
+ // to the text renderer harmlessly. Annotating an unknown type is not
96
+ // harmless, so the miss must stay explicit.
97
+ const { container } = render(<AnnotateView {...base('application/octet-stream')} />);
98
+
99
+ expect(container.querySelector('[data-mime-type="unsupported"]')).toBeInTheDocument();
100
+ expect(screen.getByText(/Annotation not supported/)).toBeInTheDocument();
101
+ expect(screen.getByText('Download File')).toBeInTheDocument();
102
+ });
103
+
104
+ it('renders one toolbar and one content shell, whatever the render mode', () => {
105
+ // The collapse this plan is really about: three branches used to repeat
106
+ // the wrapper, the toolbar block and the content div verbatim.
107
+ for (const mimeType of ['text/plain', 'application/pdf', 'image/png']) {
108
+ const { container, unmount } = render(<AnnotateView {...base(mimeType)} />);
109
+ expect(container.querySelectorAll('.semiont-annotate-view')).toHaveLength(1);
110
+ expect(container.querySelectorAll('.semiont-annotate-view__content')).toHaveLength(1);
111
+ unmount();
112
+ }
113
+ });
114
+ });
@@ -4,8 +4,6 @@ import { screen } from '@testing-library/react';
4
4
  import '@testing-library/jest-dom';
5
5
  import { AnnotationHistory } from '../AnnotationHistory';
6
6
  import { renderWithProviders } from '../../../test-utils';
7
- import type { ResourceId } from '@semiont/core';
8
- import { BehaviorSubject } from 'rxjs';
9
7
 
10
8
  // Mock @semiont/core - must use importOriginal to preserve EventBus etc.
11
9
  vi.mock('@semiont/core', async (importOriginal) => {
@@ -22,33 +20,14 @@ vi.mock('../../../contexts/TranslationContext', () => ({
22
20
  const translations: Record<string, string> = {
23
21
  history: 'History',
24
22
  loading: 'Loading...',
23
+ failed: 'Could not load history.',
24
+ retry: 'Try again',
25
25
  };
26
26
  return translations[key] || key;
27
27
  }),
28
28
  TranslationProvider: ({ children }: { children: React.ReactNode }) => children,
29
29
  }));
30
30
 
31
- const eventsSubject = new BehaviorSubject<any[] | undefined>(undefined);
32
- const annotationsSubject = new BehaviorSubject<any[] | undefined>(undefined);
33
-
34
- const stableMockClient = {
35
- browse: {
36
- events: () => eventsSubject.asObservable(),
37
- annotations: () => annotationsSubject.asObservable(),
38
- },
39
- };
40
- const stableMockSession = { client: stableMockClient };
41
- const stableActiveSession$ = new BehaviorSubject<any>(stableMockSession);
42
- const stableMockBrowser = { activeSession$: stableActiveSession$ };
43
-
44
- vi.mock('../../../session/SemiontProvider', async (importOriginal) => {
45
- const actual = await importOriginal<typeof import('../../../session/SemiontProvider')>();
46
- return {
47
- ...actual,
48
- useSemiont: () => stableMockBrowser,
49
- };
50
- });
51
-
52
31
  // Mock HistoryEvent to avoid deep rendering and mocking all its dependencies
53
32
  const MockHistoryEvent = vi.fn(({ event }: any) => (
54
33
  <div data-testid={`history-event-${event.id}`}>
@@ -63,8 +42,6 @@ vi.mock('../HistoryEvent', () => ({
63
42
  import { getAnnotationUriFromEvent } from '@semiont/core';
64
43
  const mockGetAnnotationUri = getAnnotationUriFromEvent as ReturnType<typeof vi.fn>;
65
44
 
66
- const testRId = 'res-1' as ResourceId;
67
-
68
45
  /** Returns flat StoredEventResponse shape (matches API response) */
69
46
  function makeStoredEvent(id: string, type: string, seq: number, overrides: Record<string, any> = {}): any {
70
47
  return {
@@ -91,16 +68,13 @@ describe('AnnotationHistory', () => {
91
68
  beforeEach(() => {
92
69
  vi.clearAllMocks();
93
70
  mockGetAnnotationUri.mockReturnValue(null);
94
- eventsSubject.next(undefined);
95
- annotationsSubject.next([]);
96
71
  });
97
72
 
98
73
  it('renders loading state', () => {
99
- eventsSubject.next(undefined);
100
-
101
74
  renderWithProviders(
102
75
  <AnnotationHistory
103
- rUri={testRId}
76
+ events={[]}
77
+ eventsLoading
104
78
  Link={MockLink}
105
79
  routes={mockRoutes}
106
80
  />
@@ -110,12 +84,64 @@ describe('AnnotationHistory', () => {
110
84
  expect(screen.getByText('Loading...')).toBeInTheDocument();
111
85
  });
112
86
 
113
- it('renders null when no events', () => {
114
- eventsSubject.next([]);
87
+ describe('terminal load failure', () => {
88
+ // The panel used to derive `loading` from `eventsData === undefined` and
89
+ // hard-code `const error = false`, so a failed load sat on "Loading..."
90
+ // for ever and the error branch below it was unreachable.
91
+ // See .plans/PANEL-FAILURE-STATES.md
92
+
93
+ it('reports the failure instead of staying on the loading text', () => {
94
+ renderWithProviders(
95
+ <AnnotationHistory
96
+ events={[]}
97
+ eventsLoading={false}
98
+ eventsError={new Error('Resource not found')}
99
+ Link={MockLink}
100
+ routes={mockRoutes}
101
+ />
102
+ );
103
+
104
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
105
+ expect(screen.getByText('Could not load history.')).toBeInTheDocument();
106
+ });
107
+
108
+ it('offers a retry that calls back', async () => {
109
+ const onRetryEvents = vi.fn();
110
+ const { default: userEvent } = await import('@testing-library/user-event');
111
+
112
+ renderWithProviders(
113
+ <AnnotationHistory
114
+ events={[]}
115
+ eventsError={new Error('boom')}
116
+ onRetryEvents={onRetryEvents}
117
+ Link={MockLink}
118
+ routes={mockRoutes}
119
+ />
120
+ );
121
+
122
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
123
+ expect(onRetryEvents).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it('the failure beats the loading state — a dead request is not still loading', () => {
127
+ renderWithProviders(
128
+ <AnnotationHistory
129
+ events={[]}
130
+ eventsLoading
131
+ eventsError={new Error('boom')}
132
+ Link={MockLink}
133
+ routes={mockRoutes}
134
+ />
135
+ );
136
+
137
+ expect(screen.getByText('Could not load history.')).toBeInTheDocument();
138
+ });
139
+ });
115
140
 
141
+ it('renders null when no events', () => {
116
142
  const { container } = renderWithProviders(
117
143
  <AnnotationHistory
118
- rUri={testRId}
144
+ events={[]}
119
145
  Link={MockLink}
120
146
  routes={mockRoutes}
121
147
  />
@@ -130,11 +156,10 @@ describe('AnnotationHistory', () => {
130
156
  makeStoredEvent('e1', 'mark:added', 1),
131
157
  makeStoredEvent('e2', 'mark:added', 2),
132
158
  ];
133
- eventsSubject.next(events);
134
159
 
135
160
  renderWithProviders(
136
161
  <AnnotationHistory
137
- rUri={testRId}
162
+ events={events}
138
163
  Link={MockLink}
139
164
  routes={mockRoutes}
140
165
  />
@@ -155,11 +180,10 @@ describe('AnnotationHistory', () => {
155
180
  makeStoredEvent('e4', 'job:completed', 4),
156
181
  makeStoredEvent('e5', 'mark:body-updated', 5),
157
182
  ];
158
- eventsSubject.next(events);
159
183
 
160
184
  renderWithProviders(
161
185
  <AnnotationHistory
162
- rUri={testRId}
186
+ events={events}
163
187
  Link={MockLink}
164
188
  routes={mockRoutes}
165
189
  />
@@ -173,12 +197,11 @@ describe('AnnotationHistory', () => {
173
197
 
174
198
  it('passes isRelated when hovered annotation matches event', () => {
175
199
  const events = [makeStoredEvent('e1', 'mark:added', 1)];
176
- eventsSubject.next(events);
177
200
  mockGetAnnotationUri.mockReturnValue('ann-1');
178
201
 
179
202
  renderWithProviders(
180
203
  <AnnotationHistory
181
- rUri={testRId}
204
+ events={events}
182
205
  hoveredAnnotationId="ann-1"
183
206
  Link={MockLink}
184
207
  routes={mockRoutes}
@@ -192,11 +215,10 @@ describe('AnnotationHistory', () => {
192
215
 
193
216
  it('renders history panel structure with title and list', () => {
194
217
  const events = [makeStoredEvent('e1', 'mark:added', 1)];
195
- eventsSubject.next(events);
196
218
 
197
219
  const { container } = renderWithProviders(
198
220
  <AnnotationHistory
199
- rUri={testRId}
221
+ events={events}
200
222
  Link={MockLink}
201
223
  routes={mockRoutes}
202
224
  />
@@ -13,6 +13,7 @@
13
13
  * `browse.resource` observable so the test controls resolve timing.
14
14
  */
15
15
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import { asStates } from '../../../__tests__/test-client';
16
17
  import { render, fireEvent } from '@testing-library/react';
17
18
  import '@testing-library/jest-dom';
18
19
  import { BehaviorSubject } from 'rxjs';
@@ -53,7 +54,7 @@ const stubRef = makeAnnotation('ref-stub', []);
53
54
  function setup(onReferenceHover?: (h: unknown) => void) {
54
55
  const referent$ = new BehaviorSubject<ResourceDescriptor | undefined>(undefined);
55
56
  const beckonHover = vi.fn();
56
- const browseResource = vi.fn(() => referent$);
57
+ const browseResource = vi.fn(() => asStates(referent$));
57
58
  const session = {
58
59
  client: {
59
60
  browse: { click: vi.fn(), resource: browseResource },
@@ -0,0 +1,140 @@
1
+ /**
2
+ * MULTI-RESOURCE-SCOPE Step 5 — the multi-mount invalidation contract.
3
+ *
4
+ * With N viewers mounted on one session (the embeddable "resource per chat
5
+ * message" pattern — 40–60 concurrent in the surveyed consumer), a mark
6
+ * event must cost ONE invalidation, of the right resource's list — not N.
7
+ *
8
+ * The invalidation belongs to the sdk: `BrowseNamespace.subscribeToEvents`
9
+ * already invalidates payload-keyed on mark:added/mark:removed and patches
10
+ * mark:body-updated in place. `ResourceViewer` therefore subscribes to NO
11
+ * mark:* channels at all — a viewer-side handler was pure duplication, and
12
+ * unfiltered it was an O(N) refetch amplification. This suite pins the
13
+ * system behavior over the real client with two mounted viewers:
14
+ * exactly one list invalidation per mark event, zero for body updates.
15
+ */
16
+ import { describe, it, expect, vi } from 'vitest';
17
+ import { render, act, waitFor } from '@testing-library/react';
18
+ import '@testing-library/jest-dom';
19
+ import type { ResourceDescriptor as SemiontResource, ResourceId, StoredEvent, EventOfType, UserId, EventMetadata, Annotation } from '@semiont/core';
20
+ import { annotationId, resourceId as makeResourceId } from '@semiont/core';
21
+ import { createTestSemiontWrapper } from '../../../test-utils';
22
+ import { ResourceViewer } from '../ResourceViewer';
23
+
24
+ const TEST_USER = 'did:web:test:users:test' as UserId;
25
+
26
+ function makeResource(id: string): SemiontResource & { content: string } {
27
+ return {
28
+ '@context': 'https://www.w3.org/ns/activitystreams',
29
+ '@id': id as ResourceId,
30
+ name: `Doc ${id}`,
31
+ created: '2024-01-01T00:00:00Z',
32
+ entityTypes: [],
33
+ archived: false,
34
+ representations: [{ mediaType: 'text/plain', byteSize: 10 }],
35
+ content: `Content of ${id}.`,
36
+ };
37
+ }
38
+
39
+ function mockAnnotation(rIdStr: string): Annotation {
40
+ return {
41
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
42
+ type: 'Annotation',
43
+ id: annotationId('ann-1'),
44
+ motivation: 'commenting',
45
+ created: '2026-01-01T00:00:00Z',
46
+ target: { source: rIdStr },
47
+ body: [{ type: 'TextualBody', value: 'c', purpose: 'commenting' }],
48
+ };
49
+ }
50
+
51
+ function fakeMarkAdded(rIdStr: string): StoredEvent<EventOfType<'mark:added'>> {
52
+ return {
53
+ id: 'evt-1',
54
+ type: 'mark:added',
55
+ resourceId: makeResourceId(rIdStr),
56
+ userId: TEST_USER,
57
+ version: 1,
58
+ timestamp: '2026-01-01T00:00:00Z',
59
+ payload: { annotation: mockAnnotation(rIdStr) },
60
+ metadata: { sequenceNumber: 1 } as EventMetadata,
61
+ };
62
+ }
63
+
64
+ const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
65
+
66
+ function mountTwoViewers() {
67
+ const wrapper = createTestSemiontWrapper();
68
+ const spy = vi.spyOn(wrapper.client.browse, 'invalidateAnnotationList');
69
+ render(
70
+ <>
71
+ <ResourceViewer
72
+ session={wrapper.session}
73
+ resource={makeResource('res-A')}
74
+ annotations={annotations}
75
+ onOpenResource={vi.fn()}
76
+ onOpenPanel={vi.fn()}
77
+ />
78
+ <ResourceViewer
79
+ session={wrapper.session}
80
+ resource={makeResource('res-B')}
81
+ annotations={annotations}
82
+ onOpenResource={vi.fn()}
83
+ onOpenPanel={vi.fn()}
84
+ />
85
+ </>,
86
+ );
87
+ spy.mockClear(); // only the event-driven invalidations count
88
+ return { ...wrapper, spy };
89
+ }
90
+
91
+ const flush = () => act(() => new Promise<void>((r) => setTimeout(r, 0)));
92
+
93
+ describe('ResourceViewer — multi-mount invalidation is per-resource and O(1), not O(N)', () => {
94
+ it("a mark:added for res-A costs exactly ONE invalidation — res-A's, from the sdk", async () => {
95
+ const { eventBus, spy } = mountTwoViewers();
96
+
97
+ act(() => {
98
+ eventBus.get('mark:added').next(fakeMarkAdded('res-A'));
99
+ });
100
+
101
+ await waitFor(() => expect(spy).toHaveBeenCalledWith('res-A'));
102
+ expect(spy).not.toHaveBeenCalledWith('res-B');
103
+ expect(spy).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ it("a mark:removed for res-B costs exactly ONE invalidation — res-B's", async () => {
107
+ const { eventBus, spy } = mountTwoViewers();
108
+
109
+ act(() => {
110
+ eventBus.get('mark:removed').next({
111
+ id: 'evt-2',
112
+ type: 'mark:removed',
113
+ resourceId: makeResourceId('res-B'),
114
+ userId: TEST_USER,
115
+ version: 1,
116
+ timestamp: '2026-01-01T00:00:00Z',
117
+ payload: { annotationId: annotationId('ann-1') },
118
+ metadata: { sequenceNumber: 2 } as EventMetadata,
119
+ });
120
+ });
121
+
122
+ await waitFor(() => expect(spy).toHaveBeenCalledWith('res-B'));
123
+ expect(spy).not.toHaveBeenCalledWith('res-A');
124
+ expect(spy).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it('a mark:body-updated costs ZERO list invalidations — the sdk patches in place', async () => {
128
+ const { eventBus, spy } = mountTwoViewers();
129
+
130
+ act(() => {
131
+ eventBus.get('mark:body-updated').next({
132
+ resourceId: makeResourceId('res-A'),
133
+ annotation: mockAnnotation('res-A'),
134
+ } as never);
135
+ });
136
+ await flush();
137
+
138
+ expect(spy).not.toHaveBeenCalled();
139
+ });
140
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The media-renderer registries must be reachable through `ResourceViewer` —
3
+ * the documented consumer entry point.
4
+ *
5
+ * `BrowseView` has taken a `renderers` override since EMBEDDABLE-RESOURCE-VIEWER
6
+ * step 3, and `AnnotateView` since 3b, but `ResourceViewer` forwarded neither.
7
+ * A host importing it (what docs/ANNOTATIONS.md tells consumers to do, and what
8
+ * the embeddable-surface packaging gate checks) therefore had no way to reach
9
+ * the extension point: it would have to drop to `BrowseView` directly and
10
+ * reimplement the browse/annotate switching `ResourceViewer` exists to provide.
11
+ *
12
+ * Started RED (tsc: no `browseRenderers` / `annotateRenderers` props).
13
+ * See .plans/ANNOTATE-RENDERER-REGISTRY.md (D5)
14
+ */
15
+ import React from 'react';
16
+ import { describe, it, expect, vi } from 'vitest';
17
+ import { render, screen } from '@testing-library/react';
18
+ import '@testing-library/jest-dom';
19
+ import type { SemiontSession } from '@semiont/sdk';
20
+ import { ResourceViewer } from '../ResourceViewer';
21
+ import type { MediaRendererProps } from '../browse-renderers';
22
+ import type { AnnotateMediaRendererProps } from '../annotate-renderers';
23
+
24
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
25
+ vi.mock('../../Toolbar', () => ({ Toolbar: () => null }));
26
+ vi.mock('../../CodeMirrorRenderer', () => ({
27
+ CodeMirrorRenderer: () => <div data-testid="default-text">default-cm</div>,
28
+ }));
29
+
30
+ function fakeSession(): SemiontSession {
31
+ return {
32
+ client: {
33
+ browse: { click: vi.fn() },
34
+ beckon: { hover: vi.fn() },
35
+ mark: {
36
+ changeSelection: vi.fn(), changeClick: vi.fn(), changeShape: vi.fn(),
37
+ toggleMode: vi.fn(), request: vi.fn(),
38
+ },
39
+ },
40
+ subscribe: () => () => {},
41
+ } as unknown as SemiontSession;
42
+ }
43
+
44
+ type ViewerProps = React.ComponentProps<typeof ResourceViewer>;
45
+
46
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
47
+
48
+ const base = (): ViewerProps => ({
49
+ resource: {
50
+ '@id': 'res-1',
51
+ name: 'Doc',
52
+ content: 'the-body',
53
+ representations: [{ mediaType: 'text/plain' }],
54
+ },
55
+ annotations: emptyAnnotations,
56
+ session: fakeSession(),
57
+ } as unknown as ViewerProps);
58
+
59
+ describe('ResourceViewer — media-renderer registries are reachable', () => {
60
+ it('forwards browseRenderers to the read-only path', () => {
61
+ const CustomText = ({ content }: MediaRendererProps) => (
62
+ <div data-testid="custom-browse">{content}</div>
63
+ );
64
+
65
+ render(<ResourceViewer {...base()} annotateMode={false} browseRenderers={{ text: CustomText }} />);
66
+
67
+ expect(screen.getByTestId('custom-browse')).toHaveTextContent('the-body');
68
+ });
69
+
70
+ it('forwards annotateRenderers to the annotating path', () => {
71
+ const CustomText = ({ content }: AnnotateMediaRendererProps) => (
72
+ <div data-testid="custom-annotate">{content}</div>
73
+ );
74
+
75
+ render(<ResourceViewer {...base()} annotateMode annotateRenderers={{ text: CustomText }} />);
76
+
77
+ expect(screen.getByTestId('custom-annotate')).toHaveTextContent('the-body');
78
+ expect(screen.queryByTestId('default-text')).not.toBeInTheDocument();
79
+ });
80
+
81
+ it('without overrides the defaults still render, in both modes', () => {
82
+ const { unmount } = render(<ResourceViewer {...base()} annotateMode />);
83
+ expect(screen.getByTestId('default-text')).toBeInTheDocument();
84
+ unmount();
85
+
86
+ render(<ResourceViewer {...base()} annotateMode={false} />);
87
+ expect(screen.getByText(/the-body/)).toBeInTheDocument();
88
+ });
89
+ });