@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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * COMPOSE-IN-MODAL P1 — the compose strategy's form, as a pure step.
3
+ *
4
+ * The from-reference slice of the compose page, lifted: name, save location,
5
+ * entity types (read-only tags when the reference fixed them, picker
6
+ * otherwise), language, and the editor. Deliberately NONE of the page's
7
+ * upload/format/encoding machinery — the fences reference mode already
8
+ * proved are here as absence pins. The draft is CONTROLLED by the host
9
+ * (WIZARD-NAVIGATION D3: stepping Back must not discard typed work), and
10
+ * the evidence display is the host's job (plan A3: no context redux here).
11
+ */
12
+ import { describe, it, expect, vi } from 'vitest';
13
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
14
+ import '@testing-library/jest-dom';
15
+ import { ComposeStep, type ComposeDraft } from '../ComposeStep';
16
+
17
+ // Mock CodeMirrorRenderer to avoid CodeMirror dependencies (same mock the
18
+ // compose page's own tests use).
19
+ vi.mock('../../CodeMirrorRenderer', () => ({
20
+ CodeMirrorRenderer: ({ content, onChange, editable }: any) => (
21
+ <textarea
22
+ data-testid="code-editor"
23
+ value={content}
24
+ disabled={!editable}
25
+ onChange={(e) => onChange?.(e.target.value)}
26
+ />
27
+ ),
28
+ }));
29
+
30
+ const T = {
31
+ resourceTitle: 'New resource title',
32
+ resourceTitlePlaceholder: 'Title…',
33
+ saveLocation: 'Save location',
34
+ entityTypes: 'Entity types',
35
+ language: 'Language',
36
+ contentLabel: 'Content',
37
+ back: 'Back',
38
+ createAndLink: 'Create & Link',
39
+ creatingAndLinking: 'Creating…',
40
+ };
41
+
42
+ const DRAFT: ComposeDraft = {
43
+ name: 'Black Hawk',
44
+ storagePath: 'people/black-hawk.md',
45
+ content: 'Sauk leader.',
46
+ entityTypes: [],
47
+ language: 'en',
48
+ };
49
+
50
+ function renderStep(over: Partial<React.ComponentProps<typeof ComposeStep>> = {}) {
51
+ const onDraftChange = vi.fn();
52
+ const onBack = vi.fn();
53
+ const onCompose = vi.fn<(p: unknown) => Promise<void>>().mockResolvedValue(undefined);
54
+ const utils = render(
55
+ <ComposeStep
56
+ draft={DRAFT}
57
+ onDraftChange={onDraftChange}
58
+ referenceEntityTypes={['Person']}
59
+ entityTypeOptions={['Person', 'Topic', 'Location']}
60
+ showLineNumbers={false}
61
+ hoverDelayMs={300}
62
+ onBack={onBack}
63
+ onCompose={onCompose}
64
+ translations={T}
65
+ {...over}
66
+ />,
67
+ );
68
+ return { ...utils, onDraftChange, onBack, onCompose };
69
+ }
70
+
71
+ describe('ComposeStep — the from-reference slice, and nothing else', () => {
72
+ it('renders the draft: name, save location, content, language', () => {
73
+ renderStep();
74
+ expect(screen.getByLabelText(T.resourceTitle)).toHaveValue('Black Hawk');
75
+ expect(screen.getByLabelText(T.saveLocation)).toHaveValue('people/black-hawk.md');
76
+ expect(screen.getByTestId('code-editor')).toHaveValue('Sauk leader.');
77
+ expect(screen.getByLabelText(T.language)).toBeInTheDocument();
78
+ });
79
+
80
+ it('edits flow through onDraftChange — the host owns the draft (D3)', () => {
81
+ const { onDraftChange } = renderStep();
82
+ fireEvent.change(screen.getByLabelText(T.resourceTitle), { target: { value: 'Black Hawk (Sauk)' } });
83
+ expect(onDraftChange).toHaveBeenCalledWith({ name: 'Black Hawk (Sauk)' });
84
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'A Sauk leader.' } });
85
+ expect(onDraftChange).toHaveBeenCalledWith({ content: 'A Sauk leader.' });
86
+ });
87
+
88
+ it('reference-fixed entity types render as read-only tags, not a picker (D6)', () => {
89
+ renderStep(); // referenceEntityTypes: ['Person']
90
+ expect(screen.getByText('Person')).toBeInTheDocument();
91
+ // No toggling: the types were chosen when the reference was created.
92
+ expect(screen.queryByRole('button', { name: /Person/ })).not.toBeInTheDocument();
93
+ expect(screen.queryByRole('button', { name: /Topic/ })).not.toBeInTheDocument();
94
+ });
95
+
96
+ it('an empty reference set falls back to the picker over the owner-supplied options (D6)', () => {
97
+ const { onDraftChange } = renderStep({ referenceEntityTypes: [] });
98
+ const topic = screen.getByRole('button', { name: /Topic/ });
99
+ fireEvent.click(topic);
100
+ expect(onDraftChange).toHaveBeenCalledWith({ entityTypes: ['Topic'] });
101
+ });
102
+
103
+ it('carries NONE of the page-only machinery: no upload, no format, no encoding', () => {
104
+ const { container } = renderStep();
105
+ expect(container.querySelector('.semiont-form__upload-dropzone')).toBeNull();
106
+ expect(container.querySelector('.semiont-form__content-source-toggle')).toBeNull();
107
+ expect(container.querySelector('#format-select')).toBeNull();
108
+ expect(container.querySelector('#charset-select')).toBeNull();
109
+ });
110
+
111
+ it('renders no context of its own — the evidence is host-stacked (A3)', () => {
112
+ const { container } = renderStep();
113
+ expect(container.querySelector('.semiont-gather-pane')).toBeNull();
114
+ expect(container.querySelector('.semiont-gather__outer')).toBeNull();
115
+ });
116
+
117
+ it('submit emits the full params with the file:// prefix applied at the seam', async () => {
118
+ const { onCompose } = renderStep({ referenceEntityTypes: ['Person'] });
119
+ fireEvent.click(screen.getByRole('button', { name: T.createAndLink }));
120
+ await waitFor(() => expect(onCompose).toHaveBeenCalledTimes(1));
121
+ expect(onCompose).toHaveBeenCalledWith({
122
+ name: 'Black Hawk',
123
+ storagePath: 'file://people/black-hawk.md',
124
+ content: 'Sauk leader.',
125
+ entityTypes: ['Person'],
126
+ language: 'en',
127
+ });
128
+ });
129
+
130
+ it('the footer is a wizard footer: Back + Create, pending while in flight, re-enabled on rejection (A4)', async () => {
131
+ let reject!: (e: Error) => void;
132
+ const onCompose = vi.fn(() => new Promise<void>((_, r) => { reject = r; }));
133
+ const { container } = renderStep({ onCompose });
134
+
135
+ expect(container.querySelector('.semiont-modal__actions--wizard')).not.toBeNull();
136
+ fireEvent.click(screen.getByRole('button', { name: T.createAndLink }));
137
+ expect(await screen.findByRole('button', { name: T.creatingAndLinking })).toBeDisabled();
138
+
139
+ reject(new Error('boom'));
140
+ expect(await screen.findByRole('button', { name: T.createAndLink })).toBeEnabled();
141
+ });
142
+
143
+ it('Back is the only retreat and dismissal never lives in the footer (A4)', () => {
144
+ const { container, onBack } = renderStep();
145
+ const footerButtons = Array.from(
146
+ container.querySelectorAll('.semiont-modal__actions button'),
147
+ ).map((b) => b.textContent ?? '');
148
+ expect(footerButtons.filter((l) => /cancel|✕/i.test(l))).toEqual([]);
149
+ fireEvent.click(screen.getByRole('button', { name: `◀ ${T.back}` }));
150
+ expect(onBack).toHaveBeenCalledTimes(1);
151
+ });
152
+ });
@@ -16,13 +16,12 @@ const t = {
16
16
  includeSummary: 'Include summary',
17
17
  depth: 'Depth',
18
18
  maxResources: 'Max resources',
19
- cancel: 'Cancel',
20
19
  gather: 'Gather',
21
20
  };
