@semiont/react-ui 0.5.25 → 0.5.27

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 (267) hide show
  1. package/README.md +28 -4
  2. package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js +677 -0
  3. package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js.map +1 -0
  4. package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css +185 -0
  5. package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css.map +1 -0
  6. package/dist/{ar-CULRHLNG.js → ar-OU3DQGC6.js} +65 -7
  7. package/dist/ar-OU3DQGC6.js.map +1 -0
  8. package/dist/{bn-HYR7CEL2.js → bn-PHWCR6AZ.js} +65 -7
  9. package/dist/bn-PHWCR6AZ.js.map +1 -0
  10. package/dist/chunk-5VDTWRXJ.js +287 -0
  11. package/dist/chunk-5VDTWRXJ.js.map +1 -0
  12. package/dist/{chunk-TG2XQEC4.js → chunk-BTUG3VJJ.js} +18 -298
  13. package/dist/chunk-BTUG3VJJ.js.map +1 -0
  14. package/dist/{chunk-NH6L2GIL.js → chunk-PCRRBU7H.js} +65 -7
  15. package/dist/chunk-PCRRBU7H.js.map +1 -0
  16. package/dist/{cs-YBRWY7NH.js → cs-NEXYZ62I.js} +65 -7
  17. package/dist/cs-NEXYZ62I.js.map +1 -0
  18. package/dist/{da-PHDEOBV3.js → da-FAT43YCU.js} +65 -7
  19. package/dist/da-FAT43YCU.js.map +1 -0
  20. package/dist/{de-O4UCCVLJ.js → de-KRQ3WBCU.js} +65 -7
  21. package/dist/de-KRQ3WBCU.js.map +1 -0
  22. package/dist/{el-MVGO6WZV.js → el-Q4IU3EKC.js} +65 -7
  23. package/dist/el-Q4IU3EKC.js.map +1 -0
  24. package/dist/{en-2KQILFKQ.js → en-2KADE7N4.js} +6 -2
  25. package/dist/{es-USSLXQY7.js → es-2IO3R2MK.js} +65 -7
  26. package/dist/es-2IO3R2MK.js.map +1 -0
  27. package/dist/{fa-WMAREFWY.js → fa-Q5GOQFXX.js} +65 -7
  28. package/dist/fa-Q5GOQFXX.js.map +1 -0
  29. package/dist/{fi-B3NBNOTS.js → fi-R6NMRWBE.js} +65 -7
  30. package/dist/fi-R6NMRWBE.js.map +1 -0
  31. package/dist/{fr-TXYK5T5W.js → fr-QY4D5E6J.js} +65 -7
  32. package/dist/fr-QY4D5E6J.js.map +1 -0
  33. package/dist/{he-JCJACW5D.js → he-X6Y77XHY.js} +65 -7
  34. package/dist/he-X6Y77XHY.js.map +1 -0
  35. package/dist/{hi-VOQLZNJU.js → hi-XL7IOEQM.js} +65 -7
  36. package/dist/hi-XL7IOEQM.js.map +1 -0
  37. package/dist/{id-BFN5RZNZ.js → id-QZ4ZZS67.js} +65 -7
  38. package/dist/id-QZ4ZZS67.js.map +1 -0
  39. package/dist/index.css +241 -508
  40. package/dist/index.css.map +1 -1
  41. package/dist/index.d.ts +155 -54
  42. package/dist/index.js +1815 -1642
  43. package/dist/index.js.map +1 -1
  44. package/dist/integrations/css-modules-helper.js +1 -3
  45. package/dist/integrations/css-modules-helper.js.map +1 -1
  46. package/dist/{it-TWWDHKKM.js → it-ME2Q7OB6.js} +65 -7
  47. package/dist/it-ME2Q7OB6.js.map +1 -0
  48. package/dist/{ja-YRQLEBFM.js → ja-HX7QASRI.js} +65 -7
  49. package/dist/ja-HX7QASRI.js.map +1 -0
  50. package/dist/{ko-PG6B3CRB.js → ko-EQO5AHAV.js} +65 -7
  51. package/dist/ko-EQO5AHAV.js.map +1 -0
  52. package/dist/{ms-7EZECVKM.js → ms-UILFSF3I.js} +65 -7
  53. package/dist/ms-UILFSF3I.js.map +1 -0
  54. package/dist/{nl-HP753YAP.js → nl-NNI45PMK.js} +65 -7
  55. package/dist/nl-NNI45PMK.js.map +1 -0
  56. package/dist/{no-JUD5WX3N.js → no-MAL5F6P6.js} +65 -7
  57. package/dist/no-MAL5F6P6.js.map +1 -0
  58. package/dist/{pl-RVUL4CBM.js → pl-4RMY6JAJ.js} +65 -7
  59. package/dist/pl-4RMY6JAJ.js.map +1 -0
  60. package/dist/{pt-RPOVD4QM.js → pt-VQH2X3QQ.js} +65 -7
  61. package/dist/pt-VQH2X3QQ.js.map +1 -0
  62. package/dist/{ro-DMYWQTRD.js → ro-XXCQO2OG.js} +65 -7
  63. package/dist/ro-XXCQO2OG.js.map +1 -0
  64. package/dist/{sv-DMZ4FGXD.js → sv-7Z6EJ6U4.js} +65 -7
  65. package/dist/sv-7Z6EJ6U4.js.map +1 -0
  66. package/dist/test-utils.css +1 -1
  67. package/dist/test-utils.css.map +1 -1
  68. package/dist/test-utils.d.ts +1 -1
  69. package/dist/test-utils.js +5 -3
  70. package/dist/test-utils.js.map +1 -1
  71. package/dist/{th-4HVBJBQ2.js → th-L3DXXYVQ.js} +65 -7
  72. package/dist/th-L3DXXYVQ.js.map +1 -0
  73. package/dist/{tr-6BDPU5ML.js → tr-P3DFVLFJ.js} +65 -7
  74. package/dist/tr-P3DFVLFJ.js.map +1 -0
  75. package/dist/{uk-TBZBT2TL.js → uk-7C5D3PXS.js} +65 -7
  76. package/dist/uk-7C5D3PXS.js.map +1 -0
  77. package/dist/{vi-UMKZKZL3.js → vi-ZLTLEO7P.js} +65 -7
  78. package/dist/vi-ZLTLEO7P.js.map +1 -0
  79. package/dist/{zh-YBJWJJYB.js → zh-5FHVWDIN.js} +65 -7
  80. package/dist/zh-5FHVWDIN.js.map +1 -0
  81. package/package.json +7 -7
  82. package/src/components/AssistProgress.tsx +144 -134
  83. package/src/components/{EntityFoundLog.tsx → ItemFoundLog.tsx} +12 -7
  84. package/src/components/ProtectedErrorBoundary.css +3 -1
  85. package/src/components/StatusDisplay.css +35 -35
  86. package/src/components/Toast.css +3 -1
  87. package/src/components/__tests__/AssistProgress.test.tsx +216 -148
  88. package/src/components/annotation/annotation-entries.css +0 -4
  89. package/src/components/annotation/annotations.css +1 -1
  90. package/src/components/annotation/references.css +0 -4
  91. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  92. package/src/components/image-annotation/SvgDrawingCanvas.tsx +1 -1
  93. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +7 -9
  94. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +1 -2
  95. package/src/components/layout/Header.css +0 -4
  96. package/src/components/layout/LeftSidebar.css +0 -4
  97. package/src/components/modals/ConfigureGenerationStep.tsx +109 -48
  98. package/src/components/modals/ConfigureSearchStep.tsx +23 -40
  99. package/src/components/modals/ReferenceWizardModal.tsx +55 -16
  100. package/src/components/modals/ResourceGenerateModal.tsx +24 -8
  101. package/src/components/modals/ResourceSearchModal.tsx +1 -1
  102. package/src/components/modals/SearchModal.css +5 -17
  103. package/src/components/modals/SearchModal.tsx +1 -1
  104. package/src/components/modals/SearchResultsStep.tsx +2 -20
  105. package/src/components/modals/WizardFooter.tsx +63 -0
  106. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +255 -0
  107. package/src/components/modals/__tests__/ConfigureSearchStep.test.tsx +72 -0
  108. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +256 -0
  109. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +1 -0
  110. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +6 -2
  111. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +6 -2
  112. package/src/components/modals/__tests__/WizardFooter.test.tsx +235 -0
  113. package/src/components/modals/modals.css +16 -42
  114. package/src/components/navigation/NavigationTabs.css +0 -20
  115. package/src/components/pdf-annotation/PdfAnnotationCanvas.css +162 -6
  116. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +623 -232
  117. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +764 -7
  118. package/src/components/pdf-annotation/__tests__/estimate-slot-height.test.ts +53 -0
  119. package/src/components/pdf-annotation/estimate-slot-height.ts +31 -0
  120. package/src/components/resource/AnnotateView.tsx +16 -5
  121. package/src/components/resource/AnnotationHistory.tsx +2 -0
  122. package/src/components/resource/BrowseView.tsx +15 -4
  123. package/src/components/resource/ResourceViewer.tsx +23 -24
  124. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +50 -0
  125. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +2 -0
  126. package/src/components/resource/__tests__/BrowseView.test.tsx +29 -3
  127. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +47 -3
  128. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +33 -0
  129. package/src/components/resource/annotate-renderers.tsx +10 -1
  130. package/src/components/resource/browse-renderers.tsx +10 -1
  131. package/src/components/resource/event-formatting.ts +0 -3
  132. package/src/components/resource/panels/AssessmentEntry.tsx +1 -1
  133. package/src/components/resource/panels/AssessmentPanel.css +5 -6
  134. package/src/components/resource/panels/AssistSection.css +68 -271
  135. package/src/components/resource/panels/AssistSection.tsx +4 -4
  136. package/src/components/resource/panels/AssistShell.tsx +12 -4
  137. package/src/components/resource/panels/CollaborationPanel.css +47 -22
  138. package/src/components/resource/panels/CollaborationPanel.tsx +93 -2
  139. package/src/components/resource/panels/CommentEntry.tsx +1 -1
  140. package/src/components/resource/panels/CommentsPanel.css +0 -16
  141. package/src/components/resource/panels/HighlightEntry.tsx +1 -1
  142. package/src/components/resource/panels/HighlightPanel.css +0 -7
  143. package/src/components/resource/panels/JsonLdPanel.css +0 -4
  144. package/src/components/resource/panels/ReferenceEntry.tsx +1 -1
  145. package/src/components/resource/panels/ReferencesPanel.css +0 -12
  146. package/src/components/resource/panels/ReferencesPanel.tsx +12 -20
  147. package/src/components/resource/panels/ResourceInfoPanel.css +0 -32
  148. package/src/components/resource/panels/ResourceInfoPanel.tsx +1 -1
  149. package/src/components/resource/panels/StatisticsPanel.css +0 -20
  150. package/src/components/resource/panels/TagEntry.tsx +1 -1
  151. package/src/components/resource/panels/TaggingPanel.css +0 -4
  152. package/src/components/resource/panels/TaggingPanel.tsx +2 -10
  153. package/src/components/resource/panels/UnifiedAnnotationsPanel.css +0 -4
  154. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +0 -1
  155. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +48 -50
  156. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +11 -6
  157. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +89 -0
  158. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +1 -2
  159. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +0 -1
  160. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +44 -36
  161. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +0 -1
  162. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +1 -1
  163. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +34 -53
  164. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +37 -1
  165. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +0 -1
  166. package/src/components/settings/SettingsPanel.css +0 -4
  167. package/src/features/auth/auth.css +9 -33
  168. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +25 -1
  169. package/src/features/resource-compose/state/compose-page-state-unit.ts +11 -2
  170. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +55 -0
  171. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +29 -0
  172. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +53 -3
  173. package/src/features/resource-discovery/state/discover-state-unit.ts +29 -8
  174. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +26 -0
  175. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +42 -21
  176. package/src/features/resource-viewer/hooks/__tests__/useResourceViewedReport.test.tsx +63 -0
  177. package/src/features/resource-viewer/hooks/useResourceViewedReport.ts +33 -0
  178. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +2 -2
  179. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +1 -1
  180. package/src/styles/core/buttons.css +3 -1
  181. package/src/styles/core/forms.css +0 -21
  182. package/src/styles/core/indicators.css +0 -4
  183. package/src/styles/core/inputs.css +1 -5
  184. package/src/styles/core/progress.css +2 -34
  185. package/src/styles/core/sliders.css +0 -4
  186. package/src/styles/core/toggles.css +0 -4
  187. package/src/styles/features/compose.css +1 -1
  188. package/src/styles/features/resource-discovery.css +13 -0
  189. package/src/styles/features/resource-viewer.css +1 -21
  190. package/src/styles/features/static-pages.css +11 -11
  191. package/src/styles/motivations/motivation-assessment.css +2 -2
  192. package/src/styles/motivations/motivation-comment.css +2 -2
  193. package/src/styles/motivations/motivation-reference.css +2 -2
  194. package/src/styles/panels/history-panel.css +0 -40
  195. package/src/styles/panels/user-panel.css +0 -3
  196. package/src/styles/patterns/panel-helpers.css +0 -27
  197. package/src/styles/patterns/panel-sections.css +0 -59
  198. package/src/styles/patterns/panels-base.css +4 -36
  199. package/src/styles/utilities/motion.css +15 -15
  200. package/src/styles/variables.css +29 -1
  201. package/translations/ar.json +59 -5
  202. package/translations/bn.json +59 -5
  203. package/translations/cs.json +59 -5
  204. package/translations/da.json +59 -5
  205. package/translations/de.json +59 -5
  206. package/translations/el.json +59 -5
  207. package/translations/en.json +59 -5
  208. package/translations/es.json +59 -5
  209. package/translations/fa.json +59 -5
  210. package/translations/fi.json +59 -5
  211. package/translations/fr.json +59 -5
  212. package/translations/he.json +59 -5
  213. package/translations/hi.json +59 -5
  214. package/translations/id.json +59 -5
  215. package/translations/it.json +59 -5
  216. package/translations/ja.json +59 -5
  217. package/translations/ko.json +59 -5
  218. package/translations/ms.json +59 -5
  219. package/translations/nl.json +59 -5
  220. package/translations/no.json +59 -5
  221. package/translations/pl.json +59 -5
  222. package/translations/pt.json +59 -5
  223. package/translations/ro.json +59 -5
  224. package/translations/sv.json +59 -5
  225. package/translations/th.json +59 -5
  226. package/translations/tr.json +59 -5
  227. package/translations/uk.json +59 -5
  228. package/translations/vi.json +59 -5
  229. package/translations/zh.json +59 -5
  230. package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js +0 -462
  231. package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js.map +0 -1
  232. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css +0 -98
  233. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css.map +0 -1
  234. package/dist/ar-CULRHLNG.js.map +0 -1
  235. package/dist/bn-HYR7CEL2.js.map +0 -1
  236. package/dist/chunk-NH6L2GIL.js.map +0 -1
  237. package/dist/chunk-TG2XQEC4.js.map +0 -1
  238. package/dist/chunk-XUDKYAVC.js +0 -21
  239. package/dist/chunk-XUDKYAVC.js.map +0 -1
  240. package/dist/cs-YBRWY7NH.js.map +0 -1
  241. package/dist/da-PHDEOBV3.js.map +0 -1
  242. package/dist/de-O4UCCVLJ.js.map +0 -1
  243. package/dist/el-MVGO6WZV.js.map +0 -1
  244. package/dist/es-USSLXQY7.js.map +0 -1
  245. package/dist/fa-WMAREFWY.js.map +0 -1
  246. package/dist/fi-B3NBNOTS.js.map +0 -1
  247. package/dist/fr-TXYK5T5W.js.map +0 -1
  248. package/dist/he-JCJACW5D.js.map +0 -1
  249. package/dist/hi-VOQLZNJU.js.map +0 -1
  250. package/dist/id-BFN5RZNZ.js.map +0 -1
  251. package/dist/it-TWWDHKKM.js.map +0 -1
  252. package/dist/ja-YRQLEBFM.js.map +0 -1
  253. package/dist/ko-PG6B3CRB.js.map +0 -1
  254. package/dist/ms-7EZECVKM.js.map +0 -1
  255. package/dist/nl-HP753YAP.js.map +0 -1
  256. package/dist/no-JUD5WX3N.js.map +0 -1
  257. package/dist/pl-RVUL4CBM.js.map +0 -1
  258. package/dist/pt-RPOVD4QM.js.map +0 -1
  259. package/dist/ro-DMYWQTRD.js.map +0 -1
  260. package/dist/sv-DMZ4FGXD.js.map +0 -1
  261. package/dist/th-4HVBJBQ2.js.map +0 -1
  262. package/dist/tr-6BDPU5ML.js.map +0 -1
  263. package/dist/uk-TBZBT2TL.js.map +0 -1
  264. package/dist/vi-UMKZKZL3.js.map +0 -1
  265. package/dist/zh-YBJWJJYB.js.map +0 -1
  266. package/src/integrations/tailwind-plugin.cjs +0 -224
  267. /package/dist/{en-2KQILFKQ.js.map → en-2KADE7N4.js.map} +0 -0
