@semiont/react-ui 0.5.22 → 0.5.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/dist/{ar-ZHKO4XDR.js → ar-JRTKCMTF.js} +11 -4
  2. package/dist/ar-JRTKCMTF.js.map +1 -0
  3. package/dist/{bn-3BUDKAK5.js → bn-LO6EDSCC.js} +11 -4
  4. package/dist/bn-LO6EDSCC.js.map +1 -0
  5. package/dist/{chunk-PFUXQPWJ.js → chunk-4QMTEPQ3.js} +92 -60
  6. package/dist/chunk-4QMTEPQ3.js.map +1 -0
  7. package/dist/{chunk-R52YAGHK.js → chunk-5Y5JDCKC.js} +11 -4
  8. package/dist/chunk-5Y5JDCKC.js.map +1 -0
  9. package/dist/{cs-2EFEUKTW.js → cs-LCHHOR4O.js} +11 -4
  10. package/dist/cs-LCHHOR4O.js.map +1 -0
  11. package/dist/{da-SUD3Q44Q.js → da-TARGPMJR.js} +11 -4
  12. package/dist/da-TARGPMJR.js.map +1 -0
  13. package/dist/{de-6IC5QRX7.js → de-SBH4QBPJ.js} +11 -4
  14. package/dist/de-SBH4QBPJ.js.map +1 -0
  15. package/dist/{el-Q4NAXFRC.js → el-2D2OLGWB.js} +11 -4
  16. package/dist/el-2D2OLGWB.js.map +1 -0
  17. package/dist/{en-P2L4X27N.js → en-QFIEG3CA.js} +2 -2
  18. package/dist/{es-YUKZBD4W.js → es-U6YK2LX6.js} +11 -4
  19. package/dist/es-U6YK2LX6.js.map +1 -0
  20. package/dist/{fa-OJ47LILE.js → fa-S7SZE37M.js} +11 -4
  21. package/dist/fa-S7SZE37M.js.map +1 -0
  22. package/dist/{fi-QFFL5CUB.js → fi-CBL3NIXN.js} +11 -4
  23. package/dist/fi-CBL3NIXN.js.map +1 -0
  24. package/dist/{fr-BOQZZWP4.js → fr-EABUHV6S.js} +11 -4
  25. package/dist/fr-EABUHV6S.js.map +1 -0
  26. package/dist/{he-LXDY3TFA.js → he-THWD244Y.js} +11 -4
  27. package/dist/he-THWD244Y.js.map +1 -0
  28. package/dist/{hi-HOJER77V.js → hi-GHGFZJ44.js} +11 -4
  29. package/dist/hi-GHGFZJ44.js.map +1 -0
  30. package/dist/{id-I5WQ6EWD.js → id-POZOUMDW.js} +11 -4
  31. package/dist/id-POZOUMDW.js.map +1 -0
  32. package/dist/index.d.ts +269 -69
  33. package/dist/index.js +2080 -1861
  34. package/dist/index.js.map +1 -1
  35. package/dist/{it-UYH642IJ.js → it-KDYJADSH.js} +11 -4
  36. package/dist/it-KDYJADSH.js.map +1 -0
  37. package/dist/{ja-JTVWAC4L.js → ja-Z5Z6ZF3P.js} +11 -4
  38. package/dist/ja-Z5Z6ZF3P.js.map +1 -0
  39. package/dist/{ko-IHXFMOIZ.js → ko-7X3XUXRN.js} +11 -4
  40. package/dist/ko-7X3XUXRN.js.map +1 -0
  41. package/dist/{ms-JH25VFLO.js → ms-BTWEIW43.js} +11 -4
  42. package/dist/ms-BTWEIW43.js.map +1 -0
  43. package/dist/{nl-SURXW7IQ.js → nl-IS2CGF5I.js} +11 -4
  44. package/dist/nl-IS2CGF5I.js.map +1 -0
  45. package/dist/{no-HXIKQFDZ.js → no-AAMEVA26.js} +11 -4
  46. package/dist/no-AAMEVA26.js.map +1 -0
  47. package/dist/{pl-7A4KU6XK.js → pl-PVXUQYFT.js} +11 -4
  48. package/dist/pl-PVXUQYFT.js.map +1 -0
  49. package/dist/{pt-6MGYP7Q5.js → pt-3ITQ76TL.js} +11 -4
  50. package/dist/pt-3ITQ76TL.js.map +1 -0
  51. package/dist/{ro-IROCHNIA.js → ro-FRB4IIVS.js} +11 -4
  52. package/dist/ro-FRB4IIVS.js.map +1 -0
  53. package/dist/{sv-WDJD75CV.js → sv-DS3RO24M.js} +11 -4
  54. package/dist/sv-DS3RO24M.js.map +1 -0
  55. package/dist/test-utils.js +9 -3
  56. package/dist/test-utils.js.map +1 -1
  57. package/dist/{th-RIO2E6B3.js → th-AX66MWWB.js} +11 -4
  58. package/dist/th-AX66MWWB.js.map +1 -0
  59. package/dist/{tr-TZ7ERCIN.js → tr-7KTB67TT.js} +11 -4
  60. package/dist/tr-7KTB67TT.js.map +1 -0
  61. package/dist/{uk-ZFWDKLUE.js → uk-SMQS2MAQ.js} +11 -4
  62. package/dist/uk-SMQS2MAQ.js.map +1 -0
  63. package/dist/{vi-UIOSP2JG.js → vi-D3JXL2EZ.js} +11 -4
  64. package/dist/vi-D3JXL2EZ.js.map +1 -0
  65. package/dist/{zh-6R6YUY46.js → zh-S754AWFS.js} +11 -4
  66. package/dist/zh-S754AWFS.js.map +1 -0
  67. package/package.json +4 -4
  68. package/src/components/annotation-popups/JsonLdView.tsx +1 -1
  69. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +8 -2
  70. package/src/components/modals/ReferenceWizardModal.tsx +2 -1
  71. package/src/components/modals/ResourceGenerateModal.tsx +10 -4
  72. package/src/components/modals/ResourceSearchModal.tsx +3 -1
  73. package/src/components/modals/SearchModal.tsx +3 -1
  74. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +5 -0
  75. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +2 -1
  76. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +2 -1
  77. package/src/components/resource/AnnotateView.tsx +70 -138
  78. package/src/components/resource/AnnotationHistory.tsx +53 -17
  79. package/src/components/resource/BrowseView.tsx +3 -2
  80. package/src/components/resource/ResourceViewer.tsx +39 -22
  81. package/src/components/resource/__tests__/AnnotateView.registry.test.tsx +114 -0
  82. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +63 -41
  83. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +2 -1
  84. package/src/components/resource/__tests__/ResourceViewer.multi-mount-invalidation.test.tsx +140 -0
  85. package/src/components/resource/__tests__/ResourceViewer.registry.test.tsx +89 -0
  86. package/src/components/resource/annotate-renderers.tsx +155 -0
  87. package/src/components/resource/panels/JsonLdPanel.tsx +1 -1
  88. package/src/components/resource/panels/ReferencesPanel.tsx +38 -3
  89. package/src/components/resource/panels/TagEntry.tsx +5 -1
  90. package/src/components/resource/panels/TaggingPanel.tsx +6 -1
  91. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +41 -0
  92. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +8 -2
  93. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +90 -0
  94. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +2 -2
  95. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +2 -2
  96. package/src/components/resource/panels/__tests__/UnifiedAnnotationsPanel.failure.test.tsx +78 -0
  97. package/src/components/settings/SettingsPanel.tsx +6 -2
  98. package/src/components/settings/__tests__/SettingsPanel.test.tsx +28 -3
  99. package/src/features/admin-devops/__tests__/AdminDevOpsPage.test.tsx +3 -11
  100. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +0 -3
  101. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -1
  102. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -3
  103. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +1 -1
  104. package/src/features/admin-security/__tests__/AdminSecurityPage.test.tsx +0 -8
  105. package/src/features/admin-security/components/AdminSecurityPage.tsx +0 -3
  106. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +10 -9
  107. package/src/features/admin-security/state/admin-security-state-unit.ts +3 -2
  108. package/src/features/admin-users/__tests__/AdminUsersPage.test.tsx +0 -1
  109. package/src/features/admin-users/components/AdminUsersPage.tsx +0 -3
  110. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +7 -6
  111. package/src/features/admin-users/state/admin-users-state-unit.ts +3 -2
  112. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +36 -26
  113. package/src/features/auth-welcome/state/welcome-state-unit.ts +7 -2
  114. package/src/features/moderate-entity-tags/__tests__/EntityTagsPage.test.tsx +0 -2
  115. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +0 -3
  116. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +37 -12
  117. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +9 -10
  118. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +3 -5
  119. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +0 -3
  120. package/src/features/moderate-tag-schemas/__tests__/TagSchemasPage.test.tsx +3 -5
  121. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +0 -3
  122. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -1
  123. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -3
  124. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +17 -7
  125. package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -3
  126. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +33 -12
  127. package/src/features/resource-compose/state/compose-page-state-unit.ts +9 -8
  128. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +3 -2
  129. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +25 -3
  130. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +90 -25
  131. package/src/features/resource-discovery/state/discover-state-unit.ts +30 -30
  132. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +9 -5
  133. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +70 -40
  134. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +111 -8
  135. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +167 -27
  136. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +74 -9
  137. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +29 -28
  138. package/translations/ar.json +10 -3
  139. package/translations/bn.json +10 -3
  140. package/translations/cs.json +10 -3
  141. package/translations/da.json +10 -3
  142. package/translations/de.json +10 -3
  143. package/translations/el.json +10 -3
  144. package/translations/en.json +10 -3
  145. package/translations/es.json +10 -3
  146. package/translations/fa.json +10 -3
  147. package/translations/fi.json +10 -3
  148. package/translations/fr.json +10 -3
  149. package/translations/he.json +10 -3
  150. package/translations/hi.json +10 -3
  151. package/translations/id.json +10 -3
  152. package/translations/it.json +10 -3
  153. package/translations/ja.json +10 -3
  154. package/translations/ko.json +10 -3
  155. package/translations/ms.json +10 -3
  156. package/translations/nl.json +10 -3
  157. package/translations/no.json +10 -3
  158. package/translations/pl.json +10 -3
  159. package/translations/pt.json +10 -3
  160. package/translations/ro.json +10 -3
  161. package/translations/sv.json +10 -3
  162. package/translations/th.json +10 -3
  163. package/translations/tr.json +10 -3
  164. package/translations/uk.json +10 -3
  165. package/translations/vi.json +10 -3
  166. package/translations/zh.json +10 -3
  167. package/dist/ar-ZHKO4XDR.js.map +0 -1
  168. package/dist/bn-3BUDKAK5.js.map +0 -1
  169. package/dist/chunk-PFUXQPWJ.js.map +0 -1
  170. package/dist/chunk-R52YAGHK.js.map +0 -1
  171. package/dist/cs-2EFEUKTW.js.map +0 -1
  172. package/dist/da-SUD3Q44Q.js.map +0 -1
  173. package/dist/de-6IC5QRX7.js.map +0 -1
  174. package/dist/el-Q4NAXFRC.js.map +0 -1
  175. package/dist/es-YUKZBD4W.js.map +0 -1
  176. package/dist/fa-OJ47LILE.js.map +0 -1
  177. package/dist/fi-QFFL5CUB.js.map +0 -1
  178. package/dist/fr-BOQZZWP4.js.map +0 -1
  179. package/dist/he-LXDY3TFA.js.map +0 -1
  180. package/dist/hi-HOJER77V.js.map +0 -1
  181. package/dist/id-I5WQ6EWD.js.map +0 -1
  182. package/dist/it-UYH642IJ.js.map +0 -1
  183. package/dist/ja-JTVWAC4L.js.map +0 -1
  184. package/dist/ko-IHXFMOIZ.js.map +0 -1
  185. package/dist/ms-JH25VFLO.js.map +0 -1
  186. package/dist/nl-SURXW7IQ.js.map +0 -1
  187. package/dist/no-HXIKQFDZ.js.map +0 -1
  188. package/dist/pl-7A4KU6XK.js.map +0 -1
  189. package/dist/pt-6MGYP7Q5.js.map +0 -1
  190. package/dist/ro-IROCHNIA.js.map +0 -1
  191. package/dist/sv-WDJD75CV.js.map +0 -1
  192. package/dist/th-RIO2E6B3.js.map +0 -1
  193. package/dist/tr-TZ7ERCIN.js.map +0 -1
  194. package/dist/uk-ZFWDKLUE.js.map +0 -1
  195. package/dist/vi-UIOSP2JG.js.map +0 -1
  196. package/dist/zh-6R6YUY46.js.map +0 -1
  197. /package/dist/{en-P2L4X27N.js.map → en-QFIEG3CA.js.map} +0 -0