22
21
 
23
22
  describe('ConfigureGatherStep', () => {
24
23
  it('renders the intro and the default option values', () => {
25
- render(<ConfigureGatherStep onGather={vi.fn()} onCancel={vi.fn()} translations={t} />);
24
+ render(<ConfigureGatherStep onGather={vi.fn()} translations={t} />);
26
25
  expect(screen.getByText('Choose what to include.')).toBeInTheDocument();
27
26
  expect(screen.getByLabelText('Include content')).toBeChecked();
28
27
  expect(screen.getByLabelText('Include summary')).toBeChecked();
@@ -32,14 +31,14 @@ describe('ConfigureGatherStep', () => {
32
31
 
33
32
  it('emits the default config on submit', () => {
34
33
  const onGather = vi.fn();
35
- render(<ConfigureGatherStep onGather={onGather} onCancel={vi.fn()} translations={t} />);
34
+ render(<ConfigureGatherStep onGather={onGather} translations={t} />);
36
35
  fireEvent.click(screen.getByRole('button', { name: /Gather/ }));
37
36
  expect(onGather).toHaveBeenCalledWith({ includeContent: true, includeSummary: true, depth: 2, maxResources: 10 });
38
37
  });
39
38
 
40
39
  it('reflects edits in the emitted config', () => {
41
40
  const onGather = vi.fn();
42
- render(<ConfigureGatherStep onGather={onGather} onCancel={vi.fn()} translations={t} />);
41
+ render(<ConfigureGatherStep onGather={onGather} translations={t} />);
43
42
  fireEvent.click(screen.getByLabelText('Include content')); // uncheck
44
43
  fireEvent.click(screen.getByLabelText('Include summary')); // uncheck
45
44
  fireEvent.change(screen.getByLabelText('Depth'), { target: { value: '4' } });
@@ -48,32 +47,21 @@ describe('ConfigureGatherStep', () => {
48
47
  expect(onGather).toHaveBeenCalledWith({ includeContent: false, includeSummary: false, depth: 4, maxResources: 25 });
49
48
  });
50
49
 
51
- it('honors the defaults prop', () => {
52
- const onGather = vi.fn();
53
- render(
54
- <ConfigureGatherStep
55
- defaults={{ includeSummary: false, depth: 1, maxResources: 5 }}
56
- onGather={onGather}
57
- onCancel={vi.fn()}
58
- translations={t}
59
- />,
60
- );
61
- expect(screen.getByLabelText('Include summary')).not.toBeChecked();
62
- expect(screen.getByLabelText('Depth')).toHaveValue(1);
63
- fireEvent.click(screen.getByRole('button', { name: /Gather/ }));
64
- expect(onGather).toHaveBeenCalledWith({ includeContent: true, includeSummary: false, depth: 1, maxResources: 5 });
65
- });
66
-
67
- it('calls onCancel from the cancel button', () => {
68
- const onCancel = vi.fn();
69
- render(<ConfigureGatherStep onGather={vi.fn()} onCancel={onCancel} translations={t} />);
70
- fireEvent.click(screen.getByRole('button', { name: /Cancel/ }));
71
- expect(onCancel).toHaveBeenCalledTimes(1);
50
+ it('the footer is the wizard footer: advance only, no dismissal, no flex (GFR A5)', () => {
51
+ // Dismissal lives on the modal's corner ✕/Esc/backdrop, never in a step
52
+ // footer (WIZARD-NAVIGATION D1); this is the first step, so no retreat either.
53
+ const { container } = render(<ConfigureGatherStep onGather={vi.fn()} translations={t} />);
54
+ const footer = container.querySelector('.semiont-modal__actions--wizard');
55
+ expect(footer).not.toBeNull();
56
+ const buttons = Array.from(footer!.querySelectorAll('button'));
57
+ expect(buttons).toHaveLength(1);
58
+ expect(buttons[0]!.textContent).not.toMatch(/cancel|✕/i);
59
+ expect(buttons[0]!.className).not.toContain('semiont-button--flex');
72
60
  });
73
61
 
74
62
  it('renders the children slot (exclusion picker)', () => {
75
63
  render(
76
- <ConfigureGatherStep onGather={vi.fn()} onCancel={vi.fn()} translations={t}>
64
+ <ConfigureGatherStep onGather={vi.fn()} translations={t}>
77
65
  <div>EXCLUDE-SLOT</div>
78
66
  </ConfigureGatherStep>,
79
67
  );
@@ -12,11 +12,14 @@ import { useState } from 'react';
12
12
  import { render, screen, fireEvent } from '@testing-library/react';
13
13
  import '@testing-library/jest-dom';
14
14
  import type { CollaboratorEntry, GatheredContext } from '@semiont/core';
15
- import { ConfigureGenerationStep } from '../ConfigureGenerationStep';
15
+ import { GENERATABLE_MEDIA_TYPES, capabilitiesOf } from '@semiont/core';
16
+ import { ConfigureGenerationStep, freshGenerationDraft } from '../ConfigureGenerationStep';
17
+ import type { GenerationDraft } from '../ConfigureGenerationStep';
16
18
 
17
19
  const translations = {
18
20
  resourceTitle: 'Title',
19
21
  resourceTitlePlaceholder: 'Title…',
22
+ saveLocation: 'Save location',
20
23
  additionalInstructions: 'Instructions',
21
24
  additionalInstructionsPlaceholder: 'Instructions…',
22
25
  language: 'Language',
@@ -28,6 +31,8 @@ const translations = {
28
31
  maxLengthHelp: 'How long the generated resource may be.',
29
32
  // Named model + ceiling, so the bound is legible rather than mysterious (D6).
30
33
  maxLengthCeiling: 'Limited to {{maxOutputTokens}} tokens by {{model}}.',
34
+ outputFormat: 'Format',
35
+ formatExtensionMismatch: 'Save location must end in {{extension}} to match the selected format.',
31
36
  back: 'Back',
32
37
  generate: 'Generate',
33
38
  };
@@ -35,10 +40,7 @@ const translations = {
35
40
  const context = { resources: [], annotations: [] } as unknown as GatheredContext;
36
41
 
37
42
  /** The wizard's initial draft (WIZARD-NAVIGATION D3). */
38
- const DRAFT = {
39
- title: 'Untitled', storagePath: '', prompt: '', language: 'en',
40
- temperature: 0.7, maxTokensText: '500',
41
- };
43
+ const DRAFT: GenerationDraft = freshGenerationDraft('Untitled', 'en');
42
44
 
43
45
  const agentWithCeiling = (maxOutputTokens: number): CollaboratorEntry =>
44
46
  ({
@@ -58,12 +60,13 @@ const agentWithCeiling = (maxOutputTokens: number): CollaboratorEntry =>
58
60
  * than the bare component: a `vi.fn()` for `onConfigChange` would swallow every edit
59
61
  * and quietly turn each assertion below into a test of nothing.
60
62
  */
61
- function Harness({ generationAgent }: { generationAgent?: CollaboratorEntry }) {
63
+ function Harness({ generationAgent, defaultFolder }: { generationAgent?: CollaboratorEntry; defaultFolder?: string }) {
62
64
  const [draft, setDraft] = useState(DRAFT);
63
65
  return (
64
66
  <ConfigureGenerationStep
65
67
  config={draft}
66
68
  onConfigChange={setDraft}
69
+ {...(defaultFolder !== undefined ? { defaultFolder } : {})}
67
70
  context={context}
68
71
  onBack={vi.fn()}
69
72
  onGenerate={onGenerateSpy}
@@ -75,9 +78,14 @@ function Harness({ generationAgent }: { generationAgent?: CollaboratorEntry }) {
75
78
 
76
79
  let onGenerateSpy = vi.fn();
77
80
 
78
- function renderStep(generationAgent?: CollaboratorEntry) {
81
+ function renderStep(generationAgent?: CollaboratorEntry, defaultFolder?: string) {
79
82
  onGenerateSpy = vi.fn();
80
- const utils = render(<Harness {...(generationAgent ? { generationAgent } : {})} />);
83
+ const utils = render(
84
+ <Harness
85
+ {...(generationAgent ? { generationAgent } : {})}
86
+ {...(defaultFolder !== undefined ? { defaultFolder } : {})}
87
+ />,
88
+ );
81
89
  const input = () => screen.getByLabelText('Max length') as HTMLInputElement;
82
90
  return { ...utils, input, props: { onGenerate: onGenerateSpy } };
83
91
  }
@@ -96,6 +104,17 @@ describe('ConfigureGenerationStep — ceiling awareness', () => {
96
104
  expect(screen.getByText(/Limited to 64000 tokens by claude-sonnet-5\./)).toBeInTheDocument();
97
105
  });
98
106
 
107
+ it('the ceiling sentence is a row-level footnote, never a column child', () => {
108
+ // A full sentence inside the 5.5rem Max Length column wraps one word per
109
+ // line and its height taxes the whole flex row — the void under the other
110
+ // controls IS this caption. It renders full-width under the row instead.
111
+ renderStep(agentWithCeiling(64_000));
112
+
113
+ const help = screen.getByText(/Limited to 64000 tokens/);
114
+ expect(help).toHaveClass('semiont-form__help--row');
115
+ expect(help.closest('.semiont-form__field--narrow')).toBeNull();
116
+ });
117
+
99
118
  it('will not commit a value above the ceiling', () => {
100
119
  // `max` on a number input does not prevent TYPING an over-value — it only
101
120
  // marks the field invalid. D6 says the value cannot be entered, so assert
@@ -253,3 +272,182 @@ describe('ConfigureGenerationStep — the draft is the owner\'s', () => {
253
272
  expect(props.onGenerate.mock.calls[0]![0]).not.toHaveProperty('prompt');
254
273
  });
255
274
  });
275
+
276
+ // ── GENERATION-OUTPUT-FORMAT P2 — the output format is a choice ─────────────
277
+ // The wire, the worker and the registry have carried three generatable types
278
+ // the whole time; only the control was missing.
279
+
280
+ describe('ConfigureGenerationStep — the output format (D1, D2)', () => {
281
+ const formatSelect = () => screen.getByLabelText(translations.outputFormat) as HTMLSelectElement;
282
+
283
+ it('offers exactly the registry-generatable types, in registry order, by their registry labels', () => {
284
+ // DERIVED from GENERATABLE_MEDIA_TYPES, never a literal list: promoting a
285
+ // fourth row must light it up here for free, and a hand-written array
286
+ // would silently lie the moment the registry moves.
287
+ renderStep();
288
+ const options = Array.from(formatSelect().options);
289
+ expect(options.map((o) => o.value)).toEqual([...GENERATABLE_MEDIA_TYPES]);
290
+ expect(options.map((o) => o.textContent)).toEqual(
291
+ GENERATABLE_MEDIA_TYPES.map((t) => capabilitiesOf(t)!.label),
292
+ );
293
+ });
294
+
295
+ it('defaults to markdown, and SENDS what it shows (D2)', () => {
296
+ // The worker already defaults to markdown when the key is absent, so an
297
+ // unsent field would produce the right artifact by accident. The point is
298
+ // that the displayed value is the transmitted one.
299
+ const { props } = renderStep();
300
+ expect(formatSelect().value).toBe('text/markdown');
301
+ fillRequired();
302
+ fireEvent.click(screen.getByText(new RegExp(translations.generate)));
303
+ expect(props.onGenerate.mock.calls[0]![0]).toMatchObject({ outputMediaType: 'text/markdown' });
304
+ });
305
+
306
+ it('carries a chosen format into the submitted config', () => {
307
+ const { props } = renderStep();
308
+ fireEvent.change(screen.getByLabelText('Save location'), { target: { value: 'generated/out.pdf' } });
309
+ fireEvent.change(formatSelect(), { target: { value: 'application/pdf' } });
310
+ fireEvent.click(screen.getByText(new RegExp(translations.generate)));
311
+ expect(props.onGenerate.mock.calls[0]![0]).toMatchObject({ outputMediaType: 'application/pdf' });
312
+ });
313
+ });
314
+
315
+ describe('ConfigureGenerationStep — the GUI refuses a format/extension mismatch (D7)', () => {
316
+ const formatSelect = () => screen.getByLabelText(translations.outputFormat) as HTMLSelectElement;
317
+ const generateButton = () => screen.getByText(new RegExp(translations.generate)).closest('button')!;
318
+
319
+ it('blocks submission and explains, naming the extension the format expects', () => {
320
+ // D7: the worker is faithful and incurious — it will write a PDF to a .md
321
+ // path and only log it — so this form is the ONLY refusal in the chain.
322
+ const { props } = renderStep();
323
+ fireEvent.change(screen.getByLabelText('Save location'), { target: { value: 'generated/out.md' } });
324
+ fireEvent.change(formatSelect(), { target: { value: 'application/pdf' } });
325
+
326
+ expect(generateButton()).toBeDisabled();
327
+ expect(screen.getByText(/must end in \.pdf/)).toBeInTheDocument();
328
+ fireEvent.click(generateButton());
329
+ expect(props.onGenerate).not.toHaveBeenCalled();
330
+ });
331
+
332
+ it('clears when EITHER control is corrected', () => {
333
+ const { props } = renderStep();
334
+ fireEvent.change(screen.getByLabelText('Save location'), { target: { value: 'generated/out.md' } });
335
+ fireEvent.change(formatSelect(), { target: { value: 'application/pdf' } });
336
+ expect(generateButton()).toBeDisabled();
337
+
338
+ // Fix the path…
339
+ fireEvent.change(screen.getByLabelText('Save location'), { target: { value: 'generated/out.pdf' } });
340
+ expect(generateButton()).not.toBeDisabled();
341
+ expect(screen.queryByText(/must end in/)).toBeNull();
342
+
343
+ // …or, from a fresh mismatch, fix the format instead.
344
+ fireEvent.change(formatSelect(), { target: { value: 'text/plain' } });
345
+ expect(generateButton()).toBeDisabled();
346
+ fireEvent.change(formatSelect(), { target: { value: 'application/pdf' } });
347
+ expect(generateButton()).not.toBeDisabled();
348
+
349
+ fireEvent.click(generateButton());
350
+ expect(props.onGenerate).toHaveBeenCalledTimes(1);
351
+ });
352
+
353
+ it('never greets an untouched form with a refusal — the default state is coherent', () => {
354
+ // markdown + the placeholder's `.md` shape: a user who has typed nothing
355
+ // must not be told they are wrong.
356
+ renderStep();
357
+ expect(screen.queryByText(/must end in/)).toBeNull();
358
+ fillRequired(); // generated/out.md, markdown selected
359
+ expect(generateButton()).not.toBeDisabled();
360
+ });
361
+
362
+ it('refuses an extensionless path, and accepts a differently-cased one', () => {
363
+ // Open question 2, both edges: the rule is "ends with the registry
364
+ // extension", so no extension fails it; case is not a real mismatch.
365
+ renderStep();
366
+ fireEvent.change(screen.getByLabelText('Save location'), { target: { value: 'generated/notes' } });
367
+ expect(generateButton()).toBeDisabled();
368
+
369
+ fireEvent.change(screen.getByLabelText('Save location'), { target: { value: 'generated/NOTES.MD' } });
370
+ expect(generateButton()).not.toBeDisabled();
371
+ });
372
+ });
373
+
374
+ describe('ConfigureGenerationStep — the hint echo (GEP P1c, D8)', () => {
375
+ it('echoes a non-empty hint, and renders nothing without one', () => {
376
+ const { container, rerender } = render(
377
+ <ConfigureGenerationStep
378
+ context={context} config={DRAFT} onConfigChange={vi.fn()}
379
+ onBack={vi.fn()} onGenerate={vi.fn()} translations={translations}
380
+ hintEcho={{ label: 'Hint', value: 'the ancient city, not the modern province' }}
381
+ />,
382
+ );
383
+ const echo = container.querySelector('.semiont-wizard__hint-echo');
384
+ expect(echo).not.toBeNull();
385
+ expect(echo!.textContent).toContain('the ancient city, not the modern province');
386
+
387
+ rerender(
388
+ <ConfigureGenerationStep
389
+ context={context} config={DRAFT} onConfigChange={vi.fn()}
390
+ onBack={vi.fn()} onGenerate={vi.fn()} translations={translations}
391
+ />,
392
+ );
393
+ expect(container.querySelector('.semiont-wizard__hint-echo')).toBeNull();
394
+ });
395
+ });
396
+
397
+ describe('the Save location proposes a path and stops following once touched (D11)', () => {
398
+ const path = () => screen.getByLabelText('Save location') as HTMLInputElement;
399
+ const title = () => screen.getByLabelText('Title') as HTMLInputElement;
400
+
401
+ it('opens PRE-FILLED beside the source, named for the title', () => {
402
+ renderStep(undefined, 'research');
403
+ expect(path().value).toBe('research/untitled.md');
404
+ });
405
+
406
+ it('proposes a bare filename when the source has no folder', () => {
407
+ renderStep(undefined, '');
408
+ expect(path().value).toBe('untitled.md');
409
+ });
410
+
411
+ it('FOLLOWS the title while untouched', () => {
412
+ renderStep(undefined, 'research');
413
+ fireEvent.change(title(), { target: { value: 'A New Name' } });
414
+ expect(path().value).toBe('research/a-new-name.md');
415
+ });
416
+
417
+ it('FOLLOWS the format while untouched — extension included', () => {
418
+ renderStep(undefined, 'research');
419
+ fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'application/pdf' } });
420
+ expect(path().value).toBe('research/untitled.pdf');
421
+ });
422
+
423
+ it('STOPS following once the path is edited — the title then moves freely', () => {
424
+ renderStep(undefined, 'research');
425
+ fireEvent.change(path(), { target: { value: 'elsewhere/mine.md' } });
426
+ fireEvent.change(title(), { target: { value: 'A New Name' } });
427
+ expect(path().value).toBe('elsewhere/mine.md');
428
+ });
429
+
430
+ it('CLEARING the field un-touches it, restoring the proposal', () => {
431
+ renderStep(undefined, 'research');
432
+ fireEvent.change(path(), { target: { value: 'elsewhere/mine.md' } });
433
+ fireEvent.change(path(), { target: { value: '' } });
434
+ expect(path().value).toBe('research/untitled.md');
435
+ });
436
+
437
+ it('submits the PROPOSED path when the user never touched it', () => {
438
+ const { props } = renderStep(undefined, 'research');
439
+ fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
440
+ expect(props.onGenerate).toHaveBeenCalledWith(
441
+ expect.objectContaining({ storageUri: 'file://research/untitled.md' }),
442
+ );
443
+ });
444
+
445
+ it('a proposed path never trips the D7 mismatch refusal — it carries the right extension', () => {
446
+ const { props } = renderStep(undefined, 'research');
447
+ fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'application/pdf' } });
448
+ fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
449
+ expect(props.onGenerate).toHaveBeenCalledWith(
450
+ expect.objectContaining({ storageUri: 'file://research/untitled.pdf' }),
451
+ );
452
+ });
453
+ });
@@ -16,7 +16,7 @@ import { ConfigureSearchStep, type SearchConfig } from '../ConfigureSearchStep';
16
16
  const T = {
17
17
  maxResults: 'Max Results', semanticScoring: 'Semantic Scoring',
18
18
  semanticScoringHelp: 'Use AI to score results by semantic relevance',
19
- back: 'Back', search: 'Search', searching: 'Searching…',
19
+ back: 'Back', search: 'Search', searching: 'Searching…', searchFailed: 'Search failed',
20
20
  };
21
21
 
22
22
  const CONFIG: SearchConfig = { limit: 10, useSemanticScoring: true };
@@ -50,6 +50,29 @@ describe('ConfigureSearchStep', () => {
50
50
  expect(onConfigChange).toHaveBeenCalledWith({ limit: 10, useSemanticScoring: false });
51
51
  });
52
52
 
53
+ it('echoes the hint being steered by, and stays silent without one (GEP P1c, D8)', () => {
54
+ // The thing the hint steers goes invisible behind the step transition —
55
+ // one quiet line keeps it visible and advertises that the hint did something.
56
+ const { container, rerender } = render(
57
+ <ConfigureSearchStep
58
+ config={CONFIG} onConfigChange={vi.fn()} onBack={vi.fn()} onSearch={vi.fn()}
59
+ translations={T}
60
+ hintEcho={{ label: 'Hint', value: 'the ancient city, not the modern province' }}
61
+ />,
62
+ );
63
+ const echo = container.querySelector('.semiont-wizard__hint-echo');
64
+ expect(echo).not.toBeNull();
65
+ expect(echo!.textContent).toContain('the ancient city, not the modern province');
66
+
67
+ rerender(
68
+ <ConfigureSearchStep
69
+ config={CONFIG} onConfigChange={vi.fn()} onBack={vi.fn()} onSearch={vi.fn()}
70
+ translations={T}
71
+ />,
72
+ );
73
+ expect(container.querySelector('.semiont-wizard__hint-echo')).toBeNull();
74
+ });
75
+
53
76
  it('submits whatever the owner currently holds, not a default', async () => {
54
77
  // The point of controlled state: a step that kept its own copy could submit
55
78
  // something the wizard never saw.