@semiont/react-ui 0.5.27 → 0.5.29

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 (192) hide show
  1. package/README.md +3 -3
  2. package/dist/{PdfAnnotationCanvas.client-EVUPTUWZ.js → PdfAnnotationCanvas.client-LSH3PZPY.js} +32 -5
  3. package/dist/{ar-OU3DQGC6.js → ar-AZBRADEB.js} +43 -23
  4. package/dist/{bn-PHWCR6AZ.js → bn-CCEW6AOS.js} +43 -23
  5. package/dist/{chunk-VXASXU4K.js → chunk-3ZBKCWUZ.js} +1 -1
  6. package/dist/{chunk-5VDTWRXJ.js → chunk-G4VHL4UA.js} +31 -31
  7. package/dist/{chunk-PCRRBU7H.js → chunk-SDKFZLEX.js} +43 -23
  8. package/dist/{cs-NEXYZ62I.js → cs-HXPF2CTE.js} +43 -23
  9. package/dist/{da-FAT43YCU.js → da-K5NTK7LH.js} +43 -23
  10. package/dist/{de-KRQ3WBCU.js → de-GMRQEDZI.js} +43 -23
  11. package/dist/{el-Q4IU3EKC.js → el-EUB4CI4G.js} +43 -23
  12. package/dist/{en-2KADE7N4.js → en-R5XA26NF.js} +2 -2
  13. package/dist/{es-2IO3R2MK.js → es-Z32DAZ6W.js} +43 -23
  14. package/dist/{fa-Q5GOQFXX.js → fa-G7HN6B32.js} +43 -23
  15. package/dist/{fi-R6NMRWBE.js → fi-3YBLZO7I.js} +43 -23
  16. package/dist/{fr-QY4D5E6J.js → fr-VUBH7BBS.js} +43 -23
  17. package/dist/{he-X6Y77XHY.js → he-WQQEY26B.js} +43 -23
  18. package/dist/{hi-XL7IOEQM.js → hi-HJZ6W4JB.js} +43 -23
  19. package/dist/{id-QZ4ZZS67.js → id-ZOGZ37MI.js} +43 -23
  20. package/dist/index.css +279 -74
  21. package/dist/index.d.ts +318 -393
  22. package/dist/index.js +2619 -2687
  23. package/dist/{it-ME2Q7OB6.js → it-DT6UW66F.js} +43 -23
  24. package/dist/{ja-HX7QASRI.js → ja-RUKQCOLO.js} +43 -23
  25. package/dist/{ko-EQO5AHAV.js → ko-ZH5ADKZM.js} +43 -23
  26. package/dist/{ms-UILFSF3I.js → ms-Y2AFPZTU.js} +43 -23
  27. package/dist/{nl-NNI45PMK.js → nl-OQEWIXNA.js} +43 -23
  28. package/dist/{no-MAL5F6P6.js → no-KPAYJZWY.js} +43 -23
  29. package/dist/{pl-4RMY6JAJ.js → pl-WSXK2UUD.js} +43 -23
  30. package/dist/{pt-VQH2X3QQ.js → pt-NWRJRUCK.js} +43 -23
  31. package/dist/{ro-XXCQO2OG.js → ro-3CGVMOC6.js} +43 -23
  32. package/dist/{sv-7Z6EJ6U4.js → sv-HGSEBA7S.js} +43 -23
  33. package/dist/test-utils.js +16 -7
  34. package/dist/{th-L3DXXYVQ.js → th-NZ4G352Z.js} +43 -23
  35. package/dist/{tr-P3DFVLFJ.js → tr-LGGS4DLW.js} +43 -23
  36. package/dist/{uk-7C5D3PXS.js → uk-AIX536L4.js} +43 -23
  37. package/dist/{vi-ZLTLEO7P.js → vi-V6CVQZLQ.js} +43 -23
  38. package/dist/{zh-5FHVWDIN.js → zh-AGKPGI3L.js} +43 -23
  39. package/package.json +16 -15
  40. package/src/components/AssistProgress.tsx +36 -2
  41. package/src/components/CodeMirrorRenderer.tsx +8 -8
  42. package/src/components/StatusDisplay.tsx +13 -13
  43. package/src/components/__tests__/AssistProgress.test.tsx +59 -0
  44. package/src/components/__tests__/StatusDisplay.test.tsx +9 -9
  45. package/src/components/modals/ComposeStep.tsx +233 -0
  46. package/src/components/modals/ConfigureGatherStep.tsx +10 -18
  47. package/src/components/modals/ConfigureGenerationStep.tsx +178 -34
  48. package/src/components/modals/ConfigureSearchStep.tsx +19 -0
  49. package/src/components/modals/ContextSummary.tsx +185 -72
  50. package/src/components/modals/CorpusPane.tsx +75 -0
  51. package/src/components/modals/DiscardPrompt.tsx +38 -0
  52. package/src/components/modals/GatherContextStep.tsx +237 -107
  53. package/src/components/modals/ReferenceWizardModal.tsx +252 -59
  54. package/src/components/modals/ResourceGenerateModal.tsx +225 -112
  55. package/src/components/modals/SearchModal.css +337 -101
  56. package/src/components/modals/SearchResultsStep.tsx +61 -68
  57. package/src/components/modals/WizardFooter.tsx +7 -1
  58. package/src/components/modals/__tests__/ComposeStep.test.tsx +152 -0
  59. package/src/components/modals/__tests__/ConfigureGatherStep.test.tsx +14 -26
  60. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +206 -8
  61. package/src/components/modals/__tests__/ConfigureSearchStep.test.tsx +24 -1
  62. package/src/components/modals/__tests__/GatherContextStep.resource.test.tsx +463 -25
  63. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +442 -39
  64. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +300 -86
  65. package/src/components/modals/__tests__/SearchResultsStep.test.tsx +61 -0
  66. package/src/components/modals/__tests__/WizardFooter.test.tsx +116 -9
  67. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +34 -1
  68. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +38 -1
  69. package/src/components/resource/AnnotateView.tsx +3 -3
  70. package/src/components/resource/BrowseView.tsx +8 -8
  71. package/src/components/resource/DownloadFileLink.tsx +1 -1
  72. package/src/components/resource/ResourceViewer.tsx +4 -4
  73. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +1 -1
  74. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +1 -1
  75. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +1 -1
  76. package/src/components/resource/__tests__/BrowseView.test.tsx +11 -11
  77. package/src/components/resource/__tests__/download-link.test.tsx +6 -6
  78. package/src/components/resource/annotate-renderers.tsx +3 -3
  79. package/src/components/resource/panels/AssistSection.css +35 -0
  80. package/src/components/resource/panels/ReferenceEntry.tsx +11 -4
  81. package/src/components/resource/panels/ReferencesPanel.tsx +9 -1
  82. package/src/components/resource/panels/ResourceInfoPanel.tsx +89 -20
  83. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +7 -2
  84. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +41 -0
  85. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  86. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +84 -3
  87. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +149 -7
  88. package/src/features/auth/__tests__/SignInForm.a11y.test.tsx +12 -12
  89. package/src/features/auth/components/SignInForm.tsx +53 -53
  90. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +2 -2
  91. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +0 -65
  92. package/src/features/resource-compose/components/ResourceComposePage.tsx +35 -133
  93. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +1 -100
  94. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -57
  95. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +1 -1
  96. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +2 -2
  97. package/src/features/resource-discovery/state/discover-state-unit.ts +1 -1
  98. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +73 -17
  99. package/src/features/resource-viewer/__tests__/generation-options.test.ts +53 -0
  100. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +148 -83
  101. package/src/features/resource-viewer/generation-options.ts +29 -0
  102. package/src/styles/core/forms.css +35 -8
  103. package/src/styles/features/resource-viewer.css +0 -22
  104. package/src/styles/features/resource.css +0 -4
  105. package/src/styles/index.css +0 -1
  106. package/translations/ar.json +42 -22
  107. package/translations/bn.json +42 -22
  108. package/translations/cs.json +42 -22
  109. package/translations/da.json +42 -22
  110. package/translations/de.json +42 -22
  111. package/translations/el.json +42 -22
  112. package/translations/en.json +42 -22
  113. package/translations/es.json +42 -22
  114. package/translations/fa.json +42 -22
  115. package/translations/fi.json +42 -22
  116. package/translations/fr.json +42 -22
  117. package/translations/he.json +42 -22
  118. package/translations/hi.json +42 -22
  119. package/translations/id.json +42 -22
  120. package/translations/it.json +42 -22
  121. package/translations/ja.json +42 -22
  122. package/translations/ko.json +42 -22
  123. package/translations/ms.json +42 -22
  124. package/translations/nl.json +42 -22
  125. package/translations/no.json +42 -22
  126. package/translations/pl.json +42 -22
  127. package/translations/pt.json +42 -22
  128. package/translations/ro.json +42 -22
  129. package/translations/sv.json +42 -22
  130. package/translations/th.json +42 -22
  131. package/translations/tr.json +42 -22
  132. package/translations/uk.json +42 -22
  133. package/translations/vi.json +42 -22
  134. package/translations/zh.json +42 -22
  135. package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js.map +0 -1
  136. package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css.map +0 -1
  137. package/dist/ar-OU3DQGC6.js.map +0 -1
  138. package/dist/bn-PHWCR6AZ.js.map +0 -1
  139. package/dist/chunk-5VDTWRXJ.js.map +0 -1
  140. package/dist/chunk-BTUG3VJJ.js.map +0 -1
  141. package/dist/chunk-O23TLLXW.js.map +0 -1
  142. package/dist/chunk-PCRRBU7H.js.map +0 -1
  143. package/dist/chunk-QT7LYM72.js.map +0 -1
  144. package/dist/chunk-VXASXU4K.js.map +0 -1
  145. package/dist/cs-NEXYZ62I.js.map +0 -1
  146. package/dist/da-FAT43YCU.js.map +0 -1
  147. package/dist/de-KRQ3WBCU.js.map +0 -1
  148. package/dist/el-Q4IU3EKC.js.map +0 -1
  149. package/dist/en-2KADE7N4.js.map +0 -1
  150. package/dist/es-2IO3R2MK.js.map +0 -1
  151. package/dist/fa-Q5GOQFXX.js.map +0 -1
  152. package/dist/fi-R6NMRWBE.js.map +0 -1
  153. package/dist/fr-QY4D5E6J.js.map +0 -1
  154. package/dist/he-X6Y77XHY.js.map +0 -1
  155. package/dist/hi-XL7IOEQM.js.map +0 -1
  156. package/dist/id-QZ4ZZS67.js.map +0 -1
  157. package/dist/index.css.map +0 -1
  158. package/dist/index.js.map +0 -1
  159. package/dist/integrations/css-modules-helper.js.map +0 -1
  160. package/dist/integrations/styled-components-theme.js.map +0 -1
  161. package/dist/it-ME2Q7OB6.js.map +0 -1
  162. package/dist/ja-HX7QASRI.js.map +0 -1
  163. package/dist/ko-EQO5AHAV.js.map +0 -1
  164. package/dist/ms-UILFSF3I.js.map +0 -1
  165. package/dist/nl-NNI45PMK.js.map +0 -1
  166. package/dist/no-MAL5F6P6.js.map +0 -1
  167. package/dist/pl-4RMY6JAJ.js.map +0 -1
  168. package/dist/pt-VQH2X3QQ.js.map +0 -1
  169. package/dist/ro-XXCQO2OG.js.map +0 -1
  170. package/dist/sv-7Z6EJ6U4.js.map +0 -1
  171. package/dist/test-utils.css.map +0 -1
  172. package/dist/test-utils.js.map +0 -1
  173. package/dist/th-L3DXXYVQ.js.map +0 -1
  174. package/dist/tr-P3DFVLFJ.js.map +0 -1
  175. package/dist/uk-7C5D3PXS.js.map +0 -1
  176. package/dist/vi-ZLTLEO7P.js.map +0 -1
  177. package/dist/zh-5FHVWDIN.js.map +0 -1
  178. package/src/components/modals/ResourceSearchModal.tsx +0 -224
  179. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +0 -163
  180. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -137
  181. package/src/features/admin-exchange/__tests__/ExportCard.test.tsx +0 -41
  182. package/src/features/admin-exchange/__tests__/ImportCard.test.tsx +0 -148
  183. package/src/features/admin-exchange/__tests__/ImportProgress.test.tsx +0 -117
  184. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -117
  185. package/src/features/admin-exchange/components/ExportCard.tsx +0 -35
  186. package/src/features/admin-exchange/components/ImportCard.tsx +0 -183
  187. package/src/features/admin-exchange/components/ImportProgress.tsx +0 -80
  188. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +0 -185
  189. package/src/features/admin-exchange/state/exchange-state-unit.ts +0 -133
  190. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -118
  191. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -118
  192. package/src/styles/features/exchange.css +0 -374
