@semiont/react-ui 0.5.28 → 0.5.30

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 (113) hide show
  1. package/README.md +3 -3
  2. package/dist/{PdfAnnotationCanvas.client-C2IGG4SW.js → PdfAnnotationCanvas.client-LSH3PZPY.js} +4 -4
  3. package/dist/{ar-FABPTJVB.js → ar-AZBRADEB.js} +5 -1
  4. package/dist/{bn-LKP6BDMH.js → bn-CCEW6AOS.js} +5 -1
  5. package/dist/{chunk-VXASXU4K.js → chunk-3ZBKCWUZ.js} +1 -1
  6. package/dist/{chunk-2YHEE63S.js → chunk-G4VHL4UA.js} +31 -31
  7. package/dist/{chunk-2GOHTTHW.js → chunk-SDKFZLEX.js} +5 -1
  8. package/dist/{cs-YQYNWHRG.js → cs-HXPF2CTE.js} +5 -1
  9. package/dist/{da-QHZOYKRY.js → da-K5NTK7LH.js} +5 -1
  10. package/dist/{de-GKA7RD7J.js → de-GMRQEDZI.js} +5 -1
  11. package/dist/{el-45T257AL.js → el-EUB4CI4G.js} +5 -1
  12. package/dist/{en-RVW5EQ66.js → en-R5XA26NF.js} +2 -2
  13. package/dist/{es-AVER7RU4.js → es-Z32DAZ6W.js} +5 -1
  14. package/dist/{fa-K3VPK3O2.js → fa-G7HN6B32.js} +5 -1
  15. package/dist/{fi-5NEKF7EB.js → fi-3YBLZO7I.js} +5 -1
  16. package/dist/{fr-FUMNDLBZ.js → fr-VUBH7BBS.js} +5 -1
  17. package/dist/{he-3JGNA3PR.js → he-WQQEY26B.js} +5 -1
  18. package/dist/{hi-IJY375DF.js → hi-HJZ6W4JB.js} +5 -1
  19. package/dist/{id-LWHKJ7ZD.js → id-ZOGZ37MI.js} +5 -1
  20. package/dist/index.d.ts +157 -257
  21. package/dist/index.js +763 -1209
  22. package/dist/{it-EBVOV4RW.js → it-DT6UW66F.js} +5 -1
  23. package/dist/{ja-KWRIX2NF.js → ja-RUKQCOLO.js} +5 -1
  24. package/dist/{ko-L5CJMFDS.js → ko-ZH5ADKZM.js} +5 -1
  25. package/dist/{ms-LSNMJDR2.js → ms-Y2AFPZTU.js} +5 -1
  26. package/dist/{nl-ZB32MB7U.js → nl-OQEWIXNA.js} +5 -1
  27. package/dist/{no-4GTNY7M5.js → no-KPAYJZWY.js} +5 -1
  28. package/dist/{pl-LUHBLTHB.js → pl-WSXK2UUD.js} +5 -1
  29. package/dist/{pt-C4H3UNQ6.js → pt-NWRJRUCK.js} +5 -1
  30. package/dist/{ro-GGITS5V4.js → ro-3CGVMOC6.js} +5 -1
  31. package/dist/{sv-T56RTLBP.js → sv-HGSEBA7S.js} +5 -1
  32. package/dist/test-utils.js +16 -7
  33. package/dist/{th-22X2IWCB.js → th-NZ4G352Z.js} +5 -1
  34. package/dist/{tr-77TFG62H.js → tr-LGGS4DLW.js} +5 -1
  35. package/dist/{uk-MIIL6NXT.js → uk-AIX536L4.js} +5 -1
  36. package/dist/{vi-7JYOIGNV.js → vi-V6CVQZLQ.js} +5 -1
  37. package/dist/{zh-ID6DRYU4.js → zh-AGKPGI3L.js} +5 -1
  38. package/package.json +13 -13
  39. package/src/components/StatusDisplay.tsx +13 -13
  40. package/src/components/__tests__/StatusDisplay.test.tsx +9 -9
  41. package/src/components/modals/ComposeStep.tsx +19 -4
  42. package/src/components/modals/ConfigureGenerationStep.tsx +143 -15
  43. package/src/components/modals/ReferenceWizardModal.tsx +14 -9
  44. package/src/components/modals/ResourceGenerateModal.tsx +10 -5
  45. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +171 -8
  46. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +25 -1
  47. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +24 -1
  48. package/src/components/modals/__tests__/WizardFooter.test.tsx +3 -5
  49. package/src/components/resource/BrowseView.tsx +3 -3
  50. package/src/components/resource/DownloadFileLink.tsx +1 -1
  51. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +1 -1
  52. package/src/components/resource/__tests__/BrowseView.test.tsx +2 -2
  53. package/src/components/resource/__tests__/download-link.test.tsx +6 -6
  54. package/src/components/resource/panels/ReferenceEntry.tsx +2 -2
  55. package/src/components/resource/panels/ReferencesPanel.tsx +5 -1
  56. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  57. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +51 -2
  58. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +1 -1
  59. package/src/features/auth/__tests__/SignInForm.a11y.test.tsx +12 -12
  60. package/src/features/auth/components/SignInForm.tsx +53 -53
  61. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +2 -2
  62. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +2 -2
  63. package/src/features/resource-compose/components/ResourceComposePage.tsx +18 -3
  64. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +1 -1
  65. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +2 -2
  66. package/src/features/resource-discovery/state/discover-state-unit.ts +1 -1
  67. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +2 -2
  68. package/src/features/resource-viewer/__tests__/generation-options.test.ts +53 -0
  69. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +16 -25
  70. package/src/features/resource-viewer/generation-options.ts +29 -0
  71. package/src/styles/index.css +0 -1
  72. package/translations/ar.json +4 -0
  73. package/translations/bn.json +4 -0
  74. package/translations/cs.json +4 -0
  75. package/translations/da.json +4 -0
  76. package/translations/de.json +4 -0
  77. package/translations/el.json +4 -0
  78. package/translations/en.json +4 -0
  79. package/translations/es.json +4 -0
  80. package/translations/fa.json +4 -0
  81. package/translations/fi.json +4 -0
  82. package/translations/fr.json +4 -0
  83. package/translations/he.json +4 -0
  84. package/translations/hi.json +4 -0
  85. package/translations/id.json +4 -0
  86. package/translations/it.json +4 -0
  87. package/translations/ja.json +4 -0
  88. package/translations/ko.json +4 -0
  89. package/translations/ms.json +4 -0
  90. package/translations/nl.json +4 -0
  91. package/translations/no.json +4 -0
  92. package/translations/pl.json +4 -0
  93. package/translations/pt.json +4 -0
  94. package/translations/ro.json +4 -0
  95. package/translations/sv.json +4 -0
  96. package/translations/th.json +4 -0
  97. package/translations/tr.json +4 -0
  98. package/translations/uk.json +4 -0
  99. package/translations/vi.json +4 -0
  100. package/translations/zh.json +4 -0
  101. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -137
  102. package/src/features/admin-exchange/__tests__/ExportCard.test.tsx +0 -41
  103. package/src/features/admin-exchange/__tests__/ImportCard.test.tsx +0 -148
  104. package/src/features/admin-exchange/__tests__/ImportProgress.test.tsx +0 -117
  105. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -117
  106. package/src/features/admin-exchange/components/ExportCard.tsx +0 -35
  107. package/src/features/admin-exchange/components/ImportCard.tsx +0 -183
  108. package/src/features/admin-exchange/components/ImportProgress.tsx +0 -80
  109. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +0 -185
  110. package/src/features/admin-exchange/state/exchange-state-unit.ts +0 -133
  111. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -118
  112. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -118
  113. package/src/styles/features/exchange.css +0 -374
