@semiont/react-ui 0.5.23 → 0.5.25

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 (190) hide show
  1. package/dist/{PdfAnnotationCanvas.client-V7Q3BEEQ.js → PdfAnnotationCanvas.client-EHQWF6BR.js} +45 -10
  2. package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js.map +1 -0
  3. package/dist/{ar-JRTKCMTF.js → ar-CULRHLNG.js} +20 -6
  4. package/dist/ar-CULRHLNG.js.map +1 -0
  5. package/dist/{bn-LO6EDSCC.js → bn-HYR7CEL2.js} +20 -6
  6. package/dist/bn-HYR7CEL2.js.map +1 -0
  7. package/dist/{chunk-5Y5JDCKC.js → chunk-NH6L2GIL.js} +20 -6
  8. package/dist/chunk-NH6L2GIL.js.map +1 -0
  9. package/dist/{chunk-RO5CRVIA.js → chunk-TG2XQEC4.js} +92 -60
  10. package/dist/chunk-TG2XQEC4.js.map +1 -0
  11. package/dist/{cs-LCHHOR4O.js → cs-YBRWY7NH.js} +20 -6
  12. package/dist/cs-YBRWY7NH.js.map +1 -0
  13. package/dist/{da-TARGPMJR.js → da-PHDEOBV3.js} +20 -6
  14. package/dist/da-PHDEOBV3.js.map +1 -0
  15. package/dist/{de-SBH4QBPJ.js → de-O4UCCVLJ.js} +20 -6
  16. package/dist/de-O4UCCVLJ.js.map +1 -0
  17. package/dist/{el-2D2OLGWB.js → el-MVGO6WZV.js} +20 -6
  18. package/dist/el-MVGO6WZV.js.map +1 -0
  19. package/dist/{en-QFIEG3CA.js → en-2KQILFKQ.js} +4 -2
  20. package/dist/{es-U6YK2LX6.js → es-USSLXQY7.js} +20 -6
  21. package/dist/es-USSLXQY7.js.map +1 -0
  22. package/dist/{fa-S7SZE37M.js → fa-WMAREFWY.js} +20 -6
  23. package/dist/fa-WMAREFWY.js.map +1 -0
  24. package/dist/{fi-CBL3NIXN.js → fi-B3NBNOTS.js} +20 -6
  25. package/dist/fi-B3NBNOTS.js.map +1 -0
  26. package/dist/{fr-EABUHV6S.js → fr-TXYK5T5W.js} +20 -6
  27. package/dist/fr-TXYK5T5W.js.map +1 -0
  28. package/dist/{he-THWD244Y.js → he-JCJACW5D.js} +20 -6
  29. package/dist/he-JCJACW5D.js.map +1 -0
  30. package/dist/{hi-GHGFZJ44.js → hi-VOQLZNJU.js} +20 -6
  31. package/dist/hi-VOQLZNJU.js.map +1 -0
  32. package/dist/{id-POZOUMDW.js → id-BFN5RZNZ.js} +20 -6
  33. package/dist/id-BFN5RZNZ.js.map +1 -0
  34. package/dist/index.css +0 -36
  35. package/dist/index.css.map +1 -1
  36. package/dist/index.d.ts +102 -53
  37. package/dist/index.js +2132 -2097
  38. package/dist/index.js.map +1 -1
  39. package/dist/{it-KDYJADSH.js → it-TWWDHKKM.js} +20 -6
  40. package/dist/it-TWWDHKKM.js.map +1 -0
  41. package/dist/{ja-Z5Z6ZF3P.js → ja-YRQLEBFM.js} +20 -6
  42. package/dist/ja-YRQLEBFM.js.map +1 -0
  43. package/dist/{ko-7X3XUXRN.js → ko-PG6B3CRB.js} +20 -6
  44. package/dist/ko-PG6B3CRB.js.map +1 -0
  45. package/dist/{ms-BTWEIW43.js → ms-7EZECVKM.js} +20 -6
  46. package/dist/ms-7EZECVKM.js.map +1 -0
  47. package/dist/{nl-IS2CGF5I.js → nl-HP753YAP.js} +20 -6
  48. package/dist/nl-HP753YAP.js.map +1 -0
  49. package/dist/{no-AAMEVA26.js → no-JUD5WX3N.js} +20 -6
  50. package/dist/no-JUD5WX3N.js.map +1 -0
  51. package/dist/{pl-PVXUQYFT.js → pl-RVUL4CBM.js} +20 -6
  52. package/dist/pl-RVUL4CBM.js.map +1 -0
  53. package/dist/{pt-3ITQ76TL.js → pt-RPOVD4QM.js} +20 -6
  54. package/dist/pt-RPOVD4QM.js.map +1 -0
  55. package/dist/{ro-FRB4IIVS.js → ro-DMYWQTRD.js} +20 -6
  56. package/dist/ro-DMYWQTRD.js.map +1 -0
  57. package/dist/{sv-DS3RO24M.js → sv-DMZ4FGXD.js} +20 -6
  58. package/dist/sv-DMZ4FGXD.js.map +1 -0
  59. package/dist/test-utils.js +4 -3
  60. package/dist/test-utils.js.map +1 -1
  61. package/dist/{th-AX66MWWB.js → th-4HVBJBQ2.js} +20 -6
  62. package/dist/th-4HVBJBQ2.js.map +1 -0
  63. package/dist/{tr-7KTB67TT.js → tr-6BDPU5ML.js} +20 -6
  64. package/dist/tr-6BDPU5ML.js.map +1 -0
  65. package/dist/{uk-SMQS2MAQ.js → uk-TBZBT2TL.js} +20 -6
  66. package/dist/uk-TBZBT2TL.js.map +1 -0
  67. package/dist/{vi-D3JXL2EZ.js → vi-UMKZKZL3.js} +20 -6
  68. package/dist/vi-UMKZKZL3.js.map +1 -0
  69. package/dist/{zh-S754AWFS.js → zh-YBJWJJYB.js} +20 -6
  70. package/dist/zh-YBJWJJYB.js.map +1 -0
  71. package/package.json +8 -8
  72. package/src/components/AssistProgress.tsx +42 -30
  73. package/src/components/EntityFoundLog.tsx +38 -0
  74. package/src/components/__tests__/AssistProgress.test.tsx +86 -28
  75. package/src/components/annotation-popups/JsonLdView.tsx +1 -1
  76. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +8 -2
  77. package/src/components/modals/ReferenceWizardModal.tsx +2 -1
  78. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +112 -12
  79. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +247 -1
  80. package/src/components/resource/AnnotateView.tsx +14 -7
  81. package/src/components/resource/BrowseView.tsx +2 -7
  82. package/src/components/resource/DownloadFileLink.tsx +49 -0
  83. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +58 -0
  84. package/src/components/resource/__tests__/download-link.test.tsx +129 -0
  85. package/src/components/resource/panels/AssistSection.css +0 -43
  86. package/src/components/resource/panels/AssistSection.tsx +11 -2
  87. package/src/components/resource/panels/AssistShell.tsx +5 -1
  88. package/src/components/resource/panels/JsonLdPanel.tsx +1 -1
  89. package/src/components/resource/panels/ReferencesPanel.tsx +12 -13
  90. package/src/components/resource/panels/TaggingPanel.tsx +21 -1
  91. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +2 -2
  92. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +9 -4
  93. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +1 -1
  94. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +8 -2
  95. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +26 -0
  96. package/src/components/settings/SettingsPanel.tsx +6 -2
  97. package/src/components/settings/__tests__/SettingsPanel.test.tsx +28 -3
  98. package/src/features/admin-devops/__tests__/AdminDevOpsPage.test.tsx +3 -11
  99. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +0 -3
  100. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -1
  101. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -3
  102. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +1 -1
  103. package/src/features/admin-security/__tests__/AdminSecurityPage.test.tsx +0 -8
  104. package/src/features/admin-security/components/AdminSecurityPage.tsx +0 -3
  105. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +1 -1
  106. package/src/features/admin-users/__tests__/AdminUsersPage.test.tsx +0 -1
  107. package/src/features/admin-users/components/AdminUsersPage.tsx +0 -3
  108. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +1 -1
  109. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +1 -1
  110. package/src/features/moderate-entity-tags/__tests__/EntityTagsPage.test.tsx +0 -2
  111. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +0 -3
  112. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +1 -1
  113. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +3 -5
  114. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +0 -3
  115. package/src/features/moderate-tag-schemas/__tests__/TagSchemasPage.test.tsx +3 -5
  116. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +0 -3
  117. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -1
  118. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -3
  119. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +17 -7
  120. package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -3
  121. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +1 -1
  122. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +0 -1
  123. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +0 -3
  124. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +1 -1
  125. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +40 -8
  126. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +20 -16
  127. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +1 -1
  128. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +1 -1
  129. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -2
  130. package/translations/ar.json +17 -5
  131. package/translations/bn.json +17 -5
  132. package/translations/cs.json +17 -5
  133. package/translations/da.json +17 -5
  134. package/translations/de.json +17 -5
  135. package/translations/el.json +17 -5
  136. package/translations/en.json +17 -5
  137. package/translations/es.json +17 -5
  138. package/translations/fa.json +17 -5
  139. package/translations/fi.json +17 -5
  140. package/translations/fr.json +17 -5
  141. package/translations/he.json +17 -5
  142. package/translations/hi.json +17 -5
  143. package/translations/id.json +17 -5
  144. package/translations/it.json +17 -5
  145. package/translations/ja.json +17 -5
  146. package/translations/ko.json +17 -5
  147. package/translations/ms.json +17 -5
  148. package/translations/nl.json +17 -5
  149. package/translations/no.json +17 -5
  150. package/translations/pl.json +17 -5
  151. package/translations/pt.json +17 -5
  152. package/translations/ro.json +17 -5
  153. package/translations/sv.json +17 -5
  154. package/translations/th.json +17 -5
  155. package/translations/tr.json +17 -5
  156. package/translations/uk.json +17 -5
  157. package/translations/vi.json +17 -5
  158. package/translations/zh.json +17 -5
  159. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +0 -1
  160. package/dist/ar-JRTKCMTF.js.map +0 -1
  161. package/dist/bn-LO6EDSCC.js.map +0 -1
  162. package/dist/chunk-5Y5JDCKC.js.map +0 -1
  163. package/dist/chunk-RO5CRVIA.js.map +0 -1
  164. package/dist/cs-LCHHOR4O.js.map +0 -1
  165. package/dist/da-TARGPMJR.js.map +0 -1
  166. package/dist/de-SBH4QBPJ.js.map +0 -1
  167. package/dist/el-2D2OLGWB.js.map +0 -1
  168. package/dist/es-U6YK2LX6.js.map +0 -1
  169. package/dist/fa-S7SZE37M.js.map +0 -1
  170. package/dist/fi-CBL3NIXN.js.map +0 -1
  171. package/dist/fr-EABUHV6S.js.map +0 -1
  172. package/dist/he-THWD244Y.js.map +0 -1
  173. package/dist/hi-GHGFZJ44.js.map +0 -1
  174. package/dist/id-POZOUMDW.js.map +0 -1
  175. package/dist/it-KDYJADSH.js.map +0 -1
  176. package/dist/ja-Z5Z6ZF3P.js.map +0 -1
  177. package/dist/ko-7X3XUXRN.js.map +0 -1
  178. package/dist/ms-BTWEIW43.js.map +0 -1
  179. package/dist/nl-IS2CGF5I.js.map +0 -1
  180. package/dist/no-AAMEVA26.js.map +0 -1
  181. package/dist/pl-PVXUQYFT.js.map +0 -1
  182. package/dist/pt-3ITQ76TL.js.map +0 -1
  183. package/dist/ro-FRB4IIVS.js.map +0 -1
  184. package/dist/sv-DS3RO24M.js.map +0 -1
  185. package/dist/th-AX66MWWB.js.map +0 -1
  186. package/dist/tr-7KTB67TT.js.map +0 -1
  187. package/dist/uk-SMQS2MAQ.js.map +0 -1
  188. package/dist/vi-D3JXL2EZ.js.map +0 -1
  189. package/dist/zh-S754AWFS.js.map +0 -1
  190. /package/dist/{en-QFIEG3CA.js.map → en-2KQILFKQ.js.map} +0 -0