@@ -19,7 +19,7 @@
19
19
  * settings.
20
20
  */
21
21
  import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
22
- import { screen } from '@testing-library/react';
22
+ import { screen, fireEvent, waitFor } from '@testing-library/react';
23
23
  import userEvent from '@testing-library/user-event';
24
24
  import '@testing-library/jest-dom';
25
25
  import type { GatheredContext } from '@semiont/core';
@@ -37,7 +37,7 @@ const CONTEXT = {
37
37
  focus: {
38
38
  kind: 'annotation',
39
39
  annotation: { id: 'ann-1' },
40
- sourceResource: { id: 'res-1', name: 'Scythian steppe' },
40
+ sourceResource: { '@id': 'res-1', name: 'Scythian steppe' },
41
41
  selected: { text: 'Caspian Sea' },
42
42
  },
43
43
  graph: { nodes: [], edges: [] },
@@ -45,24 +45,45 @@ const CONTEXT = {
45
45
  } as unknown as GatheredContext;
46
46
 
47
47
  const T = {
48
- gatherTitle: 'Gathered Context', configureGenerationTitle: 'Configure Generation',
49
- configureSearchTitle: 'Configure Search', searchResultsTitle: 'Search Results',
48
+ resolveTitle: 'Resolve Reference',
50
49
  sourceContextLabel: 'Source', connectionsLabel: 'Connections', citedByLabel: 'Cited by',
51
- userHintLabel: 'Hint', userHintPlaceholder: 'Describe what this refers to…',
50
+ userHintLabel: 'Hint', userHintEffect: 'steers Search and Generate',
51
+ userHintPlaceholder: 'Describe what this refers to…',
52
+ graphPaneTitle: 'In the graph', graphEmpty: 'No links yet.', resourceLinkLabel: 'Resource link',
53
+ corpusPaneTitle: 'In the corpus', corpusEmpty: 'Nothing similar.',
54
+ excludedReceipt: '{{types}} excluded', machineRead: 'OCR',
52
55
  loadingContext: 'Loading…', failedContext: 'Failed',
53
- cancel: 'Cancel', search: 'Search', searching: 'Searching…', generate: 'Generate',
56
+ search: 'Search', searching: 'Searching…', generate: 'Generate',
54
57
  compose: 'Compose', resolutionStrategyLabel: 'Resolution Strategy', back: 'Back',
55
58
  link: 'Link', score: 'Score', noResults: 'No results',
56
- resourceTitle: 'Resource Title', resourceTitlePlaceholder: 'Title…',
59
+ resourceTitle: 'Resource Title', resourceTitlePlaceholder: 'Title…', saveLocation: 'Save location',
57
60
  additionalInstructions: 'Additional Instructions', additionalInstructionsPlaceholder: '…',
58
61
  language: 'Language', languageHelp: '', creativity: 'Creativity',
59
62
  creativityFocused: 'Focused', creativityCreative: 'Creative',
60
63
  maxLength: 'Max length', maxLengthHelp: '', maxLengthCeiling: 'Limited to {{maxOutputTokens}} by {{model}}.',
64
+ outputFormat: 'Format',
65
+ formatExtensionMismatch: 'Save location must end in {{extension}} to match the selected format.',
61
66
  maxResults: 'Max Results', semanticScoring: 'Semantic Scoring', semanticScoringHelp: '',
67
+ searchFailed: 'Search failed',
68
+ contentLabel: 'Content', entityTypes: 'Entity types',
69
+ createAndLink: 'Create & Link', creatingAndLinking: 'Creating…',
70
+ discardDraftPrompt: 'Discard this draft?', discardDraft: 'Discard', keepEditing: 'Keep editing',
62
71
  };
63
72
 
73
+ // ComposeStep embeds the editor; same mock the page's own tests use.
74
+ vi.mock('../../CodeMirrorRenderer', () => ({
75
+ CodeMirrorRenderer: ({ content, onChange, editable }: any) => (
76
+ <textarea
77
+ data-testid="code-editor"
78
+ value={content}
79
+ disabled={!editable}
80
+ onChange={(e) => onChange?.(e.target.value)}
81
+ />
82
+ ),
83
+ }));
84
+
64
85
  function renderWizard(over: Partial<React.ComponentProps<typeof ReferenceWizardModal>> = {}) {
65
- const onComposeNavigate = vi.fn();
86
+ const onComposeSubmit = vi.fn<(referenceId: string, params: unknown) => Promise<void>>().mockResolvedValue(undefined);
66
87
  const onGenerateSubmit = vi.fn();
67
88
  const onLinkResource = vi.fn();
68
89
  const onClose = vi.fn();
@@ -75,13 +96,14 @@ function renderWizard(over: Partial<React.ComponentProps<typeof ReferenceWizardM
75
96
  resourceId="res-1"
76
97
  defaultTitle="Caspian Sea"
77
98
  entityTypes={['Location']}
99
+ entityTypeOptions={['Person', 'Topic', 'Location']}
78
100
  locale="en"
79
101
  context={CONTEXT}
80
102
  contextLoading={false}
81
103
  contextError={null}
82
104
  onGenerateSubmit={onGenerateSubmit}
83
105
  onLinkResource={onLinkResource}
84
- onComposeNavigate={onComposeNavigate}
106
+ onComposeSubmit={onComposeSubmit}
85
107
  translations={T}
86
108
  {...over}
87
109
  />,
@@ -92,7 +114,7 @@ function renderWizard(over: Partial<React.ComponentProps<typeof ReferenceWizardM
92
114
  const client = utils.session!.client;
93
115
  const searchSpy = vi.spyOn(client.match, 'requestSearch');
94
116
 
95
- return { ...utils, client, searchSpy, onComposeNavigate, onGenerateSubmit, onLinkResource, onClose };
117
+ return { ...utils, client, searchSpy, onComposeSubmit, onGenerateSubmit, onLinkResource, onClose };
96
118
  }
97
119
 
98
120
  /** Type the hint into the gather step's textarea. */
@@ -103,17 +125,32 @@ async function typeHint(text: string) {
103
125
  describe('ReferenceWizardModal — the Hint reaches every strategy, at focus.userHint', () => {
104
126
  beforeEach(() => { vi.clearAllMocks(); });
105
127
 
106
- it('compose carries the hint inside focus, not at the top level', async () => {
107
- const { onComposeNavigate } = renderWizard();
108
- await typeHint('the lake, not the myth');
109
- await userEvent.click(screen.getByText(new RegExp(T.compose)));
128
+ it('the gather panel carries the widened class (GEP P2, D2)', () => {
129
+ const { baseElement } = renderWizard();
130
+ const panel = baseElement.querySelector('.semiont-search-modal__panel--gather');
131
+ expect(panel).not.toBeNull();
132
+ expect(panel!.className).toContain('semiont-search-modal__panel--wide');
133
+ });
110
134
 
111
- expect(onComposeNavigate).toHaveBeenCalledTimes(1);
112
- const context = onComposeNavigate.mock.calls[0]![0] as Record<string, unknown>;
113
- expect((context.focus as Record<string, unknown>).userHint).toBe('the lake, not the myth');
114
- // The shape the old code produced. `GatheredContext` has no top-level
115
- // `userHint`, so anything reading the schema would never look here.
116
- expect(context).not.toHaveProperty('userHint');
135
+ it('the typed hint stays visible after stepping into configure-search (GEP P1c, D8)', async () => {
136
+ // The thing being steered must not vanish while you steer it.
137
+ const { baseElement } = renderWizard();
138
+ await typeHint('the ancient city');
139
+ await userEvent.click(screen.getByRole('button', { name: /Search…/ }));
140
+ const echo = baseElement.querySelector('.semiont-wizard__hint-echo');
141
+ expect(echo).not.toBeNull();
142
+ expect(echo!.textContent).toContain('the ancient city');
143
+ });
144
+
145
+ it('compose is a step now — choosing it navigates nowhere (COMPOSE-IN-MODAL D1)', async () => {
146
+ // The old flow stashed the context in sessionStorage and navigated to the
147
+ // compose page; both are gone. Choosing ✍️ Compose unfolds the compose
148
+ // form below the evidence like the other strategies.
149
+ renderWizard();
150
+ await userEvent.click(screen.getByText(new RegExp(T.compose)));
151
+ expect(screen.getByRole('button', { name: T.createAndLink })).toBeInTheDocument(); // the compose form is open
152
+ expect(screen.getByText(T.resolveTitle)).toBeInTheDocument(); // ONE title, every step
153
+ expect(screen.getByLabelText(T.resourceTitle)).toHaveValue('Caspian Sea'); // seeded draft
117
154
  });
118
155
 
119
156
  it('search sends the same enriched context over the wire', async () => {
@@ -131,20 +168,311 @@ describe('ReferenceWizardModal — the Hint reaches every strategy, at focus.use
131
168
  it('generation gets it too — the path that used to drop it silently', async () => {
132
169
  renderWizard();
133
170
  await typeHint('focus on hydrology');
134
- await userEvent.click(screen.getByText(new RegExp(T.generate)));
171
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
135
172
 
136
173
  // The step renders the context it was handed; the hint is visible in the
137
174
  // gathered-context panel it shows, which is only true if the ENRICHED
138
175
  // object was passed down rather than the raw prop.
139
- expect(screen.getByText(T.configureGenerationTitle)).toBeInTheDocument();
176
+ expect(screen.getByLabelText(T.resourceTitle)).toBeInTheDocument();
177
+ });
178
+
179
+ });
180
+
181
+ describe('ReferenceWizardModal — the context stays in view on the strategy steps', () => {
182
+ beforeEach(() => { vi.clearAllMocks(); });
183
+
184
+ // Choosing Generate or Search unfolds that strategy's form BELOW the
185
+ // evidence — it must not navigate away from it. The gather step stays the
186
+ // decision surface (chooser footer, hint textarea); the strategy steps keep
187
+ // the RESOLUTION STRATEGY band for continuity, collapsed from three choices
188
+ // to the chosen one — a passive echo, not a second chooser: the OTHER
189
+ // strategies' buttons are gone, and there is no second hint textarea.
190
+ it('configure-search renders the evidence above the form, the strategy band collapsed to Search', async () => {
191
+ const { baseElement } = renderWizard();
192
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
193
+
194
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull(); // quotation strip
195
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument(); // graph pane
196
+ expect(screen.getByText(T.maxResults)).toBeInTheDocument(); // the form, below
197
+ // The band survives the step: header + the chosen strategy, nothing clickable.
198
+ const footer = baseElement.querySelector('.semiont-gather__footer');
199
+ expect(footer).not.toBeNull();
200
+ expect(footer!.textContent).toContain(T.resolutionStrategyLabel);
201
+ expect(footer!.textContent).toContain(`🔍 ${T.search}`);
202
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
203
+ expect(screen.queryByRole('button', { name: `✨ ${T.generate}…` })).not.toBeInTheDocument();
204
+ expect(screen.queryByRole('button', { name: `✍️ ${T.compose}` })).not.toBeInTheDocument();
205
+ expect(screen.queryByPlaceholderText(T.userHintPlaceholder)).not.toBeInTheDocument();
206
+ const panel = baseElement.querySelector('.semiont-search-modal__panel')!;
207
+ expect(panel.className).toContain('semiont-search-modal__panel--wide'); // evidence needs the width
208
+ });
209
+
210
+ it('search-results keeps the evidence and the collapsed band above the results', async () => {
211
+ // Same grammar as the configure steps — the results replace the FORM
212
+ // region, not the evidence. The step itself is pure results now (D10
213
+ // amended); the host stacks the full display above it.
214
+ const { client, baseElement } = renderWizard();
215
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
216
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
217
+ client.bus.get('match:search-results').next({
218
+ referenceId: 'ann-1',
219
+ response: [{ '@id': 'res-9', name: 'Caspian Sea', score: 54.2 }],
220
+ } as never);
221
+ await screen.findByRole('button', { name: new RegExp(T.link) }); // results are up
222
+
223
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull(); // quotation strip
224
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument(); // graph pane
225
+ expect(screen.getByText(T.corpusPaneTitle)).toBeInTheDocument(); // full display, not the redux
226
+ const footer = baseElement.querySelector('.semiont-gather__footer');
227
+ expect(footer).not.toBeNull();
228
+ expect(footer!.textContent).toContain(`🔍 ${T.search}`);
229
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
230
+ expect(screen.getByRole('button', { name: `🔗 ${T.link}` })).toBeInTheDocument(); // the results
231
+ const panel = baseElement.querySelector('.semiont-search-modal__panel')!;
232
+ expect(panel.className).toContain('semiont-search-modal__panel--gather');
233
+ });
234
+
235
+ it('configure-generation renders the evidence above the form, the strategy band collapsed to Generate', async () => {
236
+ const { baseElement } = renderWizard();
237
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
238
+
239
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull();
240
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument();
241
+ expect(screen.getByText(T.resourceTitle)).toBeInTheDocument();
242
+ const footer = baseElement.querySelector('.semiont-gather__footer');
243
+ expect(footer).not.toBeNull();
244
+ expect(footer!.textContent).toContain(T.resolutionStrategyLabel);
245
+ expect(footer!.textContent).toContain(`✨ ${T.generate}`);
246
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
247
+ // The echo is a receipt LINE, not a control row: label and value share
248
+ // the --echo band, and the chooser's button-row wrapper is gone — the
249
+ // value must never sit where controls sit (it wore a control's costume:
250
+ // inherited base size + link blue).
251
+ expect(footer!.className).toContain('semiont-gather__footer--echo');
252
+ expect(footer!.querySelector('.semiont-gather__actions')).toBeNull();
253
+ expect(footer!.querySelector('.semiont-gather__chosen-strategy')).not.toBeNull();
254
+ expect(screen.queryByText(new RegExp(`^🔍 ${T.search}…`))).not.toBeInTheDocument();
255
+ const panel = baseElement.querySelector('.semiont-search-modal__panel')!;
256
+ expect(panel.className).toContain('semiont-search-modal__panel--wide');
257
+
258
+ // ONE scroll pane for the whole step: evidence and parameters scroll
259
+ // together, the form in full at the bottom, the evidence tucking up under
260
+ // the modal top. An independently-scrollable form inside the stack is what
261
+ // squeezed the parameters out of view.
262
+ const scroll = baseElement.querySelector('.semiont-wizard__step-scroll');
263
+ expect(scroll).not.toBeNull();
264
+ expect(scroll!.querySelector('.semiont-gather__outer')).not.toBeNull();
265
+ const form = scroll!.querySelector('form.semiont-form');
266
+ expect(form).not.toBeNull();
267
+ expect(form!.className).not.toContain('semiont-form--scrollable');
140
268
  });
269
+ });
270
+
271
+ describe('ReferenceWizardModal — compose is the fourth in-modal strategy (COMPOSE-IN-MODAL P2)', () => {
272
+ beforeEach(() => { vi.clearAllMocks(); });
141
273
 
142
- it('leaves the context untouched when no hint was typed', async () => {
143
- const { onComposeNavigate } = renderWizard();
274
+ async function enterCompose() {
144
275
  await userEvent.click(screen.getByText(new RegExp(T.compose)));
276
+ }
277
+
278
+ it('compose renders the evidence above the form, the strategy band collapsed to Compose', async () => {
279
+ const { baseElement } = renderWizard();
280
+ await enterCompose();
281
+
282
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull();
283
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument();
284
+ const footer = baseElement.querySelector('.semiont-gather__footer');
285
+ expect(footer).not.toBeNull();
286
+ expect(footer!.textContent).toContain(T.resolutionStrategyLabel);
287
+ expect(footer!.textContent).toContain(`✍️ ${T.compose}`);
288
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
289
+ // ONE scroll pane holding display + form (same grammar as the others).
290
+ const scroll = baseElement.querySelector('.semiont-wizard__step-scroll');
291
+ expect(scroll).not.toBeNull();
292
+ expect(scroll!.querySelector('.semiont-gather__outer')).not.toBeNull();
293
+ expect(scroll!.querySelector('form.semiont-form')).not.toBeNull();
294
+ });
295
+
296
+ it('the draft survives Back and re-entry (WIZARD-NAVIGATION D3)', async () => {
297
+ renderWizard();
298
+ await enterCompose();
299
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'A Sauk leader.' } });
300
+ await userEvent.click(screen.getByRole('button', { name: `◀ ${T.back}` }));
301
+ expect(screen.getByText(new RegExp(T.compose))).toBeInTheDocument(); // back on gather
302
+ await enterCompose();
303
+ expect(screen.getByTestId('code-editor')).toHaveValue('A Sauk leader.');
304
+ });
305
+
306
+ it('submit emits against the annotation with the reference-fixed entity types, then closes', async () => {
307
+ const { onComposeSubmit, onClose } = renderWizard();
308
+ await enterCompose();
309
+ fireEvent.change(screen.getByLabelText(T.saveLocation), { target: { value: 'people/caspian-sea.md' } });
310
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'The lake.' } });
311
+ await userEvent.click(screen.getByRole('button', { name: T.createAndLink }));
312
+
313
+ await waitFor(() => expect(onComposeSubmit).toHaveBeenCalledTimes(1));
314
+ expect(onComposeSubmit).toHaveBeenCalledWith('ann-1', {
315
+ name: 'Caspian Sea',
316
+ storagePath: 'file://people/caspian-sea.md',
317
+ content: 'The lake.',
318
+ entityTypes: ['Location'],
319
+ language: 'en',
320
+ });
321
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
322
+ });
323
+
324
+ it('a rejected submit keeps the modal open with the footer re-enabled', async () => {
325
+ const onComposeSubmit = vi.fn<(referenceId: string, params: unknown) => Promise<void>>()
326
+ .mockRejectedValue(new Error('boom'));
327
+ const { onClose } = renderWizard({ onComposeSubmit });
328
+ await enterCompose();
329
+ fireEvent.change(screen.getByLabelText(T.saveLocation), { target: { value: 'people/x.md' } });
330
+ await userEvent.click(screen.getByRole('button', { name: T.createAndLink }));
331
+
332
+ await waitFor(() => expect(onComposeSubmit).toHaveBeenCalled());
333
+ expect(onClose).not.toHaveBeenCalled();
334
+ expect(await screen.findByRole('button', { name: T.createAndLink })).toBeEnabled();
335
+ });
145
336
 
146
- const context = onComposeNavigate.mock.calls[0]![0];
147
- expect(context).toBe(CONTEXT); // same reference: nothing was rebuilt
337
+ // D4: a modal dies on ✕/Escape/backdrop; a non-empty draft must not die
338
+ // with it. The guard is an INLINE prompt — never window.confirm — and the
339
+ // footer stays dismissal-free (A4).
340
+ it('dismissing a dirty draft asks first; Keep editing stays, Discard closes', async () => {
341
+ const { onClose } = renderWizard();
342
+ await enterCompose();
343
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'typed work' } });
344
+
345
+ await userEvent.click(screen.getByLabelText('Close'));
346
+ expect(onClose).not.toHaveBeenCalled();
347
+ expect(screen.getByText(T.discardDraftPrompt)).toBeInTheDocument();
348
+
349
+ await userEvent.click(screen.getByRole('button', { name: T.keepEditing }));
350
+ expect(screen.queryByText(T.discardDraftPrompt)).not.toBeInTheDocument();
351
+ expect(onClose).not.toHaveBeenCalled();
352
+
353
+ await userEvent.click(screen.getByLabelText('Close'));
354
+ await userEvent.click(screen.getByRole('button', { name: T.discardDraft }));
355
+ expect(onClose).toHaveBeenCalledTimes(1);
356
+ });
357
+
358
+ it('a clean draft dismisses without any prompt', async () => {
359
+ const { onClose } = renderWizard();
360
+ await enterCompose();
361
+ await userEvent.click(screen.getByLabelText('Close'));
362
+ expect(screen.queryByText(T.discardDraftPrompt)).not.toBeInTheDocument();
363
+ expect(onClose).toHaveBeenCalledTimes(1);
364
+ });
365
+ });
366
+
367
+ // GATHER-AT-THE-TOP P1: the dirty guard widens. D4 — dirtiness is
368
+ // step-independent (typed work guards dismissal wherever the user currently
369
+ // is); D5 — typed text only (title beyond seed, save location, instructions,
370
+ // content). The generation draft gets the same protection compose has.
371
+ describe('ReferenceWizardModal — the dirty guard widens (GATHER-AT-THE-TOP D4/D5)', () => {
372
+ beforeEach(() => { vi.clearAllMocks(); });
373
+
374
+ it('a typed generation draft guards dismissal', async () => {
375
+ const { onClose } = renderWizard();
376
+ await userEvent.click(screen.getByRole('button', { name: /Generate…/ }));
377
+ fireEvent.change(screen.getByLabelText(T.saveLocation), { target: { value: 'people/x.md' } });
378
+
379
+ await userEvent.click(screen.getByLabelText('Close'));
380
+ expect(onClose).not.toHaveBeenCalled();
381
+ expect(screen.getByText(T.discardDraftPrompt)).toBeInTheDocument();
382
+ });
383
+
384
+ it('a defaultTitle drifting while open does not fake a dirty draft', async () => {
385
+ // The wizard's defaultTitle derives from observable state and can re-emit
386
+ // while the modal is open. The baseline moves, the user has typed nothing —
387
+ // dirtiness compares against what was SEEDED, not the live prop.
388
+ const { rerender, onClose, onComposeSubmit, onGenerateSubmit, onLinkResource } = renderWizard();
389
+ rerender(
390
+ <ReferenceWizardModal
391
+ isOpen
392
+ onClose={onClose}
393
+ annotationId="ann-1"
394
+ resourceId="res-1"
395
+ defaultTitle="Caspian Sea (moved)"
396
+ entityTypes={['Location']}
397
+ entityTypeOptions={['Person', 'Topic', 'Location']}
398
+ locale="en"
399
+ context={CONTEXT}
400
+ contextLoading={false}
401
+ contextError={null}
402
+ onGenerateSubmit={onGenerateSubmit}
403
+ onLinkResource={onLinkResource}
404
+ onComposeSubmit={onComposeSubmit}
405
+ translations={T}
406
+ />,
407
+ );
408
+
409
+ await userEvent.click(screen.getByLabelText('Close'));
410
+ expect(screen.queryByText(T.discardDraftPrompt)).not.toBeInTheDocument();
411
+ expect(onClose).toHaveBeenCalledTimes(1);
412
+ });
413
+
414
+ it('a compose draft still guards after stepping Back to the evidence — D4 kills the step gate', async () => {
415
+ const { onClose } = renderWizard();
416
+ await userEvent.click(screen.getByText(new RegExp(T.compose)));
417
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'typed work' } });
418
+ await userEvent.click(screen.getByRole('button', { name: new RegExp(T.back) }));
419
+
420
+ await userEvent.click(screen.getByLabelText('Close'));
421
+ expect(onClose).not.toHaveBeenCalled();
422
+ expect(screen.getByText(T.discardDraftPrompt)).toBeInTheDocument();
423
+ });
424
+ });
425
+
426
+ // The gather-loading state is not a blank modal: the annotation's exact text
427
+ // and entity types are wizard props — known before any wire round-trip — and
428
+ // the pane headers are static. Render them immediately with skeleton bars so
429
+ // the loading screen is the loaded screen minus the data.
430
+ describe('ReferenceWizardModal — the gather skeleton', () => {
431
+ beforeEach(() => { vi.clearAllMocks(); });
432
+
433
+ it('while gathering, the known annotation facts and per-cell animations render immediately', () => {
434
+ const { baseElement } = renderWizard({ context: null, contextLoading: true });
435
+
436
+ expect(screen.getByText(/"Caspian Sea"/)).toBeInTheDocument(); // quoted exact text
437
+ expect(screen.getByText('Location')).toBeInTheDocument(); // entity chip
438
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument(); // pane headers in place
439
+ expect(screen.getByText(T.corpusPaneTitle)).toBeInTheDocument();
440
+ // One dots animation PER zone — source excerpt, Neighborhood, Similar
441
+ // passages — each where its content will land; no central block, no bars.
442
+ expect(baseElement.querySelectorAll('.semiont-gather__skeleton-dots')).toHaveLength(3);
443
+ expect(baseElement.querySelectorAll('.semiont-gather__skeleton-bar')).toHaveLength(0);
444
+ // The sentence survives for screen readers only.
445
+ expect(screen.getByText(T.loadingContext)).toHaveClass('semiont-sr-only');
446
+ });
447
+
448
+ it('the source line, Hint, and strategy chooser render during loading — buttons grayed until context lands', () => {
449
+ // The resource name is a page fact (the wizard opens FROM the source
450
+ // resource); the Hint is wizard state, safely typed while the gather
451
+ // runs; the chooser's buttons already gate on contextReady.
452
+ renderWizard({ context: null, contextLoading: true, resourceName: 'Cedar County, Iowa' });
453
+
454
+ expect(screen.getByText(/Source "Cedar County, Iowa"/)).toBeInTheDocument();
455
+ expect(screen.getByPlaceholderText(T.userHintPlaceholder)).toBeInTheDocument();
456
+ expect(screen.getByRole('button', { name: /Search…/ })).toBeDisabled();
457
+ expect(screen.getByRole('button', { name: /Generate…/ })).toBeDisabled();
458
+ expect(screen.getByRole('button', { name: new RegExp(T.compose) })).toBeDisabled();
459
+ });
460
+
461
+ it('a hint typed during the gather survives context arrival', () => {
462
+ const { rerender, onClose, onComposeSubmit, onGenerateSubmit, onLinkResource } = renderWizard({ context: null, contextLoading: true });
463
+ fireEvent.change(screen.getByPlaceholderText(T.userHintPlaceholder), { target: { value: 'early steer' } });
464
+
465
+ rerender(
466
+ <ReferenceWizardModal
467
+ isOpen onClose={onClose} annotationId="ann-1" resourceId="res-1"
468
+ defaultTitle="Caspian Sea" entityTypes={['Location']}
469
+ entityTypeOptions={['Person', 'Topic', 'Location']} locale="en"
470
+ context={CONTEXT} contextLoading={false} contextError={null}
471
+ onGenerateSubmit={onGenerateSubmit} onLinkResource={onLinkResource}
472
+ onComposeSubmit={onComposeSubmit} translations={T}
473
+ />,
474
+ );
475
+ expect(screen.getByPlaceholderText(T.userHintPlaceholder)).toHaveValue('early steer');
148
476
  });
149
477
  });