@@ -0,0 +1,155 @@
1
+ 'use client';
2
+
3
+ import { lazy, Suspense, type ComponentType } from 'react';
4
+ import type { Annotation } from '@semiont/core';
5
+ import type { SemiontSession } from '@semiont/sdk';
6
+ import { SvgDrawingCanvas } from '../image-annotation/SvgDrawingCanvas';
7
+ import { CodeMirrorRenderer } from '../CodeMirrorRenderer';
8
+ import { segmentTextWithAnnotations } from '../../lib/text-segmentation';
9
+ import type { SelectionMotivation, ShapeType } from '../annotation/AnnotateToolbar';
10
+
11
+ // Lazy-load the PDF component to avoid SSR issues with browser PDF.js loading.
12
+ // Kept lazy here deliberately: hoisting it to a static import would make it
13
+ // eager again and reintroduce the SSR problem this indirection exists for.
14
+ const PdfAnnotationCanvas = lazy(() =>
15
+ import('../pdf-annotation/PdfAnnotationCanvas.client').then((mod) => ({ default: mod.PdfAnnotationCanvas })),
16
+ );
17
+
18
+ /**
19
+ * Common props for an ANNOTATING media renderer. The read-only sibling is
20
+ * `MediaRendererProps` in `browse-renderers.tsx`; this one additionally
21
+ * carries the active tool, because annotating is an interaction, not a paint.
22
+ *
23
+ * `content` is decoded text for the text renderer, or a media URL for the
24
+ * image / PDF renderers. `annotations` is the FLAT list — a renderer derives
25
+ * whatever shape it needs (the text one segments it), so a custom renderer is
26
+ * never handed a projection only one implementation can use.
27
+ * See .plans/ANNOTATE-RENDERER-REGISTRY.md (D1)
28
+ */
29
+ export interface AnnotateMediaRendererProps {
30
+ content: string;
31
+ mimeType: string;
32
+ resourceUri: string;
33
+ annotations: Annotation[];
34
+ /** Session for interaction routing — the canvases emit via `session.client`. */
35
+ session: SemiontSession | null;
36
+
37
+ /** Active drawing tool, or null when nothing is selected (read-only paint). */
38
+ drawingMode: ShapeType | null;
39
+ selectedMotivation: SelectionMotivation | null;
40
+ hoveredAnnotationId?: string | null;
41
+ hoverDelayMs?: number;
42
+
43
+ // Text-path extras. Optional on the shared interface: a custom image or PDF
44
+ // renderer simply ignores them, the same way `MediaRendererProps.session` is
45
+ // documented to be ignorable.
46
+ newAnnotationIds?: Set<string>;
47
+ scrollToAnnotationId?: string | null;
48
+ showLineNumbers?: boolean;
49
+ enableWidgets?: boolean;
50
+ getTargetResourceName?: (resourceId: string) => string | undefined;
51
+ generatingReferenceId?: string | null;
52
+ }
53
+
54
+ /** Annotating media dispatch, keyed by the registry render mode. */
55
+ export type AnnotateMediaRenderers = Partial<Record<string, ComponentType<AnnotateMediaRendererProps>>>;
56
+
57
+ export function TextAnnotateRenderer({
58
+ content,
59
+ annotations,
60
+ session,
61
+ newAnnotationIds,
62
+ hoveredAnnotationId,
63
+ scrollToAnnotationId,
64
+ showLineNumbers = false,
65
+ hoverDelayMs = 150,
66
+ enableWidgets = false,
67
+ getTargetResourceName,
68
+ generatingReferenceId,
69
+ }: AnnotateMediaRendererProps) {
70
+ // D1: segmentation is a text-rendering concern, derived here rather than
71
+ // threaded through the shared interface.
72
+ const segments = segmentTextWithAnnotations(content, annotations);
73
+
74
+ return (
75
+ <CodeMirrorRenderer
76
+ content={content}
77
+ segments={segments}
78
+ editable={false}
79
+ newAnnotationIds={newAnnotationIds}
80
+ {...(hoveredAnnotationId !== undefined && { hoveredAnnotationId })}
81
+ {...(scrollToAnnotationId !== undefined && { scrollToAnnotationId })}
82
+ sourceView={true}
83
+ showLineNumbers={showLineNumbers}
84
+ hoverDelayMs={hoverDelayMs}
85
+ enableWidgets={enableWidgets}
86
+ session={session}
87
+ {...(getTargetResourceName && { getTargetResourceName })}
88
+ {...(generatingReferenceId !== undefined && { generatingReferenceId })}
89
+ />
90
+ );
91
+ }
92
+
93
+ export function ImageAnnotateRenderer({
94
+ content,
95
+ resourceUri,
96
+ annotations,
97
+ session,
98
+ drawingMode,
99
+ selectedMotivation,
100
+ hoveredAnnotationId,
101
+ hoverDelayMs = 150,
102
+ }: AnnotateMediaRendererProps) {
103
+ if (!content) return null;
104
+ return (
105
+ <SvgDrawingCanvas
106
+ imageUrl={content}
107
+ resourceUri={resourceUri}
108
+ existingAnnotations={annotations}
109
+ drawingMode={drawingMode}
110
+ selectedMotivation={selectedMotivation}
111
+ session={session}
112
+ hoveredAnnotationId={hoveredAnnotationId || null}
113
+ hoverDelayMs={hoverDelayMs}
114
+ />
115
+ );
116
+ }
117
+
118
+ export function PdfAnnotateRenderer({
119
+ content,
120
+ resourceUri,
121
+ annotations,
122
+ session,
123
+ drawingMode,
124
+ selectedMotivation,
125
+ hoveredAnnotationId,
126
+ hoverDelayMs = 150,
127
+ }: AnnotateMediaRendererProps) {
128
+ if (!content) return null;
129
+ return (
130
+ <Suspense fallback={<div className="semiont-annotate-view__loading">Loading PDF viewer...</div>}>
131
+ <PdfAnnotationCanvas
132
+ pdfUrl={content}
133
+ resourceUri={resourceUri}
134
+ existingAnnotations={annotations}
135
+ drawingMode={drawingMode}
136
+ selectedMotivation={selectedMotivation}
137
+ session={session}
138
+ hoveredAnnotationId={hoveredAnnotationId || null}
139
+ hoverDelayMs={hoverDelayMs}
140
+ />
141
+ </Suspense>
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Default annotating media renderers. `AnnotateView` merges a caller's
147
+ * `renderers` override on top of these, so a host can swap one renderer (its
148
+ * own PDF viewer, say) or add a mode without forking the view — the same seam
149
+ * `defaultBrowseRenderers` gives the read-only path.
150
+ */
151
+ export const defaultAnnotateRenderers: Record<'text' | 'image' | 'pdf', ComponentType<AnnotateMediaRendererProps>> = {
152
+ text: TextAnnotateRenderer,
153
+ image: ImageAnnotateRenderer,
154
+ pdf: PdfAnnotateRenderer,
155
+ };
@@ -7,7 +7,7 @@ import { json } from '@codemirror/lang-json';
7
7
  import { oneDark } from '@codemirror/theme-one-dark';
8
8
  import { syntaxHighlighting } from '@codemirror/language';
9
9
  import { jsonLightTheme, jsonLightHighlightStyle } from '../../../lib/codemirror-json-theme';
10
- import { useLineNumbers } from '../../../hooks/useLineNumbers';
10
+ import { useLineNumbers } from '../../../contexts/LineNumbersContext';
11
11
  import { useResourceGraph } from '../../../hooks/useResourceGraph';
12
12
  import type { ResourceId } from '@semiont/core';
13
13
  import './JsonLdPanel.css';
@@ -58,9 +58,25 @@ interface Props {
58
58
 
59
59
  // Reference-specific props
60
60
  allEntityTypes: string[];
61
+ /**
62
+ * The entity-type list failed to load. Without it the picker's empty branch
63
+ * asserts "no entity types available" — a claim about the knowledge base
64
+ * that a failed load cannot support.
65
+ * See .plans/PANEL-FAILURE-STATES.md
66
+ */
67
+ entityTypesError?: Error | null;
61
68
  generatingReferenceId?: string | null;
62
69
  referencedBy?: ReferencedBy[];
63
70
  referencedByLoading?: boolean;
71
+ /**
72
+ * The incoming-references load failed terminally (B15). Distinct from
73
+ * `referencedByLoading` — without it a dead request is indistinguishable
74
+ * from one still in flight, and the panel says "Loading..." forever.
75
+ * See .plans/PANEL-FAILURE-STATES.md
76
+ */
77
+ referencedByError?: Error | null;
78
+ /** Retry the failed incoming-references load. */
79
+ onRetryReferencedBy?: () => void;
64
80
  pendingAnnotation: PendingAnnotation | null;
65
81
  scrollToAnnotationId?: string | null;
66
82
  onScrollCompleted?: () => void;
@@ -91,9 +107,12 @@ export function ReferencesPanel({
91
107
  Link,
92
108
  routes,
93
109
  allEntityTypes,
110
+ entityTypesError = null,
94
111
  generatingReferenceId,
95
112
  referencedBy = [],
96
113
  referencedByLoading = false,
114
+ referencedByError = null,
115
+ onRetryReferencedBy,
97
116
  pendingAnnotation,
98
117
  scrollToAnnotationId,
99
118
  onScrollCompleted,
@@ -403,7 +422,7 @@ export function ReferencesPanel({
403
422
  ))
404
423
  ) : (
405
424
  <p className="semiont-assist-widget__no-types">
406
- {t('noEntityTypes')}
425
+ {entityTypesError ? t('entityTypesFailed') : t('noEntityTypes')}
407
426
  </p>
408
427
  )}
409
428
  </div>
@@ -485,13 +504,29 @@ export function ReferencesPanel({
485
504
  <div className="semiont-panel__divider">
486
505
  <h3 className="semiont-panel__subtitle">
487
506
  {t('incomingReferences')} ({referencedBy.length})
488
- {referencedByLoading && (
507
+ {referencedByLoading && !referencedByError && (
489
508
  <span className="semiont-panel__loading-indicator">({t('loading')})</span>
490
509
  )}
491
510
  </h3>
492
511
  </div>
493
512
 
494
- {referencedBy.length > 0 ? (
513
+ {referencedByError ? (
514
+ <p className="semiont-panel__empty-message semiont-panel__empty-message--small">
515
+ {t('incomingReferencesFailed')}
516
+ {onRetryReferencedBy && (
517
+ <>
518
+ {' '}
519
+ <button
520
+ type="button"
521
+ onClick={onRetryReferencedBy}
522
+ className="semiont-panel__inline-action"
523
+ >
524
+ {t('retry')}
525
+ </button>
526
+ </>
527
+ )}
528
+ </p>
529
+ ) : referencedBy.length > 0 ? (
495
530
  <div className="semiont-panel__list">
496
531
  {referencedBy.map((ref) => {
497
532
  const resourceId = ref.target.source;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useMemo } from 'react';
4
+ import { isReady } from '@semiont/sdk';
4
5
  import type { Ref } from 'react';
5
6
  import type { Annotation } from '@semiont/core';
6
7
  import { getAnnotationExactText } from '@semiont/core';
@@ -42,7 +43,10 @@ export function TagEntry({
42
43
  [session],
43
44
  );
44
45
  const schemas = useObservable(tagSchemas$);
45
- const schema = schemaId && schemas ? schemas.find((s) => s.id === schemaId) ?? null : null;
46
+ const schema =
47
+ schemaId && schemas && isReady(schemas)
48
+ ? schemas.value.find((s) => s.id === schemaId) ?? null
49
+ : null;
46
50
 
47
51
  return (
48
52
  <div
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
4
+ import { isReady } from '@semiont/sdk';
4
5
  import { useTranslations } from '../../../contexts/TranslationContext';
5
6
  import { useObservable } from '../../../hooks/useObservable';
6
7
  import type { SemiontSession } from '@semiont/sdk';
@@ -91,7 +92,11 @@ export function TaggingPanel({
91
92
  () => session?.client.browse.tagSchemas() ?? null,
92
93
  [session],
93
94
  );
94
- const schemasObserved = useObservable(tagSchemas$);
95
+ const schemasState = useObservable(tagSchemas$);
96
+ // D1 unwrap: value-or-undefined, with the third outcome explicit — a
97
+ // failed registry read renders like still-loading here (the panel's
98
+ // failure UI is PANEL-FAILURE-STATES follow-up work, not this migration).
99
+ const schemasObserved = schemasState && isReady(schemasState) ? schemasState.value : undefined;
95
100
  const schemas = schemasObserved ?? [];
96
101
  // True only AFTER the registry has resolved AND it's empty — distinct
97
102
  // from the initial-loading state (`schemasObserved === undefined`),
@@ -46,6 +46,14 @@ interface UnifiedAnnotationsPanelProps {
46
46
 
47
47
  // All annotations (grouped internally by motivation)
48
48
  annotations: Annotation[];
49
+ /**
50
+ * The annotations load failed terminally (B15). Every tab here derives from
51
+ * the one `annotations` array, so without this the panel presents an empty
52
+ * array as fact — "no highlights" for a resource that may be full of them.
53
+ * See .plans/PANEL-FAILURE-STATES.md
54
+ */
55
+ annotationsError?: Error | null;
56
+ onRetryAnnotations?: () => void;
49
57
 
50
58
  // Annotators (pure static data - no handlers)
51
59
  annotators: Record<string, Annotator>;
@@ -65,6 +73,9 @@ interface UnifiedAnnotationsPanelProps {
65
73
  generatingReferenceId?: string | null;
66
74
  referencedBy?: any[];
67
75
  referencedByLoading?: boolean;
76
+ entityTypesError?: Error | null;
77
+ referencedByError?: Error | null;
78
+ onRetryReferencedBy?: () => void;
68
79
 
69
80
  // Resource context — threaded to every per-motivation panel, which stamps it
70
81
  // as `source` on mark:submit (multi-viewer routing).
@@ -174,6 +185,33 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
174
185
  return `semiont-unified-panel__tab-button${isActive ? ' semiont-unified-panel__tab-button--active' : ''}`;
175
186
  };
176
187
 
188
+ if (props.annotationsError) {
189
+ // The tabs below all read one array; an empty one after a failed load is
190
+ // not "no annotations", it is "we do not know".
191
+ return (
192
+ <div className="semiont-unified-panel">
193
+ <h3 className="semiont-unified-panel__title">
194
+ {t('title')}
195
+ </h3>
196
+ <p className="semiont-unified-panel__error">
197
+ {t('annotationsFailed')}
198
+ {props.onRetryAnnotations && (
199
+ <>
200
+ {' '}
201
+ <button
202
+ type="button"
203
+ onClick={props.onRetryAnnotations}
204
+ className="semiont-panel__inline-action"
205
+ >
206
+ {t('retry')}
207
+ </button>
208
+ </>
209
+ )}
210
+ </p>
211
+ </div>
212
+ );
213
+ }
214
+
177
215
  return (
178
216
  <div className="semiont-unified-panel">
179
217
  {/* Panel Title */}
@@ -284,9 +322,12 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
284
322
  locale={commonProps.locale}
285
323
  sourceLanguage={commonProps.sourceLanguage}
286
324
  allEntityTypes={props.allEntityTypes || []}
325
+ entityTypesError={props.entityTypesError}
287
326
  generatingReferenceId={props.generatingReferenceId}
288
327
  referencedBy={props.referencedBy}
289
328
  referencedByLoading={props.referencedByLoading}
329
+ referencedByError={props.referencedByError}
330
+ onRetryReferencedBy={props.onRetryReferencedBy}
290
331
  Link={props.Link}
291
332
  routes={props.routes}
292
333
  />
@@ -32,9 +32,15 @@ vi.mock('../../../lib/codemirror-json-theme', () => ({
32
32
  }));
33
33
 
34
34
  // Mock the hooks the panel consumes — NOT the session/client stack.
35
- vi.mock('@/hooks/useLineNumbers');
35
+ vi.mock('@/contexts/LineNumbersContext', async () => {
36
+ const actual = await vi.importActual('@/contexts/LineNumbersContext');
37
+ // Mock ONLY the hook: renderWithProviders mounts the real
38
+ // LineNumbersProvider, which an auto-mock would swallow (rendering
39
+ // nothing), while these tests want a fixed showLineNumbers value.
40
+ return { ...actual, useLineNumbers: vi.fn() };
41
+ });
36
42
  vi.mock('@/hooks/useResourceGraph');
37
- import { useLineNumbers } from '@/hooks/useLineNumbers';
43
+ import { useLineNumbers } from '@/contexts/LineNumbersContext';
38
44
  import { useResourceGraph } from '@/hooks/useResourceGraph';
39
45
 
40
46
  const RID = resourceId('test-resource-1');
@@ -84,4 +84,94 @@ describe('ReferencesPanel — headless (session prop, no providers)', () => {
84
84
  fireEvent.click(entryText);
85
85
  expect(client.browse.click).toHaveBeenCalledWith('ref-1', 'linking');
86
86
  });
87
+
88
+ describe('incoming references — terminal load failure', () => {
89
+ // `referencedByLoading` alone cannot distinguish "still in flight" from
90
+ // "dead" (B15), so the panel used to render "Loading..." for ever.
91
+ // See .plans/PANEL-FAILURE-STATES.md
92
+
93
+ type PanelProps = React.ComponentProps<typeof ReferencesPanel>;
94
+ const base = (): PanelProps => {
95
+ const { session } = fakeSession();
96
+ return {
97
+ session,
98
+ resourceId: 'res-1' as const,
99
+ annotations: [],
100
+ isAssisting: false,
101
+ progress: null,
102
+ pendingAnnotation: null,
103
+ allEntityTypes: [],
104
+ Link: TestLink,
105
+ routes: testRoutes,
106
+ };
107
+ };
108
+
109
+ it('reports the failure instead of an endless loading line', () => {
110
+ render(
111
+ <ReferencesPanel
112
+ {...base()}
113
+ referencedBy={[]}
114
+ referencedByLoading
115
+ referencedByError={new Error('Resource not found')}
116
+ />,
117
+ );
118
+
119
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
120
+ expect(screen.getByText(/Could not load incoming references/)).toBeInTheDocument();
121
+ });
122
+
123
+ it('offers a retry that calls back', () => {
124
+ const onRetryReferencedBy = vi.fn();
125
+ render(
126
+ <ReferencesPanel
127
+ {...base()}
128
+ referencedBy={[]}
129
+ referencedByError={new Error('boom')}
130
+ onRetryReferencedBy={onRetryReferencedBy}
131
+ />,
132
+ );
133
+
134
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
135
+ expect(onRetryReferencedBy).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it('with no error, a still-loading list keeps its loading affordance', () => {
139
+ render(
140
+ <ReferencesPanel
141
+ {...base()}
142
+ referencedBy={[]}
143
+ referencedByLoading
144
+ />,
145
+ );
146
+
147
+ expect(screen.queryByText(/Could not load incoming references/)).not.toBeInTheDocument();
148
+ });
149
+ });
150
+
151
+ describe('entity types — terminal load failure', () => {
152
+ // The picker's empty branch says "no entity types", which is a claim about
153
+ // the knowledge base. On a failed load it is false — the KB may have
154
+ // plenty. See .plans/PANEL-FAILURE-STATES.md
155
+
156
+ it('does not present a failed entity-type load as "none exist"', () => {
157
+ const { session } = fakeSession();
158
+ render(
159
+ <ReferencesPanel
160
+ session={session}
161
+ resourceId="res-1"
162
+ annotations={[]}
163
+ isAssisting={false}
164
+ progress={null}
165
+ pendingAnnotation={null}
166
+ allEntityTypes={[]}
167
+ entityTypesError={new Error('boom')}
168
+ Link={TestLink}
169
+ routes={testRoutes}
170
+ />,
171
+ );
172
+
173
+ expect(screen.queryByText('No entity types available')).not.toBeInTheDocument();
174
+ expect(screen.getByText(/Could not load entity types/)).toBeInTheDocument();
175
+ });
176
+ });
87
177
  });
@@ -135,7 +135,7 @@ describe('TagEntry', () => {
135
135
  // transport's HTTP plumbing.
136
136
  const { SemiontWrapper, client, session } = createTestSemiontWrapper();
137
137
  vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
138
- CacheObservable.from(of([NER_SCHEMA]))
138
+ CacheObservable.from(of({ status: 'ready' as const, value: [NER_SCHEMA] }))
139
139
  );
140
140
  render(<TagEntry {...defaultProps} session={session} />, { wrapper: SemiontWrapper });
141
141
 
@@ -149,7 +149,7 @@ describe('TagEntry', () => {
149
149
  // misses, the schema-name `<span>` is not rendered.
150
150
  const { SemiontWrapper, client, session } = createTestSemiontWrapper();
151
151
  vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
152
- CacheObservable.from(of([]))
152
+ CacheObservable.from(of({ status: 'ready' as const, value: [] }))
153
153
  );
154
154
  const { container } = render(<TagEntry {...defaultProps} session={session} />, { wrapper: SemiontWrapper });
155
155
 
@@ -56,7 +56,7 @@ const TEST_TAG_SCHEMAS: TagSchema[] = [
56
56
  const renderWithEventBus = (component: React.ReactElement<{ session: SemiontSession | null }>, tracker?: ReturnType<typeof createEventTracker>) => {
57
57
  const { SemiontWrapper, eventBus, client, session } = createTestSemiontWrapper();
58
58
  vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
59
- CacheObservable.from(of(TEST_TAG_SCHEMAS))
59
+ CacheObservable.from(of({ status: 'ready' as const, value: TEST_TAG_SCHEMAS }))
60
60
  );
61
61
  if (tracker) tracker._attach(eventBus);
62
62
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -74,7 +74,7 @@ const renderWithEventBus = (component: React.ReactElement<{ session: SemiontSess
74
74
  const renderWithEmptyRegistry = (component: React.ReactElement<{ session: SemiontSession | null }>) => {
75
75
  const { SemiontWrapper, client, session } = createTestSemiontWrapper();
76
76
  vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
77
- CacheObservable.from(of([]))
77
+ CacheObservable.from(of({ status: 'ready' as const, value: [] }))
78
78
  );
79
79
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
80
80
  <SemiontWrapper>{children}</SemiontWrapper>
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The annotations panel must be able to say the annotations failed to load.
3
+ *
4
+ * Every tab in this panel derives from one `annotations` array. When that load
5
+ * fails terminally (B15) the array is empty — indistinguishable from a
6
+ * resource that genuinely has no annotations, so the panel cheerfully reports
7
+ * "no highlights" for a resource full of them. That is the same
8
+ * apparent-data-loss shape as the PDF-annotations bug, arrived at from a
9
+ * different direction.
10
+ *
11
+ * See .plans/PANEL-FAILURE-STATES.md
12
+ */
13
+ import React from 'react';
14
+ import { describe, it, expect, vi } from 'vitest';
15
+ import { render, screen, fireEvent } from '@testing-library/react';
16
+ import '@testing-library/jest-dom';
17
+ import type { SemiontSession } from '@semiont/sdk';
18
+ import type { RouteBuilder } from '../../../../contexts/RoutingContext';
19
+ import { UnifiedAnnotationsPanel } from '../UnifiedAnnotationsPanel';
20
+ import { ANNOTATORS } from '../../../../lib/annotation-registry';
21
+
22
+ const TestLink = ({ href, children, ...rest }: any) => <a href={href} {...rest}>{children}</a>;
23
+ const testRoutes = {
24
+ resourceDetail: (id: string) => `/r/${id}`,
25
+ } as unknown as RouteBuilder;
26
+
27
+ function fakeSession(): SemiontSession {
28
+ return {
29
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
30
+ subscribe: () => () => {},
31
+ } as unknown as SemiontSession;
32
+ }
33
+
34
+ type PanelProps = React.ComponentProps<typeof UnifiedAnnotationsPanel>;
35
+
36
+ const base = (): PanelProps => ({
37
+ session: fakeSession(),
38
+ annotations: [],
39
+ annotators: ANNOTATORS,
40
+ allEntityTypes: [],
41
+ pendingAnnotation: null,
42
+ resourceId: 'res-1',
43
+ Link: TestLink,
44
+ routes: testRoutes,
45
+ });
46
+
47
+ describe('UnifiedAnnotationsPanel — annotations load failure', () => {
48
+ it('reports the failure rather than presenting an empty annotation set as fact', () => {
49
+ render(
50
+ <UnifiedAnnotationsPanel
51
+ {...base()}
52
+ annotationsError={new Error('Resource not found')}
53
+ />,
54
+ );
55
+
56
+ expect(screen.getByText(/Could not load annotations/)).toBeInTheDocument();
57
+ });
58
+
59
+ it('offers a retry that calls back', () => {
60
+ const onRetryAnnotations = vi.fn();
61
+ render(
62
+ <UnifiedAnnotationsPanel
63
+ {...base()}
64
+ annotationsError={new Error('boom')}
65
+ onRetryAnnotations={onRetryAnnotations}
66
+ />,
67
+ );
68
+
69
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
70
+ expect(onRetryAnnotations).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it('says nothing about failure when the annotations are merely empty', () => {
74
+ render(<UnifiedAnnotationsPanel {...base()} />);
75
+
76
+ expect(screen.queryByText(/Could not load annotations/)).not.toBeInTheDocument();
77
+ });
78
+ });
@@ -5,10 +5,10 @@ import { LOCALES } from '@semiont/core';
5
5
  import { useTranslations } from '../../contexts/TranslationContext';
6
6
  import { useLanguageChangeAnnouncements } from '../LiveRegion';
7
7
  import { useSemiont } from '../../session/SemiontProvider';
8
+ import { useLineNumbers } from '../../contexts/LineNumbersContext';
8
9
  import './SettingsPanel.css';
9
10
 
10
11
  interface SettingsPanelProps {
11
- showLineNumbers: boolean;
12
12
  theme: 'light' | 'dark' | 'system';
13
13
  locale: string;
14
14
  isPendingLocaleChange?: boolean;
@@ -24,7 +24,6 @@ interface SettingsPanelProps {
24
24
  * @emits settings:hover-delay-changed - Hover delay changed by user. Payload: { hoverDelayMs: number }
25
25
  */
26
26
  export function SettingsPanel({
27
- showLineNumbers,
28
27
  theme,
29
28
  locale,
30
29
  isPendingLocaleChange = false,
@@ -32,6 +31,11 @@ export function SettingsPanel({
32
31
  }: SettingsPanelProps) {
33
32
  const t = useTranslations('Settings');
34
33
  const semiont = useSemiont();
34
+ // The switch renders the SHARED line-numbers state, not a prop snapshot:
35
+ // its toggle is applied by a bus subscriber in ToolbarPanels, so a prop
36
+ // fed from any per-route source is a second copy that never updates.
37
+ // See .plans/bugs/line-numbers-toggle-desynced-by-hoist.md
38
+ const { showLineNumbers } = useLineNumbers();
35
39
  const { announceLanguageChanging, announceLanguageChanged } = useLanguageChangeAnnouncements();
36
40
 
37
41
  // Track previous locale to detect changes