@@ -7,13 +7,19 @@
7
7
  * cancel/dismiss arrive as callbacks the caller wires (job.cancelRequest /
8
8
  * mark.dismissProgress). Feature blocks are data-presence-driven so each call
9
9
  * site keeps its current visuals by passing what it always had.
10
+ *
11
+ * i18n contract (ASSIST-SURFACE-WARTS Lane A): every string this component
12
+ * renders comes from `translations`. There are NO English fallbacks — a
13
+ * missing key must be a type error at the call site, not a silent English
14
+ * leak in a Japanese UI. Tests use key-echo strings ('tr.complete') so an
15
+ * assertion failing means "rendered something other than what was passed".
10
16
  */
11
17
  import { describe, it, expect, vi } from 'vitest';
12
18
  import { render, screen } from '@testing-library/react';
13
19
  import userEvent from '@testing-library/user-event';
14
20
  import '@testing-library/jest-dom';
15
21
  import type { components } from '@semiont/core';
16
- import { AssistProgress } from '../AssistProgress';
22
+ import { AssistProgress, type AssistProgressTranslations } from '../AssistProgress';
17
23
 
18
24
  type JobProgress = components['schemas']['JobProgress'];
19
25
 
@@ -21,13 +27,26 @@ const running = (over: Partial<JobProgress> = {}): JobProgress => ({
21
27
  stage: 'analyzing', percentage: 40, message: 'working on it', ...over,
22
28
  });