150
478
 
@@ -158,7 +486,8 @@ describe('ReferenceWizardModal — a new run starts clean (D3 flip side)', () =>
158
486
  isOpen: false, onClose: vi.fn(), annotationId: 'ann-1', resourceId: 'res-1',
159
487
  defaultTitle: 'Caspian Sea', entityTypes: ['Location'], locale: 'en',
160
488
  context: CONTEXT, contextLoading: false, contextError: null,
161
- onGenerateSubmit: vi.fn(), onLinkResource: vi.fn(), onComposeNavigate: vi.fn(),
489
+ onGenerateSubmit: vi.fn(), onLinkResource: vi.fn(),
490
+ onComposeSubmit: vi.fn<(r: string, p: unknown) => Promise<void>>().mockResolvedValue(undefined),
162
491
  translations: T,
163
492
  };
164
493
  rerender(<ReferenceWizardModal {...props} />);
@@ -166,7 +495,7 @@ describe('ReferenceWizardModal — a new run starts clean (D3 flip side)', () =>
166
495
 
167
496
  // Back is lossless WITHIN a run (D3); a new run inherits nothing.
168
497
  expect(screen.getByPlaceholderText(T.userHintPlaceholder)).toHaveValue('');
169
- expect(screen.getByText(T.gatherTitle)).toBeInTheDocument();
498
+ expect(screen.getByRole('button', { name: `🔍 ${T.search}…` })).toBeInTheDocument(); // back on the gather step
170
499
  });
