@semiont/react-ui 0.5.5 → 0.5.7

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 (218) hide show
  1. package/README.md +59 -55
  2. package/dist/{PdfAnnotationCanvas.client-CN3C3S55.js → PdfAnnotationCanvas.client-NIMALXNZ.js} +7 -27
  3. package/dist/PdfAnnotationCanvas.client-NIMALXNZ.js.map +1 -0
  4. package/dist/{ar-U2EXWUMQ.js → ar-SONK6MON.js} +3 -7
  5. package/dist/ar-SONK6MON.js.map +1 -0
  6. package/dist/{bn-DRJGV772.js → bn-ZKPRITNG.js} +3 -7
  7. package/dist/bn-ZKPRITNG.js.map +1 -0
  8. package/dist/{chunk-3Q3TUKWP.js → chunk-Y2EEAOMZ.js} +29 -29
  9. package/dist/{cs-PTWDM23V.js → cs-LPXQ7NHQ.js} +3 -7
  10. package/dist/cs-LPXQ7NHQ.js.map +1 -0
  11. package/dist/{da-KSNIKYSS.js → da-6TKY7MCY.js} +6 -10
  12. package/dist/da-6TKY7MCY.js.map +1 -0
  13. package/dist/{de-F2XBEWFY.js → de-C3GNII74.js} +3 -7
  14. package/dist/de-C3GNII74.js.map +1 -0
  15. package/dist/{el-DLD2GWAP.js → el-UBCXQDJ7.js} +3 -7
  16. package/dist/el-UBCXQDJ7.js.map +1 -0
  17. package/dist/{es-WLPYWGB5.js → es-BQ23TRI7.js} +11 -15
  18. package/dist/es-BQ23TRI7.js.map +1 -0
  19. package/dist/{fa-BAXHSDZG.js → fa-AFTBZB77.js} +3 -7
  20. package/dist/fa-AFTBZB77.js.map +1 -0
  21. package/dist/{fi-FCHSYVOT.js → fi-WOYNLZC2.js} +3 -7
  22. package/dist/fi-WOYNLZC2.js.map +1 -0
  23. package/dist/{fr-3UERBSL6.js → fr-NDSMIFJM.js} +3 -7
  24. package/dist/fr-NDSMIFJM.js.map +1 -0
  25. package/dist/{he-F6F3FV2K.js → he-VJXVRDOY.js} +3 -7
  26. package/dist/he-VJXVRDOY.js.map +1 -0
  27. package/dist/{hi-4BK6IK7Q.js → hi-BF6PHIE2.js} +3 -7
  28. package/dist/hi-BF6PHIE2.js.map +1 -0
  29. package/dist/{id-7ECCWP3J.js → id-GXG5QCZY.js} +3 -7
  30. package/dist/id-GXG5QCZY.js.map +1 -0
  31. package/dist/index.css +103 -0
  32. package/dist/index.css.map +1 -1
  33. package/dist/index.d.ts +271 -120
  34. package/dist/index.js +877 -698
  35. package/dist/index.js.map +1 -1
  36. package/dist/{it-234Z6XK6.js → it-XKHHCBAF.js} +3 -7
  37. package/dist/it-XKHHCBAF.js.map +1 -0
  38. package/dist/{ja-PJWQI4OQ.js → ja-TX7VM4XD.js} +3 -7
  39. package/dist/ja-TX7VM4XD.js.map +1 -0
  40. package/dist/{ko-APUEW2RS.js → ko-DNC7EQ7J.js} +3 -7
  41. package/dist/ko-DNC7EQ7J.js.map +1 -0
  42. package/dist/{ms-PJBZWZWD.js → ms-POZGBKPH.js} +3 -7
  43. package/dist/ms-POZGBKPH.js.map +1 -0
  44. package/dist/{nl-L4C3ZBCU.js → nl-IRMTKI7Z.js} +4 -11
  45. package/dist/nl-IRMTKI7Z.js.map +1 -0
  46. package/dist/{no-QE5N5KNG.js → no-ZUDJA4S6.js} +20 -24
  47. package/dist/no-ZUDJA4S6.js.map +1 -0
  48. package/dist/{pl-5Q2D23PD.js → pl-2NGAXL5U.js} +3 -7
  49. package/dist/pl-2NGAXL5U.js.map +1 -0
  50. package/dist/{pt-AIGUOIOC.js → pt-ABMCXZUM.js} +118 -122
  51. package/dist/pt-ABMCXZUM.js.map +1 -0
  52. package/dist/{ro-T56CSHTY.js → ro-VOJP6O5X.js} +3 -7
  53. package/dist/ro-VOJP6O5X.js.map +1 -0
  54. package/dist/{sv-L4TJQ2UH.js → sv-4HVFIIE5.js} +43 -47
  55. package/dist/sv-4HVFIIE5.js.map +1 -0
  56. package/dist/test-utils.js +2 -2
  57. package/dist/test-utils.js.map +1 -1
  58. package/dist/{th-6O7Y6O2Q.js → th-IFPZP3HQ.js} +3 -7
  59. package/dist/th-IFPZP3HQ.js.map +1 -0
  60. package/dist/{tr-D4CQCSNO.js → tr-2GYEAMJ4.js} +3 -7
  61. package/dist/tr-2GYEAMJ4.js.map +1 -0
  62. package/dist/{uk-2HMQG6ND.js → uk-XCJBVLLD.js} +3 -7
  63. package/dist/uk-XCJBVLLD.js.map +1 -0
  64. package/dist/{vi-XVJ4RUEJ.js → vi-4FR7CB2F.js} +3 -7
  65. package/dist/vi-4FR7CB2F.js.map +1 -0
  66. package/dist/{zh-K2KDPGHK.js → zh-NSKFOINB.js} +3 -7
  67. package/dist/zh-NSKFOINB.js.map +1 -0
  68. package/package.json +17 -13
  69. package/src/components/Button/__tests__/Button.test.tsx +0 -2
  70. package/src/components/CodeMirrorRenderer.tsx +2 -0
  71. package/src/components/ErrorBoundary.tsx +0 -9
  72. package/src/components/ProtectedErrorBoundary.css +119 -0
  73. package/src/components/ProtectedErrorBoundary.tsx +24 -15
  74. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +0 -1
  75. package/src/components/__tests__/ErrorBoundary.test.tsx +20 -13
  76. package/src/components/__tests__/LiveRegion.hooks.test.tsx +1 -1
  77. package/src/components/__tests__/ProtectedErrorBoundary.test.tsx +2 -1
  78. package/src/components/__tests__/ResizeHandle.test.tsx +0 -1
  79. package/src/components/__tests__/SessionExpiryBanner.test.tsx +0 -1
  80. package/src/components/__tests__/StatusDisplay.test.tsx +0 -1
  81. package/src/components/__tests__/Toast.test.tsx +2 -3
  82. package/src/components/__tests__/Toolbar.test.tsx +0 -1
  83. package/src/components/annotation/annotations.css +14 -0
  84. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +3 -5
  85. package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +0 -1
  86. package/src/components/branding/__tests__/SemiontBranding.test.tsx +1 -2
  87. package/src/components/layout/__tests__/LeftSidebar.test.tsx +5 -6
  88. package/src/components/layout/__tests__/PageLayout.test.tsx +1 -3
  89. package/src/components/layout/__tests__/SkipLinks.a11y.test.tsx +8 -8
  90. package/src/components/layout/__tests__/UnifiedHeader.test.tsx +12 -1
  91. package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +0 -1
  92. package/src/components/modals/__tests__/PermissionDeniedModal.test.tsx +3 -4
  93. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +1 -2
  94. package/src/components/modals/__tests__/SearchModal.basic.test.tsx +1 -1
  95. package/src/components/modals/__tests__/SearchModal.keyboard.test.tsx +0 -5
  96. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +1 -2
  97. package/src/components/modals/__tests__/SearchModal.visual.test.tsx +2 -2
  98. package/src/components/modals/__tests__/SessionExpiredModal.test.tsx +0 -1
  99. package/src/components/navigation/NavigationMenu.tsx +1 -1
  100. package/src/components/navigation/__tests__/Footer.a11y.test.tsx +4 -0
  101. package/src/components/navigation/__tests__/Footer.test.tsx +3 -6
  102. package/src/components/navigation/__tests__/NavigationMenu.a11y.test.tsx +1 -1
  103. package/src/components/navigation/__tests__/NavigationMenu.test.tsx +7 -9
  104. package/src/components/navigation/__tests__/ObservableLink.test.tsx +0 -1
  105. package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +1 -2
  106. package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +0 -1
  107. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +6 -4
  108. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +10 -19
  109. package/src/components/resource/AnnotateView.tsx +35 -37
  110. package/src/components/resource/BrowseView.tsx +31 -31
  111. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +0 -1
  112. package/src/components/resource/__tests__/BrowseView.test.tsx +12 -14
  113. package/src/components/resource/__tests__/HistoryEvent.test.tsx +0 -5
  114. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +4 -6
  115. package/src/components/resource/__tests__/event-formatting.test.ts +1 -1
  116. package/src/components/resource/panels/CollaborationPanel.tsx +1 -1
  117. package/src/components/resource/panels/JsonLdPanel.tsx +33 -16
  118. package/src/components/resource/panels/ReferencesPanel.tsx +1 -1
  119. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +4 -5
  120. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +8 -7
  121. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +14 -10
  122. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +0 -1
  123. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +31 -18
  124. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +7 -6
  125. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +5 -6
  126. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +19 -13
  127. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +95 -426
  128. package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +0 -1
  129. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +5 -5
  130. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +40 -7
  131. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +4 -4
  132. package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +30 -32
  133. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +6 -6
  134. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +7 -6
  135. package/src/components/settings/__tests__/SettingsPanel.test.tsx +0 -1
  136. package/src/components/viewers/__tests__/ImageViewer.test.tsx +0 -1
  137. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +7 -10
  138. package/src/features/admin-exchange/__tests__/ImportProgress.test.tsx +38 -27
  139. package/src/features/admin-exchange/components/ImportProgress.tsx +28 -34
  140. package/src/features/auth/__tests__/SignInForm.a11y.test.tsx +2 -0
  141. package/src/features/auth/__tests__/SignUpForm.a11y.test.tsx +11 -12
  142. package/src/features/auth/__tests__/SignUpForm.test.tsx +3 -3
  143. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -0
  144. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +11 -9
  145. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +2 -1
  146. package/src/features/resource-compose/components/ResourceComposePage.tsx +36 -9
  147. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -8
  148. package/src/features/resource-discovery/__tests__/ResourceCard.test.tsx +0 -1
  149. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +33 -35
  150. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +12 -11
  151. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +204 -11
  152. package/src/features/resource-discovery/state/discover-state-unit.ts +70 -11
  153. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +2 -2
  154. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +10 -7
  155. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +37 -1
  156. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +14 -7
  157. package/src/integrations/__tests__/css-modules-helper.test.tsx +2 -3
  158. package/src/integrations/__tests__/styled-components-theme.test.ts +1 -3
  159. package/src/styles/features/exchange.css +0 -30
  160. package/src/styles/index.css +1 -0
  161. package/translations/ar.json +1 -3
  162. package/translations/bn.json +1 -3
  163. package/translations/cs.json +1 -3
  164. package/translations/da.json +4 -6
  165. package/translations/de.json +1 -3
  166. package/translations/el.json +1 -3
  167. package/translations/es.json +9 -11
  168. package/translations/fa.json +1 -3
  169. package/translations/fi.json +1 -3
  170. package/translations/fr.json +1 -3
  171. package/translations/he.json +1 -3
  172. package/translations/hi.json +1 -3
  173. package/translations/id.json +1 -3
  174. package/translations/it.json +1 -3
  175. package/translations/ja.json +1 -3
  176. package/translations/ko.json +1 -3
  177. package/translations/ms.json +1 -3
  178. package/translations/nl.json +2 -7
  179. package/translations/no.json +18 -20
  180. package/translations/pl.json +1 -3
  181. package/translations/pt.json +116 -118
  182. package/translations/ro.json +1 -3
  183. package/translations/sv.json +41 -43
  184. package/translations/th.json +1 -3
  185. package/translations/tr.json +1 -3
  186. package/translations/uk.json +1 -3
  187. package/translations/vi.json +1 -3
  188. package/translations/zh.json +1 -3
  189. package/dist/PdfAnnotationCanvas.client-CN3C3S55.js.map +0 -1
  190. package/dist/ar-U2EXWUMQ.js.map +0 -1
  191. package/dist/bn-DRJGV772.js.map +0 -1
  192. package/dist/cs-PTWDM23V.js.map +0 -1
  193. package/dist/da-KSNIKYSS.js.map +0 -1
  194. package/dist/de-F2XBEWFY.js.map +0 -1
  195. package/dist/el-DLD2GWAP.js.map +0 -1
  196. package/dist/es-WLPYWGB5.js.map +0 -1
  197. package/dist/fa-BAXHSDZG.js.map +0 -1
  198. package/dist/fi-FCHSYVOT.js.map +0 -1
  199. package/dist/fr-3UERBSL6.js.map +0 -1
  200. package/dist/he-F6F3FV2K.js.map +0 -1
  201. package/dist/hi-4BK6IK7Q.js.map +0 -1
  202. package/dist/id-7ECCWP3J.js.map +0 -1
  203. package/dist/it-234Z6XK6.js.map +0 -1
  204. package/dist/ja-PJWQI4OQ.js.map +0 -1
  205. package/dist/ko-APUEW2RS.js.map +0 -1
  206. package/dist/ms-PJBZWZWD.js.map +0 -1
  207. package/dist/nl-L4C3ZBCU.js.map +0 -1
  208. package/dist/no-QE5N5KNG.js.map +0 -1
  209. package/dist/pl-5Q2D23PD.js.map +0 -1
  210. package/dist/pt-AIGUOIOC.js.map +0 -1
  211. package/dist/ro-T56CSHTY.js.map +0 -1
  212. package/dist/sv-L4TJQ2UH.js.map +0 -1
  213. package/dist/th-6O7Y6O2Q.js.map +0 -1
  214. package/dist/tr-D4CQCSNO.js.map +0 -1
  215. package/dist/uk-2HMQG6ND.js.map +0 -1
  216. package/dist/vi-XVJ4RUEJ.js.map +0 -1
  217. package/dist/zh-K2KDPGHK.js.map +0 -1
  218. /package/dist/{chunk-3Q3TUKWP.js.map → chunk-Y2EEAOMZ.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { BehaviorSubject, firstValueFrom } from 'rxjs';
3
- import { filter } from 'rxjs/operators';
3
+ import { filter, skip, take, toArray } from 'rxjs/operators';
4
4
  import type { SemiontClient } from '@semiont/sdk';
5
5
  import type { ShellStateUnit } from '../../../../state/shell-state-unit';
6
6
  import { createDiscoverStateUnit } from '../discover-state-unit';
@@ -9,23 +9,43 @@ function mockBrowse(): ShellStateUnit {
9
9
  return { dispose: vi.fn() } as unknown as ShellStateUnit;
10
10
  }
11
11
 
12
+ interface BrowseFilters {
13
+ limit?: number;
14
+ archived?: boolean;
15
+ search?: string;
16
+ entityType?: string;
17
+ }
18
+
12
19
  function mockClient(overrides: {
13
20
  resources$?: BehaviorSubject<unknown[] | undefined>;
14
21
  entityTypes$?: BehaviorSubject<string[] | undefined>;
15
- } = {}): SemiontClient {
16
- const resources$ = overrides.resources$ ?? new BehaviorSubject<unknown[] | undefined>([{ '@id': 'r1' }]);
17
- const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
18
- return {
22
+ resourcesFn?: (filters: BrowseFilters) => BehaviorSubject<unknown[] | undefined>;
23
+ } = {}): { client: SemiontClient; resourceCalls: BrowseFilters[] } {
24
+ const resourceCalls: BrowseFilters[] = [];
25
+ const defaultResources$ =
26
+ overrides.resources$ ?? new BehaviorSubject<unknown[] | undefined>([{ '@id': 'r1' }]);
27
+ const entityTypes$ =
28
+ overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
29
+
30
+ const resourcesFn = overrides.resourcesFn ?? (() => defaultResources$);
31
+
32
+ const client = {
19
33
  browse: {
20
- resources: () => resources$.asObservable(),
34
+ resources: (filters: BrowseFilters = {}) => {
35
+ resourceCalls.push(filters);
36
+ return resourcesFn(filters).asObservable();
37
+ },
21
38
  entityTypes: () => entityTypes$.asObservable(),
22
39
  },
23
40
  } as unknown as SemiontClient;
41
+
42
+ return { client, resourceCalls };
24
43
  }
25
44
 
26
45
  describe('createDiscoverStateUnit', () => {
27
46
  it('exposes recent resources from browse namespace', async () => {
28
- const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
47
+ const { client } = mockClient();
48
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
29
49
 
30
50
  const recent = await firstValueFrom(stateUnit.recentResources$);
31
51
  expect(recent).toEqual([{ '@id': 'r1' }]);
@@ -34,7 +54,8 @@ describe('createDiscoverStateUnit', () => {
34
54
  });
35
55
 
36
56
  it('exposes entity types from browse namespace', async () => {
37
- const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
57
+ const { client } = mockClient();
58
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
38
59
 
39
60
  const types = await firstValueFrom(stateUnit.entityTypes$);
40
61
  expect(types).toEqual(['Person']);
@@ -42,9 +63,21 @@ describe('createDiscoverStateUnit', () => {
42
63
  stateUnit.dispose();
43
64
  });
44
65
 
66
+ it('falls back to [] when entityTypes() emits undefined', async () => {
67
+ const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
68
+ const { client } = mockClient({ entityTypes$ });
69
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
70
+
71
+ const types = await firstValueFrom(stateUnit.entityTypes$);
72
+ expect(types).toEqual([]);
73
+
74
+ stateUnit.dispose();
75
+ });
76
+
45
77
  it('reports loading when resources are undefined', async () => {
46
78
  const resources$ = new BehaviorSubject<unknown[] | undefined>(undefined);
47
- const stateUnit = createDiscoverStateUnit(mockClient({ resources$ }), mockBrowse());
79
+ const { client } = mockClient({ resources$ });
80
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
48
81
 
49
82
  const loading = await firstValueFrom(stateUnit.isLoadingRecent$);
50
83
  expect(loading).toBe(true);
@@ -57,7 +90,8 @@ describe('createDiscoverStateUnit', () => {
57
90
  });
58
91
 
59
92
  it('exposes a search pipeline', () => {
60
- const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
93
+ const { client } = mockClient();
94
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
61
95
 
62
96
  expect(stateUnit.search).toBeDefined();
63
97
  expect(typeof stateUnit.search.setQuery).toBe('function');
@@ -68,9 +102,168 @@ describe('createDiscoverStateUnit', () => {
68
102
 
69
103
  it('disposes browse and search on dispose', () => {
70
104
  const browse = mockBrowse();
71
- const stateUnit = createDiscoverStateUnit(mockClient(), browse);
105
+ const { client } = mockClient();
106
+ const stateUnit = createDiscoverStateUnit(client, browse);
72
107
  stateUnit.dispose();
73
108
 
74
109
  expect(browse.dispose).toHaveBeenCalled();
75
110
  });
111
+
112
+ it('initial selectedEntityType$ is empty and recent fetch carries no entityType', async () => {
113
+ const { client, resourceCalls } = mockClient();
114
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
115
+
116
+ await firstValueFrom(stateUnit.recentResources$);
117
+
118
+ expect(resourceCalls).toHaveLength(1);
119
+ expect(resourceCalls[0]).toEqual({ limit: 10, archived: false });
120
+ expect(await firstValueFrom(stateUnit.selectedEntityType$)).toBe('');
121
+
122
+ stateUnit.dispose();
123
+ });
124
+
125
+ it('setSelectedEntityType drives a refetch with the entityType filter', async () => {
126
+ const { client, resourceCalls } = mockClient();
127
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
128
+
129
+ // Prime the first subscription so the switchMap is live.
130
+ const sub = stateUnit.recentResources$.subscribe();
131
+
132
+ stateUnit.setSelectedEntityType('Person');
133
+
134
+ expect(await firstValueFrom(stateUnit.selectedEntityType$)).toBe('Person');
135
+ // Two calls expected: initial '' then 'Person'.
136
+ expect(resourceCalls.length).toBeGreaterThanOrEqual(2);
137
+ expect(resourceCalls.at(-1)).toEqual({ limit: 10, archived: false, entityType: 'Person' });
138
+
139
+ sub.unsubscribe();
140
+ stateUnit.dispose();
141
+ });
142
+
143
+ it('search with an empty query yields no results without hitting the wire', async () => {
144
+ vi.useFakeTimers();
145
+ try {
146
+ const { client, resourceCalls } = mockClient();
147
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
148
+
149
+ const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
150
+ const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
151
+
152
+ await vi.advanceTimersByTimeAsync(300);
153
+
154
+ expect(collected.at(-1)).toEqual({ results: [], isSearching: false });
155
+ const searchCalls = resourceCalls.filter((c) => c.search !== undefined);
156
+ expect(searchCalls).toHaveLength(0);
157
+
158
+ sub.unsubscribe();
159
+ stateUnit.dispose();
160
+ } finally {
161
+ vi.useRealTimers();
162
+ }
163
+ });
164
+
165
+ it('search with a non-empty query and selected entityType pushes both into the filter', async () => {
166
+ vi.useFakeTimers();
167
+ try {
168
+ const results$ = new BehaviorSubject<unknown[] | undefined>([{ '@id': 'hit' }]);
169
+ const { client, resourceCalls } = mockClient({
170
+ resourcesFn: (filters) => (filters.search ? results$ : new BehaviorSubject<unknown[] | undefined>([])),
171
+ });
172
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
173
+
174
+ const sub = stateUnit.search.state$.subscribe();
175
+ stateUnit.setSelectedEntityType('Person');
176
+ stateUnit.search.setQuery('lincoln');
177
+
178
+ await vi.advanceTimersByTimeAsync(300);
179
+
180
+ const searchCalls = resourceCalls.filter((c) => c.search !== undefined);
181
+ expect(searchCalls.length).toBeGreaterThanOrEqual(1);
182
+ expect(searchCalls.at(-1)).toEqual({
183
+ search: 'lincoln',
184
+ limit: 20,
185
+ entityType: 'Person',
186
+ });
187
+
188
+ sub.unsubscribe();
189
+ stateUnit.dispose();
190
+ } finally {
191
+ vi.useRealTimers();
192
+ }
193
+ });
194
+
195
+ it('search results flow through state$ once the debounced query fetches', async () => {
196
+ vi.useFakeTimers();
197
+ try {
198
+ const results$ = new BehaviorSubject<unknown[] | undefined>([{ '@id': 'hit' }]);
199
+ const { client } = mockClient({
200
+ resourcesFn: () => results$,
201
+ });
202
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
203
+
204
+ const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
205
+ const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
206
+
207
+ stateUnit.search.setQuery('lincoln');
208
+ await vi.advanceTimersByTimeAsync(300);
209
+
210
+ expect(collected.at(-1)).toEqual({ results: [{ '@id': 'hit' }], isSearching: false });
211
+
212
+ sub.unsubscribe();
213
+ stateUnit.dispose();
214
+ } finally {
215
+ vi.useRealTimers();
216
+ }
217
+ });
218
+
219
+ it('search reports isSearching while the fetch is in flight', async () => {
220
+ vi.useFakeTimers();
221
+ try {
222
+ const inflight$ = new BehaviorSubject<unknown[] | undefined>(undefined);
223
+ const { client } = mockClient({
224
+ resourcesFn: () => inflight$,
225
+ });
226
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
227
+
228
+ const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
229
+ const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
230
+
231
+ stateUnit.search.setQuery('lincoln');
232
+ await vi.advanceTimersByTimeAsync(300);
233
+
234
+ expect(collected.at(-1)).toEqual({ results: [], isSearching: true });
235
+
236
+ sub.unsubscribe();
237
+ stateUnit.dispose();
238
+ } finally {
239
+ vi.useRealTimers();
240
+ }
241
+ });
242
+
243
+ it('search query observable echoes the latest setQuery value', async () => {
244
+ const { client } = mockClient();
245
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
246
+
247
+ const queries = stateUnit.search.query$.pipe(skip(1), take(1), toArray()).toPromise();
248
+ stateUnit.search.setQuery('alpha');
249
+
250
+ expect(await queries).toEqual(['alpha']);
251
+
252
+ stateUnit.dispose();
253
+ });
254
+
255
+ it('omits entityType from the filter when the empty sentinel is selected', async () => {
256
+ const { client, resourceCalls } = mockClient();
257
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
258
+
259
+ const sub = stateUnit.recentResources$.subscribe();
260
+ stateUnit.setSelectedEntityType('Person');
261
+ stateUnit.setSelectedEntityType('');
262
+
263
+ const last = resourceCalls.at(-1)!;
264
+ expect(last.entityType).toBeUndefined();
265
+
266
+ sub.unsubscribe();
267
+ stateUnit.dispose();
268
+ });
76
269
  });
@@ -1,20 +1,28 @@
1
- import { map, type Observable } from 'rxjs';
1
+ import { BehaviorSubject, Subject, combineLatest, of, type Observable } from 'rxjs';
2
+ import { debounceTime, distinctUntilChanged, map, startWith, switchMap, shareReplay } from 'rxjs/operators';
2
3
  import type { ResourceDescriptor } from '@semiont/core';
3
- import type { StateUnit } from '@semiont/sdk';
4
+ import type { SemiontClient, StateUnit } from '@semiont/sdk';
4
5
  import { createDisposer } from '@semiont/sdk';
5
6
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
6
- import { createSearchPipeline, type SearchPipeline } from '@semiont/sdk';
7
- import type { SemiontClient } from '@semiont/sdk';
8
7
 
9
8
  const RECENT_LIMIT = 10;
10
9
  const SEARCH_LIMIT = 20;
10
+ const DEBOUNCE_MS = 250;
11
+
12
+ export interface DiscoverSearchPipeline {
13
+ query$: Observable<string>;
14
+ state$: Observable<{ results: ResourceDescriptor[]; isSearching: boolean }>;
15
+ setQuery(value: string): void;
16
+ }
11
17
 
12
18
  export interface DiscoverStateUnit extends StateUnit {
13
19
  browse: ShellStateUnit;
14
- search: SearchPipeline<ResourceDescriptor>;
20
+ search: DiscoverSearchPipeline;
15
21
  recentResources$: Observable<ResourceDescriptor[]>;
16
22
  entityTypes$: Observable<string[]>;
17
23
  isLoadingRecent$: Observable<boolean>;
24
+ selectedEntityType$: Observable<string>;
25
+ setSelectedEntityType(value: string): void;
18
26
  }
19
27
 
20
28
  export function createDiscoverStateUnit(
@@ -22,14 +30,27 @@ export function createDiscoverStateUnit(
22
30
  browse: ShellStateUnit,
23
31
  ): DiscoverStateUnit {
24
32
  const disposer = createDisposer();
25
-
26
- const search = createSearchPipeline<ResourceDescriptor>((q) =>
27
- client.browse.resources({ search: q, limit: SEARCH_LIMIT }),
28
- );
29
- disposer.add(search);
30
33
  disposer.add(browse);
31
34
 
32
- const recent$ = client.browse.resources({ limit: RECENT_LIMIT, archived: false });
35
+ // Selected entity-type chip on the Discover page. Drives both the
36
+ // `recent` list and the search results — filtering happens on the
37
+ // backend, not via post-fetch array filtering.
38
+ const selectedEntityType$ = new BehaviorSubject<string>('');
39
+ disposer.add(() => selectedEntityType$.complete());
40
+
41
+ const queryInput$ = new Subject<string>();
42
+ disposer.add(() => queryInput$.complete());
43
+
44
+ const recent$ = selectedEntityType$.pipe(
45
+ switchMap((et) =>
46
+ client.browse.resources({
47
+ limit: RECENT_LIMIT,
48
+ archived: false,
49
+ ...(et ? { entityType: et } : {}),
50
+ }),
51
+ ),
52
+ shareReplay({ bufferSize: 1, refCount: true }),
53
+ );
33
54
 
34
55
  const recentResources$: Observable<ResourceDescriptor[]> = recent$.pipe(
35
56
  map((r) => r ?? []),
@@ -43,12 +64,50 @@ export function createDiscoverStateUnit(
43
64
  map((e) => e ?? []),
44
65
  );
45
66
 
67
+ const debouncedQuery$ = queryInput$.pipe(
68
+ startWith(''),
69
+ debounceTime(DEBOUNCE_MS),
70
+ distinctUntilChanged(),
71
+ );
72
+
73
+ const state$: Observable<{ results: ResourceDescriptor[]; isSearching: boolean }> =
74
+ combineLatest([debouncedQuery$, selectedEntityType$]).pipe(
75
+ switchMap(([q, et]) => {
76
+ const trimmed = q.trim();
77
+ if (!trimmed) {
78
+ return of({ results: [] as ResourceDescriptor[], isSearching: false });
79
+ }
80
+ return client.browse
81
+ .resources({
82
+ search: trimmed,
83
+ limit: SEARCH_LIMIT,
84
+ ...(et ? { entityType: et } : {}),
85
+ })
86
+ .pipe(
87
+ map((results) => ({
88
+ results: results ?? [],
89
+ isSearching: results === undefined,
90
+ })),
91
+ startWith({ results: [] as ResourceDescriptor[], isSearching: true }),
92
+ );
93
+ }),
94
+ shareReplay({ bufferSize: 1, refCount: true }),
95
+ );
96
+
97
+ const search: DiscoverSearchPipeline = {
98
+ query$: queryInput$.pipe(startWith('')),
99
+ state$,
100
+ setQuery: (value) => queryInput$.next(value),
101
+ };
102
+
46
103
  return {
47
104
  browse,
48
105
  search,
49
106
  recentResources$,
50
107
  entityTypes$,
51
108
  isLoadingRecent$,
109
+ selectedEntityType$: selectedEntityType$.asObservable(),
110
+ setSelectedEntityType: (value) => selectedEntityType$.next(value),
52
111
  dispose: () => disposer.dispose(),
53
112
  };
54
113
  }
@@ -13,6 +13,7 @@ import type { ResourceViewerPageProps } from '../components/ResourceViewerPage';
13
13
  import { ToastProvider } from '../../../components/Toast';
14
14
  import { ThemeProvider } from '../../../contexts/ThemeContext';
15
15
  import { createTestSemiontWrapper } from '../../../test-utils';
16
+ import type { ResourceId } from '@semiont/core';
16
17
 
17
18
  // jsdom doesn't implement window.matchMedia — mock it for useTheme
18
19
  Object.defineProperty(window, 'matchMedia', {
@@ -131,7 +132,7 @@ vi.mock('@/components/toolbar/ToolbarPanels', () => ({
131
132
  const createMockProps = (overrides?: Partial<ResourceViewerPageProps>): ResourceViewerPageProps => ({
132
133
  resource: {
133
134
  '@context': 'https://www.w3.org/ns/anno.jsonld',
134
- '@id': 'test-123',
135
+ '@id': 'test-123' as ResourceId,
135
136
  '@type': 'schema:DigitalDocument',
136
137
  name: 'Test Resource',
137
138
  description: 'A test resource for unit testing',
@@ -147,7 +148,6 @@ const createMockProps = (overrides?: Partial<ResourceViewerPageProps>): Resource
147
148
  },
148
149
  rUri: 'test-123' as any,
149
150
  locale: 'en',
150
- cacheManager: {},
151
151
  Link: ({ children }: any) => <a>{children}</a>,
152
152
  routes: {},
153
153
  refetchDocument: vi.fn().mockResolvedValue(undefined),
@@ -9,8 +9,7 @@ import React, { useState, useEffect, useCallback } from 'react';
9
9
  import type { components, ResourceDescriptor, ResourceId, GatheredContext, EventMap } from '@semiont/core';
10
10
  import type { ConnectionState } from '@semiont/core';
11
11
  import { annotationId } from '@semiont/core';
12
- import { getLanguage, getPrimaryRepresentation, getPrimaryMediaType } from '@semiont/core';
13
- import { getMimeCategory } from '@semiont/core';
12
+ import { getLanguage, getPrimaryRepresentation, getPrimaryMediaType, capabilitiesOf } from '@semiont/core';
14
13
  import { ANNOTATORS } from '@semiont/react-ui';
15
14
  import { ErrorBoundary } from '@semiont/react-ui';
16
15
  import { AnnotationHistory } from '@semiont/react-ui';
@@ -141,9 +140,12 @@ export function ResourceViewerPage({
141
140
  const { hoverDelayMs } = useHoverDelay();
142
141
  const { triggerSparkleAnimation, clearNewAnnotationId } = useResourceAnnotations();
143
142
 
144
- // Determine MIME category to choose content path
143
+ // Render mode chooses the content path: 'text' decodes inline; 'image'
144
+ // and 'pdf' go through the media-token (binary) path. 'none'/registry-miss
145
+ // fall to the text path harmlessly — the viewer shows metadata + download.
145
146
  const resourceMediaType = getPrimaryMediaType(resource) || 'text/plain';
146
- const isBinary = getMimeCategory(resourceMediaType) === 'image';
147
+ const renderMode = capabilitiesOf(resourceMediaType)?.render;
148
+ const isBinary = renderMode === 'image' || renderMode === 'pdf';
147
149
 
148
150
  // Text path: fetch and decode representation (disabled for binary — mediaToken path handles those)
149
151
  const { content: textContent, loading: textLoading } = useResourceContent(rUri, resource, !isBinary);
@@ -265,8 +267,9 @@ export function ResourceViewerPage({
265
267
 
266
268
  // Domain events flow through the bus gateway (ActorStateUnit → local EventBus).
267
269
  // BrowseNamespace cache invalidation handles annotation/resource updates.
268
- // The resource-viewer-page-state-unit calls client.subscribeToResource(resourceId)
269
- // which bridges scoped domain events into the local EventBus.
270
+ // Resource-scoped freshness follows observation (#847): subscribing to the
271
+ // resource's `browse.*` live queries acquires its scope (which bridges scoped
272
+ // domain events into the local EventBus) and releases it on teardown.
270
273
 
271
274
  const handleResourceArchive = useCallback(async () => {
272
275
  if (!semiont) return;
@@ -576,7 +579,7 @@ export function ResourceViewerPage({
576
579
 
577
580
  {/* JSON-LD Panel */}
578
581
  {activePanel === 'jsonld' && (
579
- <JsonLdPanel resource={resource} />
582
+ <JsonLdPanel resourceId={rUri} />
580
583
  )}
581
584
  </ToolbarPanels>
582
585
 
@@ -56,7 +56,6 @@ function clientWithNamespaces(overrides: {
56
56
  match: { search: vi.fn(() => new Observable(() => {})) },
57
57
  yield: { fromAnnotation: vi.fn(() => new Observable(() => {})) },
58
58
  bind: { body: vi.fn().mockResolvedValue(undefined) },
59
- subscribeToResource: vi.fn().mockReturnValue(() => {}),
60
59
  });
61
60
  }
62
61
 
@@ -151,6 +150,43 @@ describe('createResourceViewerPageStateUnit', () => {
151
150
  stateUnit.dispose();
152
151
  });
153
152
 
153
+ // isBinaryType keys off textExtractionOf(...) !== 'decode', not render mode:
154
+ // storage-tier binary (ZIP, gif/webp) must be fetched as bytes, never
155
+ // decoded as text — the client-side twin of the Phase 3a serving-side fix.
156
+ it('fetches storage-tier binary (ZIP) as bytes, never the text-decode path', async () => {
157
+ const mediaToken = vi.fn().mockResolvedValue({ token: 'tok-zip' });
158
+ const resourceRepresentation = vi.fn();
159
+ tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
160
+ const stateUnit = createResourceViewerPageStateUnit(
161
+ tc.client, RID, 'en', mockBrowse(),
162
+ { mediaType: 'application/zip' },
163
+ );
164
+
165
+ const token = await firstValueFrom(stateUnit.mediaToken$.pipe(filter((t) => t !== null)));
166
+ expect(token).toBe('tok-zip');
167
+ expect(resourceRepresentation).not.toHaveBeenCalled();
168
+
169
+ stateUnit.dispose();
170
+ });
171
+
172
+ it('decodes a registry-miss text/* subtype via the text path (RFC 2046)', async () => {
173
+ const mediaToken = vi.fn();
174
+ const resourceRepresentation = vi.fn().mockResolvedValue({
175
+ data: new TextEncoder().encode('hi').buffer,
176
+ contentType: 'text/x-custom',
177
+ });
178
+ tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
179
+ const stateUnit = createResourceViewerPageStateUnit(
180
+ tc.client, RID, 'en', mockBrowse(),
181
+ { mediaType: 'text/x-custom' },
182
+ );
183
+
184
+ expect(resourceRepresentation).toHaveBeenCalled();
185
+ expect(mediaToken).not.toHaveBeenCalled();
186
+
187
+ stateUnit.dispose();
188
+ });
189
+
154
190
  it('wizard initializes closed', async () => {
155
191
  tc = clientWithNamespaces();
156
192
  const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
@@ -9,7 +9,7 @@ import { createGatherStateUnit, type GatherStateUnit } from '@semiont/sdk';
9
9
  import { createMatchStateUnit } from '@semiont/sdk';
10
10
  import { createYieldStateUnit, type YieldStateUnit } from '@semiont/sdk';
11
11
  import type { SemiontClient } from '@semiont/sdk';
12
- import { decodeWithCharset } from '@semiont/core';
12
+ import { decodeWithCharset, textExtractionOf } from '@semiont/core';
13
13
  import { isHighlight, isComment, isAssessment, isReference, isTag } from '@semiont/core';
14
14
  import type { ReferencedByEntry } from '@semiont/sdk';
15
15
 
@@ -113,13 +113,17 @@ export function createResourceViewerPageStateUnit(
113
113
  const mediaToken$ = new BehaviorSubject<string | null>(null);
114
114
 
115
115
  const mediaType = options?.mediaType || 'text/plain';
116
- const isBinaryType = mediaType.startsWith('image/') || mediaType === 'application/pdf';
116
+ // "Fetch raw bytes or decode as text?" — binary iff the registry says this
117
+ // type does not decode to text. Storage-tier images (gif/webp) are
118
+ // render:'none' but still binary, and a ZIP must avoid the text path; a
119
+ // mechanical render-mode check would mis-route both into mojibake.
120
+ const isBinaryType = textExtractionOf(mediaType) !== 'decode';
117
121
 
118
122
  if (!isBinaryType && mediaType) {
119
123
  contentLoading$.next(true);
120
- client.browse.resourceRepresentation(resourceId, { accept: mediaType })
121
- .then(({ data }) => {
122
- content$.next(decodeWithCharset(data, mediaType));
124
+ client.browse.resourceRepresentation(resourceId)
125
+ .then(({ data, contentType }) => {
126
+ content$.next(decodeWithCharset(data, contentType));
123
127
  contentLoading$.next(false);
124
128
  })
125
129
  .catch(() => { contentLoading$.next(false); });
@@ -133,8 +137,11 @@ export function createResourceViewerPageStateUnit(
133
137
 
134
138
  const wizard$ = new BehaviorSubject<WizardState>(WIZARD_CLOSED);
135
139
 
136
- const unsubscribeResource = client.subscribeToResource(resourceId);
137
- disposer.add(unsubscribeResource);
140
+ // Resource-scoped freshness follows observation (#847): subscribing to the
141
+ // `browse.*(resourceId)` live queries exposed by this state unit
142
+ // (annotations$, events$, referencedBy$) acquires the resource scope for as
143
+ // long as they're observed and releases it on teardown — so no manual
144
+ // `subscribeToResource` call is needed.
138
145
 
139
146
  const bindInitiateSub = client.bus.get('bind:initiate').subscribe((event) => {
140
147
  wizard$.next({
@@ -1,5 +1,4 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import React from 'react';
3
2
  import { render } from '@testing-library/react';
4
3
  import {
5
4
  createSemiontClassName,
@@ -96,7 +95,7 @@ describe('css-modules-helper', () => {
96
95
 
97
96
  describe('mergeDataAttributes', () => {
98
97
  it('merges data attributes into props', () => {
99
- const props = { id: 'btn', className: 'foo' };
98
+ const props: Record<string, string> = { id: 'btn', className: 'foo' };
100
99
  const dataAttrs = { 'data-variant': 'primary', 'data-size': 'md' };
101
100
  const merged = mergeDataAttributes(props, dataAttrs);
102
101
 
@@ -107,7 +106,7 @@ describe('css-modules-helper', () => {
107
106
  });
108
107
 
109
108
  it('skips undefined data attributes', () => {
110
- const props = { id: 'btn' };
109
+ const props: Record<string, string> = { id: 'btn' };
111
110
  const dataAttrs = { 'data-variant': 'primary', 'data-size': undefined };
112
111
  const merged = mergeDataAttributes(props, dataAttrs);
113
112
 
@@ -122,14 +122,12 @@ describe('styled-components-theme', () => {
122
122
 
123
123
  it('calls styled.button.attrs', () => {
124
124
  let attrsArg: any;
125
- let templateArg: any;
126
125
 
127
126
  const mockStyled = {
128
127
  button: {
129
128
  attrs: (arg: any) => {
130
129
  attrsArg = arg;
131
- return (strings: TemplateStringsArray, ...exprs: any[]) => {
132
- templateArg = { strings, exprs };
130
+ return (_strings: TemplateStringsArray) => {
133
131
  return 'MockComponent';
134
132
  };
135
133
  },
@@ -362,36 +362,6 @@
362
362
  color: var(--semiont-color-gray-400);
363
363
  }
364
364
 
365
- /* Hash chain badge */
366
- .semiont-exchange__hash-badge {
367
- display: inline-flex;
368
- align-items: center;
369
- padding: 0.25rem 0.75rem;
370
- border-radius: 9999px;
371
- font-size: 0.75rem;
372
- font-weight: 500;
373
- }
374
-
375
- .semiont-exchange__hash-badge--valid {
376
- background-color: var(--semiont-color-green-100);
377
- color: var(--semiont-color-green-800);
378
- }
379
-
380
- [data-theme="dark"] .semiont-exchange__hash-badge--valid {
381
- background-color: rgba(34, 197, 94, 0.15);
382
- color: var(--semiont-color-green-300);
383
- }
384
-
385
- .semiont-exchange__hash-badge--invalid {
386
- background-color: var(--semiont-color-red-100);
387
- color: var(--semiont-color-red-800);
388
- }
389
-
390
- [data-theme="dark"] .semiont-exchange__hash-badge--invalid {
391
- background-color: rgba(239, 68, 68, 0.15);
392
- color: var(--semiont-color-red-300);
393
- }
394
-
395
365
  /* Error message */
396
366
  .semiont-exchange__error-message {
397
367
  font-size: 0.875rem;
@@ -91,6 +91,7 @@
91
91
  @import '../components/annotation/references.css';
92
92
  @import '../components/Toast.css';
93
93
  @import '../components/StatusDisplay.css';
94
+ @import '../components/ProtectedErrorBoundary.css';
94
95
  @import '../components/loading-states/loading.css';
95
96
  @import '../components/error-states/errors.css';
96
97
  @import '../features/auth/auth.css';
@@ -71,6 +71,7 @@
71
71
  "annotationRemoved": "تمت إزالة التعليق التوضيحي",
72
72
  "annotationBodyUpdated": "تم تحديث التعليق التوضيحي",
73
73
  "jobEvent": "حدث مهمة",
74
+ "embeddingComputed": "تم حساب التضمين",
74
75
  "justNow": "الآن",
75
76
  "minutesAgo": "منذ {{count}}د",
76
77
  "hoursAgo": "منذ {{count}}س",
@@ -356,8 +357,5 @@
356
357
  "semanticScoringHelp": "استخدام الذكاء الاصطناعي لتقييم النتائج حسب الصلة الدلالية",
357
358
  "searching": "جاري البحث...",
358
359
  "search": "بحث"
359
- },
360
- "History": {
361
- "embeddingComputed": "تم حساب التضمين"
362
360
  }
363
361
  }