@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
@@ -14,7 +14,8 @@ import { ToastProvider } from '../../../components/Toast';
14
14
  import { ThemeProvider } from '../../../contexts/ThemeContext';
15
15
  import { LineNumbersProvider } from '../../../contexts/LineNumbersContext';
16
16
  import { createTestSemiontWrapper } from '../../../test-utils';
17
- import type { ResourceId } from '@semiont/core';
17
+ import type { BodyOperation, EventMap, ResourceId, UserId } from '@semiont/core';
18
+ import { annotationId as makeAnnotationId, resourceId as makeResourceId } from '@semiont/core';
18
19
 
19
20
  // jsdom doesn't implement window.matchMedia — mock it for useTheme
20
21
  Object.defineProperty(window, 'matchMedia', {
@@ -46,8 +47,8 @@ const { stubBrowser, stubClient } = vi.hoisted(() => {
46
47
  const { SemiontClient, HttpTransport, HttpContentTransport } = require('@semiont/sdk');
47
48
  const { baseUrl } = require('@semiont/core');
48
49
  const transport = new HttpTransport({ baseUrl: baseUrl('http://localhost:4000') });
49
- // HttpTransport implements both ITransport and IBackendOperations; pass it
50
- // as backend so `client.auth` (used by useMediaToken) is wired.
50
+ // HttpTransport implements both ITransport and IGatewayOperations; pass it
51
+ // as gateway so `client.auth` (used by useMediaToken) is wired.
51
52
  const client = new SemiontClient(transport, new HttpContentTransport(transport), transport);
52
53
  const stubActiveSession$ = new BehaviorSubject({ client });
53
54
  const stubOpenResources$ = new BehaviorSubject([]);
@@ -102,24 +103,22 @@ useDebouncedCallback: (fn: any) => fn,
102
103
  announceResourceLoaded: vi.fn(),
103
104
  }),
104
105
  useEventSubscriptions: vi.fn(),
105
- useResourceAnnotations: () => ({
106
- clearNewAnnotationId: vi.fn(),
107
- newAnnotationIds: new Set(),
108
- markAnnotation: vi.fn(),
109
- deleteAnnotation: vi.fn(),
110
- triggerSparkleAnimation: vi.fn(),
111
- }),
106
+ useResourceAnnotations: () => sparkleContext,
112
107
  };
113
108
  });
114
109
 
110
+ // Shared, assertable spies — the page's sparkle wiring is pinned against
111
+ // these (a per-call `vi.fn()` object would be unobservable from the tests).
112
+ const sparkleContext = vi.hoisted(() => ({
113
+ clearSparkle: vi.fn(),
114
+ sparkleAnnotationIds: new Set<string>(),
115
+ markAnnotation: vi.fn(),
116
+ deleteAnnotation: vi.fn(),
117
+ triggerSparkleAnimation: vi.fn(),
118
+ }));
119
+
115
120
  vi.mock('../../../contexts/ResourceAnnotationsContext', () => ({
116
- useResourceAnnotations: () => ({
117
- clearNewAnnotationId: vi.fn(),
118
- newAnnotationIds: new Set(),
119
- markAnnotation: vi.fn(),
120
- deleteAnnotation: vi.fn(),
121
- triggerSparkleAnimation: vi.fn(),
122
- }),
121
+ useResourceAnnotations: () => sparkleContext,
123
122
  ResourceAnnotationsProvider: ({ children }: any) => children,
124
123
  }));
125
124
 
@@ -206,6 +205,63 @@ describe('ResourceViewerPage — outcome toasts reach the user', () => {
206
205
  });
207
206
  });
208
207
 