171
500
  });
172
501
 
@@ -189,13 +518,65 @@ describe('ReferenceWizardModal — the three strategies complete', () => {
189
518
  response: [{ '@id': 'res-9', name: 'Caspian Sea', score: 54.2 }],
190
519
  } as never);
191
520
 
192
- expect(await screen.findByText(T.searchResultsTitle)).toBeInTheDocument();
193
- await userEvent.click(screen.getByRole('button', { name: new RegExp(T.link) }));
521
+ await userEvent.click(await screen.findByRole('button', { name: new RegExp(T.link) }));
194
522
 
195
523
  expect(onLinkResource).toHaveBeenCalledWith('ann-1', 'res-9');
196
524
  expect(onClose).toHaveBeenCalledTimes(1);
197
525
  });
198
526
 
527
+ it('a search failure over the bus settles the spinner and surfaces the error — no eternal "Searching…"', async () => {
528
+ // The measured hang (.plans/bugs/match-search-hangs-on-neo4j-datetime-annotations.md):
529
+ // /bus/emit 400s the request, the match machinery publishes
530
+ // match:search-failed — and the wizard listened only for results, so the
531
+ // failure fired into an empty room while the button spun forever.
532
+ const { client } = renderWizard();
533
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
534
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
535
+ expect(screen.getByRole('button', { name: T.searching })).toBeDisabled();
536
+
537
+ client.bus.get('match:search-failed').next({
538
+ correlationId: 'c-1',
539
+ referenceId: 'ann-1',
540
+ error: '/bus/emit 400: Bus emit validation failed',
541
+ } as never);
542
+
543
+ // Still on configure-search, failure visible, retry available.
544
+ expect(await screen.findByText(new RegExp(T.searchFailed))).toBeInTheDocument();
545
+ expect(screen.getByText(/Bus emit validation failed/)).toBeInTheDocument();
546
+ expect(screen.getByRole('button', { name: T.search })).toBeEnabled();
547
+ });
548
+
549
+ it('a failure addressed to a different annotation changes nothing', async () => {
550
+ // Same scoping rule as results: a late failure from another annotation's
551
+ // run must not settle THIS spinner or paint an error that isn't ours.
552
+ const { client } = renderWizard();
553
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
554
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
555
+
556
+ client.bus.get('match:search-failed').next({
557
+ correlationId: 'c-2',
558
+ referenceId: 'someone-elses-annotation',
559
+ error: 'not ours',
560
+ } as never);
561
+
562
+ expect(screen.getByRole('button', { name: T.searching })).toBeDisabled();
563
+ expect(screen.queryByText(new RegExp(T.searchFailed))).not.toBeInTheDocument();
564
+ });
565
+
566
+ it('retrying after a failure clears the error while the new search runs', async () => {
567
+ const { client } = renderWizard();
568
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
569
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
570
+ client.bus.get('match:search-failed').next({
571
+ correlationId: 'c-1', referenceId: 'ann-1', error: 'boom',
572
+ } as never);
573
+ expect(await screen.findByText(new RegExp(T.searchFailed))).toBeInTheDocument();
574
+
575
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
576
+ expect(screen.queryByText(new RegExp(T.searchFailed))).not.toBeInTheDocument();
577
+ expect(screen.getByRole('button', { name: T.searching })).toBeDisabled();
578
+ });
579
+
199
580
  it('ignores results addressed to a different annotation', () => {
200
581
  // Two wizards can never be open at once, but one reply can outlive the
201
582
  // annotation it was asked for — a late answer must not hijack the step.
@@ -204,27 +585,49 @@ describe('ReferenceWizardModal — the three strategies complete', () => {
204
585
  referenceId: 'someone-elses-annotation',
205
586
  response: [{ '@id': 'res-9', name: 'Caspian Sea' }],
206
587
  } as never);
207
- expect(screen.getByText(T.gatherTitle)).toBeInTheDocument();
588
+ expect(screen.getByRole('button', { name: `🔍 ${T.search}…` })).toBeInTheDocument(); // still the gather step
208
589
  });
