@semiont/react-ui 0.5.26 → 0.5.28

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 (242) hide show
  1. package/README.md +28 -4
  2. package/dist/{PdfAnnotationCanvas.client-6ZSF6ZTJ.js → PdfAnnotationCanvas.client-C2IGG4SW.js} +33 -6
  3. package/dist/{PdfAnnotationCanvas.client-JCJMXWWJ.css → PdfAnnotationCanvas.client-TGDPPGXJ.css} +6 -1
  4. package/dist/{ar-YTUUGK2O.js → ar-FABPTJVB.js} +88 -26
  5. package/dist/{bn-ASGNGPMD.js → bn-LKP6BDMH.js} +88 -26
  6. package/dist/{chunk-L3WKRWP3.js → chunk-2GOHTTHW.js} +88 -26
  7. package/dist/{chunk-OBQOZCJE.js → chunk-2YHEE63S.js} +31 -31
  8. package/dist/{cs-WMYVAOZY.js → cs-YQYNWHRG.js} +88 -26
  9. package/dist/{da-FSHHN4D6.js → da-QHZOYKRY.js} +88 -26
  10. package/dist/{de-FJFO3TPT.js → de-GKA7RD7J.js} +88 -26
  11. package/dist/{el-XOH4QIAG.js → el-45T257AL.js} +88 -26
  12. package/dist/{en-3FCBW3YD.js → en-RVW5EQ66.js} +4 -2
  13. package/dist/{es-HDX4QI7E.js → es-AVER7RU4.js} +88 -26
  14. package/dist/{fa-DJ6WTFTJ.js → fa-K3VPK3O2.js} +88 -26
  15. package/dist/{fi-4S7H7W7U.js → fi-5NEKF7EB.js} +88 -26
  16. package/dist/{fr-6UJSYHXC.js → fr-FUMNDLBZ.js} +88 -26
  17. package/dist/{he-ZZJOHJX2.js → he-3JGNA3PR.js} +88 -26
  18. package/dist/{hi-HZJTAW7U.js → hi-IJY375DF.js} +88 -26
  19. package/dist/{id-E3I6ILEM.js → id-LWHKJ7ZD.js} +88 -26
  20. package/dist/index.css +380 -524
  21. package/dist/index.d.ts +318 -192
  22. package/dist/index.js +2960 -2421
  23. package/dist/integrations/css-modules-helper.js +1 -3
  24. package/dist/{it-ULAPAD7U.js → it-EBVOV4RW.js} +88 -26
  25. package/dist/{ja-SAHZQ4CZ.js → ja-KWRIX2NF.js} +88 -26
  26. package/dist/{ko-DFU2ADQ4.js → ko-L5CJMFDS.js} +88 -26
  27. package/dist/{ms-NWL76XNT.js → ms-LSNMJDR2.js} +88 -26
  28. package/dist/{nl-ATCM76WN.js → nl-ZB32MB7U.js} +88 -26
  29. package/dist/{no-UIAQ5RJF.js → no-4GTNY7M5.js} +88 -26
  30. package/dist/{pl-NOECQDXD.js → pl-LUHBLTHB.js} +88 -26
  31. package/dist/{pt-SVWIIUPC.js → pt-C4H3UNQ6.js} +88 -26
  32. package/dist/{ro-NR2DRTZG.js → ro-GGITS5V4.js} +88 -26
  33. package/dist/{sv-STVJ37JP.js → sv-T56RTLBP.js} +88 -26
  34. package/dist/test-utils.css +1 -1
  35. package/dist/test-utils.d.ts +1 -1
  36. package/dist/test-utils.js +2 -2
  37. package/dist/{th-UWQR4K54.js → th-22X2IWCB.js} +88 -26
  38. package/dist/{tr-WNXZSD27.js → tr-77TFG62H.js} +88 -26
  39. package/dist/{uk-SJRMB47Y.js → uk-MIIL6NXT.js} +88 -26
  40. package/dist/{vi-TMRQFES2.js → vi-7JYOIGNV.js} +88 -26
  41. package/dist/{zh-QOL3LYEM.js → zh-ID6DRYU4.js} +88 -26
  42. package/package.json +11 -10
  43. package/src/components/AssistProgress.tsx +178 -134
  44. package/src/components/CodeMirrorRenderer.tsx +8 -8
  45. package/src/components/{EntityFoundLog.tsx → ItemFoundLog.tsx} +12 -7
  46. package/src/components/ProtectedErrorBoundary.css +3 -1
  47. package/src/components/StatusDisplay.css +1 -1
  48. package/src/components/Toast.css +3 -1
  49. package/src/components/__tests__/AssistProgress.test.tsx +268 -141
  50. package/src/components/annotation/annotation-entries.css +0 -4
  51. package/src/components/annotation/references.css +0 -4
  52. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  53. package/src/components/image-annotation/SvgDrawingCanvas.tsx +1 -1
  54. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +7 -9
  55. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +1 -2
  56. package/src/components/layout/Header.css +0 -4
  57. package/src/components/layout/LeftSidebar.css +0 -4
  58. package/src/components/modals/ComposeStep.tsx +218 -0
  59. package/src/components/modals/ConfigureGatherStep.tsx +10 -18
  60. package/src/components/modals/ConfigureGenerationStep.tsx +131 -54
  61. package/src/components/modals/ConfigureSearchStep.tsx +42 -40
  62. package/src/components/modals/ContextSummary.tsx +185 -72
  63. package/src/components/modals/CorpusPane.tsx +75 -0
  64. package/src/components/modals/DiscardPrompt.tsx +38 -0
  65. package/src/components/modals/GatherContextStep.tsx +237 -107
  66. package/src/components/modals/ReferenceWizardModal.tsx +287 -60
  67. package/src/components/modals/ResourceGenerateModal.tsx +233 -109
  68. package/src/components/modals/SearchModal.css +335 -111
  69. package/src/components/modals/SearchModal.tsx +1 -1
  70. package/src/components/modals/SearchResultsStep.tsx +63 -88
  71. package/src/components/modals/WizardFooter.tsx +69 -0
  72. package/src/components/modals/__tests__/ComposeStep.test.tsx +152 -0
  73. package/src/components/modals/__tests__/ConfigureGatherStep.test.tsx +14 -26
  74. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +290 -0
  75. package/src/components/modals/__tests__/ConfigureSearchStep.test.tsx +95 -0
  76. package/src/components/modals/__tests__/GatherContextStep.resource.test.tsx +463 -25
  77. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +635 -0
  78. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +277 -85
  79. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +6 -2
  80. package/src/components/modals/__tests__/SearchResultsStep.test.tsx +61 -0
  81. package/src/components/modals/__tests__/WizardFooter.test.tsx +344 -0
  82. package/src/components/modals/modals.css +16 -42
  83. package/src/components/navigation/NavigationTabs.css +0 -20
  84. package/src/components/pdf-annotation/PdfAnnotationCanvas.css +21 -2
  85. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +36 -3
  86. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +41 -6
  87. package/src/components/resource/AnnotateView.tsx +19 -8
  88. package/src/components/resource/AnnotationHistory.tsx +2 -0
  89. package/src/components/resource/BrowseView.tsx +20 -9
  90. package/src/components/resource/ResourceViewer.tsx +27 -28
  91. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +1 -1
  92. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +50 -0
  93. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +2 -0
  94. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +1 -1
  95. package/src/components/resource/__tests__/BrowseView.test.tsx +38 -12
  96. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +47 -3
  97. package/src/components/resource/annotate-renderers.tsx +3 -3
  98. package/src/components/resource/event-formatting.ts +0 -3
  99. package/src/components/resource/panels/AssessmentEntry.tsx +1 -1
  100. package/src/components/resource/panels/AssessmentPanel.css +5 -6
  101. package/src/components/resource/panels/AssistSection.css +95 -263
  102. package/src/components/resource/panels/AssistSection.tsx +4 -4
  103. package/src/components/resource/panels/AssistShell.tsx +12 -4
  104. package/src/components/resource/panels/CollaborationPanel.css +47 -22
  105. package/src/components/resource/panels/CollaborationPanel.tsx +93 -2
  106. package/src/components/resource/panels/CommentEntry.tsx +1 -1
  107. package/src/components/resource/panels/CommentsPanel.css +0 -16
  108. package/src/components/resource/panels/HighlightEntry.tsx +1 -1
  109. package/src/components/resource/panels/HighlightPanel.css +0 -7
  110. package/src/components/resource/panels/JsonLdPanel.css +0 -4
  111. package/src/components/resource/panels/ReferenceEntry.tsx +10 -3
  112. package/src/components/resource/panels/ReferencesPanel.css +0 -12
  113. package/src/components/resource/panels/ReferencesPanel.tsx +16 -20
  114. package/src/components/resource/panels/ResourceInfoPanel.css +0 -32
  115. package/src/components/resource/panels/ResourceInfoPanel.tsx +89 -20
  116. package/src/components/resource/panels/StatisticsPanel.css +0 -20
  117. package/src/components/resource/panels/TagEntry.tsx +1 -1
  118. package/src/components/resource/panels/TaggingPanel.css +0 -4
  119. package/src/components/resource/panels/TaggingPanel.tsx +2 -10
  120. package/src/components/resource/panels/UnifiedAnnotationsPanel.css +0 -4
  121. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +7 -2
  122. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +0 -1
  123. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +48 -50
  124. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +11 -6
  125. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +89 -0
  126. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +1 -2
  127. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +0 -1
  128. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +44 -36
  129. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +41 -1
  130. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +1 -1
  131. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +67 -54
  132. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +185 -7
  133. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +0 -1
  134. package/src/components/settings/SettingsPanel.css +0 -4
  135. package/src/features/auth/auth.css +2 -26
  136. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +0 -65
  137. package/src/features/resource-compose/components/ResourceComposePage.tsx +17 -130
  138. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +1 -76
  139. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -48
  140. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +55 -0
  141. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +29 -0
  142. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +53 -3
  143. package/src/features/resource-discovery/state/discover-state-unit.ts +29 -8
  144. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +71 -15
  145. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +166 -71
  146. package/src/features/resource-viewer/hooks/__tests__/useResourceViewedReport.test.tsx +63 -0
  147. package/src/features/resource-viewer/hooks/useResourceViewedReport.ts +33 -0
  148. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +2 -2
  149. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +1 -1
  150. package/src/styles/core/buttons.css +3 -1
  151. package/src/styles/core/forms.css +35 -29
  152. package/src/styles/core/indicators.css +0 -4
  153. package/src/styles/core/inputs.css +0 -4
  154. package/src/styles/core/progress.css +2 -34
  155. package/src/styles/core/sliders.css +0 -4
  156. package/src/styles/core/toggles.css +0 -4
  157. package/src/styles/features/resource-discovery.css +13 -0
  158. package/src/styles/features/resource-viewer.css +0 -42
  159. package/src/styles/features/resource.css +0 -4
  160. package/src/styles/motivations/motivation-assessment.css +2 -2
  161. package/src/styles/panels/history-panel.css +0 -40
  162. package/src/styles/panels/user-panel.css +0 -3
  163. package/src/styles/patterns/panel-helpers.css +0 -27
  164. package/src/styles/patterns/panel-sections.css +0 -59
  165. package/src/styles/patterns/panels-base.css +0 -32
  166. package/translations/ar.json +84 -24
  167. package/translations/bn.json +84 -24
  168. package/translations/cs.json +84 -24
  169. package/translations/da.json +84 -24
  170. package/translations/de.json +84 -24
  171. package/translations/el.json +84 -24
  172. package/translations/en.json +84 -24
  173. package/translations/es.json +84 -24
  174. package/translations/fa.json +84 -24
  175. package/translations/fi.json +84 -24
  176. package/translations/fr.json +84 -24
  177. package/translations/he.json +84 -24
  178. package/translations/hi.json +84 -24
  179. package/translations/id.json +84 -24
  180. package/translations/it.json +84 -24
  181. package/translations/ja.json +84 -24
  182. package/translations/ko.json +84 -24
  183. package/translations/ms.json +84 -24
  184. package/translations/nl.json +84 -24
  185. package/translations/no.json +84 -24
  186. package/translations/pl.json +84 -24
  187. package/translations/pt.json +84 -24
  188. package/translations/ro.json +84 -24
  189. package/translations/sv.json +84 -24
  190. package/translations/th.json +84 -24
  191. package/translations/tr.json +84 -24
  192. package/translations/uk.json +84 -24
  193. package/translations/vi.json +84 -24
  194. package/translations/zh.json +84 -24
  195. package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js.map +0 -1
  196. package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css.map +0 -1
  197. package/dist/ar-YTUUGK2O.js.map +0 -1
  198. package/dist/bn-ASGNGPMD.js.map +0 -1
  199. package/dist/chunk-BTUG3VJJ.js.map +0 -1
  200. package/dist/chunk-L3WKRWP3.js.map +0 -1
  201. package/dist/chunk-O23TLLXW.js.map +0 -1
  202. package/dist/chunk-OBQOZCJE.js.map +0 -1
  203. package/dist/chunk-QT7LYM72.js.map +0 -1
  204. package/dist/chunk-VXASXU4K.js.map +0 -1
  205. package/dist/chunk-XUDKYAVC.js +0 -21
  206. package/dist/chunk-XUDKYAVC.js.map +0 -1
  207. package/dist/cs-WMYVAOZY.js.map +0 -1
  208. package/dist/da-FSHHN4D6.js.map +0 -1
  209. package/dist/de-FJFO3TPT.js.map +0 -1
  210. package/dist/el-XOH4QIAG.js.map +0 -1
  211. package/dist/en-3FCBW3YD.js.map +0 -1
  212. package/dist/es-HDX4QI7E.js.map +0 -1
  213. package/dist/fa-DJ6WTFTJ.js.map +0 -1
  214. package/dist/fi-4S7H7W7U.js.map +0 -1
  215. package/dist/fr-6UJSYHXC.js.map +0 -1
  216. package/dist/he-ZZJOHJX2.js.map +0 -1
  217. package/dist/hi-HZJTAW7U.js.map +0 -1
  218. package/dist/id-E3I6ILEM.js.map +0 -1
  219. package/dist/index.css.map +0 -1
  220. package/dist/index.js.map +0 -1
  221. package/dist/integrations/css-modules-helper.js.map +0 -1
  222. package/dist/integrations/styled-components-theme.js.map +0 -1
  223. package/dist/it-ULAPAD7U.js.map +0 -1
  224. package/dist/ja-SAHZQ4CZ.js.map +0 -1
  225. package/dist/ko-DFU2ADQ4.js.map +0 -1
  226. package/dist/ms-NWL76XNT.js.map +0 -1
  227. package/dist/nl-ATCM76WN.js.map +0 -1
  228. package/dist/no-UIAQ5RJF.js.map +0 -1
  229. package/dist/pl-NOECQDXD.js.map +0 -1
  230. package/dist/pt-SVWIIUPC.js.map +0 -1
  231. package/dist/ro-NR2DRTZG.js.map +0 -1
  232. package/dist/sv-STVJ37JP.js.map +0 -1
  233. package/dist/test-utils.css.map +0 -1
  234. package/dist/test-utils.js.map +0 -1
  235. package/dist/th-UWQR4K54.js.map +0 -1
  236. package/dist/tr-WNXZSD27.js.map +0 -1
  237. package/dist/uk-SJRMB47Y.js.map +0 -1
  238. package/dist/vi-TMRQFES2.js.map +0 -1
  239. package/dist/zh-QOL3LYEM.js.map +0 -1
  240. package/src/components/modals/ResourceSearchModal.tsx +0 -224
  241. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +0 -159
  242. package/src/integrations/tailwind-plugin.cjs +0 -224
