@semiont/react-ui 0.5.27 → 0.5.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/{PdfAnnotationCanvas.client-EVUPTUWZ.js → PdfAnnotationCanvas.client-C2IGG4SW.js} +31 -4
  2. package/dist/{ar-OU3DQGC6.js → ar-FABPTJVB.js} +39 -23
  3. package/dist/{bn-PHWCR6AZ.js → bn-LKP6BDMH.js} +39 -23
  4. package/dist/{chunk-PCRRBU7H.js → chunk-2GOHTTHW.js} +39 -23
  5. package/dist/{chunk-5VDTWRXJ.js → chunk-2YHEE63S.js} +31 -31
  6. package/dist/{cs-NEXYZ62I.js → cs-YQYNWHRG.js} +39 -23
  7. package/dist/{da-FAT43YCU.js → da-QHZOYKRY.js} +39 -23
  8. package/dist/{de-KRQ3WBCU.js → de-GKA7RD7J.js} +39 -23
  9. package/dist/{el-Q4IU3EKC.js → el-45T257AL.js} +39 -23
  10. package/dist/{en-2KADE7N4.js → en-RVW5EQ66.js} +2 -2
  11. package/dist/{es-2IO3R2MK.js → es-AVER7RU4.js} +39 -23
  12. package/dist/{fa-Q5GOQFXX.js → fa-K3VPK3O2.js} +39 -23
  13. package/dist/{fi-R6NMRWBE.js → fi-5NEKF7EB.js} +39 -23
  14. package/dist/{fr-QY4D5E6J.js → fr-FUMNDLBZ.js} +39 -23
  15. package/dist/{he-X6Y77XHY.js → he-3JGNA3PR.js} +39 -23
  16. package/dist/{hi-XL7IOEQM.js → hi-IJY375DF.js} +39 -23
  17. package/dist/{id-QZ4ZZS67.js → id-LWHKJ7ZD.js} +39 -23
  18. package/dist/index.css +279 -74
  19. package/dist/index.d.ts +170 -145
  20. package/dist/index.js +2743 -2365
  21. package/dist/{it-ME2Q7OB6.js → it-EBVOV4RW.js} +39 -23
  22. package/dist/{ja-HX7QASRI.js → ja-KWRIX2NF.js} +39 -23
  23. package/dist/{ko-EQO5AHAV.js → ko-L5CJMFDS.js} +39 -23
  24. package/dist/{ms-UILFSF3I.js → ms-LSNMJDR2.js} +39 -23
  25. package/dist/{nl-NNI45PMK.js → nl-ZB32MB7U.js} +39 -23
  26. package/dist/{no-MAL5F6P6.js → no-4GTNY7M5.js} +39 -23
  27. package/dist/{pl-4RMY6JAJ.js → pl-LUHBLTHB.js} +39 -23
  28. package/dist/{pt-VQH2X3QQ.js → pt-C4H3UNQ6.js} +39 -23
  29. package/dist/{ro-XXCQO2OG.js → ro-GGITS5V4.js} +39 -23
  30. package/dist/{sv-7Z6EJ6U4.js → sv-T56RTLBP.js} +39 -23
  31. package/dist/test-utils.js +2 -2
  32. package/dist/{th-L3DXXYVQ.js → th-22X2IWCB.js} +39 -23
  33. package/dist/{tr-P3DFVLFJ.js → tr-77TFG62H.js} +39 -23
  34. package/dist/{uk-7C5D3PXS.js → uk-MIIL6NXT.js} +39 -23
  35. package/dist/{vi-ZLTLEO7P.js → vi-7JYOIGNV.js} +39 -23
  36. package/dist/{zh-5FHVWDIN.js → zh-ID6DRYU4.js} +39 -23
  37. package/package.json +9 -8
  38. package/src/components/AssistProgress.tsx +36 -2
  39. package/src/components/CodeMirrorRenderer.tsx +8 -8
  40. package/src/components/__tests__/AssistProgress.test.tsx +59 -0
  41. package/src/components/modals/ComposeStep.tsx +218 -0
  42. package/src/components/modals/ConfigureGatherStep.tsx +10 -18
  43. package/src/components/modals/ConfigureGenerationStep.tsx +35 -19
  44. package/src/components/modals/ConfigureSearchStep.tsx +19 -0
  45. package/src/components/modals/ContextSummary.tsx +185 -72
  46. package/src/components/modals/CorpusPane.tsx +75 -0
  47. package/src/components/modals/DiscardPrompt.tsx +38 -0
  48. package/src/components/modals/GatherContextStep.tsx +237 -107
  49. package/src/components/modals/ReferenceWizardModal.tsx +238 -50
  50. package/src/components/modals/ResourceGenerateModal.tsx +216 -108
  51. package/src/components/modals/SearchModal.css +337 -101
  52. package/src/components/modals/SearchResultsStep.tsx +61 -68
  53. package/src/components/modals/WizardFooter.tsx +7 -1
  54. package/src/components/modals/__tests__/ComposeStep.test.tsx +152 -0
  55. package/src/components/modals/__tests__/ConfigureGatherStep.test.tsx +14 -26
  56. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +35 -0
  57. package/src/components/modals/__tests__/ConfigureSearchStep.test.tsx +24 -1
  58. package/src/components/modals/__tests__/GatherContextStep.resource.test.tsx +463 -25
  59. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +417 -38
  60. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +276 -85
  61. package/src/components/modals/__tests__/SearchResultsStep.test.tsx +61 -0
  62. package/src/components/modals/__tests__/WizardFooter.test.tsx +113 -4
  63. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +34 -1
  64. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +38 -1
  65. package/src/components/resource/AnnotateView.tsx +3 -3
  66. package/src/components/resource/BrowseView.tsx +5 -5
  67. package/src/components/resource/ResourceViewer.tsx +4 -4
  68. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +1 -1
  69. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +1 -1
  70. package/src/components/resource/__tests__/BrowseView.test.tsx +9 -9
  71. package/src/components/resource/annotate-renderers.tsx +3 -3
  72. package/src/components/resource/panels/AssistSection.css +35 -0
  73. package/src/components/resource/panels/ReferenceEntry.tsx +9 -2
  74. package/src/components/resource/panels/ReferencesPanel.tsx +4 -0
  75. package/src/components/resource/panels/ResourceInfoPanel.tsx +89 -20
  76. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +7 -2
  77. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +41 -0
  78. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +33 -1
  79. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +148 -6
  80. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +0 -65
  81. package/src/features/resource-compose/components/ResourceComposePage.tsx +17 -130
  82. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +1 -100
  83. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -57
  84. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +71 -15
  85. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +133 -59
  86. package/src/styles/core/forms.css +35 -8
  87. package/src/styles/features/resource-viewer.css +0 -22
  88. package/src/styles/features/resource.css +0 -4
  89. package/translations/ar.json +38 -22
  90. package/translations/bn.json +38 -22
  91. package/translations/cs.json +38 -22
  92. package/translations/da.json +38 -22
  93. package/translations/de.json +38 -22
  94. package/translations/el.json +38 -22
  95. package/translations/en.json +38 -22
  96. package/translations/es.json +38 -22
  97. package/translations/fa.json +38 -22
  98. package/translations/fi.json +38 -22
  99. package/translations/fr.json +38 -22
  100. package/translations/he.json +38 -22
  101. package/translations/hi.json +38 -22
  102. package/translations/id.json +38 -22
  103. package/translations/it.json +38 -22
  104. package/translations/ja.json +38 -22
  105. package/translations/ko.json +38 -22
  106. package/translations/ms.json +38 -22
  107. package/translations/nl.json +38 -22
  108. package/translations/no.json +38 -22
  109. package/translations/pl.json +38 -22
  110. package/translations/pt.json +38 -22
  111. package/translations/ro.json +38 -22
  112. package/translations/sv.json +38 -22
  113. package/translations/th.json +38 -22
  114. package/translations/tr.json +38 -22
  115. package/translations/uk.json +38 -22
  116. package/translations/vi.json +38 -22
  117. package/translations/zh.json +38 -22
  118. package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js.map +0 -1
  119. package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css.map +0 -1
  120. package/dist/ar-OU3DQGC6.js.map +0 -1
  121. package/dist/bn-PHWCR6AZ.js.map +0 -1
  122. package/dist/chunk-5VDTWRXJ.js.map +0 -1
  123. package/dist/chunk-BTUG3VJJ.js.map +0 -1
  124. package/dist/chunk-O23TLLXW.js.map +0 -1
  125. package/dist/chunk-PCRRBU7H.js.map +0 -1
  126. package/dist/chunk-QT7LYM72.js.map +0 -1
  127. package/dist/chunk-VXASXU4K.js.map +0 -1
  128. package/dist/cs-NEXYZ62I.js.map +0 -1
  129. package/dist/da-FAT43YCU.js.map +0 -1
  130. package/dist/de-KRQ3WBCU.js.map +0 -1
  131. package/dist/el-Q4IU3EKC.js.map +0 -1
  132. package/dist/en-2KADE7N4.js.map +0 -1
  133. package/dist/es-2IO3R2MK.js.map +0 -1
  134. package/dist/fa-Q5GOQFXX.js.map +0 -1
  135. package/dist/fi-R6NMRWBE.js.map +0 -1
  136. package/dist/fr-QY4D5E6J.js.map +0 -1
  137. package/dist/he-X6Y77XHY.js.map +0 -1
  138. package/dist/hi-XL7IOEQM.js.map +0 -1
  139. package/dist/id-QZ4ZZS67.js.map +0 -1
  140. package/dist/index.css.map +0 -1
  141. package/dist/index.js.map +0 -1
  142. package/dist/integrations/css-modules-helper.js.map +0 -1
  143. package/dist/integrations/styled-components-theme.js.map +0 -1
  144. package/dist/it-ME2Q7OB6.js.map +0 -1
  145. package/dist/ja-HX7QASRI.js.map +0 -1
  146. package/dist/ko-EQO5AHAV.js.map +0 -1
  147. package/dist/ms-UILFSF3I.js.map +0 -1
  148. package/dist/nl-NNI45PMK.js.map +0 -1
  149. package/dist/no-MAL5F6P6.js.map +0 -1
  150. package/dist/pl-4RMY6JAJ.js.map +0 -1
  151. package/dist/pt-VQH2X3QQ.js.map +0 -1
  152. package/dist/ro-XXCQO2OG.js.map +0 -1
  153. package/dist/sv-7Z6EJ6U4.js.map +0 -1
  154. package/dist/test-utils.css.map +0 -1
  155. package/dist/test-utils.js.map +0 -1
  156. package/dist/th-L3DXXYVQ.js.map +0 -1
  157. package/dist/tr-P3DFVLFJ.js.map +0 -1
  158. package/dist/uk-7C5D3PXS.js.map +0 -1
  159. package/dist/vi-ZLTLEO7P.js.map +0 -1
  160. package/dist/zh-5FHVWDIN.js.map +0 -1
  161. package/src/components/modals/ResourceSearchModal.tsx +0 -224
  162. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +0 -163