209
590
 
210
591
  it('generation submits the step\'s config against this annotation, then closes', async () => {
211
592
  const { onGenerateSubmit, onClose } = renderWizard();
212
- await userEvent.click(screen.getByText(new RegExp(T.generate)));
213
- await userEvent.type(screen.getByLabelText(/Save location/i), 'generated/out.md');
593
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
594
+ // The field arrives PRE-FILLED with the proposal now (D11). Overriding it
595
+ // is select-all-then-type, which the DOM delivers as ONE change carrying
596
+ // the replacement — `clear()` then `type()` would instead empty the field
597
+ // (restoring the proposal, by design) and append to it.
598
+ fireEvent.change(screen.getByLabelText(/Save location/i), {
599
+ target: { value: 'generated/out.md' },
600
+ });
214
601
  await userEvent.click(screen.getByRole('button', { name: T.generate }));
215
602
 
216
603
  expect(onGenerateSubmit).toHaveBeenCalledTimes(1);
217
604
  expect(onGenerateSubmit.mock.calls[0]![0]).toBe('ann-1');
218
605
  expect(onGenerateSubmit.mock.calls[0]![1].title).toBe('Caspian Sea');
606
+ // D8/D2: the payload names the URI what it is, and carries the default
607
+ // format explicitly rather than leaving the worker to assume it.
608
+ expect(onGenerateSubmit.mock.calls[0]![1]).toMatchObject({
609
+ storageUri: 'file://generated/out.md',
610
+ outputMediaType: 'text/markdown',
611
+ });
219
612
  expect(onClose).toHaveBeenCalledTimes(1);
220
613
  });