@@ -95,7 +95,9 @@ const renderWithEventBus = (component: React.ReactElement<{ session: SemiontSess
95
95
  );
96
96
  // The component is provider-free: inject the factory session so it is the
97
97
  // SAME session whose client the tracker spies on / whose bus it subscribes.
98
- return render(React.cloneElement(component, { session }), { wrapper: Wrapper });
98
+ // The injected session/client are returned too: a test that spies on what the
99
+ // panel calls has to spy on THIS client, not a second factory's.
100
+ return { ...render(React.cloneElement(component, { session }), { wrapper: Wrapper }), session, client };
99
101
  };
100
102
 
101
103
  describe('ResourceInfoPanel Component', () => {
@@ -359,6 +361,40 @@ describe('ResourceInfoPanel Component', () => {
359
361
  expect(screen.getByText('urn:semiont:resource:abc123')).toBeInTheDocument();
360
362
  });
361
363
 
364
+ it('a derived-from id is a LINK, and clicking it opens that resource', () => {
365
+ // Rendering the id is half the feature; the provenance chain is only
366
+ // walkable if the id navigates. The click goes through the session's
367
+ // client, so a panel handed no session must not throw either — the
368
+ // optional chain below is load-bearing, not defensive noise.
369
+ // `renderWithEventBus` injects its own per-test session via cloneElement,
370
+ // so the spy has to go on THAT client — one created here would never be
371
+ // the one the button calls.
372
+ const { client } = renderWithEventBus(
373
+ <ResourceInfoPanel
374
+ {...defaultProps}
375
+ wasDerivedFrom="urn:semiont:resource:abc123"
376
+ />
377
+ );
378
+ const openSpy = vi.spyOn(client.browse, 'openResource');
379
+
380
+ fireEvent.click(screen.getByText('urn:semiont:resource:abc123'));
381
+ expect(openSpy).toHaveBeenCalledTimes(1);
382
+ expect(String(openSpy.mock.calls[0]![0])).toBe('urn:semiont:resource:abc123');
383
+ });
384
+
385
+ it('renders every id when the resource derives from several', () => {
386
+ renderWithEventBus(
387
+ <ResourceInfoPanel
388
+ {...defaultProps}
389
+ wasDerivedFrom={['urn:semiont:resource:a', 'urn:semiont:resource:b'] as never}
390
+ />
391
+ );
392
+ // The separator lives inside the button (`{i > 0 && ', '}{id}`), so the
393
+ // second one's text node is ", urn:…:b" — matched loosely on purpose.
394
+ expect(screen.getByText(/urn:semiont:resource:a/)).toBeInTheDocument();
395
+ expect(screen.getByText(/urn:semiont:resource:b/)).toBeInTheDocument();
396
+ });
397
+
362
398
  it('falls back to generator for attribution when wasAttributedTo is absent', () => {
363
399
  renderWithEventBus(
364
400
  <ResourceInfoPanel
@@ -183,7 +183,6 @@ describe('TagEntry', () => {
183
183
 
184
184
  expect(clickHandler).toHaveBeenCalledWith({
185
185
  annotationId: 'tag-1',
186
- motivation: 'tagging',
187
186
  });
188
187
 
189
188
  subscription.unsubscribe();
@@ -15,10 +15,6 @@
15
15
  color: var(--semiont-text-primary);
16
16
  }
17
17
 
18
- [data-theme="dark"] .semiont-settings-panel__title {
19
- color: var(--semiont-text-primary);
20
- }
21
-
22
18
  .semiont-settings-panel__content {
23
19
  display: flex;
24
20
  flex-direction: column;
@@ -53,19 +53,11 @@
53
53
  margin-bottom: 0.5rem;
54
54
  }
55
55
 
56
- [data-theme="dark"] .semiont-auth__welcome {
57
- color: var(--semiont-text-primary);
58
- }
59
-
60
56
  .semiont-auth__subtitle {
61
57
  font-size: 1rem;
62
58
  color: var(--semiont-text-secondary);
63
59
  }
64
60
 
65
- [data-theme="dark"] .semiont-auth__subtitle {
66
- color: var(--semiont-text-secondary);
67
- }
68
-
69
61
  /* Error display */
70
62
  .semiont-auth__error-container {
71
63
  margin-bottom: 1.5rem;
@@ -129,10 +121,6 @@
129
121
  letter-spacing: 0.05em;
130
122
  }
131
123
 
132
- [data-theme="dark"] .semiont-auth__divider-text {
133
- color: var(--semiont-text-secondary);
134
- }
135
-
136
124
  /* Info text */
137
125
  .semiont-auth__info {
138
126
  text-align: center;
@@ -227,10 +215,6 @@
227
215
  margin-bottom: 0.5rem;
228
216
  }
229
217
 
230
- [data-theme="dark"] .semiont-auth__signup-title {
231
- color: var(--semiont-text-primary);
232
- }
233
-
234
218
  .semiont-auth__signup-content {
235
219
  display: flex;
236
220
  flex-direction: column;
@@ -244,10 +228,6 @@
244
228
  line-height: 1.5;
245
229
  }
246
230
 
247
- [data-theme="dark"] .semiont-auth__signup-info {
248
- color: var(--semiont-text-secondary);
249
- }
250
-
251
231
  .semiont-auth__signup-footer {
252
232
  text-align: center;
253
233
  padding-top: 1rem;
@@ -316,12 +296,12 @@
316
296
  }
317
297
 
318
298
  .semiont-form__input-row:focus-visible {
319
- outline: 2px solid var(--semiont-focus-ring, #3b82f6);
299
+ outline: 2px solid var(--semiont-focus-ring);
320
300
  outline-offset: 2px;
321
301
  }
322
302
 
323
303
  [data-theme="dark"] .semiont-form__input-row:focus-visible {
324
- outline-color: var(--semiont-focus-ring, #60a5fa);
304
+ outline-color: var(--semiont-focus-ring);
325
305
  }
326
306
 
327
307
  .semiont-form__input-row .semiont-input {
@@ -329,12 +309,12 @@
329
309
  }
330
310
 
331
311
  .semiont-form__input-row .semiont-input:focus-visible {
332
- outline: 2px solid var(--semiont-focus-ring, #3b82f6);
312
+ outline: 2px solid var(--semiont-focus-ring);
333
313
  outline-offset: 2px;
334
314
  }
335
315
 
336
316
  [data-theme="dark"] .semiont-form__input-row .semiont-input:focus-visible {
337
- outline-color: var(--semiont-focus-ring, #60a5fa);
317
+ outline-color: var(--semiont-focus-ring);
338
318
  }
339
319
 
340
320
  .semiont-form__url-status {
@@ -345,27 +325,23 @@
345
325
  }
346
326
 
347
327
  .semiont-form__url-status--checking {
348
- color: var(--semiont-text-muted, #888888);
328
+ color: var(--semiont-text-tertiary);
349
329
  animation: spin 1s linear infinite;
350
330
  display: inline-block;
351
331
  }
352
332
 
353
- [data-theme="dark"] .semiont-form__url-status--checking {
354
- color: var(--semiont-text-muted, #888888);
355
- }
356
-
357
333
  .semiont-form__url-status--ok {
358
- color: var(--semiont-success, #22c55e);
334
+ color: var(--semiont-color-success);
359
335
  }
360
336
 
361
337
  [data-theme="dark"] .semiont-form__url-status--ok {
362
- color: var(--semiont-success, #22c55e);
338
+ color: var(--semiont-color-success-light);
363
339
  }
364
340
 
365
341
  .semiont-form__url-status--error {
366
- color: var(--semiont-error, #ef4444);
342
+ color: var(--semiont-color-error);
367
343
  }
368
344
 
369
345
  [data-theme="dark"] .semiont-form__url-status--error {
370
- color: var(--semiont-error, #ef4444);
346
+ color: var(--semiont-color-error-light);
371
347
  }
@@ -83,7 +83,14 @@ describe('createComposePageStateUnit', () => {
83
83
  });
84
84
 
85
85
  it('parses storedContext in reference mode', async () => {
86
- const context = { annotation: { id: 'ann-1' }, sourceContext: 'text' };
86
+ // A real `GatheredContext` shape. The old fixture here was
87
+ // `{ annotation, sourceContext }` — not a context at all — and it round-tripped
88
+ // because nothing checked. That is the hole the guard closes.
89
+ const context = {
90
+ focus: { kind: 'annotation', annotation: { id: 'ann-1' }, sourceResource: { id: 'doc-1' } },
91
+ graph: { nodes: [], edges: [] },
92
+ metadata: {},
93
+ };
87
94
  const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {
88
95
  annotationUri: 'ann-1',
89
96
  sourceDocumentId: 'doc-1',
@@ -97,6 +104,23 @@ describe('createComposePageStateUnit', () => {
97
104
  stateUnit.dispose();
98
105
  });
99
106
 
107
+ it('ignores a stash that parses but is not a GatheredContext', async () => {
108
+ // The realistic case: an entry written by an older build, read back after a
109
+ // deploy. It used to sail through and reach `deriveViews`, which maps
110
+ // `graph.nodes` during render and throws instead of degrading.
111
+ const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {
112
+ annotationUri: 'ann-1',
113
+ sourceDocumentId: 'doc-1',
114
+ name: 'Ref',
115
+ storedContext: JSON.stringify({ annotation: { id: 'ann-1' }, sourceContext: 'text' }),
116
+ });
117
+
118
+ // Treated as NO stash: the ordinary gather path owns the outcome from here.
119
+ expect(await firstValueFrom(stateUnit.gatheredContext$)).toBeNull();
120
+
121
+ stateUnit.dispose();
122
+ });
123
+
100
124
  it('ignores malformed storedContext', async () => {
101
125
  const stateUnit = createComposePageStateUnit(sessionOf(mockClient()), mockBrowse(), {
102
126
  annotationUri: 'ann-1',
@@ -1,6 +1,6 @@
1
1
  import { BehaviorSubject, type Observable } from 'rxjs';
2
2
  import type { GatheredContext, AnnotationId, AccessToken, ResourceDescriptor, ResourceId } from '@semiont/core';
3
- import { resourceId as makeResourceId, annotationId as makeAnnotationId } from '@semiont/core';
3
+ import { isGatheredContext, resourceId as makeResourceId, annotationId as makeAnnotationId } from '@semiont/core';
4
4
  import { createDisposer } from '@semiont/sdk';
5
5
  import type { StateUnit } from '@semiont/core';
6
6
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
@@ -102,7 +102,16 @@ export function createComposePageStateUnit(
102
102
  entityTypes,
103
103
  });
104
104
  if (params.storedContext) {
105
- try { gatheredContext$.next(JSON.parse(params.storedContext)); } catch { /* ignore malformed */ }
105
+ // The stash is the one place a `GatheredContext` arrives with its type
106
+ // history severed (sessionStorage → JSON.parse), so it is checked here and
107
+ // trusted everywhere after. A stale entry — a tab held open across a deploy
108
+ // — used to reach `deriveViews`, which maps `graph.nodes` during render and
109
+ // throws rather than degrading. A failed guard is treated as NO stash: the
110
+ // ordinary gather path runs, with the loading and error states it already has.
111
+ try {
112
+ const parsed: unknown = JSON.parse(params.storedContext);
113
+ if (isGatheredContext(parsed)) gatheredContext$.next(parsed);
114
+ } catch { /* ignore malformed */ }
106
115
  }
107
116
  loading$.next(false);
108
117
  } else if (isCloneMode) {
@@ -45,6 +45,7 @@ const createMockProps = (overrides?: Partial<ResourceDiscoveryPageProps>): Resou
45
45
  searchPlaceholder: 'Search resources...',
46
46
  searchButton: 'Search',
47
47
  searching: 'Searching...',
48
+ semanticFallbackNotice: 'No title matches — showing related documents.',
48
49
  filterByEntityType: 'Filter by type',
49
50
  all: 'All',
50
51
  recentResources: 'Recent Resources',
@@ -70,6 +71,60 @@ const renderWithProviders = (ui: React.ReactElement) => {
70
71
  };
71
72
 
72
73
  describe('ResourceDiscoveryPage', () => {
74
+ // SEMANTIC-FALLBACK P3b / S11. The distinction is asserted through a stable
75
+ // hook, never the sentence: decision 3 set the wording and it will be revised
76
+ // from use, so a copy-pinned test would rot on the first edit and teach the
77
+ // next reader to weaken it.
78
+ describe('semantic fallback notice', () => {
79
+ const NOTICE = 'semiont-semantic-match-notice';
80
+
81
+ it('marks a semantic result set as distinct from a lexical one', () => {
82
+ const props = createMockProps({
83
+ searchQuery: 'kitten',
84
+ searchDocuments: [createMockResource('r1', 'All about cats')],
85
+ searchMatchKind: 'semantic',
86
+ });
87
+ renderWithProviders(<ResourceDiscoveryPage {...props} />);
88
+
89
+ expect(screen.getByTestId(NOTICE)).toBeTruthy();
90
+ });
91
+
92
+ it('says nothing when the answer was lexical', () => {
93
+ const props = createMockProps({
94
+ searchQuery: 'cats',
95
+ searchDocuments: [createMockResource('r1', 'All about cats')],
96
+ searchMatchKind: 'lexical',
97
+ });
98
+ renderWithProviders(<ResourceDiscoveryPage {...props} />);
99
+
100
+ expect(screen.queryByTestId(NOTICE)).toBeNull();
101
+ });
102
+
103
+ it('does not carry a stale label onto the no-query view', () => {
104
+ // Clearing the box returns to "recent", which no label describes; a
105
+ // leftover `semantic` must not annotate that list.
106
+ const props = createMockProps({
107
+ searchQuery: '',
108
+ searchDocuments: [],
109
+ searchMatchKind: 'semantic',
110
+ });
111
+ renderWithProviders(<ResourceDiscoveryPage {...props} />);
112
+
113
+ expect(screen.queryByTestId(NOTICE)).toBeNull();
114
+ });
115
+
116
+ it('renders exactly as today when the prop is absent', () => {
117
+ const props = createMockProps({
118
+ searchQuery: 'cats',
119
+ searchDocuments: [createMockResource('r1', 'All about cats')],
120
+ });
121
+ renderWithProviders(<ResourceDiscoveryPage {...props} />);
122
+
123
+ expect(screen.queryByTestId(NOTICE)).toBeNull();
124
+ });
125
+ });
126
+
127
+
73
128
  beforeEach(() => {
74
129
  });
75
130
 
@@ -28,6 +28,16 @@ export interface ResourceDiscoveryPageProps {
28
28
  recentError?: Error | null;
29
29
  onRetryRecent?: () => void;
30
30
  isSearching: boolean;
31
+ /**
32
+ * How the current search answer was produced — `'lexical'` (title/metadata
33
+ * matching) or `'semantic'` (the vector fallback that runs only when lexical
34
+ * matching returned nothing). Undefined before the first answer, and on the
35
+ * no-query view where `recent` renders and no label describes it.
36
+ *
37
+ * Optional so hosts that never wire it — including external consumers —
38
+ * render exactly as before (SEMANTIC-FALLBACK P3b).
39
+ */
40
+ searchMatchKind?: 'lexical' | 'semantic';
31
41
 
32
42
  // Controlled search state
33
43
  searchQuery: string;
@@ -53,6 +63,7 @@ export interface ResourceDiscoveryPageProps {
53
63
  searchPlaceholder: string;
54
64
  searchButton: string;
55
65
  searching: string;
66
+ semanticFallbackNotice: string;
56
67
  filterByEntityType: string;
57
68
  all: string;
58
69
  recentResources: string;
@@ -80,6 +91,7 @@ export function ResourceDiscoveryPage({
80
91
  recentError = null,
81
92
  onRetryRecent,
82
93
  isSearching,
94
+ searchMatchKind,
83
95
  searchQuery,
84
96
  onSearchQueryChange,
85
97
  selectedEntityType,
@@ -232,6 +244,23 @@ export function ResourceDiscoveryPage({
232
244
  }
233
245
  </h3>
234
246
 
247
+ {/* Decision 3: label it, minimally. Someone who searches "kitten"
248
+ and gets documents about cats otherwise has no way to know why,
249
+ and an unexplained answer is a worse trust outcome than the
250
+ empty page this replaced. Only ever appears where the
251
+ alternative was that empty page (S5), so it never competes with
252
+ a better answer. `aria-live` because the results it explains
253
+ arrive without any control being activated. */}
254
+ {hasSearchQuery && searchMatchKind === 'semantic' && searchDocuments.length > 0 && (
255
+ <p
256
+ className="semiont-card__documents-note"
257
+ data-testid="semiont-semantic-match-notice"
258
+ aria-live="polite"
259
+ >
260
+ {t.semanticFallbackNotice}
261
+ </p>
262
+ )}
263
+
235
264
  {showNoResultsWarning && (
236
265
  <div className="semiont-card__warning">
237
266
  <p className="semiont-card__warning-text">
@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
2
2
  import { asStates } from '../../../../__tests__/test-client';
3
3
  import { sessionOf } from '../../../../__tests__/test-client';
4
4
  import { BehaviorSubject, firstValueFrom } from 'rxjs';
5
- import { filter, skip, take, toArray } from 'rxjs/operators';
5
+ import { filter, map, skip, take, toArray } from 'rxjs/operators';
6
6
  import type { SemiontClient } from '@semiont/sdk';
7
7
  import type { ShellStateUnit } from '../../../../state/shell-state-unit';
8
8
  import { createDiscoverStateUnit } from '../discover-state-unit';
@@ -23,6 +23,8 @@ function mockClient(overrides: {
23
23
  resources$?: BehaviorSubject<unknown[] | undefined>;
24
24
  entityTypes$?: BehaviorSubject<string[] | undefined>;
25
25
  resourcesFn?: (filters: BrowseFilters) => BehaviorSubject<unknown[] | undefined>;
26
+ /** What the stubbed list envelope reports as its match kind. */
27
+ matchKind?: 'lexical' | 'semantic';
26
28
  } = {}): { client: SemiontClient; resourceCalls: BrowseFilters[] } {
27
29
  const resourceCalls: BrowseFilters[] = [];
28
30
  const defaultResources$ =
@@ -36,7 +38,11 @@ function mockClient(overrides: {
36
38
  browse: {
37
39
  resources: (filters: BrowseFilters = {}) => {
38
40
  resourceCalls.push(filters);
39
- return asStates(resourcesFn(filters).asObservable());
41
+ return asStates(resourcesFn(filters).asObservable().pipe(
42
+ // Arrays get the list envelope `resources()` now emits; explicit
43
+ // status objects (B15 fixtures) and `undefined` pass through.
44
+ map((v) => (Array.isArray(v) ? { resources: v, total: v.length, offset: 0, limit: 20, matchKind: overrides.matchKind ?? 'lexical' } : v)),
45
+ ));
40
46
  },
41
47
  entityTypes: () => asStates(entityTypes$.asObservable()),
42
48
  },
@@ -45,6 +51,50 @@ function mockClient(overrides: {
45
51
  return { client, resourceCalls };
46
52
  }
47
53
 
54
+ describe('createDiscoverStateUnit — search match kind (SEMANTIC-FALLBACK P3b)', () => {
55
+ // S10 is tier-agnostic: the label and the resources it describes must arrive
56
+ // as ONE value. Exposing `matchKind$` as a second observable beside
57
+ // `state$.results` would satisfy the component and still let a render pair
58
+ // this query's label with the previous query's list.
59
+ it('emits matchKind in the SAME value as the results it labels', async () => {
60
+ vi.useFakeTimers();
61
+ try {
62
+ const results$ = new BehaviorSubject<unknown[] | undefined>([{ '@id': 'sem-1' }]);
63
+ const { client } = mockClient({ resourcesFn: () => results$, matchKind: 'semantic' });
64
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
65
+
66
+ // Subscribe BEFORE setQuery: `queryInput$` is a plain Subject, so a value
67
+ // pushed with nothing listening is gone.
68
+ const collected: Array<{ results: unknown[]; matchKind?: string }> = [];
69
+ const sub = stateUnit.search.state$.subscribe((st) => collected.push(st));
70
+
71
+ stateUnit.search.setQuery('kitten');
72
+ await vi.advanceTimersByTimeAsync(300);
73
+
74
+ const last = collected.at(-1)!;
75
+ expect(last.results).toEqual([{ '@id': 'sem-1' }]);
76
+ expect(last.matchKind).toBe('semantic');
77
+
78
+ sub.unsubscribe();
79
+ stateUnit.dispose();
80
+ } finally {
81
+ vi.useRealTimers();
82
+ }
83
+ });
84
+
85
+ it('carries no matchKind on the empty-query view', async () => {
86
+ // Clearing the box returns to `recent`, which no label describes.
87
+ const { client } = mockClient();
88
+ const stateUnit = createDiscoverStateUnit(sessionOf(client), mockBrowse());
89
+
90
+ const first = await firstValueFrom(stateUnit.search.state$);
91
+ expect(first.results).toEqual([]);
92
+ expect(first.matchKind).toBeUndefined();
93
+
94
+ stateUnit.dispose();
95
+ });
96
+ });
97
+
48
98
  describe('createDiscoverStateUnit', () => {
49
99
  it('exposes recent resources from browse namespace', async () => {
50
100
  const { client } = mockClient();
@@ -201,7 +251,7 @@ describe('createDiscoverStateUnit', () => {
201
251
  stateUnit.search.setQuery('lincoln');
202
252
  await vi.advanceTimersByTimeAsync(300);
203
253
 
204
- expect(collected.at(-1)).toEqual({ results: [{ '@id': 'hit' }], isSearching: false });
254
+ expect(collected.at(-1)).toEqual({ results: [{ '@id': 'hit' }], isSearching: false, matchKind: 'lexical' });
205
255
 
206
256
  sub.unsubscribe();
207
257
  stateUnit.dispose();
@@ -1,5 +1,5 @@
1
1
  import { BehaviorSubject, Subject, combineLatest, of, type Observable } from 'rxjs';
2
- import { readyValue } from '@semiont/sdk';
2
+ import { readyValue, type CacheState } from '@semiont/sdk';
3
3
  import { debounceTime, distinctUntilChanged, map, startWith, switchMap, shareReplay } from 'rxjs/operators';
4
4
  import type { ResourceDescriptor } from '@semiont/core';
5
5
  import type { SemiontSession } from '@semiont/sdk';
@@ -14,7 +14,18 @@ const DEBOUNCE_MS = 250;
14
14
 
15
15
  export interface DiscoverSearchPipeline {
16
16
  query$: Observable<string>;
17
- state$: Observable<{ results: ResourceDescriptor[]; isSearching: boolean }>;
17
+ /**
18
+ * The page AND the label that describes it, as one value. `matchKind` is
19
+ * carried here rather than beside it because SEMANTIC-FALLBACK S10 is
20
+ * tier-agnostic: two separately-observable pieces of state let a render pair
21
+ * this query's label with the previous query's list. Undefined only before
22
+ * the first answer (and while a query is empty), never as a third kind.
23
+ */
24
+ state$: Observable<{
25
+ results: ResourceDescriptor[];
26
+ isSearching: boolean;
27
+ matchKind?: 'lexical' | 'semantic';
28
+ }>;
18
29
  setQuery(value: string): void;
19
30
  }
20
31
 
@@ -58,7 +69,11 @@ export function createDiscoverStateUnit(
58
69
  limit: RECENT_LIMIT,
59
70
  archived: false,
60
71
  ...(et ? { entityType: et } : {}),
61
- }),
72
+ }).pipe(
73
+ // This unit renders only the page; project it out of the list
74
+ // envelope (`matchKind` rendering is SEMANTIC-FALLBACK P3b's).
75
+ map((st): CacheState<ResourceDescriptor[]> => (st.status === 'ready' ? { status: 'ready', value: st.value.resources } : st)),
76
+ ),
62
77
  ),
63
78
  ),
64
79
  [],
@@ -74,11 +89,13 @@ export function createDiscoverStateUnit(
74
89
  distinctUntilChanged(),
75
90
  );
76
91
 
77
- const state$: Observable<{ results: ResourceDescriptor[]; isSearching: boolean }> =
92
+ const state$: DiscoverSearchPipeline['state$'] =
78
93
  combineLatest([debouncedQuery$, selectedEntityType$]).pipe(
79
94
  switchMap(([q, et]) => {
80
95
  const trimmed = q.trim();
81
96
  if (!trimmed) {
97
+ // No query, no answer to label: `recent` is what renders, and no
98
+ // matchKind describes it.
82
99
  return of({ results: [] as ResourceDescriptor[], isSearching: false });
83
100
  }
84
101
  return client.browse
@@ -88,10 +105,14 @@ export function createDiscoverStateUnit(
88
105
  ...(et ? { entityType: et } : {}),
89
106
  })
90
107
  .pipe(
91
- map((st) => ({
92
- results: readyValue(st) ?? [],
93
- isSearching: st.status === 'pending',
94
- })),
108
+ map((st) => {
109
+ const ready = readyValue(st);
110
+ return {
111
+ results: ready?.resources ?? [],
112
+ isSearching: st.status === 'pending',
113
+ ...(ready ? { matchKind: ready.matchKind } : {}),
114
+ };
115
+ }),
95
116
  startWith({ results: [] as ResourceDescriptor[], isSearching: true }),
96
117
  );
97
118
  }),
@@ -123,6 +123,15 @@ vi.mock('../../../contexts/ResourceAnnotationsContext', () => ({
123
123
  ResourceAnnotationsProvider: ({ children }: any) => children,
124
124
  }));
125
125
 
126
+ // Capture what ResourceViewerPage passes to useOutcomeToasts. The real hook is a
127
+ // pure event subscription, so standing it in costs the other specs nothing.
128
+ const outcomeToastsCalls = vi.hoisted(() => [] as unknown[]);
129
+ vi.mock('../../../hooks/useOutcomeToasts', () => ({
130
+ useOutcomeToasts: (resourceId: unknown) => {
131
+ outcomeToastsCalls.push(resourceId);
132
+ },
133
+ }));
134
+
126
135
  // Mock useEventSubscription at the direct path used by ResourceViewerPage
127
136
  // (the barrel export mock doesn't intercept direct context imports)
128
137
  const mockUseEventSubscriptions = vi.fn();
@@ -180,6 +189,23 @@ const renderWithProviders = (ui: React.ReactElement) => {
180
189
  );
181
190
  };
182
191
 
192
+ describe('ResourceViewerPage — outcome toasts reach the user', () => {
193
+ // The decline/success toast was dropped for EVERY resource:
194
+ // `useOutcomeToasts(resource.id as string)` read a property
195
+ // `ResourceDescriptor` does not declare (it carries `@id`). The descriptor's
196
+ // open index signature made the access legal and the `as string` silenced its
197
+ // `unknown` type, so the hook's `event.resourceId !== resourceId` guard
198
+ // compared a real id against `undefined` and returned early every time.
199
+ it('subscribes with the resource id, not a property the descriptor lacks', () => {
200
+ outcomeToastsCalls.length = 0;
201
+ renderWithProviders(<ResourceViewerPage {...createMockProps()} />);
202
+
203
+ expect(outcomeToastsCalls).not.toHaveLength(0);
204
+ // Must be the resource's identity — `undefined` here silences every toast.
205
+ expect(outcomeToastsCalls[0]).toBe('test-123');
206
+ });
207
+ });
208
+
183
209
  describe('ResourceViewerPage', () => {
184
210
  beforeEach(() => {
185
211
  });