@@ -12,25 +12,60 @@ import { GatherContextStep } from '../GatherContextStep';
12
12
  import { ContextSummary } from '../ContextSummary';
13
13
 
14
14
  const t = {
15
- title: 'Gather',
16
15
  loadingContext: 'Loading…',
17
16
  failedContext: 'Failed',
18
- search: 'Search',
19
- generate: 'Generate',
20
- compose: 'Compose',
21
- resolutionStrategyLabel: 'Strategy',
22
17
  sourceContextLabel: 'Source',
23
18
  connectionsLabel: 'Connections',
24
19
  citedByLabel: 'Cited by',
25
- userHintLabel: 'Hint',
26
- userHintPlaceholder: 'hint…',
20
+ graphPaneTitle: 'In the graph',
21
+ graphEmpty: 'No links yet — resolving this reference creates the first.',
22
+ resourceLinkLabel: 'Resource link',
23
+ corpusPaneTitle: 'In the corpus',
24
+ corpusEmpty: 'Nothing similar in the corpus.',
25
+ excludedReceipt: '{{types}} excluded from this recall',
26
+ machineRead: 'OCR',
27
+ score: 'Score',
28
+ };
29
+
30
+ /** Post-P3 wire shape: resourceName required, entityTypes/machineRead optional. */
31
+ const MATCHES = [
32
+ { text: 'a middling passage', resourceId: 'r-mid', resourceName: 'Middling Doc', score: 0.55 },
33
+ { text: 'the best passage', resourceId: 'r-best', resourceName: 'Best Doc', score: 0.91,
34
+ entityTypes: ['Battle'], machineRead: true },
35
+ { text: 'a weak passage', resourceId: 'r-weak', resourceName: 'Weak Doc', score: 0.12 },
36
+ ];
37
+
38
+ function withSemantic(ctx: GatheredContext, over: Record<string, unknown> = {}): GatheredContext {
39
+ return {
40
+ ...(ctx as object),
41
+ semanticContext: { similar: MATCHES, excludedEntityTypes: ['Question'], ...over },
42
+ inferredRelationshipSummary: 'Cedar County sits inside the Black Hawk Purchase.',
43
+ } as unknown as GatheredContext;
44
+ }
45
+
46
+ /** The annotation-wizard controls travel as ONE optional group (GFR D2). */
47
+ const annotate = {
48
+ userHint: '',
49
+ onUserHintChange: () => {},
50
+ onBind: () => {},
51
+ onGenerate: () => {},
52
+ onCompose: () => {},
53
+ translations: {
54
+ search: 'Search',
55
+ generate: 'Generate',
56
+ compose: 'Compose',
57
+ resolutionStrategyLabel: 'Strategy',
58
+ userHintLabel: 'Hint',
59
+ userHintEffect: 'steers Search and Generate',
60
+ userHintPlaceholder: 'hint…',
61
+ },
27
62
  };