221
614
 
615
+ it('the annotation flow can choose a format too — one control, both hosts (D4)', async () => {
616
+ const { onGenerateSubmit } = renderWizard();
617
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
618
+ await userEvent.type(screen.getByLabelText(/Save location/i), 'generated/out.pdf');
619
+ fireEvent.change(screen.getByLabelText(T.outputFormat), { target: { value: 'application/pdf' } });
620
+ await userEvent.click(screen.getByRole('button', { name: T.generate }));
621
+
622
+ expect(onGenerateSubmit.mock.calls[0]![1]).toMatchObject({ outputMediaType: 'application/pdf' });
623
+ });
624
+
222
625
  it('retreats from a configure step back to the gather step', async () => {
223
626
  renderWizard();
224
- await userEvent.click(screen.getByText(new RegExp(T.generate)));
225
- expect(screen.getByText(T.configureGenerationTitle)).toBeInTheDocument();
627
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
628
+ expect(screen.getByLabelText(T.resourceTitle)).toBeInTheDocument();
226
629
  await userEvent.click(screen.getByRole('button', { name: /Back/ }));
227
- expect(screen.getByText(T.gatherTitle)).toBeInTheDocument();
630
+ expect(screen.getByRole('button', { name: `🔍 ${T.search}…` })).toBeInTheDocument();
228
631
  });