@@ -275,7 +275,7 @@ describe('PdfAnnotationCanvas', () => {
275
275
  });
276
276
  });
277
277
 
278
- test('passes the annotation rect as browse.click third argument (A1 anchor)', async () => {
278
+ test('passes the annotation rect as browse.click second argument (A1 anchor)', async () => {
279
279
  const click = vi.fn();
280
280
  const session = {
281
281
  client: { browse: { click }, beckon: { hover: vi.fn() } },
@@ -328,8 +328,7 @@ describe('PdfAnnotationCanvas', () => {
328
328
 
329
329
  expect(click).toHaveBeenCalledTimes(1);
330
330
  expect(click.mock.calls[0]?.[0]).toBe('ann-1');
331
- expect(click.mock.calls[0]?.[1]).toBe('highlighting');
332
- const anchorRect = click.mock.calls[0]?.[2];
331
+ const anchorRect = click.mock.calls[0]?.[1];
333
332
  expect(anchorRect).toBeDefined();
334
333
  expect(typeof anchorRect.width).toBe('number');
335
334
  });
@@ -401,8 +400,7 @@ describe('PdfAnnotationCanvas', () => {
401
400
 
402
401
  expect(click).toHaveBeenCalledTimes(1);
403
402
  expect(click.mock.calls[0]?.[0]).toBe('ann-hit-1');
404
- expect(click.mock.calls[0]?.[1]).toBe('highlighting');
405
- expect(click.mock.calls[0]?.[2]).toMatchObject({
403
+ expect(click.mock.calls[0]?.[1]).toMatchObject({
406
404
  left: displayRect.x,
407
405
  top: displayRect.y,
408
406
  width: displayRect.width,
@@ -510,6 +508,7 @@ describe('PdfAnnotationCanvas', () => {
510
508
  beckon: { hover: vi.fn() },
511
509
  browse: {
512
510
  resourceAnchoredText: vi.fn().mockResolvedValue({
511
+ kind: 'extracted',
513
512
  text: 'Hello world again',
514
513
  items: [
515
514
  { start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
@@ -562,6 +561,7 @@ describe('PdfAnnotationCanvas', () => {
562
561
  } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
563
562
 
564
563
  const resourceAnchoredText = vi.fn().mockResolvedValue({
564
+ kind: 'extracted',
565
565
  text: 'Hello world again',
566
566
  items: [
567
567
  { start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
@@ -729,6 +729,7 @@ describe('PdfAnnotationCanvas', () => {
729
729
  scannedDoc();
730
730
 
731
731
  const resourceAnchoredText = vi.fn().mockResolvedValue({
732
+ kind: 'extracted',
732
733
  text: 'Hello world again',
733
734
  items: [{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 }],
734
735
  });
@@ -891,6 +892,40 @@ describe('PdfAnnotationCanvas', () => {
891
892
  });
892
893
  });
893
894
 
895
+ test('the strip sizes its scrollport to the panel that clips it — not the window', async () => {
896
+ // The strip is sticky with a CSS ceiling of 100vh — right when the
897
+ // nearest scroller is the window, a lie inside an inner-scrolled panel:
898
+ // there the strip's scrollport extends below the panel's clip, and
899
+ // `scrollIntoView({block:'nearest'})` considers a tick in that hidden
900
+ // band already in view — the active page vanished when it crossed the
901
+ // strip's bottom (never the top, whose edges coincide). Measured live:
902
+ // strip scrollport 126→846 vs panel 126→639. The fix sizes the
903
+ // scrollport to the scroller that actually clips it.
904
+ const io = stubIntersectionObserver();
905
+ scannedDoc();
906
+
907
+ const scroller = document.createElement('div');
908
+ scroller.style.overflowY = 'auto';
909
+ Object.defineProperty(scroller, 'clientHeight', { value: 513, configurable: true });
910
+ document.body.appendChild(scroller);
911
+
912
+ render(
913
+ <PdfAnnotationCanvas resourceUri="res-1"
914
+ pdfUrl={mockPdfUrl}
915
+ drawingMode={null}
916
+ pageLayout="scroll"
917
+ />,
918
+ { container: scroller },
919
+ );
920
+ await waitFor(() => {
921
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
922
+ });
923
+ io.fire([1]);
924
+
925
+ const strip = document.querySelector('.semiont-pdf-annotation-canvas__strip') as HTMLElement;
926
+ await waitFor(() => expect(strip.style.maxHeight).toBe('513px'));
927
+ });
928
+
894
929
  test('clicking a strip rectangle jumps to that page', async () => {
895
930
  const io = stubIntersectionObserver();
896
931
  scannedDoc();
@@ -1246,7 +1281,7 @@ describe('PdfAnnotationCanvas', () => {
1246
1281
 
1247
1282
  const resourceAnchoredText = vi.fn()
1248
1283
  .mockRejectedValueOnce(new Error('transport down'))
1249
- .mockResolvedValue({ text: 'later', items: [] });
1284
+ .mockResolvedValue({ kind: 'extracted', text: 'later', items: [] });
1250
1285
  const session = {
1251
1286
  client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
1252
1287
  } as unknown as import('@semiont/sdk').SemiontSession;
@@ -39,7 +39,7 @@ interface Props {
39
39
  /** Session for the shown resource — its client emits mark:* / mark.request; its bus feeds toolbar + beckon events. */
40
40
  session: SemiontSession | null;
41
41
  /** Recently-created annotation ids to sparkle (host-provided; was ResourceAnnotationsContext). */
42
- newAnnotationIds?: Set<string>;
42
+ sparkleAnnotationIds?: Set<string>;
43
43
  /** The bar's Mode control reports the chosen mode here (the owner applies it). */
44
44
  onModeChange?: (mode: boolean) => void;
45
45
  /** Render the built-in bar (default). false → no bar; selection capture and drawing stay live. */
@@ -53,7 +53,7 @@ interface Props {
53
53
  *
54
54
  * @emits mark:requested - User requested to create annotation. Payload: { selector: Selector | Selector[], motivation: SelectionMotivation }
55
55
  * @subscribes beckon:hover - Annotation hovered. Payload: { annotationId: string | null }
56
- * @subscribes beckon:focus - Scroll to and highlight annotation. Payload: { annotationId: string }
56
+ * @subscribes beckon:focus - Scroll to and highlight annotation, unless `resourceId` names a different resource (D7). Payload: { annotationId: string, resourceId?: string }
57
57
  */
58
58
  export function AnnotateView({
59
59
  content,
@@ -69,7 +69,7 @@ export function AnnotateView({
69
69
  hoverDelayMs = 150,
70
70
  annotateMode,
71
71
  session,
72
- newAnnotationIds,
72
+ sparkleAnnotationIds,
73
73
  onModeChange,
74
74
  showToolbar = true,
75
75
  renderers,
@@ -111,10 +111,21 @@ export function AnnotateView({
111
111
  // serves, so the behaviour no longer depends on which view mode is active.
112
112
  // The `scrollToAnnotationId` prop path (uiState → renderer) stays as the
113
113
  // host-facing capability it is; this is the in-app producer's route.
114
- const handleAnnotationFocus = useCallback(({ annotationId }: { annotationId?: string | null }) => {
115
- if (!containerRef.current) return;
116
- scrollAnnotationIntoView(annotationId ?? null, containerRef.current, { pulse: true });
117
- }, []);
114
+ // `resourceId` is a GUARD, not navigation (GUIDED-TOUR D7): it names the
115
+ // resource this focus applies to, and a viewer showing a different one
116
+ // ignores it. Absent means unscoped — the in-app emitters (history panel,
117
+ // annotation list) omit it because they are already scoped to the open
118
+ // resource, so treating absence as "not mine" would silence all of them.
119
+ // Focus never navigates; driving the Browser to a resource is
120
+ // `browse:resource-open`'s job.
121
+ const handleAnnotationFocus = useCallback(
122
+ ({ annotationId, resourceId }: { annotationId?: string | null; resourceId?: string }) => {
123
+ if (resourceId && resourceId !== resourceUri) return;
124
+ if (!containerRef.current) return;
125
+ scrollAnnotationIntoView(annotationId ?? null, containerRef.current, { pulse: true });
126
+ },
127
+ [resourceUri],
128
+ );
118
129
 
119
130
  // Annotation hover (session-scoped). Toolbar preference changes flow through
120
131
  // props/callbacks, not the bus (TOOLBAR-PREFS-AS-PROPS).
@@ -256,7 +267,7 @@ export function AnnotateView({
256
267
  selectedMotivation={selectedMotivation}
257
268
  hoveredAnnotationId={hoveredAnnotationId}
258
269
  hoverDelayMs={hoverDelayMs}
259
- newAnnotationIds={newAnnotationIds}
270
+ sparkleAnnotationIds={sparkleAnnotationIds}
260
271
  scrollToAnnotationId={scrollToAnnotationId}
261
272
  showLineNumbers={showLineNumbers}
262
273
  enableWidgets={enableWidgets}
@@ -51,6 +51,8 @@ export function AnnotationHistory({
51
51
 
52
52
  // Sort events by oldest first (most recent at bottom)
53
53
  // Filter out job events - they're represented by mark:body-updated events instead
54
+ // `job:progress` is no longer a persisted event type, but logs written before it
55
+ // was retired still hold them; naming it here keeps those out of the history.
54
56
  const events: StoredEventLike[] = eventsData
55
57
  .filter((e) => {
56
58
  return e.type !== 'job:started' && e.type !== 'job:progress' && e.type !== 'job:completed';
@@ -35,7 +35,7 @@ interface Props {
35
35
  /** Session for the shown resource — emits browse:click / beckon:hover; its bus feeds beckon events. */
36
36
  session: SemiontSession | null;
37
37
  /** Recently-created annotation ids to sparkle (host-provided; was ResourceAnnotationsContext). */
38
- newAnnotationIds?: Set<string>;
38
+ sparkleAnnotationIds?: Set<string>;
39
39
  /** Override the read-only media renderers (render mode → renderer); merged over the defaults. */
40
40
  renderers?: BrowseMediaRenderers;
41
41
  /** A content link (`<a href>` in the rendered content) was clicked. The viewer preventDefaults and
@@ -87,7 +87,7 @@ export const BrowseView = memo(function BrowseView({
87
87
  annotateMode,
88
88
  hoverDelayMs = 150,
89
89
  session,
90
- newAnnotationIds,
90
+ sparkleAnnotationIds,
91
91
  renderers,
92
92
  onLinkClick,
93
93
  onReferenceHover,
@@ -155,7 +155,7 @@ export const BrowseView = memo(function BrowseView({
155
155
  if (annotation) {
156
156
  // The emission site owns the geometry: the clicked span's viewport
157
157
  // rect rides the event so hosts can anchor popovers (A1).
158
- session.client.browse.click(annotation.id, annotation.motivation, annotationElement.getBoundingClientRect());
158
+ session.client.browse.click(annotation.id, annotationElement.getBoundingClientRect());
159
159
  }
160
160
  }
161
161
  };
@@ -227,11 +227,11 @@ export const BrowseView = memo(function BrowseView({
227
227
  };
228
228
 
229
229
  // Apply animation classes to new annotations
230
- if (newAnnotationIds) {
230
+ if (sparkleAnnotationIds) {
231
231
  const annotationSpans = container.querySelectorAll('[data-annotation-id]');
232
232
  annotationSpans.forEach((span) => {
233
233
  const annotationId = span.getAttribute('data-annotation-id');
234
- if (annotationId && newAnnotationIds.has(annotationId)) {
234
+ if (annotationId && sparkleAnnotationIds.has(annotationId)) {
235
235
  span.classList.add('annotation-sparkle');
236
236
  }
237
237
  });
@@ -248,7 +248,7 @@ export const BrowseView = memo(function BrowseView({
248
248
  cleanupHover();
249
249
  referentSub?.unsubscribe(); // in-flight referent load dies with the effect
250
250
  };
251
- }, [content, allAnnotations, newAnnotationIds, hoverDelayMs, session, onReferenceHover]);
251
+ }, [content, allAnnotations, sparkleAnnotationIds, hoverDelayMs, session, onReferenceHover]);
252
252
 
253
253
  // Helper to scroll annotation into view with pulse effect
254
254
  const scrollToAnnotation = useCallback((annotationId: string | null, removePulse = false) => {
@@ -263,9 +263,20 @@ export const BrowseView = memo(function BrowseView({
263
263
  scrollToAnnotation(annotationId);
264
264
  }, [scrollToAnnotation]);
265
265
 
266
- const handleAnnotationFocus = useCallback(({ annotationId }: { annotationId?: string | null }) => {
267
- scrollToAnnotation(annotationId ?? null, true);
268
- }, [scrollToAnnotation]);
266
+ // `resourceId` is a GUARD, not navigation (GUIDED-TOUR D7): it names the
267
+ // resource this focus applies to, and a viewer showing a different one
268
+ // ignores it. Absent means unscoped — the in-app emitters (history panel,
269
+ // annotation list) omit it because they are already scoped to the open
270
+ // resource, so treating absence as "not mine" would silence all of them.
271
+ // Focus never navigates; driving the Browser to a resource is
272
+ // `browse:resource-open`'s job.
273
+ const handleAnnotationFocus = useCallback(
274
+ ({ annotationId, resourceId }: { annotationId?: string | null; resourceId?: string }) => {
275
+ if (resourceId && resourceId !== resourceUri) return;
276
+ scrollToAnnotation(annotationId ?? null, true);
277
+ },
278
+ [scrollToAnnotation, resourceUri],
279
+ );
269
280
 
270
281
  useSessionEventSubscriptions(session, {
271
282
  'beckon:hover': handleAnnotationHover,
@@ -8,7 +8,7 @@ import { BrowseView, type ReferenceHover } from './BrowseView';
8
8
  import type { BrowseMediaRenderers } from './browse-renderers';
9
9
  import { PopupContainer } from '../annotation-popups/SharedPopupElements';
10
10
  import { JsonLdView } from '../annotation-popups/JsonLdView';
11
- import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components, EventMap, AnchorRect } from '@semiont/core';
11
+ import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, EventMap, AnchorRect } from '@semiont/core';
12
12
  import { getExactText, getTargetSelector, getPrimaryMediaType, isHighlight, isAssessment, isReference, isComment, isTag, getBodySource } from '@semiont/core';
13
13
  import type { SemiontSession } from '@semiont/sdk';
14
14
  import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
@@ -50,7 +50,7 @@ interface Props {
50
50
  /** Inline display variant: auto-height, no inner scroll, no pane chrome (browse path). Default: fill-the-pane. */
51
51
  inline?: boolean;
52
52
  /** Recently-created annotation ids to sparkle (threaded to the browse/annotate subtree). */
53
- newAnnotationIds?: Set<string>;
53
+ sparkleAnnotationIds?: Set<string>;
54
54
  generatingReferenceId?: string | null;
55
55
  showLineNumbers?: boolean;
56
56
  hoverDelayMs?: number;
@@ -102,7 +102,10 @@ interface Props {
102
102
  * panel opening invokes the host's `onOpenPanel` callback — the host owns
103
103
  * the panel and any `panel:open` emission.
104
104
  *
105
- * @subscribes browse:click - User clicked on annotation. Payload: { annotationId: string, motivation, anchorRect? }
105
+ * @subscribes browse:click - An annotation was clicked locally, or driven over
106
+ * the wire by another participant. Payload: { annotationId: string, anchorRect? };
107
+ * the motivation is derived from the annotation the id names, and an id this
108
+ * viewer has not loaded is a no-op.
106
109
  */
107
110
  export function ResourceViewer({
108
111
  resource,
@@ -113,7 +116,7 @@ export function ResourceViewer({
113
116
  onLinkClick,
114
117
  onReferenceHover,
115
118
  inline = false,
116
- newAnnotationIds,
119
+ sparkleAnnotationIds,
117
120
  generatingReferenceId,
118
121
  showLineNumbers = false,
119
122
  hoverDelayMs,
@@ -270,38 +273,34 @@ export function ResourceViewer({
270
273
  }, [annotateMode, selectedClick, onOpenResource]);
271
274
 
272
275
  // Annotation click coordinator - handles panel opening and scrolling
273
- const handleAnnotationClickEvent = useCallback(({ annotationId, motivation, anchorRect }: {
276
+ const handleAnnotationClickEvent = useCallback(({ annotationId, anchorRect }: {
274
277
  annotationId: string;
275
- motivation: components['schemas']['Motivation'];
276
278
  anchorRect?: AnchorRect;
277
279
  }) => {
278
- // Find the annotation metadata
279
- const metadata = Object.values(ANNOTATORS).find(a => a.matchesAnnotation({ motivation } as Annotation));
280
-
281
- if (!metadata?.hasSidePanel) {
282
- // Annotation doesn't have a side panel - let handleAnnotationClick handle it
283
- const allAnnotations = [...highlights, ...references, ...assessments, ...comments, ...tags];
284
- const annotation = allAnnotations.find(a => a.id === annotationId);
285
- if (annotation) {
286
- handleAnnotationClick(annotation);
287
- }
288
- return;
289
- }
280
+ // Resolve the annotation FIRST. The id is the whole address, and the
281
+ // motivation is derived from the annotation it names rather than carried
282
+ // beside it (TOUR-CLICK D2) — one fact, one encoding. Resolving first also
283
+ // means a click naming an annotation this viewer has not loaded (a remote
284
+ // drive that arrived while the participant was elsewhere) finds nothing
285
+ // and does nothing, which is why the channel needs no resourceId guard.
286
+ const allAnnotations = [...highlights, ...references, ...assessments, ...comments, ...tags];
287
+ const annotation = allAnnotations.find(a => a.id === annotationId);
288
+ if (!annotation) return;
289
+
290
+ // The real annotation, not a `{ motivation } as Annotation` stand-in.
291
+ const metadata = Object.values(ANNOTATORS).find(a => a.matchesAnnotation(annotation));
290
292
 
291
- if (selectedClick !== 'detail') {
292
- // Only open panels in detail mode - for other modes, let handleAnnotationClick handle it
293
- const allAnnotations = [...highlights, ...references, ...assessments, ...comments, ...tags];
294
- const annotation = allAnnotations.find(a => a.id === annotationId);
295
- if (annotation) {
296
- handleAnnotationClick(annotation);
297
- }
293
+ // No side panel, or a click mode other than detail: this is a plain
294
+ // annotation click, not a panel-opening one.
295
+ if (!metadata?.hasSidePanel || selectedClick !== 'detail') {
296
+ handleAnnotationClick(annotation);
298
297
  return;
299
298
  }
300
299
 
301
300
  // All annotations open the unified annotations panel — the host owns the panel.
302
301
  // The panel internally switches tabs based on the motivation → tab mapping in UnifiedAnnotationsPanel.
303
302
  // View geometry passes through untouched: the emitter owned it, the host anchors with it.
304
- onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation, ...(anchorRect ? { anchorRect } : {}) });
303
+ onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation: annotation.motivation, ...(anchorRect ? { anchorRect } : {}) });
305
304
  }, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick, onOpenPanel]);
306
305
 
307
306
  // Single subscription call per file (see scripts/compliance/audit-hooks-ordering.ts).
@@ -357,7 +356,7 @@ export function ResourceViewer({
357
356
  onModeChange={changeAnnotateMode}
358
357
  showToolbar={showToolbar}
359
358
  session={session}
360
- newAnnotationIds={newAnnotationIds}
359
+ sparkleAnnotationIds={sparkleAnnotationIds}
361
360
  {...(annotateRenderers && { renderers: annotateRenderers })}
362
361
  />
363
362
  ) : (
@@ -373,7 +372,7 @@ export function ResourceViewer({
373
372
  onClickActionChange={changeClickAction}
374
373
  showToolbar={showToolbar}
375
374
  session={session}
376
- newAnnotationIds={newAnnotationIds}
375
+ sparkleAnnotationIds={sparkleAnnotationIds}
377
376
  onLinkClick={onLinkClick}
378
377
  onReferenceHover={onReferenceHover}
379
378
  inline={inline}
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * EMBEDDABLE-RESOURCE-VIEWER step 1b — AnnotateView + AnnotateToolbar provider-free.
3
3
  *
4
- * AnnotateView takes `session` + `newAnnotationIds` as props; the REAL
4
+ * AnnotateView takes `session` + `sparkleAnnotationIds` as props; the REAL
5
5
  * AnnotateToolbar (not mocked — its decoupling is the crux of 1b) takes `session`
6
6
  * as a prop. CodeMirrorRenderer is mocked (heavy; already prop-based).
7
7
  *
@@ -56,3 +56,53 @@ describe('AnnotateView — beckon:focus scrolls the content', () => {
56
56
  expect(scrollSpy.mock.calls[0]?.[0]).toBe('ann-7');
57
57
  });
58
58
  });
59
+
60
+ // ─────────────────────────────────────────────────────────────────────
61
+ // GUIDED-TOUR P6 (D7) — `resourceId` is a GUARD, not navigation.
62
+ //
63
+ // The schema now says so out loud: "it names the resource this focus applies
64
+ // to, and a viewer currently showing a different resource ignores the event".
65
+ // Without the comparison the field was decorative — a guide beckoning a
66
+ // reference in doc B scrolled every participant's doc A.
67
+ // ─────────────────────────────────────────────────────────────────────
68
+ describe('AnnotateView — beckon:focus is guarded by resourceId (P6/D7)', () => {
69
+ beforeEach(() => { scrollSpy.mockClear(); });
70
+
71
+ const renderAt = (resourceUri: string) => {
72
+ const { session, client } = createTestSemiontWrapper();
73
+ render(
74
+ <AnnotateView
75
+ content="hello world"
76
+ mimeType="text/plain"
77
+ resourceUri={resourceUri}
78
+ annotations={emptyAnnotations}
79
+ uiState={uiState}
80
+ session={session}
81
+ annotateMode
82
+ />,
83
+ );
84
+ return client;
85
+ };
86
+
87
+ it('ignores a focus aimed at a DIFFERENT resource', () => {
88
+ const client = renderAt('res-1');
89
+ client.bus.get('beckon:focus').next({ annotationId: 'ann-7', resourceId: 'res-2' });
90
+ expect(scrollSpy).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('still scrolls when the resource matches', () => {
94
+ const client = renderAt('res-1');
95
+ client.bus.get('beckon:focus').next({ annotationId: 'ann-7', resourceId: 'res-1' });
96
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
97
+ });
98
+
99
+ it('still scrolls when the event names no resource at all', () => {
100
+ // `resourceId` is optional in the schema (`required: []`), and the in-app
101
+ // emitters — the history panel, the annotation list — omit it because they
102
+ // are already scoped to the open resource. A guard that treated absence as
103
+ // "not mine" would break every one of them.
104
+ const client = renderAt('res-1');
105
+ client.bus.get('beckon:focus').next({ annotationId: 'ann-7' });
106
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
107
+ });
108
+ });
@@ -173,6 +173,8 @@ describe('AnnotationHistory', () => {
173
173
  });
174
174
 
175
175
  it('filters out job events', () => {
176
+ // e3 is `job:progress`, a retired event type. Logs written before it was
177
+ // retired still hold them, so the filter must keep excluding it.
176
178
  const events = [
177
179
  makeStoredEvent('e1', 'mark:added', 1),
178
180
  makeStoredEvent('e2', 'job:started', 2),
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * EMBEDDABLE-RESOURCE-VIEWER step 1a — BrowseView renders provider-free.
3
3
  *
4
- * BrowseView takes its `session` + `newAnnotationIds` as props (not
4
+ * BrowseView takes its `session` + `sparkleAnnotationIds` as props (not
5
5
  * `useSemiont()` / `useResourceAnnotations()`), and subscribes to session-scoped
6
6
  * beckon events via `session.subscribe`. `AnnotateToolbar` is mocked here — its
7
7
  * own provider decoupling is step 1b — so this spec isolates BrowseView's body.
@@ -8,7 +8,7 @@ import { createTestSemiontWrapper } from '../../../test-utils';
8
8
 
9
9
  import type { Annotation, AnnotationId } from '@semiont/core';
10
10
 
11
- // BrowseView takes its `session` + `newAnnotationIds` as props now (step 1a) — no
11
+ // BrowseView takes its `session` + `sparkleAnnotationIds` as props now (step 1a) — no
12
12
  // ResourceAnnotationsContext / SemiontProvider reach-in. `makeSession` (below)
13
13
  // wraps the fake client so browse:click / beckon:hover land on the bus the
14
14
  // trackers listen on, and session.subscribe registers beckon:* on that bus.
@@ -125,14 +125,14 @@ function makeSession(client: SemiontClient): SemiontSession {
125
125
 
126
126
  const renderWithProviders = (
127
127
  component: React.ReactElement<React.ComponentProps<typeof BrowseView>>,
128
- options: { newAnnotationIds?: Set<string> } = {}
128
+ options: { sparkleAnnotationIds?: Set<string> } = {}
129
129
  ) => {
130
130
  const { SemiontWrapper, client } = createTestSemiontWrapper();
131
131
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
132
132
  <SemiontWrapper>{children}</SemiontWrapper>
133
133
  );
134
134
  return render(
135
- React.cloneElement(component, { session: makeSession(client), newAnnotationIds: options.newAnnotationIds }),
135
+ React.cloneElement(component, { session: makeSession(client), sparkleAnnotationIds: options.sparkleAnnotationIds }),
136
136
  { wrapper: Wrapper },
137
137
  );
138
138
  };
@@ -140,7 +140,7 @@ const renderWithProviders = (
140
140
  const renderWithEventTracking = (
141
141
  component: React.ReactElement<React.ComponentProps<typeof BrowseView>>,
142
142
  tracker: ReturnType<typeof createEventTracker>,
143
- options: { newAnnotationIds?: Set<string> } = {}
143
+ options: { sparkleAnnotationIds?: Set<string> } = {}
144
144
  ) => {
145
145
  const { SemiontWrapper, eventBus, client } = createTestSemiontWrapper();
146
146
  tracker._attach(eventBus);
@@ -148,7 +148,7 @@ const renderWithEventTracking = (
148
148
  <SemiontWrapper>{children}</SemiontWrapper>
149
149
  );
150
150
  return render(
151
- React.cloneElement(component, { session: makeSession(client), newAnnotationIds: options.newAnnotationIds }),
151
+ React.cloneElement(component, { session: makeSession(client), sparkleAnnotationIds: options.sparkleAnnotationIds }),
152
152
  { wrapper: Wrapper },
153
153
  );
154
154
  };
@@ -427,7 +427,7 @@ describe('BrowseView Component', () => {
427
427
  { id: 'comment-1', type: 'comment', motivation: 'commenting' },
428
428
  ] as const;
429
429
 
430
- for (const { id, type, motivation } of clickCases) {
430
+ for (const { id, type } of clickCases) {
431
431
  const el = document.createElement('span');
432
432
  el.setAttribute('data-annotation-id', id);
433
433
  el.setAttribute('data-annotation-type', type);
@@ -439,8 +439,7 @@ describe('BrowseView Component', () => {
439
439
  await waitFor(() => {
440
440
  expect(tracker.events.some(e =>
441
441
  e.event === 'browse:click' &&
442
- e.payload?.annotationId === id &&
443
- e.payload?.motivation === motivation
442
+ e.payload?.annotationId === id
444
443
  )).toBe(true);
445
444
  });
446
445
  }
@@ -546,7 +545,7 @@ describe('BrowseView Component', () => {
546
545
 
547
546
  describe('Annotation Animation Classes', () => {
548
547
  it('should apply sparkle class to new annotations', () => {
549
- const newAnnotationIds = new Set(['new-annotation-1']);
548
+ const sparkleAnnotationIds = new Set(['new-annotation-1']);
550
549
 
551
550
  const annotations = {
552
551
  ...defaultProps.annotations,
@@ -554,12 +553,12 @@ describe('BrowseView Component', () => {
554
553
  };
555
554
 
556
555
  renderWithProviders(<BrowseView {...defaultProps} annotations={annotations} />, {
557
- newAnnotationIds
556
+ sparkleAnnotationIds
558
557
  });
559
558
 
560
- // Verify the newAnnotationIds set contains the expected annotation
559
+ // Verify the sparkleAnnotationIds set contains the expected annotation
561
560
  // In the actual component, this triggers the sparkle class application
562
- expect(newAnnotationIds.has('new-annotation-1')).toBe(true);
561
+ expect(sparkleAnnotationIds.has('new-annotation-1')).toBe(true);
563
562
  });
564
563
  });
565
564
 
@@ -653,3 +652,30 @@ describe('BrowseView Component', () => {
653
652
  });
654
653
  });
655
654
  });
655
+
656
+ // ─────────────────────────────────────────────────────────────────────
657
+ // GUIDED-TOUR P6 (D7) — the same guard BrowseView's sibling carries.
658
+ // BrowseView subscribed to `beckon:focus` first, so it is the one that has
659
+ // been scrolling on other people's resources the longest.
660
+ // ─────────────────────────────────────────────────────────────────────
661
+ describe('BrowseView — beckon:focus is guarded by resourceId (P6/D7)', () => {
662
+ it('ignores a focus aimed at a different resource, honours one that matches', () => {
663
+ const { session, client } = createTestSemiontWrapper();
664
+ const { container } = render(
665
+ <BrowseView
666
+ content="hello world"
667
+ mimeType="text/plain"
668
+ resourceUri="res-1"
669
+ annotations={{ highlights: [], references: [], assessments: [], comments: [], tags: [] }}
670
+ session={session}
671
+ annotateMode={false}
672
+ />,
673
+ );
674
+ expect(container).toBeTruthy();
675
+
676
+ // Neither call may throw; the assertion that matters is the guard's
677
+ // existence, pinned in AnnotateView where the scroll helper is mockable.
678
+ client.bus.get('beckon:focus').next({ annotationId: 'ann-7', resourceId: 'res-2' });
679
+ client.bus.get('beckon:focus').next({ annotationId: 'ann-7', resourceId: 'res-1' });
680
+ });
681
+ });
@@ -15,7 +15,8 @@ import { describe, it, expect, vi } from 'vitest';
15
15
  import { render, screen, act, waitFor } from '@testing-library/react';
16
16
  import '@testing-library/jest-dom';
17
17
  import type { SemiontSession } from '@semiont/sdk';
18
- import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
18
+ import type { Annotation, ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
19
+ import { annotationId } from '@semiont/core';
19
20
  import { createTestSemiontWrapper } from '../../../test-utils';
20
21
  import { ResourceViewer } from '../ResourceViewer';
21
22
 
@@ -46,6 +47,23 @@ const resource: SemiontResource & { content: string } = {
46
47
 
47
48
  const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
48
49
 
50
+ /**
51
+ * A highlight the viewer has actually loaded. Required since TOUR-CLICK D2: the
52
+ * click handler resolves the annotation by id and derives the motivation from
53
+ * it, so a click for an id absent from this collection is a no-op. Before that,
54
+ * the wire carried the motivation and the viewer never looked — which is why
55
+ * the anchorRect test below used to pass against an empty collection.
56
+ */
57
+ const loadedHighlight: Annotation = {
58
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
59
+ type: 'Annotation',
60
+ id: annotationId('ann-1'),
61
+ motivation: 'highlighting',
62
+ target: { source: 'res-1', selector: { type: 'TextPositionSelector', start: 0, end: 10 } },
63
+ };
64
+
65
+ const annotationsWithHighlight = { ...annotations, highlights: [loadedHighlight] };
66
+
49
67
  describe('ResourceViewer — embeddable (bring-your-own-session, no providers)', () => {
50
68
  // GREEN: the whole browse-mode subtree (ResourceViewer → BrowseView →
51
69
  // AnnotateToolbar) renders provider-free from a bare session.
@@ -73,7 +91,7 @@ describe('ResourceViewer — embeddable (bring-your-own-session, no providers)',
73
91
  <ResourceViewer
74
92
  session={session}
75
93
  resource={resource}
76
- annotations={annotations}
94
+ annotations={annotationsWithHighlight}
77
95
  onOpenResource={vi.fn()}
78
96
  onOpenPanel={onOpenPanel}
79
97
  />,
@@ -86,7 +104,6 @@ describe('ResourceViewer — embeddable (bring-your-own-session, no providers)',
86
104
  act(() => {
87
105
  eventBus.get('browse:click').next({
88
106
  annotationId: 'ann-1',
89
- motivation: 'highlighting',
90
107
  anchorRect,
91
108
  });
92
109
  });
@@ -95,8 +112,35 @@ describe('ResourceViewer — embeddable (bring-your-own-session, no providers)',
95
112
  expect(onOpenPanel).toHaveBeenCalledWith(expect.objectContaining({
96
113
  panel: 'annotations',
97
114
  scrollToAnnotationId: 'ann-1',
115
+ // Derived from the annotation the id names, never carried on the wire.
116
+ motivation: 'highlighting',
98
117
  anchorRect: expect.objectContaining({ left: 5, width: 7 }),
99
118
  }));
100
119
  });
101
120
  });
121
+
122
+ it('ignores a browse:click naming an annotation this viewer has not loaded', async () => {
123
+ // A remote drive can arrive while the participant is looking at something
124
+ // else. Resolving the annotation FIRST makes that a no-op by construction,
125
+ // which is why the channel carries no resourceId guard (TOUR-CLICK D3).
126
+ const { session, eventBus } = createTestSemiontWrapper();
127
+ const onOpenPanel = vi.fn();
128
+
129
+ render(
130
+ <ResourceViewer
131
+ session={session}
132
+ resource={resource}
133
+ annotations={annotationsWithHighlight}
134
+ onOpenResource={vi.fn()}
135
+ onOpenPanel={onOpenPanel}
136
+ />,
137
+ );
138
+
139
+ act(() => {
140
+ eventBus.get('browse:click').next({ annotationId: 'ann-not-here' });
141
+ });
142
+
143
+ await new Promise((r) => setTimeout(r, 20));
144
+ expect(onOpenPanel).not.toHaveBeenCalled();
145
+ });
102
146
  });