28
63
 
29
64
  function resourceContext(): GatheredContext {
30
65
  return {
31
66
  focus: {
32
67
  kind: 'resource',
33
- resource: { id: 'res-1', name: 'My Resource' },
68
+ resource: { '@id': 'res-1', name: 'My Resource' },
34
69
  summary: 'A short summary',
35
70
  suggestedReferences: ['Suggested Topic'],
36
71
  content: { main: 'main content' },
@@ -58,6 +93,17 @@ describe('GatheredContext display — resource focus', () => {
58
93
  expect(container.textContent).toContain('Related Resource'); // peer connection from deriveViews
59
94
  });
60
95
 
96
+ it('loading without a pending preview stays the plain dots — the resource-gather path', () => {
97
+ // The composite Generate modal has its controls visible above the loading
98
+ // zone; only the annotation wizard passes `pending` (its known facts).
99
+ const { queryByText, getByText } = render(
100
+ <GatherContextStep context={null} contextLoading contextError={null} translations={t} />,
101
+ );
102
+ expect(getByText(t.loadingContext)).toBeInTheDocument();
103
+ expect(queryByText(t.graphPaneTitle)).toBeNull();
104
+ expect(queryByText(t.corpusPaneTitle)).toBeNull();
105
+ });
106
+
61
107
  it('GatherContextStep shows the resource strip and hides the annotation-only footer', () => {
62
108
  const { container } = render(
63
109
  <GatherContextStep
@@ -69,7 +115,7 @@ describe('GatheredContext display — resource focus', () => {
69
115
  );
70
116
  expect(container.textContent).toContain('My Resource'); // focal resource name
71
117
  expect(container.textContent).toContain('A short summary'); // resource summary
72
- expect(container.textContent).toContain('Suggested Topic'); // suggestedReferences chip
118
+ expect(container.textContent).toContain('Suggested Topic'); // suggestedReferences entry
73
119
  expect(container.textContent).toContain('Related Resource'); // graph view via ContextSummary
74
120
  // annotation-only controls are gated out for a resource focus
75
121
  expect(container.textContent).not.toContain('Strategy');
@@ -78,30 +124,422 @@ describe('GatheredContext display — resource focus', () => {
78
124
  });
79
125
 
80
126
  it('GatherContextStep still shows the footer for an annotation focus', () => {
81
- const annotationContext = {
82
- focus: {
83
- kind: 'annotation',
84
- annotation: { id: 'anno-1', motivation: 'linking' },
85
- sourceResource: { id: 'res-1', name: 'Host' },
86
- selected: { before: 'a ', text: 'term', after: ' b' },
87
- },
88
- graph: { nodes: [], edges: [] },
89
- metadata: {},
90
- } as unknown as GatheredContext;
91
127
  const { container } = render(
92
128
  <GatherContextStep
93
- context={annotationContext}
129
+ context={annotationContext()}
94
130
  contextLoading={false}
95
131
  contextError={null}
96
- userHint=""
97
- onUserHintChange={() => {}}
98
- onBind={() => {}}
99
- onGenerate={() => {}}
100
- onCompose={() => {}}
132
+ annotate={annotate}
101
133
  translations={t}
102
134
  />,
103
135
  );
104
136
  expect(container.querySelector('.semiont-gather__footer')).not.toBeNull();
105
137
  expect(container.textContent).toContain('Strategy');
106
138
  });
139
+
140
+ it('the quotation is contiguous prose — nothing interleaved, no monospace (GEP P1a, D6/D7)', () => {
141
+ // The live bug: chips injected between `text` and `after` rendered
142
+ // "Black Hawk [Person] [linking]'s band." — metadata interrupting the
143
+ // quotation it describes, mid-possessive.
144
+ const { container } = render(
145
+ <GatherContextStep
146
+ context={annotationContextWithTypes()}
147
+ contextLoading={false}
148
+ contextError={null}
149
+ annotate={annotate}
150
+ translations={t}
151
+ />,
152
+ );
153
+ const quote = container.querySelector('.semiont-gather__source-context');
154
+ expect(quote).not.toBeNull();
155
+ expect(quote!.textContent).toBe('a term b'); // before + text + after, verbatim
156
+ // D7: quoted document prose, not code.
157
+ expect(quote!.querySelector('[style*="monospace"]')).toBeNull();
158
+ });
159
+
160
+ it('metadata sits on the label row; the span WEARS its motivation (GEP P1b, D6)', () => {
161
+ const { container } = render(
162
+ <GatherContextStep
163
+ context={annotationContextWithTypes()}
164
+ contextLoading={false}
165
+ contextError={null}
166
+ annotate={annotate}
167
+ translations={t}
168
+ />,
169
+ );
170
+ const strip = container.querySelector('.semiont-gather__source-strip')!;
171
+
172
+ // Entity chips are tokens — they live on the label row, outside the quotation.
173
+ const chips = Array.from(strip.querySelectorAll('.semiont-chip'));
174
+ const topicChip = chips.find((c) => c.textContent === 'Topic');
175
+ expect(topicChip).toBeDefined();
176
+ expect(topicChip!.closest('.semiont-gather__source-context')).toBeNull();
177
+
178
+ // Motivation is WORN, never labeled: no chip says 'linking' —
179
+ expect(chips.filter((c) => c.textContent === 'linking')).toEqual([]);
180
+ // — the selected span carries the viewer's own class for the motivation
181
+ // (the registry's applied name; the motivation stylesheets declare it as a
182
+ // synonym of `semiont-motivation-reference`), with the hand-rolled inline
183
+ // highlight gone.
184
+ const span = strip.querySelector('.annotation-reference') as HTMLElement | null;
185
+ expect(span).not.toBeNull();
186
+ expect(span!.textContent).toBe('term');
187
+ expect(span!.style.backgroundColor).toBe('');
188
+
189
+ // Exactly ONE motivation-classed element: the context can place only the
190
+ // focal annotation, and the strip must not show more (D6 boundary).
191
+ const motivated = strip.querySelectorAll(
192
+ '.annotation-reference, .annotation-highlight, .annotation-comment, .annotation-assessment, .annotation-tag',
193
+ );
194
+ expect(motivated).toHaveLength(1);
195
+ });
196
+
197
+ it('suggestedReferences render as a prose list, never chips (GFR A3)', () => {
198
+ // The live values are full research prompts — sentences. The chip vocabulary
199
+ // stays for tokens (entity types, categories, counts); a pill that wraps
200
+ // across three lines is not a pill (GENERATE-FROM-RESOURCE D3).
201
+ const { container } = render(
202
+ <GatherContextStep
203
+ context={resourceContext()}
204
+ contextLoading={false}
205
+ contextError={null}
206
+ translations={t}
207
+ />,
208
+ );
209
+ const item = Array.from(container.querySelectorAll('li'))
210
+ .find((el) => el.textContent === 'Suggested Topic');
211
+ expect(item).toBeDefined();
212
+ expect(item!.className).not.toContain('semiont-chip');
213
+ // The entity-type tokens beside the summary stay chips.
214
+ const chipTexts = Array.from(container.querySelectorAll('.semiont-chip')).map((el) => el.textContent);
215
+ expect(chipTexts).toContain('Topic');
216
+ expect(chipTexts).not.toContain('Suggested Topic');
217
+ });
218
+
219
+ // ── GEP P2 — the evidence panes ─────────────────────────────────────────────
220
+
221
+ it('the graph pane shows its title and the inferred summary as prose header (P2)', () => {
222
+ const { container } = render(
223
+ <GatherContextStep
224
+ context={withSemantic(resourceContext())}
225
+ contextLoading={false} contextError={null} translations={t}
226
+ />,
227
+ );
228
+ const pane = container.querySelector('.semiont-gather-pane--graph');
229
+ expect(pane).not.toBeNull();
230
+ expect(pane!.textContent).toContain('In the graph');
231
+ expect(pane!.textContent).toContain('Cedar County sits inside the Black Hawk Purchase.');
232
+ });
233
+
234
+ it('the corpus pane ranks match cards by descending score, named and marked (P2, D4)', () => {
235
+ const { container } = render(
236
+ <GatherContextStep
237
+ context={withSemantic(resourceContext())}
238
+ contextLoading={false} contextError={null} translations={t}
239
+ />,
240
+ );
241
+ const cards = Array.from(container.querySelectorAll('.semiont-corpus__card'));
242
+ expect(cards).toHaveLength(3);
243
+ // Descending by score, whatever order the wire delivered.
244
+ expect(cards.map((c) => c.querySelector('.semiont-corpus__source')!.textContent))
245
+ .toEqual(['Best Doc', 'Middling Doc', 'Weak Doc']);
246
+ // The card names its source (D9's required field), shows the snippet, and
247
+ // formats the 0–1 cosine to two places (logged deviation — the search step's
248
+ // raw render is a different scale).
249
+ expect(cards[0]!.textContent).toContain('the best passage');
250
+ expect(cards[0]!.textContent).toContain('0.91');
251
+ // Match-level entityTypes are tokens — chips on the card.
252
+ expect(cards[0]!.querySelector('.semiont-chip')!.textContent).toBe('Battle');
253
+ // machineRead is a trust marker that appears ONLY when the flag travels.
254
+ expect(cards[0]!.textContent).toContain('OCR');
255
+ expect(cards[1]!.textContent).not.toContain('OCR');
256
+ });
257
+
258
+ it('the exclusion receipt renders when present, and only then (P2, D4)', () => {
259
+ const { container, rerender } = render(
260
+ <GatherContextStep
261
+ context={withSemantic(resourceContext())}
262
+ contextLoading={false} contextError={null} translations={t}
263
+ />,
264
+ );
265
+ expect(container.querySelector('.semiont-corpus__receipt')!.textContent)
266
+ .toContain('Question excluded from this recall');
267
+
268
+ rerender(
269
+ <GatherContextStep
270
+ context={withSemantic(resourceContext(), { excludedEntityTypes: [] })}
271
+ contextLoading={false} contextError={null} translations={t}
272
+ />,
273
+ );
274
+ expect(container.querySelector('.semiont-corpus__receipt')).toBeNull();
275
+ });
276
+
277
+ it('emptiness is evidence: each pane renders its empty-state copy (P2, D1)', () => {
278
+ // resourceContext() has no semanticContext and a graph whose views resolve
279
+ // to a connection — so build a truly empty context for both panes.
280
+ const empty = {
281
+ ...(resourceContext() as object),
282
+ graph: { nodes: [], edges: [] },
283
+ } as unknown as GatheredContext;
284
+ const { container } = render(
285
+ <GatherContextStep context={empty} contextLoading={false} contextError={null} translations={t} />,
286
+ );
287
+ expect(container.querySelector('.semiont-gather-pane--graph')!.textContent)
288
+ .toContain('No links yet');
289
+ expect(container.querySelector('.semiont-gather-pane--corpus')!.textContent)
290
+ .toContain('Nothing similar in the corpus.');
291
+ });
292
+
293
+ it('the hint sits OUTSIDE the pane grid, full width above the footer (P2, D5)', () => {
294
+ const { container } = render(
295
+ <GatherContextStep
296
+ context={annotationContext()}
297
+ contextLoading={false} contextError={null}
298
+ annotate={annotate} translations={t}
299
+ />,
300
+ );
301
+ const textarea = container.querySelector('.semiont-gather__hint-textarea');
302
+ expect(textarea).not.toBeNull();
303
+ expect(textarea!.closest('.semiont-gather__body')).toBeNull(); // out of the grid
304
+ expect(container.querySelector('.semiont-gather__hint-row')).not.toBeNull();
305
+ });
306
+
307
+ // ── GEP P4 — the graph pane's body is an actual graph ───────────────────────
308
+ // Post-P3 topology: a citation is its linking ANNOTATION (annotation-of → the
309
+ // citing resource, cites → the focal one); siblings are annotations ON the
310
+ // focal resource. The viz draws the derived neighborhood: focal + peers +
311
+ // citers + siblings; the citation's intermediary annotation collapses into
312
+ // its edge.
313
+
314
+ function vizContext(): GatheredContext {
315
+ return {
316
+ ...(resourceContext() as object),
317
+ graph: {
318
+ nodes: [
319
+ { id: 'res-1', type: 'resource', label: 'My Resource' },
320
+ { id: 'res-2', type: 'resource', label: 'Peer Doc', entityTypes: ['Topic'] },
321
+ { id: 'res-3', type: 'resource', label: 'Citing Doc' },
322
+ { id: 'ann-c', type: 'annotation', label: 'linking',
323
+ annotation: { id: 'ann-c', motivation: 'linking' } },
324
+ { id: 'ann-s', type: 'annotation', label: 'commenting', entityTypes: ['Person'],
325
+ annotation: { id: 'ann-s', motivation: 'commenting',
326
+ target: { source: 'res-1', selector: [
327
+ { type: 'TextPositionSelector', start: 5, end: 22 },
328
+ { type: 'TextQuoteSelector', exact: 'the disputed span' },
329
+ ] },
330
+ body: [
331
+ { type: 'TextualBody', value: 'Person', purpose: 'tagging' },
332
+ { type: 'TextualBody', value: 'a sibling note' },
333
+ ] } },
334
+ ],
335
+ edges: [
336
+ { source: 'res-1', target: 'res-2', type: 'related', bidirectional: true },
337
+ { source: 'ann-c', target: 'res-3', type: 'annotation-of' },
338
+ { source: 'ann-c', target: 'res-1', type: 'cites' },
339
+ { source: 'ann-s', target: 'res-1', type: 'annotation-of' },
340
+ ],
341
+ },
342
+ } as unknown as GatheredContext;
343
+ }
344
+
345
+ function renderViz() {
346
+ return render(
347
+ <GatherContextStep
348
+ context={vizContext()} contextLoading={false} contextError={null} translations={t}
349
+ />,
350
+ );
351
+ }
352
+
353
+ it('draws the neighborhood as SVG — the interim lists are gone (P4, D3)', () => {
354
+ const { container } = renderViz();
355
+ const pane = container.querySelector('.semiont-gather-pane--graph')!;
356
+ expect(pane.querySelector('svg')).not.toBeNull();
357
+ // focal + peer + citer + sibling; the citing annotation collapses into its edge
358
+ expect(pane.querySelectorAll('.semiont-graph__node')).toHaveLength(4);
359
+ expect(pane.querySelectorAll('.semiont-graph__edge')).toHaveLength(3);
360
+ expect(pane.querySelector('ul')).toBeNull(); // replaced, not joined
361
+ });
362
+
363
+ it('the focal node is visually distinct, and there is exactly one (P4)', () => {
364
+ const { container } = renderViz();
365
+ const focal = container.querySelectorAll('.semiont-graph__node--focal');
366
+ expect(focal).toHaveLength(1);
367
+ expect(focal[0]!.textContent).toContain('My Resource');
368
+ });
369
+
370
+ it('siblings are labeled by their quoted text — motivation and note stay in hover (P4, D11)', () => {
371
+ // A node that just says "linking" is uninterpretable; the annotation's
372
+ // identity is the text it wraps (TextQuoteSelector.exact, on the embedded
373
+ // W3C annotation). Motivation is conveyed by the node's styling and hover.
374
+ const { container } = renderViz();
375
+ const sibling = container.querySelector('[data-node-id="ann-s"]');
376
+ expect(sibling).not.toBeNull();
377
+ expect(sibling!.querySelector('text')!.textContent).toBe('the disputed span');
378
+ const title = sibling!.querySelector('title');
379
+ expect(title).not.toBeNull();
380
+ expect(title!.textContent).toContain('commenting');
381
+ expect(title!.textContent).toContain('a sibling note');
382
+ // The tagging-purpose body is the entity-type tag — hover lists the type
383
+ // ONCE (from entityTypes), not again as a body value.
384
+ expect(title!.textContent!.match(/Person/g)).toHaveLength(1);
385
+ // Peer hover carries its entity types.
386
+ expect(container.querySelector('[data-node-id="res-2"] title')!.textContent).toContain('Topic');
387
+ });
388
+
389
+ /** A resource-level provenance annotation: target has no selector (nothing
390
+ * to quote); the body's SpecificResource names what it links to. */
391
+ function provenanceContext(bodySource: string): GatheredContext {
392
+ return {
393
+ ...(resourceContext() as object),
394
+ graph: {
395
+ nodes: [
396
+ { id: 'res-1', type: 'resource', label: 'My Resource' },
397
+ { id: 'res-9', type: 'resource', label: 'Generated Doc' },
398
+ { id: 'ann-p', type: 'annotation', label: 'linking',
399
+ annotation: { id: 'ann-p', motivation: 'linking',
400
+ target: { source: 'res-1' },
401
+ body: [{ type: 'SpecificResource', source: bodySource, purpose: 'linking' }] } },
402
+ ],
403
+ edges: [{ source: 'ann-p', target: 'res-1', type: 'annotation-of' }],
404
+ },
405
+ } as unknown as GatheredContext;
406
+ }
407
+
408
+ it('a resource-level linking annotation is labeled by what it links to, not its motivation', () => {
409
+ const { container } = render(<ContextSummary context={provenanceContext('res-9')} translations={t} />);
410
+ const node = container.querySelector('[data-node-id="ann-p"]')!;
411
+ expect(node.querySelector('text')!.textContent).toBe('→ Generated Doc');
412
+ // Hover carries the full linked name; the motivation stays hover-only.
413
+ expect(node.querySelector('title')!.textContent).toContain('Generated Doc');
414
+ expect(node.querySelector('title')!.textContent).toContain('linking');
415
+ });
416
+
417
+ it('an unresolvable link target falls back to the translated label — never raw vocabulary', () => {
418
+ const { container } = render(<ContextSummary context={provenanceContext('res-404')} translations={t} />);
419
+ const node = container.querySelector('[data-node-id="ann-p"]')!;
420
+ expect(node.querySelector('text')!.textContent).toBe('Resource link');
421
+ });
422
+
423
+ it('layout is deterministic — same context, same positions (P4, D3)', () => {
424
+ const positions = () => {
425
+ const { container, unmount } = renderViz();
426
+ const snap = Array.from(container.querySelectorAll('.semiont-graph__node rect'))
427
+ .map((r) => `${r.closest('[data-node-id]')!.getAttribute('data-node-id')}@${r.getAttribute('x')},${r.getAttribute('y')}`)
428
+ .join('|');
429
+ unmount();
430
+ return snap;
431
+ };
432
+ const first = positions();
433
+ expect(first.length).toBeGreaterThan(0);
434
+ expect(positions()).toBe(first);
435
+ });
436
+
437
+ // ── Graph geometry — compaction + wrap, everything draws (no-clipping) ─────
438
+ // The layout adapts to what's populated: absent citer/peer layers free their
439
+ // columns for the sibling band to wrap into, and the viewBox shrinks to the
440
+ // columns actually used. Nothing is ever capped or hidden — overflow rides
441
+ // the wizard pane's scroll.
442
+
443
+ /** Focal resource + N sibling annotations, no citers, no peers. */
444
+ function middleOnlyContext(siblingCount: number): GatheredContext {
445
+ return {
446
+ ...(resourceContext() as object),
447
+ graph: {
448
+ nodes: [
449
+ { id: 'res-1', type: 'resource', label: 'My Resource' },
450
+ ...Array.from({ length: siblingCount }, (_, i) => ({
451
+ id: `ann-${i}`, type: 'annotation', label: 'commenting',
452
+ annotation: { id: `ann-${i}`, motivation: 'commenting',
453
+ target: { source: 'res-1', selector: [
454
+ { type: 'TextQuoteSelector', exact: `span ${i}` },
455
+ ] },
456
+ body: [] },
457
+ })),
458
+ ],
459
+ edges: Array.from({ length: siblingCount }, (_, i) => ({
460
+ source: `ann-${i}`, target: 'res-1', type: 'annotation-of',
461
+ })),
462
+ },
463
+ } as unknown as GatheredContext;
464
+ }
465
+
466
+ describe('graph geometry adapts to the populated layers', () => {
467
+ it('a lone sibling collapses to one natural-size column — no dead thirds', () => {
468
+ const { container } = render(<ContextSummary context={middleOnlyContext(1)} translations={t} />);
469
+ const svg = container.querySelector('svg')!;
470
+ // One 180-wide column + margins; two rows (focal above, sibling below).
471
+ expect(svg.getAttribute('viewBox')).toBe('0 0 196 120');
472
+ // Natural-size cap: 1 viewBox unit never exceeds 1px, so a narrow graph
473
+ // renders small instead of stretching to fill the pane.
474
+ expect((svg as SVGElement).style.maxWidth).toBe('196px');
475
+ });
476
+
477
+ it('siblings wrap into the freed columns instead of chaining (5 → 3-across grid)', () => {
478
+ const { container } = render(<ContextSummary context={middleOnlyContext(5)} translations={t} />);
479
+ const svg = container.querySelector('svg')!;
480
+ // Three columns, three rows (focal + two sibling rows) — not six rows.
481
+ expect(svg.getAttribute('viewBox')).toBe('0 0 628 172');
482
+ const rects = Array.from(container.querySelectorAll('.semiont-graph__node--annotation rect'));
483
+ expect(new Set(rects.map((r) => r.getAttribute('x')))).toEqual(new Set(['8', '224', '440']));
484
+ expect(Math.max(...rects.map((r) => Number(r.getAttribute('y'))))).toBe(112); // row 2
485
+ });
486
+
487
+ it('wrap compacts, never clips — every sibling and edge still draws', () => {
488
+ const { container } = render(<ContextSummary context={middleOnlyContext(11)} translations={t} />);
489
+ expect(container.querySelectorAll('.semiont-graph__node--annotation')).toHaveLength(11);
490
+ expect(container.querySelectorAll('.semiont-graph__edge--annotation-of')).toHaveLength(11);
491
+ });
492
+
493
+ it('a fully populated graph keeps the three-layer full-width shape', () => {
494
+ // vizContext has citers AND peers — no freed columns, the middle band
495
+ // stays a single column and the chain is the correct rendering.
496
+ const { container } = renderViz();
497
+ const svg = container.querySelector('svg')!;
498
+ expect(svg.getAttribute('viewBox')!.startsWith('0 0 628 ')).toBe(true);
499
+ });
500
+
501
+ it('the floating cited-line caption is gone — the drawn citer nodes ARE the count', () => {
502
+ const { container } = renderViz();
503
+ const pane = container.querySelector('.semiont-gather-pane--graph')!;
504
+ expect(container.querySelector('[data-node-id="res-3"]')).not.toBeNull(); // citer drawn
505
+ expect(pane.querySelector('.semiont-graph__cited-line')).toBeNull();
506
+ expect(pane.textContent).not.toContain('Cited by (1)');
507
+ });
508
+ });
509
+
510
+ it('an annotation focus WITHOUT the annotate group renders display-only (GFR A2)', () => {
511
+ // The resolution controls belong to the caller that can serve them. A
512
+ // display-only caller must never get a hint textarea wired to nothing —
513
+ // which is what the old always-on gate produced for an annotation focus.
514
+ const { container } = render(
515
+ <GatherContextStep
516
+ context={annotationContext()}
517
+ contextLoading={false}
518
+ contextError={null}
519
+ translations={t}
520
+ />,
521
+ );
522
+ expect(container.querySelector('.semiont-gather__footer')).toBeNull();
523
+ expect(container.querySelector('.semiont-gather__hint-textarea')).toBeNull();
524
+ });
107
525
  });
526
+
527
+ function annotationContext(): GatheredContext {
528
+ return {
529
+ focus: {
530
+ kind: 'annotation',
531
+ annotation: { id: 'anno-1', motivation: 'linking' },
532
+ sourceResource: { '@id': 'res-1', name: 'Host' },
533
+ selected: { before: 'a ', text: 'term', after: ' b' },
534
+ },
535
+ graph: { nodes: [], edges: [] },
536
+ metadata: {},
537
+ } as unknown as GatheredContext;
538
+ }
539
+
540
+ /** Same annotation focus, with anchor entity types — the GEP P1 chip fixtures. */
541
+ function annotationContextWithTypes(): GatheredContext {
542
+ const ctx = annotationContext() as GatheredContext & { metadata: { entityTypes?: string[] } };
543
+ ctx.metadata = { entityTypes: ['Topic'] };
544
+ return ctx;
545
+ }