229
632
  });
230
633
 
@@ -236,11 +639,11 @@ describe('ReferenceWizardModal — nothing fires without an annotation to resolv
236
639
  beforeEach(() => { vi.clearAllMocks(); });
237
640
 
238
641
  it('compose, search and generate all no-op', async () => {
239
- const { onComposeNavigate, onGenerateSubmit, searchSpy, client } =
642
+ const { onGenerateSubmit, searchSpy, client } =
240
643
  renderWizard({ annotationId: null, resourceId: null });
241
644
 
242
645
  await userEvent.click(screen.getByText(new RegExp(T.compose)));
243
- expect(onComposeNavigate).not.toHaveBeenCalled();
646
+ expect(screen.queryByRole('button', { name: T.createAndLink })).not.toBeInTheDocument();
244
647
 
245
648
  await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
246
649
  await userEvent.click(screen.getByRole('button', { name: T.search }));
@@ -250,7 +653,7 @@ describe('ReferenceWizardModal — nothing fires without an annotation to resolv
250
653
  client.bus.get('match:search-results').next({
251
654
  referenceId: 'ann-1', response: [{ '@id': 'res-9', name: 'X' }],
252
655
  } as never);
253
- expect(screen.queryByText(T.searchResultsTitle)).not.toBeInTheDocument();
656
+ expect(screen.queryByRole('button', { name: new RegExp(T.link) })).not.toBeInTheDocument();
254
657
  expect(onGenerateSubmit).not.toHaveBeenCalled();
255
658
  });
256
659
  });