208
+ // RESOLUTION-SPARKLE D2: a reference resolving — from ANY strategy, local or
209
+ // remote — arrives as mark:body-updated with a linking-add operation, and the
210
+ // page turns exactly that into a sparkle. The harness mocks
211
+ // useEventSubscriptions, so the pin drives the captured handler directly.
212
+ describe('ResourceViewerPage — resolution sparkles', () => {
213
+ const bodyUpdated = (operations: BodyOperation[]): EventMap['mark:body-updated'] => ({
214
+ id: 'evt-1',
215
+ timestamp: '2026-08-21T12:00:00Z',
216
+ resourceId: makeResourceId('test-123'),
217
+ userId: 'did:web:example.com:users:alice' as UserId,
218
+ version: 1,
219
+ type: 'mark:body-updated',
220
+ payload: { annotationId: makeAnnotationId('ann-7'), operations },
221
+ metadata: { sequenceNumber: 1 },
222
+ });
223
+
224
+ const renderAndGetHandler = () => {
225
+ mockUseEventSubscriptions.mockClear();
226
+ sparkleContext.triggerSparkleAnimation.mockClear();
227
+ renderWithProviders(<ResourceViewerPage {...createMockProps()} />);
228
+ const map = mockUseEventSubscriptions.mock.calls.at(-1)?.[0] as
229
+ Record<string, (event: EventMap['mark:body-updated']) => void>;
230
+ return map['mark:body-updated'];
231
+ };
232
+
233
+ it('an operation adding a linking SpecificResource sparkles the annotation', () => {
234
+ const handler = renderAndGetHandler();
235
+ expect(handler).toBeDefined();
236
+
237
+ handler!(bodyUpdated([
238
+ { op: 'add', item: { type: 'SpecificResource', source: 'res-target', purpose: 'linking' } },
239
+ ]));
240
+
241
+ expect(sparkleContext.triggerSparkleAnimation).toHaveBeenCalledWith('ann-7');
242
+ });
243
+
244
+ it('unlink (remove-only operations) stays dark — A2', () => {
245
+ const handler = renderAndGetHandler();
246
+
247
+ handler?.(bodyUpdated([
248
+ { op: 'remove', item: { type: 'SpecificResource', source: 'res-target', purpose: 'linking' } },
249
+ ]));
250
+
251
+ expect(sparkleContext.triggerSparkleAnimation).not.toHaveBeenCalled();
252
+ });
253
+
254
+ it('a TextualBody add stays dark', () => {
255
+ const handler = renderAndGetHandler();
256
+
257
+ handler?.(bodyUpdated([
258
+ { op: 'add', item: { type: 'TextualBody', value: 'Person', purpose: 'tagging' } },
259
+ ]));
260
+
261
+ expect(sparkleContext.triggerSparkleAnimation).not.toHaveBeenCalled();
262
+ });
263
+ });
264
+
209
265
  describe('ResourceViewerPage', () => {
210
266
  beforeEach(() => {
211
267
  });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * GENERATION-OUTPUT-FORMAT D8 — the submitted config becomes generation
3
+ * options in ONE place, by spread.
4
+ *
5
+ * Both page handlers used to build the options object field-by-field, so any
6
+ * field not explicitly listed was silently dropped. That is precisely what
7
+ * stranded `outputMediaType` in the SDK one layer down (P1), relocated one
8
+ * layer up. These pins state the PROPERTY — everything the config carries is
9
+ * forwarded — rather than enumerating today's fields, so a knob added later
10
+ * cannot regress the mode back into existence.
11
+ */
12
+ import { describe, it, expect } from 'vitest';
13
+ import type { GatheredContext } from '@semiont/core';
14
+ import { toGenerationOptions } from '../generation-options';
15
+ import type { GenerationConfig } from '../../../components/modals/ConfigureGenerationStep';
16
+
17
+ const context = { focus: { kind: 'resource' } } as unknown as GatheredContext;
18
+
19
+ const config: GenerationConfig = {
20
+ title: 'Cedar County',
21
+ storageUri: 'file://research/notes.pdf',
22
+ prompt: 'lead with the treaty',
23
+ language: 'en',
24
+ temperature: 0.7,
25
+ maxTokens: 500,
26
+ outputMediaType: 'application/pdf',
27
+ context,
28
+ };
29
+
30
+ describe('toGenerationOptions', () => {
31
+ it('forwards EVERY field the config carries except the context', () => {
32
+ // Stated structurally on purpose: the expectation is derived from the
33
+ // same object, so adding a field to GenerationConfig extends this pin
34
+ // automatically. A field-by-field assertion would have passed happily
35
+ // while the real handler dropped the new one.
36
+ const { context: _positional, ...forwarded } = config;
37
+ expect(toGenerationOptions(config, 'fr')).toMatchObject(forwarded);
38
+ });
39
+
40
+ it('does not pass the context as an option — it is the positional argument', () => {
41
+ expect(toGenerationOptions(config, 'fr')).not.toHaveProperty('context');
42
+ });
43
+
44
+ it('injects the sourceLanguage, which comes from the viewed resource and not the form', () => {
45
+ expect(toGenerationOptions(config, 'fr').sourceLanguage).toBe('fr');
46
+ });
47
+
48
+ it('omits sourceLanguage entirely when the resource has no language', () => {
49
+ // Absence must stay absence: a manufactured '' would tell the prompt the
50
+ // source is in a language named empty string.
51
+ expect(toGenerationOptions(config, undefined)).not.toHaveProperty('sourceLanguage');
52
+ });
53
+ });
@@ -6,12 +6,12 @@
6
6
  */
7
7
 
8
8
  import React, { useState, useEffect, useCallback } from 'react';
9
- import { assistProgressTranslations } from '../../../lib/assist-progress-copy';
10
9
  import { useResourceViewedReport } from '../hooks/useResourceViewedReport';
11
- import type { components, ResourceDescriptor, ResourceId, GatheredContext, EventMap } from '@semiont/core';
10
+ import type { components, ResourceDescriptor, ResourceId, EventMap } from '@semiont/core';
12
11
  import type { ConnectionState } from '@semiont/core';
13
- import { annotationId } from '@semiont/core';
14
- import { getLanguage, getPrimaryRepresentation, getPrimaryMediaType, capabilitiesOf } from '@semiont/core';
12
+ import { annotationId, folderOf } from '@semiont/core';
13
+ import type { ComposeParams } from '../../../components/modals/ComposeStep';
14
+ import { getLanguage, getPrimaryRepresentation, getPrimaryMediaType, getStorageUri, capabilitiesOf, extensionForMediaType } from '@semiont/core';
15
15
  import { ANNOTATORS } from '@semiont/react-ui';
16
16
  import { ErrorBoundary } from '@semiont/react-ui';
17
17
  import { AnnotationHistory } from '@semiont/react-ui';
@@ -30,6 +30,7 @@ import { useCollaborators } from '../../../hooks/useCollaborators';
30
30
  import { mediaUrl } from '../../../lib/media-url';
31
31
  import { useToast } from '../../../components/Toast';
32
32
  import { useOutcomeToasts } from '../../../hooks/useOutcomeToasts';
33
+ import { useGenerationArrival } from '../../../hooks/useGenerationArrival';
33
34
  import { useTheme } from '../../../contexts/ThemeContext';
34
35
  import { useLineNumbers } from '../../../contexts/LineNumbersContext';
35
36
  import { useHoverDelay } from '../../../hooks/useHoverDelay';
@@ -45,8 +46,8 @@ import { useShellStateUnit } from '../../../hooks/useShellStateUnit';
45
46
  import { useTranslations } from '../../../contexts/TranslationContext';
46
47
  import { ReferenceWizardModal } from '../../../components/modals/ReferenceWizardModal';
47
48
  import { ResourceGenerateModal } from '../../../components/modals/ResourceGenerateModal';
48
- import { AssistProgress } from '../../../components/AssistProgress';
49
49
  import type { GenerationConfig } from '../../../components/modals/ConfigureGenerationStep';
50
+ import { toGenerationOptions } from '../generation-options';
50
51
 
51
52
  type SemiontResource = ResourceDescriptor;
52
53
 
@@ -120,6 +121,7 @@ export interface ResourceViewerPageProps {
120
121
  * @subscribes yield:clone - Clone the current resource
121
122
  * @subscribes beckon:sparkle - Trigger sparkle animation
122
123
  * @subscribes mark:added - Annotation was created (sparkle)
124
+ * @subscribes mark:body-updated - Reference resolved via a linking-add operation (sparkle, both loci)
123
125
  * @subscribes browse:resource-open - Open a resource in the viewer (local links + the launcher's tour verbs)
124
126
  * @subscribes browse:entity-type-clicked - Navigate filtered by entity type
125
127
  *
@@ -143,7 +145,6 @@ export function ResourceViewerPage({
143
145
  // Translations
144
146
  const tw = useTranslations('ReferenceWizard');
145
147
  const tg = useTranslations('ResourceGenerate');
146
- const ta = useTranslations('AssistProgress');
147
148
 
148
149
  const browser = useSemiont();
149
150
  const session = useObservable(browser.activeSession$);
@@ -177,7 +178,7 @@ export function ResourceViewerPage({
177
178
  const { theme } = useTheme();
178
179
  const { showLineNumbers } = useLineNumbers();
179
180
  const { hoverDelayMs } = useHoverDelay();
180
- const { triggerSparkleAnimation, clearNewAnnotationId, newAnnotationIds } = useResourceAnnotations();
181
+ const { triggerSparkleAnimation, clearSparkle, sparkleAnnotationIds } = useResourceAnnotations();
181
182
 
182
183
  // Render mode chooses the content path: 'text' decodes inline; 'image'
183
184
  // and 'pdf' go through the media-token (binary) path. 'none'/registry-miss
@@ -240,9 +241,27 @@ export function ResourceViewerPage({
240
241
  const onScrollCompleted = stateUnit?.browse.onScrollCompleted;
241
242
  const generationProgress = useObservable(stateUnit?.yield.progress$) ?? null;
242
243
  const isGenerating = useObservable(stateUnit?.yield.isGenerating$) ?? false;
244
+ const generationOutcome = useObservable(stateUnit?.yield.outcome$) ?? null;
245
+
246
+ // GENERATION-ARRIVAL P2: a completion witnessed on this page reveals the
247
+ // derivation edge the worker minted — the annotations panel opens on
248
+ // References, scrolls to the provenance reference, and its sparkle is
249
+ // re-armed (the mark:added glow burned its window unseen). Never navigates
250
+ // (A2); a held outcome on remount stays quiet (D6, inside the hook).
251
+ const handleGenerationArrival = useCallback((annId: string) => {
252
+ browser.emit('panel:open', { panel: 'annotations', scrollToAnnotationId: annId, motivation: 'linking' });
253
+ triggerSparkleAnimation(annId);
254
+ }, [browser, triggerSparkleAnimation]);
255
+ useGenerationArrival(generationOutcome, annotations, handleGenerationArrival);
256
+
243
257
  const gatherContext = useObservable(stateUnit?.gather.context$) ?? null;
244
258
  const gatherLoading = useObservable(stateUnit?.gather.loading$) ?? false;
245
259
  const gatherError = useObservable(stateUnit?.gather.error$) ?? null;
260
+ // Resource-gather slots (FLOW-LIFECYCLE-CONVERGENCE D2a: separate from the
261
+ // annotation slots above — the two gathers can be live at once).
262
+ const resourceGatherContext = useObservable(stateUnit?.gather.resourceContext$) ?? null;
263
+ const resourceGatherLoading = useObservable(stateUnit?.gather.resourceLoading$) ?? false;
264
+ const resourceGatherError = useObservable(stateUnit?.gather.resourceError$) ?? null;
246
265
  const wizardState = useObservable(stateUnit?.wizard$);
247
266
  const wizardOpen = wizardState?.open ?? false;
248
267
  const wizardAnnotationId = wizardState?.annotationId ?? null;
@@ -256,35 +275,19 @@ export function ResourceViewerPage({
256
275
  }, [stateUnit]);
257
276
 
258
277
  const handleWizardGenerateSubmit = useCallback((referenceId: string, config: GenerationConfig) => {
259
- clearNewAnnotationId(annotationId(referenceId));
260
- stateUnit?.yield.generate(config.context, {
261
- title: config.title,
262
- storageUri: config.storagePath,
263
- prompt: config.prompt,
264
- language: config.language,
265
- // The source resource is the one the user is viewing — fed into the
266
- // prompt so the LLM understands the embedded context (selected
267
- // passage, surrounding text) regardless of UI/target language.
268
- sourceLanguage: getLanguage(resource),
269
- temperature: config.temperature,
270
- maxTokens: config.maxTokens,
271
- });
272
- }, [stateUnit, clearNewAnnotationId, resource]);
278
+ clearSparkle(annotationId(referenceId));
279
+ // D8: forwarded by spread in ONE place, so a knob added to the form is
280
+ // never dropped on the way to the wire. `sourceLanguage` is the viewed
281
+ // resource's language — a page fact the form cannot know.
282
+ stateUnit?.yield.generate(config.context, toGenerationOptions(config, getLanguage(resource)));
283
+ }, [stateUnit, clearSparkle, resource]);
273
284
 
274
285
  // Resource-generate flow (GENERATE-FROM-BUTTON): drive the SAME yield progress$
275
286
  // the annotation path uses so the full `AssistProgress` widget shows — NOT a
276
287
  // toast. Both paths are one `generate(context, options)` now: the context's
277
288
  // focus.kind (resource here, annotation above) decides the shape.
278
289
  const handleResourceGenerateSubmit = useCallback((_resourceId: string, config: GenerationConfig) => {
279
- stateUnit?.yield.generate(config.context, {
280
- title: config.title,
281
- storageUri: config.storagePath,
282
- ...(config.prompt ? { prompt: config.prompt } : {}),
283
- language: config.language,
284
- sourceLanguage: getLanguage(resource),
285
- temperature: config.temperature,
286
- maxTokens: config.maxTokens,
287
- });
290
+ stateUnit?.yield.generate(config.context, toGenerationOptions(config, getLanguage(resource)));
288
291
  }, [stateUnit, resource]);
289
292
 
290
293
  const handleWizardLinkResource = useCallback(async (referenceId: string, targetResourceId: string) => {
@@ -301,26 +304,43 @@ export function ResourceViewerPage({
301
304
  }
302
305
  }, [rUri, semiont, showSuccess, showError]);
303
306
 
304
- const handleWizardComposeNavigate = useCallback((
305
- context: GatheredContext,
306
- annId: string,
307
- resId: string,
308
- title: string,
309
- entTypes: string[],
310
- ) => {
311
- // Store context in sessionStorage for the compose page
312
- sessionStorage.setItem(`gather-context:${annId}`, JSON.stringify(context));
313
- const params = new URLSearchParams({
314
- annotationUri: annId,
315
- sourceDocumentId: resId,
316
- name: title,
317
- entityTypes: entTypes.join(','),
318
- });
319
- browser.emit('nav:push', {
320
- path: `/know/compose?${params.toString()}`,
321
- reason: 'compose-from-wizard',
322
- });
323
- }, [browser]);
307
+ // COMPOSE-IN-MODAL P3: create-and-link, in place. The old flow stashed the
308
+ // context in sessionStorage and navigated to the compose page; the modal
309
+ // already holds the context, so the side-channel dies with the mode.
310
+ // Text-only by design — uploads stay on the standalone compose page.
311
+ const handleWizardComposeSubmit = useCallback(async (referenceId: string, params: ComposeParams) => {
312
+ if (!semiont) throw new Error('No active session');
313
+ try {
314
+ const format = 'text/markdown';
315
+ const file = new File(
316
+ [new Blob([params.content], { type: format })],
317
+ params.name + extensionForMediaType(format),
318
+ { type: format },
319
+ );
320
+ const newResourceId = await new Promise<ResourceId>((resolve, reject) => {
321
+ semiont.yield.resource({
322
+ name: params.name,
323
+ file,
324
+ format,
325
+ entityTypes: params.entityTypes,
326
+ language: params.language,
327
+ storageUri: params.storagePath,
328
+ }).subscribe({
329
+ next: (event) => { if (event.phase === 'finished') resolve(event.resourceId); },
330
+ error: reject,
331
+ });
332
+ });
333
+ await semiont.bind.body(
334
+ rUri,
335
+ annotationId(referenceId),
336
+ [{ op: 'add', item: { type: 'SpecificResource' as const, source: newResourceId, purpose: 'linking' as const } }],
337
+ );
338
+ showSuccess('Reference successfully linked to the new resource');
339
+ } catch (error) {
340
+ showError(`Failed to compose resource: ${error instanceof Error ? error.message : String(error)}`);
341
+ throw error; // ComposeStep re-enables its footer on rejection
342
+ }
343
+ }, [semiont, rUri, showSuccess, showError]);
324
344
 
325
345
  // Add resource to open tabs when it loads, and record it as this KB's
326
346
  // last-viewed for the /know landing route to resume from. Both are per-KB
@@ -329,7 +349,7 @@ export function ResourceViewerPage({
329
349
  useEffect(() => {
330
350
  if (resource && rUri) {
331
351
  const mediaType = getPrimaryMediaType(resource);
332
- browser.addOpenResource(rUri, resource.name, mediaType || undefined, resource.storageUri);
352
+ browser.addOpenResource(rUri, resource.name, mediaType || undefined, getStorageUri(resource));
333
353
  browser.setLastViewedResource(rUri);
334
354
  }
335
355
  }, [resource, rUri, browser]);
@@ -392,6 +412,19 @@ export function ResourceViewerPage({
392
412
  triggerSparkleAnimation(stored.payload.annotation.id);
393
413
  }, [triggerSparkleAnimation]);
394
414
 
415
+ // RESOLUTION-SPARKLE D2: a reference resolving — Compose, Search → Link, a
416
+ // Generate job landing, or a remote collaborator — is a body update whose
417
+ // operations add a linking SpecificResource. Exactly that sparkles; an unlink
418
+ // (remove) or an entity-tag body change stays dark.
419
+ const handleAnnotationBodyUpdated = useCallback((stored: EventMap['mark:body-updated']) => {
420
+ const resolves = stored.payload.operations.some(
421
+ (op) => op.op === 'add' && op.item.type === 'SpecificResource' && op.item.purpose === 'linking',
422
+ );
423
+ if (resolves) {
424
+ triggerSparkleAnimation(stored.payload.annotationId);
425
+ }
426
+ }, [triggerSparkleAnimation]);
427
+
395
428
  const handleResourceOpen = useCallback(({ resourceId }: { resourceId: string }) => {
396
429
  if (routes.resourceDetail) {
397
430
  const path = routes.resourceDetail(resourceId);
@@ -421,6 +454,7 @@ export function ResourceViewerPage({
421
454
  'yield:clone': handleResourceClone,
422
455
  'beckon:sparkle': handleAnnotationSparkle,
423
456
  'mark:added': handleAnnotationAdded,
457
+ 'mark:body-updated': handleAnnotationBodyUpdated,
424
458
  'browse:resource-open': handleResourceOpen,
425
459
  'browse:entity-type-clicked': handleEntityTypeClicked,
426
460
  });
@@ -490,21 +524,6 @@ export function ResourceViewerPage({
490
524
  </h2>
491
525
  </div>
492
526
  </div>
493
- {/* Resource-generation progress (GENERATE-FROM-BUTTON P7) — no annotationId ⇒ a resource-gen job */}
494
- {generationProgress && !generationProgress.annotationId && (
495
- <AssistProgress
496
- progress={generationProgress}
497
- dataType="generation"
498
- // The ending is the owner's fact and every owner must state it
499
- // (CLEAN-PROGRESS D1). This call site used to pass neither
500
- // `ended` nor `onDismiss`, so a finished generation stayed in its
501
- // running form and then vanished on a timer.
502
- ended={!isGenerating}
503
- onCancel={() => session?.client.job.cancelRequest('generation')}
504
- onDismiss={() => stateUnit?.yield.dismissProgress()}
505
- translations={assistProgressTranslations(ta)}
506
- />
507
- )}
508
527
  {/* Scrollable body wrapper - contains document content, header is sibling above */}
509
528
  <div className="semiont-document-viewer__scrollable-body" lang={getLanguage(resource) || undefined}>
510
529
  <ErrorBoundary
@@ -544,7 +563,7 @@ export function ResourceViewerPage({
544
563
  onSelectionMotivationChange={toolbarPrefs.setSelectionMotivation}
545
564
  shape={toolbarPrefs.shape}
546
565
  onShapeChange={toolbarPrefs.setShape}
547
- newAnnotationIds={newAnnotationIds}
566
+ sparkleAnnotationIds={sparkleAnnotationIds}
548
567
  generatingReferenceId={generationProgress?.annotationId ?? null}
549
568
  showLineNumbers={showLineNumbers}
550
569
  hoverDelayMs={hoverDelayMs}
@@ -594,6 +613,7 @@ export function ResourceViewerPage({
594
613
  onRetryAnnotations={stateUnit?.annotations.retry}
595
614
  entityTypesError={entityTypesError}
596
615
  generatingReferenceId={generationProgress?.annotationId ?? null}
616
+ sparkleAnnotationIds={sparkleAnnotationIds}
597
617
  referencedBy={referencedBy}
598
618
  referencedByLoading={referencedByLoading}
599
619
  referencedByError={referencedByError}
@@ -604,7 +624,7 @@ export function ResourceViewerPage({
604
624
  scrollToAnnotationId={scrollToAnnotationId}
605
625
  hoveredAnnotationId={hoveredAnnotationId}
606
626
  onScrollCompleted={onScrollCompleted}
607
- initialTab={panelInitialTab?.tab as any}
627
+ initialTab={panelInitialTab?.tab}
608
628
  initialTabGeneration={panelInitialTab?.generation}
609
629
  Link={Link}
610
630
  routes={routes}
@@ -636,7 +656,7 @@ export function ResourceViewerPage({
636
656
  documentLocale={getLanguage(resource)}
637
657
  primaryMediaType={primaryMediaType}
638
658
  primaryByteSize={primaryByteSize}
639
- storageUri={resource.storageUri}
659
+ storageUri={getStorageUri(resource)}
640
660
  isArchived={resource.archived ?? false}
641
661
  dateCreated={resource.dateCreated}
642
662
  dateModified={resource.dateModified}
@@ -644,6 +664,15 @@ export function ResourceViewerPage({
644
664
  wasDerivedFrom={resource.wasDerivedFrom}
645
665
  generator={resource.generator as components['schemas']['Agent'] | components['schemas']['Agent'][] | undefined}
646
666
  onGenerate={() => setGenerateOpen(true)}
667
+ // The panel is generation's progress surface (GENERATE-FROM-
668
+ // RESOURCE D7); no annotationId ⇒ a resource-gen job — the
669
+ // annotation path's frame renders in the reference wizard.
670
+ isGenerating={isGenerating}
671
+ generationProgress={
672
+ generationProgress && !generationProgress.annotationId ? generationProgress : null
673
+ }
674
+ generationOutcome={generationOutcome}
675
+ onDismissProgress={() => stateUnit?.yield.dismissProgress()}
647
676
  />
648
677
  )}
649
678
 
@@ -680,27 +709,35 @@ export function ResourceViewerPage({
680
709
  annotationId={wizardAnnotationId}
681
710
  resourceId={wizardResourceId}
682
711
  defaultTitle={wizardDefaultTitle}
712
+ defaultFolder={folderOf(getStorageUri(resource))}
683
713
  entityTypes={wizardEntityTypes}
714
+ resourceName={resource.name}
684
715
  locale={locale}
685
716
  context={gatherContext}
686
717
  contextLoading={gatherLoading}
687
718
  contextError={gatherError}
688
719
  onGenerateSubmit={handleWizardGenerateSubmit}
689
720
  onLinkResource={handleWizardLinkResource}
690
- onComposeNavigate={handleWizardComposeNavigate}
721
+ onComposeSubmit={handleWizardComposeSubmit}
722
+ entityTypeOptions={allEntityTypes}
723
+ hoverDelayMs={hoverDelayMs}
691
724
  translations={{
692
- gatherTitle: tw('gatherTitle'),
693
- configureGenerationTitle: tw('configureGenerationTitle'),
694
- configureSearchTitle: tw('configureSearchTitle'),
695
- searchResultsTitle: tw('searchResultsTitle'),
725
+ resolveTitle: tw('resolveTitle'),
696
726
  sourceContextLabel: tw('sourceContextLabel'),
697
727
  connectionsLabel: tw('connectionsLabel'),
698
728
  citedByLabel: tw('citedByLabel'),
699
729
  userHintLabel: tw('userHintLabel'),
730
+ userHintEffect: tw('userHintEffect'),
700
731
  userHintPlaceholder: tw('userHintPlaceholder'),
732
+ graphPaneTitle: tw('graphPaneTitle'),
733
+ graphEmpty: tw('graphEmpty'),
734
+ resourceLinkLabel: tw('resourceLinkLabel'),
735
+ corpusPaneTitle: tw('corpusPaneTitle'),
736
+ corpusEmpty: tw('corpusEmpty'),
737
+ excludedReceipt: tw('excludedReceipt'),
738
+ machineRead: tw('machineRead'),
701
739
  loadingContext: tw('loadingContext'),
702
740
  failedContext: tw('failedContext'),
703
- cancel: tw('cancel'),
704
741
  search: tw('search'),
705
742
  searching: tw('searching'),
706
743
  generate: tw('generate'),
@@ -712,6 +749,7 @@ export function ResourceViewerPage({
712
749
  noResults: tw('noResults'),
713
750
  resourceTitle: tw('resourceTitle'),
714
751
  resourceTitlePlaceholder: tw('resourceTitlePlaceholder'),
752
+ saveLocation: tw('saveLocation'),
715
753
  additionalInstructions: tw('additionalInstructions'),
716
754
  additionalInstructionsPlaceholder: tw('additionalInstructionsPlaceholder'),
717
755
  language: tw('language'),
@@ -722,9 +760,19 @@ export function ResourceViewerPage({
722
760
  maxLength: tw('maxLength'),
723
761
  maxLengthHelp: tw('maxLengthHelp'),
724
762
  maxLengthCeiling: tw('maxLengthCeiling'),
763
+ outputFormat: tw('outputFormat'),
764
+ formatExtensionMismatch: tw('formatExtensionMismatch'),
725
765
  maxResults: tw('maxResults'),
726
766
  semanticScoring: tw('semanticScoring'),
727
767
  semanticScoringHelp: tw('semanticScoringHelp'),
768
+ searchFailed: tw('searchFailed'),
769
+ entityTypes: tw('entityTypes'),
770
+ contentLabel: tw('contentLabel'),
771
+ createAndLink: tw('createAndLink'),
772
+ creatingAndLinking: tw('creatingAndLinking'),
773
+ discardDraftPrompt: tw('discardDraftPrompt'),
774
+ discardDraft: tw('discardDraft'),
775
+ keepEditing: tw('keepEditing'),
728
776
  }}
729
777
  />
730
778
 
@@ -734,31 +782,43 @@ export function ResourceViewerPage({
734
782
  isOpen={generateOpen}
735
783
  onClose={() => setGenerateOpen(false)}
736
784
  resourceId={rUri}
737
- defaultTitle=""
785
+ // Seed the proposed title from the source resource's name (GFR D4/A4);
786
+ // the field stays editable and required.
787
+ defaultTitle={resource.name}
788
+ defaultFolder={folderOf(getStorageUri(resource))}
738
789
  locale={locale}
739
790
  entityTypeOptions={allEntityTypes}
740
791
  onGenerateSubmit={handleResourceGenerateSubmit}
792
+ gatherContext={resourceGatherContext}
793
+ gatherLoading={resourceGatherLoading}
794
+ gatherError={resourceGatherError}
795
+ onGather={(options) => stateUnit?.gather.gatherResource(rUri, options)}
741
796
  translations={{
742
- gatherTitle: tg('gatherTitle'),
743
- reviewTitle: tg('reviewTitle'),
744
- configureTitle: tg('configureTitle'),
745
- next: tg('next'),
746
- back: tg('back'),
747
- cancel: tg('cancel'),
797
+ title: tg('title'),
748
798
  gatherIntro: tg('gatherIntro'),
749
799
  includeContent: tg('includeContent'),
750
800
  includeSummary: tg('includeSummary'),
751
801
  gatherDepth: tg('gatherDepth'),
752
802
  gatherMaxResources: tg('gatherMaxResources'),
753
803
  gatherButton: tg('gatherButton'),
754
- excludeLabel: tg('excludeLabel'),
804
+ editGather: tg('editGather'),
805
+ recallLabel: tg('recallLabel'),
755
806
  loadingContext: tg('loadingContext'),
756
807
  failedContext: tg('failedContext'),
757
808
  sourceContextLabel: tg('sourceContextLabel'),
758
809
  connectionsLabel: tg('connectionsLabel'),
759
810
  citedByLabel: tg('citedByLabel'),
811
+ graphPaneTitle: tg('graphPaneTitle'),
812
+ graphEmpty: tg('graphEmpty'),
813
+ resourceLinkLabel: tg('resourceLinkLabel'),
814
+ corpusPaneTitle: tg('corpusPaneTitle'),
815
+ corpusEmpty: tg('corpusEmpty'),
816
+ excludedReceipt: tg('excludedReceipt'),
817
+ machineRead: tg('machineRead'),
818
+ score: tg('score'),
760
819
  resourceTitle: tg('resourceTitle'),
761
820
  resourceTitlePlaceholder: tg('resourceTitlePlaceholder'),
821
+ saveLocation: tg('saveLocation'),
762
822
  additionalInstructions: tg('additionalInstructions'),
763
823
  additionalInstructionsPlaceholder: tg('additionalInstructionsPlaceholder'),
764
824
  language: tg('language'),
@@ -769,7 +829,12 @@ export function ResourceViewerPage({
769
829
  maxLength: tg('maxLength'),
770
830
  maxLengthHelp: tg('maxLengthHelp'),
771
831
  maxLengthCeiling: tg('maxLengthCeiling'),
832
+ outputFormat: tg('outputFormat'),
833
+ formatExtensionMismatch: tg('formatExtensionMismatch'),
772
834
  generate: tg('generate'),
835
+ discardDraftPrompt: tg('discardDraftPrompt'),
836
+ discardDraft: tg('discardDraft'),
837
+ keepEditing: tg('keepEditing'),
773
838
  }}
774
839
  />
775
840
  </div>
@@ -0,0 +1,29 @@
1
+ import type { GenerationOptions } from '@semiont/sdk';
2
+ import type { GenerationConfig } from '../../components/modals/ConfigureGenerationStep';
3
+
4
+ /**
5
+ * The submitted form config becomes generation options — in ONE place, by
6
+ * spread (GENERATION-OUTPUT-FORMAT D8).
7
+ *
8
+ * Both page handlers previously built this object field-by-field, so a knob
9
+ * the list didn't mention was silently dropped on the way to the wire. That
10
+ * is exactly how `outputMediaType` came to be unreachable one layer down
11
+ * (P1's finding), and copying the pattern here would have re-created it for
12
+ * every future knob. Spreading means the default is "forwarded"; only the two
13
+ * genuine transformations are spelled out:
14
+ *
15
+ * - `context` leaves the bag — it is `fromContext`'s positional argument.
16
+ * - `sourceLanguage` joins it — the language of the resource being VIEWED,
17
+ * which the form cannot know and the page can. Omitted entirely when
18
+ * unknown, so absence stays absence rather than becoming `''`.
19
+ */
20
+ export function toGenerationOptions(
21
+ config: GenerationConfig,
22
+ sourceLanguage: string | undefined,
23
+ ): GenerationOptions {
24
+ const { context: _positional, ...options } = config;
25
+ return {
26
+ ...options,
27
+ ...(sourceLanguage ? { sourceLanguage } : {}),
28
+ };
29
+ }