23
29
 
30
+ /** Every required key, echoing its own name; override per test. */
31
+ const T = (over: Partial<AssistProgressTranslations> = {}): AssistProgressTranslations => ({
32
+ cancel: 'tr.cancel',
33
+ inProgress: 'tr.inProgress',
34
+ complete: 'tr.complete',
35
+ failed: 'tr.failed',
36
+ close: 'tr.close',
37
+ paramsTitle: 'tr.paramsTitle',
38
+ processing: (label: string) => `tr.processing(${label})`,
39
+ ...over,
40
+ });
41
+
24
42
  describe('AssistProgress', () => {
25
43
  it('renders provider-free: message, params, and data hooks', () => {
26
44
  const { container } = render(
27
45
  <AssistProgress
28
46
  progress={running({ requestParams: [{ label: 'Density', value: '5' }] })}
29
47
  dataType="comment"
30
- />,
48
+ translations={T()}
49
+ />,
31
50
  );
32
51
  expect(screen.getByText('working on it')).toBeInTheDocument();
33
52
  expect(screen.getByText(/Density/)).toBeInTheDocument();
@@ -37,12 +56,24 @@ describe('AssistProgress', () => {
37
56
  expect(root).toHaveAttribute('data-status', 'analyzing');
38
57
  });
39
58
 
59
+ it('renders the request-parameters title from translations, never a literal', () => {
60
+ render(
61
+ <AssistProgress
62
+ progress={running({ requestParams: [{ label: 'Density', value: '5' }] })}
63
+ dataType="comment"
64
+ translations={T()}
65
+ />,
66
+ );
67
+ expect(screen.getByText('tr.paramsTitle')).toBeInTheDocument();
68
+ expect(screen.queryByText('Request Parameters:')).not.toBeInTheDocument();
69
+ });
70
+
40
71
  it('renders a title header only when given one', () => {
41
72
  const { rerender } = render(
42
- <AssistProgress progress={running()} dataType="reference" translations={{ title: 'Annotating Entity References' }} />,
73
+ <AssistProgress progress={running()} dataType="reference" translations={T({ title: 'Annotating Entity References' })} />,
43
74
  );
44
75
  expect(screen.getByText('Annotating Entity References')).toBeInTheDocument();
45
- rerender(<AssistProgress progress={running()} dataType="comment" />);
76
+ rerender(<AssistProgress progress={running()} dataType="comment" translations={T()} />);
46
77
  expect(screen.queryByText('Annotating Entity References')).not.toBeInTheDocument();
47
78
  });
48
79
 
@@ -50,7 +81,7 @@ describe('AssistProgress', () => {
50
81
  // Cancel lives in the title header — both flows that offer cancel
51
82
  // (reference detection, generation) render the titled profile.
52
83
  const onCancel = vi.fn();
53
- const tr = { title: 'Generating Resource', cancel: 'Cancel Job' };
84
+ const tr = T({ title: 'Generating Resource', cancel: 'Cancel Job' });
54
85
  const { rerender } = render(
55
86
  <AssistProgress progress={running()} dataType="generation" onCancel={onCancel} translations={tr} />,
56
87
  );
@@ -68,13 +99,20 @@ describe('AssistProgress', () => {
68
99
  expect(screen.queryByTitle('Cancel Job')).not.toBeInTheDocument();
69
100
  });
70
101
 
71
- it('stage branching: complete shows + complete copy, error shows + message', () => {
102
+ it('stage branching: both terminal stages prefer the job message, then the translated copy', () => {
103
+ // The job composes detail no static string can ("Created 14 highlights"),
104
+ // so it wins on BOTH terminal stages; the translated copy covers the
105
+ // required-but-empty message case (pinned below).
72
106
  const { rerender } = render(
73
- <AssistProgress progress={running({ stage: 'complete' })} dataType="reference" translations={{ complete: 'All done!' }} />,
107
+ <AssistProgress progress={running({ stage: 'complete' })} dataType="reference" translations={T({ complete: 'All done!' })} />,
108
+ );
109
+ expect(screen.getByText('working on it')).toBeInTheDocument();
110
+ rerender(
111
+ <AssistProgress progress={running({ stage: 'complete', message: '' })} dataType="reference" translations={T({ complete: 'All done!' })} />,
74
112
  );
75
113
  expect(screen.getByText('All done!')).toBeInTheDocument();
76
114
  rerender(
77
- <AssistProgress progress={running({ stage: 'error', message: 'it broke' })} dataType="reference" translations={{ failed: 'Failed' }} />,
115
+ <AssistProgress progress={running({ stage: 'error', message: 'it broke' })} dataType="reference" translations={T({ failed: 'Failed' })} />,
78
116
  );
79
117
  expect(screen.getByText('it broke')).toBeInTheDocument();
80
118
  });
@@ -83,7 +121,7 @@ describe('AssistProgress', () => {
83
121
  render(
84
122
  <AssistProgress
85
123
  progress={running({ completedEntityTypes: [{ entityType: 'Person', foundCount: 3 }] })}
86
- dataType="reference" translations={{ found: (n) => `Found ${n}` }}
124
+ dataType="reference" translations={T({ found: (n) => `Found ${n}` })}
87
125
  />,
88
126
  );
89
127
  expect(screen.getByText('Person:')).toBeInTheDocument();
@@ -94,55 +132,75 @@ describe('AssistProgress', () => {
94
132
  const { rerender } = render(
95
133
  <AssistProgress
96
134
  progress={running({ currentEntityType: 'Location' })}
97
- dataType="reference" translations={{ current: (l) => `Processing: ${l}` }}
135
+ dataType="reference" translations={T({ current: (l) => `Current: ${l}` })}
98
136
  />,
99
137
  );
100
- expect(screen.getByText('Processing: Location')).toBeInTheDocument();
138
+ expect(screen.getByText('Current: Location')).toBeInTheDocument();
101
139
  rerender(
102
140
  <AssistProgress
103
141
  progress={running({ currentCategory: 'Rule', processedCategories: 2, totalCategories: 5 })}
104
- dataType="tag" />,
142
+ dataType="tag" translations={T()}
143
+ />,
105
144
  );
106
145
  expect(screen.getByText(/Rule/)).toBeInTheDocument();
107
146
  expect(screen.getByText(/2\/5/)).toBeInTheDocument();
108
147
  });
109
148
 
149
+ it('falls back to the generic processing formatter — never the English literal', () => {
150
+ // The reference flow passes its own `current` wording; every other flow
151
+ // (tag categories, and any flow without one) uses `processing`. Neither
152
+ // path may render a hardcoded "Processing: ".
153
+ const { rerender } = render(
154
+ <AssistProgress progress={running({ currentEntityType: 'Location' })} dataType="reference" translations={T()} />,
155
+ );
156
+ expect(screen.getByText('tr.processing(Location)')).toBeInTheDocument();
157
+ rerender(
158
+ <AssistProgress progress={running({ currentCategory: 'Rule' })} dataType="tag" translations={T()} />,
159
+ );
160
+ expect(screen.getByText(/tr\.processing\(Rule\)/)).toBeInTheDocument();
161
+ expect(screen.queryByText(/^Processing: /)).not.toBeInTheDocument();
162
+ });
163
+
110
164
  it('renders the percentage bar only when opted in', () => {
111
165
  const { container, rerender } = render(
112
- <AssistProgress progress={running({ percentage: 40 })} dataType="tag" showPercentBar />,
166
+ <AssistProgress progress={running({ percentage: 40 })} dataType="tag" showPercentBar translations={T()} />,
113
167
  );
114
168
  const fill = container.querySelector('.semiont-progress-bar__fill');
115
169
  expect(fill).toBeInTheDocument();
116
170
  expect(fill).toHaveStyle({ width: '40%' });
117
171
  rerender(
118
- <AssistProgress progress={running({ percentage: 40 })} dataType="reference" />,
172
+ <AssistProgress progress={running({ percentage: 40 })} dataType="reference" translations={T()} />,
119
173
  );
120
174
  expect(container.querySelector('.semiont-progress-bar__fill')).not.toBeInTheDocument();
121
175
  });
122
176
 
123
- it('cancel and dismiss carry accessible names, with safe fallbacks when untranslated', () => {
124
- // ✕ / × glyphs alone are meaningless to screen readers; the controls must
125
- // have an accessible name even when a caller forgets the translations.
177
+ it('cancel and dismiss take their accessible names from translations', () => {
178
+ // ✕ / × glyphs alone are meaningless to screen readers, and an English
179
+ // fallback is meaningless to a non-English reader. The labels are
180
+ // required keys, so there is nothing to fall back TO.
126
181
  render(
127
182
  <AssistProgress progress={running()} dataType="generation"
128
183
  onCancel={vi.fn()} onDismiss={vi.fn()}
129
- translations={{ title: 'Generating' }} />,
184
+ translations={T({ title: 'Generating' })} />,
130
185
  );
131
- expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
132
- expect(screen.getByLabelText('Dismiss')).toBeInTheDocument();
186
+ expect(screen.getByLabelText('tr.cancel')).toBeInTheDocument();
187
+ expect(screen.getByLabelText('tr.close')).toBeInTheDocument();
188
+ expect(screen.queryByLabelText('Cancel')).not.toBeInTheDocument();
189
+ expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
133
190
  });
134
191
 
135
- it('terminal stages never render a blank status line', () => {
192
+ it('terminal stages never render a blank status line, and never an English one', () => {
136
193
  // JobProgress.message is required but can be '' — a terminal display with
137
- // no text at all is worse than a generic word.
194
+ // no text at all is worse than a generic word, but that word must be the
195
+ // caller's translated one.
138
196
  const { rerender } = render(
139
- <AssistProgress progress={running({ stage: 'complete', message: '' })} dataType="reference" />,
197
+ <AssistProgress progress={running({ stage: 'complete', message: '' })} dataType="reference" translations={T()} />,
140
198
  );
141
- expect(screen.getByText('Complete')).toBeInTheDocument();
199
+ expect(screen.getByText('tr.complete')).toBeInTheDocument();
142
200
  rerender(
143
- <AssistProgress progress={running({ stage: 'error', message: '' })} dataType="reference" />,
201
+ <AssistProgress progress={running({ stage: 'error', message: '' })} dataType="reference" translations={T()} />,
144
202
  );
145
- expect(screen.getByText('Failed')).toBeInTheDocument();
203
+ expect(screen.getByText('tr.failed')).toBeInTheDocument();
146
204
  });
147
205
 
148
206
  it('renders dismiss whenever the caller offers it (WHEN is the caller\'s policy)', async () => {
@@ -151,12 +209,12 @@ describe('AssistProgress', () => {
151
209
  // "callback present → affordance rendered".
152
210
  const onDismiss = vi.fn();
153
211
  const { rerender } = render(
154
- <AssistProgress progress={running()} dataType="highlight" translations={{ close: 'Close' }} />,
212
+ <AssistProgress progress={running()} dataType="highlight" translations={T({ close: 'Close' })} />,
155
213
  );
156
214
  expect(screen.queryByLabelText('Close')).not.toBeInTheDocument();
157
215
  rerender(
158
216
  <AssistProgress progress={running()} dataType="highlight"
159
- onDismiss={onDismiss} translations={{ close: 'Close' }} />,
217
+ onDismiss={onDismiss} translations={T({ close: 'Close' })} />,
160
218
  );
161
219
  await userEvent.click(screen.getByLabelText('Close'));
162
220
  expect(onDismiss).toHaveBeenCalledOnce();
@@ -7,7 +7,7 @@ import { json } from '@codemirror/lang-json';
7
7
  import { oneDark } from '@codemirror/theme-one-dark';
8
8
  import { syntaxHighlighting } from '@codemirror/language';
9
9
  import { jsonLightTheme, jsonLightHighlightStyle } from '../../lib/codemirror-json-theme';
10
- import { useLineNumbers } from '../../hooks/useLineNumbers';
10
+ import { useLineNumbers } from '../../contexts/LineNumbersContext';
11
11
 
12
12
  import type { Annotation } from '@semiont/core';
13
13
 
@@ -47,9 +47,15 @@ vi.mock('../../../lib/codemirror-json-theme', () => ({
47
47
  jsonLightHighlightStyle: {},
48
48
  }));
49
49
 
50
- vi.mock('@/hooks/useLineNumbers');
50
+ vi.mock('@/contexts/LineNumbersContext', async () => {
51
+ const actual = await vi.importActual('@/contexts/LineNumbersContext');
52
+ // Mock ONLY the hook: renderWithProviders mounts the real
53
+ // LineNumbersProvider, which an auto-mock would swallow (rendering
54
+ // nothing), while these tests want a fixed showLineNumbers value.
55
+ return { ...actual, useLineNumbers: vi.fn() };
56
+ });
51
57
 
52
- import { useLineNumbers } from '@/hooks/useLineNumbers';
58
+ import { useLineNumbers } from '@/contexts/LineNumbersContext';
53
59
  import { renderWithProviders } from '../../../test-utils';
54
60
  import { JsonLdView } from '../JsonLdView';
55
61
 
@@ -3,6 +3,7 @@
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
  import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
5
5
  import type { GatheredContext } from '@semiont/core';
6
+ import { uuidV4 } from '@semiont/core';
6
7
  import { useSemiont } from '../../session/SemiontProvider';
7
8
  import { useObservable } from '../../hooks/useObservable';
8
9
  import { useEventSubscription } from '../../contexts/useEventSubscription';
@@ -144,7 +145,7 @@ export function ReferenceWizardModal({
144
145
  setIsSearching(true);
145
146
  const contextWithHint = userHint ? { ...context, userHint } : context;
146
147
  session?.client.match.requestSearch({
147
- correlationId: crypto.randomUUID(),
148
+ correlationId: uuidV4(),
148
149
  resourceId,
149
150
  referenceId: annotationId,
150
151
  context: contextWithHint,
@@ -1,10 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
4
- import type { Annotation, AnchorRect } from '@semiont/core';
4
+ import type { Annotation, AnchorRect, AnchoredText } from '@semiont/core';
5
5
  import { resourceId as toResourceId } from '@semiont/core';
6
6
  import { toViewportAnchorRect } from '../../lib/anchor-rect';
7
- import { createFragmentSelector } from '@semiont/core';
7
+ import { createFragmentSelector, anchorRuns, isTextRun, textUnder } from '@semiont/core';
8
8
  import { rectsForPage } from './rects-for-page';
9
9
  import { createHoverHandlers, type SemiontSession } from '@semiont/sdk';
10
10
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
@@ -61,7 +61,7 @@ interface PdfAnnotationCanvasProps {
61
61
  * PDF annotation canvas with page navigation and rectangle drawing
62
62
  *
63
63
  * @emits browse:click - Annotation clicked on PDF. Payload: { annotationId: string, motivation: Motivation }
64
- * @emits mark:requested - New annotation drawn on PDF. Payload: { selector: FragmentSelector, motivation: SelectionMotivation }
64
+ * @emits mark:requested - New annotation drawn on PDF. Payload: { selector: [FragmentSelector, TextQuoteSelector?], motivation: SelectionMotivation } — the quote is the text under the rectangle, omitted when the page has no text layer
65
65
  * @emits beckon:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }
66
66
  */
67
67
  export function PdfAnnotationCanvas({
@@ -83,6 +83,13 @@ export function PdfAnnotationCanvas({
83
83
  const [isLoading, setIsLoading] = useState(true);
84
84
  const [error, setError] = useState<string | null>(null);
85
85
  const [pageDimensions, setPageDimensions] = useState<{ width: number; height: number } | null>(null);
86
+ /**
87
+ * The current page's text and per-run geometry, read once when the page
88
+ * loads so a drag can be quoted without a round trip. Null while the page
89
+ * is loading; `items` is empty on a scanned page, which has no text layer
90
+ * for the browser to read.
91
+ */
92
+ const [pageAnchored, setPageAnchored] = useState<AnchoredText | null>(null);
86
93
  const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);
87
94
  const [scale] = useState(1.5); // Fixed scale for better quality
88
95
 
@@ -125,6 +132,36 @@ export function PdfAnnotationCanvas({
125
132
  };
126
133
  }, [pdfUrl]);
127
134
 
135
+ /**
136
+ * The server-derived map, fetched once per document rather than once per
137
+ * page-load effect (PERSIST-ANCHORS P4). The map is WHOLE-RESOURCE — one
138
+ * artifact covering every page — so re-reading it from the per-page effect
139
+ * meant a full refetch and re-decode on every page turn; on a 400-page scan
140
+ * that is one decode per interaction instead of one per document.
141
+ *
142
+ * The cache holds the in-flight promise so concurrent page loads share one
143
+ * fetch. Answers cache — including "no map" and a stored decline, which are
144
+ * definitive — but a transport failure clears the entry, so the next page
145
+ * load retries instead of pinning the whole document to geometry-only.
146
+ */
147
+ const resourceAnchoredRef = useRef<{ uri: string; outcome: Promise<AnchoredText | null> } | null>(null);
148
+ const fetchResourceAnchored = useCallback((): Promise<AnchoredText | null> => {
149
+ if (!session) return Promise.resolve(null); // no session yet — don't cache its absence
150
+ const cached = resourceAnchoredRef.current;
151
+ if (cached && cached.uri === resourceUri) return cached.outcome;
152
+
153
+ const uri = resourceUri;
154
+ const outcome = session.client.browse.resourceAnchoredText(toResourceId(uri)).then(
155
+ (served) => (served && !('declined' in served) ? served : null),
156
+ () => {
157
+ if (resourceAnchoredRef.current?.uri === uri) resourceAnchoredRef.current = null;
158
+ return null;
159
+ },
160
+ );
161
+ resourceAnchoredRef.current = { uri, outcome };
162
+ return outcome;
163
+ }, [session, resourceUri]);
164
+
128
165
  // Load current page when page number changes
129
166
  useEffect(() => {
130
167
  if (!pdfDoc) return;
@@ -132,6 +169,49 @@ export function PdfAnnotationCanvas({
132
169
  let cancelled = false;
133
170
  const doc = pdfDoc;
134
171
 
172
+ // Never quote the previous page's text under this page's rectangles.
173
+ setPageAnchored(null);
174
+
175
+ /**
176
+ * The map a rectangle on this page quotes from, or null when there is
177
+ * none to be had.
178
+ *
179
+ * Never rejects, which is what lets the render run alongside it: quoting
180
+ * is the optional half of loading a page, so a failure here degrades to
181
+ * geometry-only rather than reaching the caller's error path. It is also
182
+ * the only reason `Promise.all` below is safe — a rejection from either
183
+ * side would leave the other promise dangling, and only the render can
184
+ * reject.
185
+ */
186
+ async function resolveAnchored(page: Awaited<ReturnType<typeof doc.getPage>>): Promise<AnchoredText | null> {
187
+ try {
188
+ // The page's text layer, read once here rather than at drag time —
189
+ // `handleMouseUp` stays synchronous, and a native page costs nothing
190
+ // extra since pdf.js already parsed it to draw the page.
191
+ const runs = (await page.getTextContent()).items.filter(isTextRun);
192
+ if (runs.length > 0) return anchorRuns(runs, pageNumber);
193
+
194
+ // No runs means a scanned page: the characters exist only as pixels
195
+ // and pdf.js has nothing to give. The server derived a map at ingest,
196
+ // so ask for it rather than leaving the annotation anonymous.
197
+ // Whole-resource — served once per document via the cache above —
198
+ // and `textUnder` filters by page: the same shape the native branch
199
+ // produces, so nothing downstream branches.
200
+ //
201
+ // `null` is the ordinary answer for a document that has no map and
202
+ // never will; a failure is equally non-fatal. Either way the
203
+ // annotation ships with geometry only, which is what shipped before
204
+ // this existed. The served record is the full extraction outcome
205
+ // (PERSIST-ANCHORS D1); a stored decline means extraction ran and
206
+ // found nothing to anchor — for this canvas the same degradation as
207
+ // no map at all. A success outcome IS the anchoring shape, plus
208
+ // provenance this canvas does not read.
209
+ return await fetchResourceAnchored();
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
135
215
  async function loadPage() {
136
216
  try {
137
217
  const page = await doc.getPage(pageNumber);
@@ -145,11 +225,21 @@ export function PdfAnnotationCanvas({
145
225
  height: viewport.height
146
226
  });
147
227
 
148
- // Render page to image
149
- const { dataUrl } = await renderPdfPageToDataUrl(page, scale);
228
+ // Anchoring and rendering are independent, and only one of them the
229
+ // reader is waiting on. Sequencing them put a network round-trip in
230
+ // front of the pixels on exactly the documents that need it most: a
231
+ // scanned page fetches its map from the server, and rendering behind
232
+ // that await is how "failing to quote it must not fail to show it"
233
+ // became true of errors but not of latency. Started together, the
234
+ // page appears on its own schedule.
235
+ const [anchored, { dataUrl }] = await Promise.all([
236
+ resolveAnchored(page),
237
+ renderPdfPageToDataUrl(page, scale),
238
+ ]);
150
239
 
151
240
  if (cancelled) return;
152
241
 
242
+ setPageAnchored(anchored);
153
243
  setPageImageUrl(dataUrl);
154
244
  } catch (err) {
155
245
  if (cancelled) return;
@@ -164,7 +254,7 @@ export function PdfAnnotationCanvas({
164
254
  return () => {
165
255
  cancelled = true;
166
256
  };
167
- }, [pdfDoc, pageNumber, scale]);
257
+ }, [pdfDoc, pageNumber, scale, fetchResourceAnchored]);
168
258
 
169
259
  // Update display dimensions on resize
170
260
  useEffect(() => {
@@ -318,15 +408,25 @@ export function PdfAnnotationCanvas({
318
408
  // Create FragmentSelector
319
409
  const fragmentSelector = createFragmentSelector(pdfCoord);
320
410
 
411
+ // What the box was drawn around. Without it the annotation is a rectangle
412
+ // with no memory of its own content: the panel entry is blank, search over
413
+ // annotation text misses it, and an export has nothing to print. Empty on a
414
+ // scanned page or over an image — emit no quote at all rather than an empty
415
+ // one, which would assert the box was drawn around nothing.
416
+ const quoted = pageAnchored ? textUnder(pageAnchored, pdfCoord) : '';
417
+
321
418
  // Emit annotation:requested event with FragmentSelector
322
419
  if (selectedMotivation) {
323
420
  session.client.mark.request(
324
421
  toResourceId(resourceUri),
325
- {
326
- type: 'FragmentSelector',
327
- conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
328
- value: fragmentSelector,
329
- },
422
+ [
423
+ {
424
+ type: 'FragmentSelector',
425
+ conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
426
+ value: fragmentSelector,
427
+ },
428
+ ...(quoted ? [{ type: 'TextQuoteSelector' as const, exact: quoted }] : []),
429
+ ],
330
430
  selectedMotivation,
331
431
  );
332
432
  }
@@ -336,7 +436,7 @@ export function PdfAnnotationCanvas({
336
436
  setIsDrawing(false);
337
437
  // Note: We keep selection so the preview remains visible
338
438
  // It will be cleared when drawingMode changes or user starts new selection
339
- }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri]);
439
+ }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri, pageAnchored]);
340
440
 
341
441
  // Every FragmentSelector rect on the current page — one per line for a
342
442
  // multi-line (multi-selector) annotation, exactly one for a manual annotation.