@@ -12,7 +12,9 @@ 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',
@@ -29,6 +31,8 @@ const translations = {
29
31
  maxLengthHelp: 'How long the generated resource may be.',
30
32
  // Named model + ceiling, so the bound is legible rather than mysterious (D6).
31
33
  maxLengthCeiling: 'Limited to {{maxOutputTokens}} tokens by {{model}}.',
34
+ outputFormat: 'Format',
35
+ formatExtensionMismatch: 'Save location must end in {{extension}} to match the selected format.',
32
36
  back: 'Back',
33
37
  generate: 'Generate',
34
38
  };
@@ -36,10 +40,7 @@ const translations = {
36
40
  const context = { resources: [], annotations: [] } as unknown as GatheredContext;
37
41
 
38
42
  /** The wizard's initial draft (WIZARD-NAVIGATION D3). */
39
- const DRAFT = {
40
- title: 'Untitled', storagePath: '', prompt: '', language: 'en',
41
- temperature: 0.7, maxTokensText: '500',
42
- };
43
+ const DRAFT: GenerationDraft = freshGenerationDraft('Untitled', 'en');
43
44
 
44
45
  const agentWithCeiling = (maxOutputTokens: number): CollaboratorEntry =>
45
46
  ({
@@ -59,12 +60,13 @@ const agentWithCeiling = (maxOutputTokens: number): CollaboratorEntry =>
59
60
  * than the bare component: a `vi.fn()` for `onConfigChange` would swallow every edit
60
61
  * and quietly turn each assertion below into a test of nothing.
61
62
  */
62
- function Harness({ generationAgent }: { generationAgent?: CollaboratorEntry }) {
63
+ function Harness({ generationAgent, defaultFolder }: { generationAgent?: CollaboratorEntry; defaultFolder?: string }) {
63
64
  const [draft, setDraft] = useState(DRAFT);
64
65
  return (
65
66
  <ConfigureGenerationStep
66
67
  config={draft}
67
68
  onConfigChange={setDraft}
69
+ {...(defaultFolder !== undefined ? { defaultFolder } : {})}
68
70
  context={context}
69
71
  onBack={vi.fn()}
70
72
  onGenerate={onGenerateSpy}
@@ -76,9 +78,14 @@ function Harness({ generationAgent }: { generationAgent?: CollaboratorEntry }) {
76
78
 
77
79
  let onGenerateSpy = vi.fn();
78
80
 
79
- function renderStep(generationAgent?: CollaboratorEntry) {
81
+ function renderStep(generationAgent?: CollaboratorEntry, defaultFolder?: string) {
80
82
  onGenerateSpy = vi.fn();
81
- const utils = render(<Harness {...(generationAgent ? { generationAgent } : {})} />);
83
+ const utils = render(
84
+ <Harness
85
+ {...(generationAgent ? { generationAgent } : {})}
86
+ {...(defaultFolder !== undefined ? { defaultFolder } : {})}
87
+ />,
88
+ );
82
89
  const input = () => screen.getByLabelText('Max length') as HTMLInputElement;
83
90
  return { ...utils, input, props: { onGenerate: onGenerateSpy } };
84
91
  }
@@ -266,6 +273,104 @@ describe('ConfigureGenerationStep — the draft is the owner\'s', () => {
266
273
  });
267
274
  });
268
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
+
269
374
  describe('ConfigureGenerationStep — the hint echo (GEP P1c, D8)', () => {
270
375
  it('echoes a non-empty hint, and renders nothing without one', () => {
271
376
  const { container, rerender } = render(
@@ -288,3 +393,61 @@ describe('ConfigureGenerationStep — the hint echo (GEP P1c, D8)', () => {
288
393
  expect(container.querySelector('.semiont-wizard__hint-echo')).toBeNull();
289
394
  });
290
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
+ });
@@ -61,6 +61,8 @@ const T = {
61
61
  language: 'Language', languageHelp: '', creativity: 'Creativity',
62
62
  creativityFocused: 'Focused', creativityCreative: 'Creative',
63
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.',
64
66
  maxResults: 'Max Results', semanticScoring: 'Semantic Scoring', semanticScoringHelp: '',
65
67
  searchFailed: 'Search failed',
66
68
  contentLabel: 'Content', entityTypes: 'Entity types',
@@ -589,15 +591,37 @@ describe('ReferenceWizardModal — the three strategies complete', () => {
589
591
  it('generation submits the step\'s config against this annotation, then closes', async () => {
590
592
  const { onGenerateSubmit, onClose } = renderWizard();
591
593
  await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
592
- await userEvent.type(screen.getByLabelText(/Save location/i), 'generated/out.md');
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
+ });
593
601
  await userEvent.click(screen.getByRole('button', { name: T.generate }));
594
602
 
595
603
  expect(onGenerateSubmit).toHaveBeenCalledTimes(1);
596
604
  expect(onGenerateSubmit.mock.calls[0]![0]).toBe('ann-1');
597
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
+ });
598
612
  expect(onClose).toHaveBeenCalledTimes(1);
599
613
  });
600
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
+
601
625
  it('retreats from a configure step back to the gather step', async () => {
602
626
  renderWizard();
603
627
  await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
@@ -60,6 +60,8 @@ const T = {
60
60
  maxLength: 'Max Length',
61
61
  maxLengthHelp: 'Max help',
62
62
  maxLengthCeiling: 'Limited to {{maxOutputTokens}} tokens by {{model}}.',
63
+ outputFormat: 'Format',
64
+ formatExtensionMismatch: 'Save location must end in {{extension}} to match the selected format.',
63
65
  generate: 'Generate',
64
66
  discardDraftPrompt: 'Discard this draft?',
65
67
  discardDraft: 'Discard',
@@ -214,13 +216,34 @@ describe('ResourceGenerateModal', () => {
214
216
  'res-1',
215
217
  expect.objectContaining({
216
218
  title: 'Generated Doc',
217
- storagePath: 'file://generated/out.md',
219
+ // D8: the submitted payload names this what it IS — a full storage
220
+ // URI, built here by prefixing `file://`. It was called `storagePath`
221
+ // while holding a URI, which is why the page handlers had to rename
222
+ // it field-by-field on the way out.
223
+ storageUri: 'file://generated/out.md',
224
+ // D2: the default is sent, not inherited from the worker.
225
+ outputMediaType: 'text/markdown',
218
226
  context: RESOURCE_CONTEXT,
219
227
  }),
220
228
  );
221
229
  expect(onClose).toHaveBeenCalled();
222
230
  });
223
231
 
232
+ it('a chosen output format survives the host to onGenerateSubmit', () => {
233
+ renderModal({ gatherContext: RESOURCE_CONTEXT });
234
+ fireEvent.click(screen.getByRole('button', { name: /Gather/ }));
235
+
236
+ fireEvent.change(screen.getByLabelText(T.resourceTitle), { target: { value: 'Generated Doc' } });
237
+ fireEvent.change(screen.getByLabelText(T.saveLocation), { target: { value: 'generated/out.pdf' } });
238
+ fireEvent.change(screen.getByLabelText(T.outputFormat), { target: { value: 'application/pdf' } });
239
+ fireEvent.click(screen.getByRole('button', { name: /Generate/ }));
240
+
241
+ expect(onGenerateSubmit).toHaveBeenCalledWith(
242
+ 'res-1',
243
+ expect.objectContaining({ outputMediaType: 'application/pdf', storageUri: 'file://generated/out.pdf' }),
244
+ );
245
+ });
246
+
224
247
  it('a failed gather shows its failure in place', () => {
225
248
  // The props are the state unit's slots verbatim; the modal renders them
226
249
  // without owning them (FLC A6).
@@ -14,7 +14,7 @@ import userEvent from '@testing-library/user-event';
14
14
  import '@testing-library/jest-dom';
15
15
  import { WizardFooter } from '../WizardFooter';
16
16
  import { ConfigureSearchStep } from '../ConfigureSearchStep';
17
- import { ConfigureGenerationStep } from '../ConfigureGenerationStep';
17
+ import { ConfigureGenerationStep, freshGenerationDraft } from '../ConfigureGenerationStep';
18
18
  import { SearchResultsStep } from '../SearchResultsStep';
19
19
  import { ConfigureGatherStep } from '../ConfigureGatherStep';
20
20
  import { GatherContextStep } from '../GatherContextStep';
@@ -116,6 +116,7 @@ const GEN_T = {
116
116
  additionalInstructionsPlaceholder: '', language: 'Language', languageHelp: '',
117
117
  creativity: 'Creativity', creativityFocused: 'Focused', creativityCreative: 'Creative',
118
118
  maxLength: 'Max Length', maxLengthHelp: '', maxLengthCeiling: 'Limited to {{max}} by {{model}}.',
119
+ outputFormat: 'Format', formatExtensionMismatch: 'Must end in {{extension}}.',
119
120
  back: 'Back', generate: 'Generate',
120
121
  };
121
122
 
@@ -173,10 +174,7 @@ describe('every step footer follows the grammar (A1, A2)', () => {
173
174
  const { container } = render(
174
175
  <ConfigureGenerationStep
175
176
  context={CONTEXT}
176
- config={{
177
- title: 'Caspian Sea', storagePath: '', prompt: '', language: 'en',
178
- temperature: 0.7, maxTokensText: '500',
179
- }}
177
+ config={freshGenerationDraft('Caspian Sea', 'en')}
180
178
  onConfigChange={vi.fn()}
181
179
  onBack={vi.fn()}
182
180
  onGenerate={vi.fn()}
@@ -12,7 +12,7 @@ import type { AnnotationsCollection } from '../../types/annotation-props';
12
12
  import {
13
13
  buildSourceToRenderedMap,
14
14
  buildTextNodeIndex,
15
- resolveAnnotationRanges,
15
+ resolveAnnotationSpans,
16
16
  applyHighlights,
17
17
  clearHighlights,
18
18
  toOverlayAnnotations,
@@ -122,8 +122,8 @@ export const BrowseView = memo(function BrowseView({
122
122
  const container = containerRef.current;
123
123
  const offsetMap = buildSourceToRenderedMap(content, container);
124
124
  const textNodeIndex = buildTextNodeIndex(container);
125
- const ranges = resolveAnnotationRanges(overlayAnnotations, offsetMap, textNodeIndex);
126
- applyHighlights(ranges);
125
+ const spans = resolveAnnotationSpans(overlayAnnotations, offsetMap);
126
+ applyHighlights(spans, textNodeIndex);
127
127
 
128
128
  return () => clearHighlights(container);
129
129
  }, [content, overlayAnnotations]);
@@ -8,7 +8,7 @@ import { mediaUrl } from '../../lib/media-url';
8
8
  interface Props {
9
9
  /** The '@id' of the resource whose bytes to offer. */
10
10
  resourceUri: string;
11
- /** Session for that resource — its client mints the media token and supplies the backend origin. */
11
+ /** Session for that resource — its client mints the media token and supplies the gateway origin. */
12
12
  session: SemiontSession | null;
13
13
  }
14
14
 
@@ -28,7 +28,7 @@ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
28
28
  vi.mock('../../../lib/annotation-overlay', () => ({
29
29
  buildSourceToRenderedMap: vi.fn(() => new Map()),
30
30
  buildTextNodeIndex: vi.fn(() => []),
31
- resolveAnnotationRanges: vi.fn(() => new Map()),
31
+ resolveAnnotationSpans: vi.fn(() => []),
32
32
  applyHighlights: vi.fn(),
33
33
  clearHighlights: vi.fn(),
34
34
  toOverlayAnnotations: vi.fn(() => []),
@@ -39,11 +39,11 @@ vi.mock('remark-gfm', () => ({
39
39
  default: vi.fn(),
40
40
  }));
41
41
 
42
- // Mock annotation-overlay — DOM Range API is not available in jsdom
42
+ // Mock annotation-overlay — these tests exercise BrowseView wiring, not the overlay
43
43
  vi.mock('../../../lib/annotation-overlay', () => ({
44
44
  buildSourceToRenderedMap: vi.fn(() => new Map()),
45
45
  buildTextNodeIndex: vi.fn(() => []),
46
- resolveAnnotationRanges: vi.fn(() => new Map()),
46
+ resolveAnnotationSpans: vi.fn(() => []),
47
47
  applyHighlights: vi.fn(),
48
48
  clearHighlights: vi.fn(),
49
49
  toOverlayAnnotations: vi.fn(() => []),
@@ -8,7 +8,7 @@
8
8
  * so the alias takes bearer + `?token=` only. Both views shipped a bare
9
9
  * `/api/resources/${id}` — tokenless, so a 401, AND relative, so under
10
10
  * bring-your-own-session embedding it resolves against the HOST app's origin
11
- * rather than the backend's.
11
+ * rather than the gateway's.
12
12
  *
13
13
  * Started RED (both hrefs were `/api/resources/res-1`).
14
14
  */
@@ -26,7 +26,7 @@ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({ SvgDrawingCanvas: ()
26
26
  vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
27
27
  vi.mock('remark-gfm', () => ({ default: () => ({}) }));
28
28
 
29
- const BACKEND = 'http://backend.test:4000';
29
+ const GATEWAY = 'http://gateway.test:4000';
30
30
  const UNSUPPORTED = 'application/octet-stream';
31
31
 
32
32
  const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
@@ -50,7 +50,7 @@ function sessionPending(): SemiontSession {
50
50
 
51
51
  /**
52
52
  * A transport-only client: `SemiontClient.auth` is `AuthNamespace | undefined`,
53
- * and a host wiring a bare transport (no `IBackendOperations`) has no `auth`.
53
+ * and a host wiring a bare transport (no `IGatewayOperations`) has no `auth`.
54
54
  */
55
55
  function sessionWithoutAuth(): SemiontSession {
56
56
  return fakeSession({});
@@ -59,7 +59,7 @@ function sessionWithoutAuth(): SemiontSession {
59
59
  function fakeSession(clientExtras: Record<string, unknown>): SemiontSession {
60
60
  return {
61
61
  client: {
62
- baseUrl: BACKEND,
62
+ baseUrl: GATEWAY,
63
63
  mark: { request: vi.fn() },
64
64
  browse: { click: vi.fn() },
65
65
  beckon: { hover: vi.fn() },
@@ -94,11 +94,11 @@ const VIEWS = [
94
94
  ] as const;
95
95
 
96
96
  describe.each(VIEWS)('$name — unsupported-media download link', ({ render: renderView }) => {
97
- it('points at the backend origin and carries the media token', async () => {
97
+ it('points at the gateway origin and carries the media token', async () => {
98
98
  renderView(sessionMinting('tok-abc'));
99
99
 
100
100
  const link = await screen.findByRole('link', { name: 'Download File' });
101
- expect(link).toHaveAttribute('href', `${BACKEND}/api/resources/res-1?token=tok-abc`);
101
+ expect(link).toHaveAttribute('href', `${GATEWAY}/api/resources/res-1?token=tok-abc`);
102
102
  expect(link).toHaveAttribute('download');
103
103
  });
104
104
 
@@ -11,7 +11,7 @@ import type { SemiontSession } from '@semiont/sdk';
11
11
  import { renderAgentLabel } from './agent-label';
12
12
  import { useHoverEmitter } from '../../../hooks/useHoverEmitter';
13
13
 
14
- // Extended annotation type with runtime properties added by backend enrichment
14
+ // Extended annotation type with runtime properties added by gateway enrichment
15
15
  interface EnrichedAnnotation extends Annotation {
16
16
  _resolvedDocumentName?: string;
17
17
  _resolvedDocumentMediaType?: string;
@@ -58,7 +58,7 @@ export function ReferenceEntry({
58
58
  const hasSvgSelector = getSvgSelector(selector);
59
59
  const annotationType = hasFragmentSelector ? 'Fragment annotation' : hasSvgSelector ? 'Image annotation' : 'Annotation';
60
60
 
61
- // Extract resolved document name and media type if enriched by backend
61
+ // Extract resolved document name and media type if enriched by gateway
62
62
  const enrichedReference = reference as EnrichedAnnotation;
63
63
  const resolvedDocumentName = enrichedReference._resolvedDocumentName;
64
64
  const resolvedDocumentMediaType = enrichedReference._resolvedDocumentMediaType;
@@ -279,7 +279,11 @@ export function ReferencesPanel({
279
279
  source: resourceId,
280
280
  motivation: 'linking',
281
281
  selector: pendingAnnotation.selector,
282
- body: entityType ? [{ type: 'TextualBody', value: entityType, purpose: 'tagging' }] : [],
282
+ // Entity types are optional, and MarkSubmitEvent.body is minItems:1
283
+ // an empty array is a 400, not an empty annotation.
284
+ ...(entityType
285
+ ? { body: [{ type: 'TextualBody' as const, value: entityType, purpose: 'tagging' as const }] }
286
+ : {}),
283
287
  });
284
288
  setPendingEntityTypes([]);
285
289
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Written after an e2e failure (test 05) where `ReferencesPanel` rendered
5
5
  * "No entity types available" even though the client provably received a
6
- * 9-string entity-types array from the backend.
6
+ * 9-string entity-types array from the gateway.
7
7
  *
8
8
  * This test closes the Layer 5-6 gap: the existing `ResourceViewerPage.test.tsx`
9
9
  * stubs `UnifiedAnnotationsPanel`/`ReferencesPanel` as `<div data-testid>`s,
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import React from 'react';
3
- import { render, screen, waitFor } from '@testing-library/react';
3
+ import { render, screen, waitFor, within } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import '@testing-library/jest-dom';
6
6
  import { ReferencesPanel } from '../ReferencesPanel';
@@ -20,7 +20,7 @@ function createEventTracker() {
20
20
  events,
21
21
  clear: () => { events.length = 0; },
22
22
  _attach(eventBus: EventBus) {
23
- const panelEvents = ['mark:assist-request'] as const;
23
+ const panelEvents = ['mark:assist-request', 'mark:submit'] as const;
24
24
  panelEvents.forEach((eventName) => {
25
25
  eventBus.get(eventName).subscribe((payload: any) => {
26
26
  events.push({ event: eventName, payload });
@@ -66,6 +66,7 @@ vi.mock('../../../../contexts/TranslationContext', () => ({
66
66
  includeDescriptiveReferences: 'Include descriptive references',
67
67
  descriptiveReferencesTooltip: 'Also find phrases like \'the CEO\', \'the tech giant\', \'the physicist\' (in addition to names)',
68
68
  cancel: 'Cancel',
69
+ createReference: 'Create Reference',
69
70
  annotating: 'Annotating...',
70
71
  // CLEAN-PROGRESS D3: the widget's own strings come from the
71
72
  // AssistProgress namespace now, not from this panel's.
@@ -179,6 +180,54 @@ describe('ReferencesPanel Component', () => {
179
180
  });
180
181
  });
181
182
 
183
+ // Entity types are optional on a reference, but `MarkSubmitEvent.body` is
184
+ // `minItems: 1` — so "no types selected" must OMIT body, not send `[]`.
185
+ // Sending `[]` 400s at /bus/emit, and `mark.submit` is fire-and-forget, so
186
+ // the failure is silent: the button looks inert. Found live 2026-08-24.
187
+ describe('Create Reference payload', () => {
188
+ const pendingLinking = {
189
+ motivation: 'linking' as const,
190
+ selector: { type: 'TextQuoteSelector' as const, exact: 'thylakoid' },
191
+ };
192
+
193
+ const submitPayloads = (tracker: ReturnType<typeof createEventTracker>) =>
194
+ tracker.events.filter(e => e.event === 'mark:submit').map(e => e.payload);
195
+
196
+ // The panel's own entity-type chips, not AssistSection's identically
197
+ // labelled ones.
198
+ const renderPrompt = (tracker: ReturnType<typeof createEventTracker>) => {
199
+ const { container } = renderWithEventBus(
200
+ <ReferencesPanel {...panelProps()} pendingAnnotation={pendingLinking} />,
201
+ tracker,
202
+ );
203
+ return within(container.querySelector('.semiont-annotation-prompt') as HTMLElement);
204
+ };
205
+
206
+ it('omits body entirely when no entity type is selected', async () => {
207
+ const tracker = createEventTracker();
208
+ const prompt = renderPrompt(tracker);
209
+
210
+ await userEvent.click(prompt.getByRole('button', { name: /create reference/i }));
211
+
212
+ const [payload] = submitPayloads(tracker);
213
+ expect(payload).toBeDefined();
214
+ expect(payload).not.toHaveProperty('body');
215
+ });
216
+
217
+ it('sends a one-item body when an entity type is selected', async () => {
218
+ const tracker = createEventTracker();
219
+ const prompt = renderPrompt(tracker);
220
+
221
+ await userEvent.click(prompt.getByRole('button', { name: 'Person' }));
222
+ await userEvent.click(prompt.getByRole('button', { name: /create reference/i }));
223
+
224
+ const [payload] = submitPayloads(tracker);
225
+ expect(payload.body).toEqual([
226
+ { type: 'TextualBody', value: 'Person', purpose: 'tagging' },
227
+ ]);
228
+ });
229
+ });
230
+
182
231
  describe('Entity Type Selection', () => {
183
232
  it('should toggle entity type selection on click', async () => {
184
233
  renderWithEventBus(<ReferencesPanel {...panelProps()} />);
@@ -73,7 +73,7 @@ function createEventTracker() {
73
73
  eventBus.get('yield:clone').subscribe((payload: any) => {
74
74
  events.push({ event: 'yield:clone', payload });
75
75
  });
76
- // `mark:archive` / `mark:unarchive` are backend-routed via `actor.emit`;
76
+ // `mark:archive` / `mark:unarchive` are gateway-routed via `actor.emit`;
77
77
  // spy on the namespace methods instead of subscribing to a local bus.
78
78
  const origArchive = client.mark.archive.bind(client.mark);
79
79
  const origUnarchive = client.mark.unarchive.bind(client.mark);