@semiont/react-ui 0.5.24 → 0.5.26

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 (179) hide show
  1. package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js +677 -0
  2. package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js.map +1 -0
  3. package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css +180 -0
  4. package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css.map +1 -0
  5. package/dist/{ar-JRTKCMTF.js → ar-YTUUGK2O.js} +32 -6
  6. package/dist/ar-YTUUGK2O.js.map +1 -0
  7. package/dist/{bn-LO6EDSCC.js → bn-ASGNGPMD.js} +32 -6
  8. package/dist/bn-ASGNGPMD.js.map +1 -0
  9. package/dist/{chunk-4QMTEPQ3.js → chunk-BTUG3VJJ.js} +18 -298
  10. package/dist/chunk-BTUG3VJJ.js.map +1 -0
  11. package/dist/{chunk-5Y5JDCKC.js → chunk-L3WKRWP3.js} +32 -6
  12. package/dist/chunk-L3WKRWP3.js.map +1 -0
  13. package/dist/chunk-OBQOZCJE.js +287 -0
  14. package/dist/chunk-OBQOZCJE.js.map +1 -0
  15. package/dist/{cs-LCHHOR4O.js → cs-WMYVAOZY.js} +32 -6
  16. package/dist/cs-WMYVAOZY.js.map +1 -0
  17. package/dist/{da-TARGPMJR.js → da-FSHHN4D6.js} +32 -6
  18. package/dist/da-FSHHN4D6.js.map +1 -0
  19. package/dist/{de-SBH4QBPJ.js → de-FJFO3TPT.js} +32 -6
  20. package/dist/de-FJFO3TPT.js.map +1 -0
  21. package/dist/{el-2D2OLGWB.js → el-XOH4QIAG.js} +32 -6
  22. package/dist/el-XOH4QIAG.js.map +1 -0
  23. package/dist/{en-QFIEG3CA.js → en-3FCBW3YD.js} +6 -2
  24. package/dist/{es-U6YK2LX6.js → es-HDX4QI7E.js} +32 -6
  25. package/dist/es-HDX4QI7E.js.map +1 -0
  26. package/dist/{fa-S7SZE37M.js → fa-DJ6WTFTJ.js} +32 -6
  27. package/dist/fa-DJ6WTFTJ.js.map +1 -0
  28. package/dist/{fi-CBL3NIXN.js → fi-4S7H7W7U.js} +32 -6
  29. package/dist/fi-4S7H7W7U.js.map +1 -0
  30. package/dist/{fr-EABUHV6S.js → fr-6UJSYHXC.js} +32 -6
  31. package/dist/fr-6UJSYHXC.js.map +1 -0
  32. package/dist/{he-THWD244Y.js → he-ZZJOHJX2.js} +32 -6
  33. package/dist/he-ZZJOHJX2.js.map +1 -0
  34. package/dist/{hi-GHGFZJ44.js → hi-HZJTAW7U.js} +32 -6
  35. package/dist/hi-HZJTAW7U.js.map +1 -0
  36. package/dist/{id-POZOUMDW.js → id-E3I6ILEM.js} +32 -6
  37. package/dist/id-E3I6ILEM.js.map +1 -0
  38. package/dist/index.css +140 -94
  39. package/dist/index.css.map +1 -1
  40. package/dist/index.d.ts +85 -24
  41. package/dist/index.js +1782 -1697
  42. package/dist/index.js.map +1 -1
  43. package/dist/{it-KDYJADSH.js → it-ULAPAD7U.js} +32 -6
  44. package/dist/it-ULAPAD7U.js.map +1 -0
  45. package/dist/{ja-Z5Z6ZF3P.js → ja-SAHZQ4CZ.js} +32 -6
  46. package/dist/ja-SAHZQ4CZ.js.map +1 -0
  47. package/dist/{ko-7X3XUXRN.js → ko-DFU2ADQ4.js} +32 -6
  48. package/dist/ko-DFU2ADQ4.js.map +1 -0
  49. package/dist/{ms-BTWEIW43.js → ms-NWL76XNT.js} +32 -6
  50. package/dist/ms-NWL76XNT.js.map +1 -0
  51. package/dist/{nl-IS2CGF5I.js → nl-ATCM76WN.js} +32 -6
  52. package/dist/nl-ATCM76WN.js.map +1 -0
  53. package/dist/{no-AAMEVA26.js → no-UIAQ5RJF.js} +32 -6
  54. package/dist/no-UIAQ5RJF.js.map +1 -0
  55. package/dist/{pl-PVXUQYFT.js → pl-NOECQDXD.js} +32 -6
  56. package/dist/pl-NOECQDXD.js.map +1 -0
  57. package/dist/{pt-3ITQ76TL.js → pt-SVWIIUPC.js} +32 -6
  58. package/dist/pt-SVWIIUPC.js.map +1 -0
  59. package/dist/{ro-FRB4IIVS.js → ro-NR2DRTZG.js} +32 -6
  60. package/dist/ro-NR2DRTZG.js.map +1 -0
  61. package/dist/{sv-DS3RO24M.js → sv-STVJ37JP.js} +32 -6
  62. package/dist/sv-STVJ37JP.js.map +1 -0
  63. package/dist/test-utils.js +5 -3
  64. package/dist/test-utils.js.map +1 -1
  65. package/dist/{th-AX66MWWB.js → th-UWQR4K54.js} +32 -6
  66. package/dist/th-UWQR4K54.js.map +1 -0
  67. package/dist/{tr-7KTB67TT.js → tr-WNXZSD27.js} +32 -6
  68. package/dist/tr-WNXZSD27.js.map +1 -0
  69. package/dist/{uk-SMQS2MAQ.js → uk-SJRMB47Y.js} +32 -6
  70. package/dist/uk-SJRMB47Y.js.map +1 -0
  71. package/dist/{vi-D3JXL2EZ.js → vi-TMRQFES2.js} +32 -6
  72. package/dist/vi-TMRQFES2.js.map +1 -0
  73. package/dist/{zh-S754AWFS.js → zh-QOL3LYEM.js} +32 -6
  74. package/dist/zh-QOL3LYEM.js.map +1 -0
  75. package/package.json +8 -8
  76. package/src/components/AssistProgress.tsx +42 -30
  77. package/src/components/EntityFoundLog.tsx +38 -0
  78. package/src/components/StatusDisplay.css +35 -35
  79. package/src/components/__tests__/AssistProgress.test.tsx +86 -28
  80. package/src/components/annotation/annotations.css +1 -1
  81. package/src/components/modals/SearchModal.css +5 -5
  82. package/src/components/pdf-annotation/PdfAnnotationCanvas.css +143 -6
  83. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +689 -198
  84. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +1008 -3
  85. package/src/components/pdf-annotation/__tests__/estimate-slot-height.test.ts +53 -0
  86. package/src/components/pdf-annotation/estimate-slot-height.ts +31 -0
  87. package/src/components/resource/AnnotateView.tsx +14 -7
  88. package/src/components/resource/BrowseView.tsx +2 -7
  89. package/src/components/resource/DownloadFileLink.tsx +49 -0
  90. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +58 -0
  91. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +33 -0
  92. package/src/components/resource/__tests__/download-link.test.tsx +129 -0
  93. package/src/components/resource/annotate-renderers.tsx +10 -1
  94. package/src/components/resource/browse-renderers.tsx +10 -1
  95. package/src/components/resource/panels/AssistSection.css +0 -43
  96. package/src/components/resource/panels/AssistSection.tsx +11 -2
  97. package/src/components/resource/panels/AssistShell.tsx +5 -1
  98. package/src/components/resource/panels/ReferencesPanel.tsx +12 -13
  99. package/src/components/resource/panels/TaggingPanel.tsx +21 -1
  100. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +2 -2
  101. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +9 -4
  102. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +1 -1
  103. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +26 -0
  104. package/src/features/auth/auth.css +10 -10
  105. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +58 -3
  106. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +18 -7
  107. package/src/styles/core/inputs.css +1 -1
  108. package/src/styles/features/compose.css +1 -1
  109. package/src/styles/features/resource-viewer.css +1 -1
  110. package/src/styles/features/static-pages.css +11 -11
  111. package/src/styles/motivations/motivation-comment.css +2 -2
  112. package/src/styles/motivations/motivation-reference.css +2 -2
  113. package/src/styles/patterns/panels-base.css +4 -4
  114. package/src/styles/utilities/motion.css +15 -15
  115. package/src/styles/variables.css +29 -1
  116. package/translations/ar.json +27 -5
  117. package/translations/bn.json +27 -5
  118. package/translations/cs.json +27 -5
  119. package/translations/da.json +27 -5
  120. package/translations/de.json +27 -5
  121. package/translations/el.json +27 -5
  122. package/translations/en.json +27 -5
  123. package/translations/es.json +27 -5
  124. package/translations/fa.json +27 -5
  125. package/translations/fi.json +27 -5
  126. package/translations/fr.json +27 -5
  127. package/translations/he.json +27 -5
  128. package/translations/hi.json +27 -5
  129. package/translations/id.json +27 -5
  130. package/translations/it.json +27 -5
  131. package/translations/ja.json +27 -5
  132. package/translations/ko.json +27 -5
  133. package/translations/ms.json +27 -5
  134. package/translations/nl.json +27 -5
  135. package/translations/no.json +27 -5
  136. package/translations/pl.json +27 -5
  137. package/translations/pt.json +27 -5
  138. package/translations/ro.json +27 -5
  139. package/translations/sv.json +27 -5
  140. package/translations/th.json +27 -5
  141. package/translations/tr.json +27 -5
  142. package/translations/uk.json +27 -5
  143. package/translations/vi.json +27 -5
  144. package/translations/zh.json +27 -5
  145. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css +0 -98
  146. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css.map +0 -1
  147. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js +0 -427
  148. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +0 -1
  149. package/dist/ar-JRTKCMTF.js.map +0 -1
  150. package/dist/bn-LO6EDSCC.js.map +0 -1
  151. package/dist/chunk-4QMTEPQ3.js.map +0 -1
  152. package/dist/chunk-5Y5JDCKC.js.map +0 -1
  153. package/dist/cs-LCHHOR4O.js.map +0 -1
  154. package/dist/da-TARGPMJR.js.map +0 -1
  155. package/dist/de-SBH4QBPJ.js.map +0 -1
  156. package/dist/el-2D2OLGWB.js.map +0 -1
  157. package/dist/es-U6YK2LX6.js.map +0 -1
  158. package/dist/fa-S7SZE37M.js.map +0 -1
  159. package/dist/fi-CBL3NIXN.js.map +0 -1
  160. package/dist/fr-EABUHV6S.js.map +0 -1
  161. package/dist/he-THWD244Y.js.map +0 -1
  162. package/dist/hi-GHGFZJ44.js.map +0 -1
  163. package/dist/id-POZOUMDW.js.map +0 -1
  164. package/dist/it-KDYJADSH.js.map +0 -1
  165. package/dist/ja-Z5Z6ZF3P.js.map +0 -1
  166. package/dist/ko-7X3XUXRN.js.map +0 -1
  167. package/dist/ms-BTWEIW43.js.map +0 -1
  168. package/dist/nl-IS2CGF5I.js.map +0 -1
  169. package/dist/no-AAMEVA26.js.map +0 -1
  170. package/dist/pl-PVXUQYFT.js.map +0 -1
  171. package/dist/pt-3ITQ76TL.js.map +0 -1
  172. package/dist/ro-FRB4IIVS.js.map +0 -1
  173. package/dist/sv-DS3RO24M.js.map +0 -1
  174. package/dist/th-AX66MWWB.js.map +0 -1
  175. package/dist/tr-7KTB67TT.js.map +0 -1
  176. package/dist/uk-SMQS2MAQ.js.map +0 -1
  177. package/dist/vi-D3JXL2EZ.js.map +0 -1
  178. package/dist/zh-S754AWFS.js.map +0 -1
  179. /package/dist/{en-QFIEG3CA.js.map → en-3FCBW3YD.js.map} +0 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * PDF-CONTINUOUS-SCROLL S1b.
3
+ *
4
+ * The scroll column reserves space for unmounted pages. If the reservation is
5
+ * wrong, every mount and unmount changes the column's height and the scrollbar
6
+ * jumps — which is what S1 shipped, by reserving the raster height rather than
7
+ * the displayed one.
8
+ *
9
+ * These pins hold the estimate to the CSS the image is actually under:
10
+ * `max-width: 100%; height: auto`.
11
+ */
12
+ import { describe, it, expect } from 'vitest';
13
+ import { estimateSlotHeight } from '../estimate-slot-height';
14
+
15
+ // US Letter at scale 1.5: 918 × 1188 raster, aspect 792/612.
16
+ const ASPECT = 792 / 612;
17
+ const RASTER_W = 918;
18
+
19
+ describe('estimateSlotHeight', () => {
20
+ it('scales to the column when the column is narrower than the raster', () => {
21
+ // The common case: `max-width: 100%` shrinks the image to the column.
22
+ expect(estimateSlotHeight(600, RASTER_W, ASPECT)).toBe(Math.round(600 * ASPECT));
23
+ });
24
+
25
+ it('never upscales past the raster — max-width shrinks only', () => {
26
+ // A column wider than the raster leaves the image at natural size, so the
27
+ // height stops growing. Reserving columnWidth * aspect here would over-
28
+ // reserve and leave a gap under every page.
29
+ expect(estimateSlotHeight(1600, RASTER_W, ASPECT)).toBe(Math.round(RASTER_W * ASPECT));
30
+ });
31
+
32
+ it('is exactly the raster height when the column matches the raster', () => {
33
+ expect(estimateSlotHeight(RASTER_W, RASTER_W, ASPECT)).toBe(Math.round(RASTER_W * ASPECT));
34
+ });
35
+
36
+ it('does NOT use the raster height as a width-independent constant', () => {
37
+ // The S1 defect, stated as a pin: the raster is 1188px tall, but in a
38
+ // 600px column the page occupies ~776px. Reserving 1188 is what made the
39
+ // column's height lurch on every mount.
40
+ const rasterHeight = Math.round(RASTER_W * ASPECT); // 1188
41
+ expect(estimateSlotHeight(600, RASTER_W, ASPECT)).not.toBe(rasterHeight);
42
+ expect(estimateSlotHeight(600, RASTER_W, ASPECT)).toBeLessThan(rasterHeight);
43
+ });
44
+
45
+ it('answers null until every measurement is in, rather than guessing', () => {
46
+ // A wrong reservation is worse than none: an unsized slot is stable, a
47
+ // mis-sized one moves when the page lands.
48
+ expect(estimateSlotHeight(null, RASTER_W, ASPECT)).toBeNull();
49
+ expect(estimateSlotHeight(600, null, ASPECT)).toBeNull();
50
+ expect(estimateSlotHeight(600, RASTER_W, null)).toBeNull();
51
+ expect(estimateSlotHeight(0, RASTER_W, ASPECT)).toBeNull();
52
+ });
53
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The height a page will occupy once rendered, computed BEFORE rendering it.
3
+ *
4
+ * The scrolling column reserves space for pages it has not mounted. If the
5
+ * reserved height differs from the height the page actually takes, every mount
6
+ * and unmount changes the column's total height and the scrollbar jumps under
7
+ * the reader's cursor — the defect S1 shipped by reserving the *raster* height
8
+ * (`getViewport({ scale }).height`) instead of the *displayed* one.
9
+ *
10
+ * This models the CSS the image is under exactly:
11
+ *
12
+ * .semiont-pdf-annotation-canvas__image { max-width: 100%; height: auto; }
13
+ *
14
+ * `max-width` shrinks but never upscales, so the displayed width is
15
+ * `min(columnWidth, rasterWidth)` and the height follows the page's aspect
16
+ * ratio. Aspect is scale-invariant, so it can come from any viewport.
17
+ *
18
+ * @param columnWidth Measured inner width of the scroll column, in CSS px.
19
+ * @param rasterWidth Width of the rendered raster, in px (`viewport(scale).width`).
20
+ * @param aspect Page height ÷ width, from any viewport scale.
21
+ * @returns Reserved height in CSS px, or null when a measurement is not in yet.
22
+ */
23
+ export function estimateSlotHeight(
24
+ columnWidth: number | null,
25
+ rasterWidth: number | null,
26
+ aspect: number | null,
27
+ ): number | null {
28
+ if (!columnWidth || !rasterWidth || !aspect) return null;
29
+ if (columnWidth <= 0 || rasterWidth <= 0 || aspect <= 0) return null;
30
+ return Math.round(Math.min(columnWidth, rasterWidth) * aspect);
31
+ }
@@ -5,9 +5,11 @@ import { capabilitiesOf, resourceId as toResourceId } from '@semiont/core';
5
5
  import { ANNOTATORS } from '../../lib/annotation-registry';
6
6
  import { buildTextSelectors, fallbackTextPosition } from '../../lib/text-selection-handler';
7
7
  import { defaultAnnotateRenderers, type AnnotateMediaRenderers } from './annotate-renderers';
8
+ import { DownloadFileLink } from './DownloadFileLink';
8
9
  import type { EditorView } from '@codemirror/view';
9
10
  import type { SemiontSession } from '@semiont/sdk';
10
11
  import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
12
+ import { scrollAnnotationIntoView } from '../../lib/scroll-utils';
11
13
 
12
14
  // Type augmentation for custom DOM properties
13
15
  interface EnrichedHTMLElement extends HTMLElement {
@@ -51,6 +53,7 @@ interface Props {
51
53
  *
52
54
  * @emits mark:requested - User requested to create annotation. Payload: { selector: Selector | Selector[], motivation: SelectionMotivation }
53
55
  * @subscribes beckon:hover - Annotation hovered. Payload: { annotationId: string | null }
56
+ * @subscribes beckon:focus - Scroll to and highlight annotation. Payload: { annotationId: string }
54
57
  */
55
58
  export function AnnotateView({
56
59
  content,
@@ -104,10 +107,20 @@ export function AnnotateView({
104
107
  onUIStateChangeRef.current?.({ hoveredAnnotationId: annotationId });
105
108
  }, []);
106
109
 
110
+ // "Scroll to and highlight this annotation" — the same contract BrowseView
111
+ // serves, so the behaviour no longer depends on which view mode is active.
112
+ // The `scrollToAnnotationId` prop path (uiState → renderer) stays as the
113
+ // host-facing capability it is; this is the in-app producer's route.
114
+ const handleAnnotationFocus = useCallback(({ annotationId }: { annotationId?: string | null }) => {
115
+ if (!containerRef.current) return;
116
+ scrollAnnotationIntoView(annotationId ?? null, containerRef.current, { pulse: true });
117
+ }, []);
118
+
107
119
  // Annotation hover (session-scoped). Toolbar preference changes flow through
108
120
  // props/callbacks, not the bus (TOOLBAR-PREFS-AS-PROPS).
109
121
  useSessionEventSubscriptions(session, {
110
122
  'beckon:hover': handleAnnotationHover,
123
+ 'beckon:focus': handleAnnotationFocus,
111
124
  });
112
125
 
113
126
  // Handle text annotation with sparkle or immediate creation
@@ -210,13 +223,7 @@ export function AnnotateView({
210
223
  <p className="semiont-annotate-view__empty-message">
211
224
  Annotation not supported for {mimeType}
212
225
  </p>
213
- <a
214
- href={`/api/resources/${resourceUri}`}
215
- download
216
- className="semiont-button semiont-button--primary"
217
- >
218
- Download File
219
- </a>
226
+ <DownloadFileLink resourceUri={resourceUri} session={session} />
220
227
  </div>
221
228
  </div>
222
229
  );
@@ -21,6 +21,7 @@ import {
21
21
  import type { SemiontSession } from '@semiont/sdk';
22
22
  import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
23
23
  import { defaultBrowseRenderers, type BrowseMediaRenderers } from './browse-renderers';
24
+ import { DownloadFileLink } from './DownloadFileLink';
24
25
 
25
26
  interface Props {
26
27
  content: string;
@@ -295,13 +296,7 @@ export const BrowseView = memo(function BrowseView({
295
296
  <p className="semiont-browse-view__empty-message">
296
297
  Preview not available for {mimeType}
297
298
  </p>
298
- <a
299
- href={`/api/resources/${resourceUri}`}
300
- download
301
- className="semiont-button semiont-button--primary"
302
- >
303
- Download File
304
- </a>
299
+ <DownloadFileLink resourceUri={resourceUri} session={session} />
305
300
  </div>
306
301
  </div>
307
302
  );
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { resourceId as toResourceId } from '@semiont/core';
4
+ import type { SemiontSession } from '@semiont/sdk';
5
+ import { useMediaToken } from '../../hooks/useMediaToken';
6
+ import { mediaUrl } from '../../lib/media-url';
7
+
8
+ interface Props {
9
+ /** The '@id' of the resource whose bytes to offer. */
10
+ resourceUri: string;
11
+ /** Session for that resource — its client mints the media token and supplies the backend origin. */
12
+ session: SemiontSession | null;
13
+ }
14
+
15
+ /**
16
+ * The "Download File" affordance on the unsupported-media fallback, shared by
17
+ * the browse and annotate views.
18
+ *
19
+ * Its own component rather than inline markup for two reasons. The token is a
20
+ * hook, and this fallback is a branch — mounting the component only in that
21
+ * branch keeps every text/image/PDF view from minting a media token it will
22
+ * never use. And the URL rule (absolute + `?token=`) lives in `mediaUrl`,
23
+ * where both views and the viewer page read the same one.
24
+ *
25
+ * Before the token lands the anchor renders as a disabled button: a tokenless
26
+ * URL is a guaranteed 401, and a link that silently fails is worse than one
27
+ * visibly not ready yet. A client with no `auth` namespace (a host on a bare
28
+ * transport) stays in that state permanently, which is the honest answer —
29
+ * it cannot mint tokens at all.
30
+ */
31
+ export function DownloadFileLink({ resourceUri, session }: Props) {
32
+ const client = session?.client ?? null;
33
+ const { token } = useMediaToken(client, toResourceId(resourceUri));
34
+ const href = mediaUrl(client, toResourceId(resourceUri), token);
35
+
36
+ if (!href) {
37
+ return (
38
+ <button type="button" disabled className="semiont-button semiont-button--primary semiont-button--disabled">
39
+ Download File
40
+ </button>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <a href={href} download className="semiont-button semiont-button--primary">
46
+ Download File
47
+ </a>
48
+ );
49
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ASSIST-SURFACE-WARTS Lane D — in-content scroll on `beckon:focus`.
3
+ *
4
+ * `beckon:focus` is the established "scroll to and highlight this annotation"
5
+ * contract; BrowseView has subscribed to it all along. AnnotateView did not,
6
+ * so the same event scrolled the content in browse mode and did nothing in
7
+ * annotate mode. With the history panel now producing the event, that asymmetry
8
+ * becomes user-visible: the same click works or doesn't depending on the mode.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { render } from '@testing-library/react';
12
+ import '@testing-library/jest-dom';
13
+ import type { AnnotationUIState } from '../../../types/annotation-props';
14
+ import { createTestSemiontWrapper } from '../../../test-utils';
15
+
16
+ const scrollSpy = vi.hoisted(() => vi.fn());
17
+ vi.mock('../../../lib/scroll-utils', () => ({
18
+ scrollAnnotationIntoView: scrollSpy,
19
+ }));
20
+ vi.mock('../../CodeMirrorRenderer', () => ({
21
+ CodeMirrorRenderer: () => <div data-annotation-id="ann-7">cm-mock</div>,
22
+ }));
23
+
24
+ import { AnnotateView } from '../AnnotateView';
25
+
26
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
27
+ const uiState: AnnotationUIState = {
28
+ selectedMotivation: 'linking',
29
+ selectedClick: 'detail',
30
+ selectedShape: 'rectangle',
31
+ hoveredAnnotationId: null,
32
+ scrollToAnnotationId: null,
33
+ };
34
+
35
+ describe('AnnotateView — beckon:focus scrolls the content', () => {
36
+ beforeEach(() => { scrollSpy.mockClear(); });
37
+
38
+ it('scrolls to the annotation when the session emits beckon:focus', () => {
39
+ const { session, client } = createTestSemiontWrapper();
40
+
41
+ render(
42
+ <AnnotateView
43
+ content="hello world"
44
+ mimeType="text/plain"
45
+ resourceUri="res-1"
46
+ annotations={emptyAnnotations}
47
+ uiState={uiState}
48
+ session={session}
49
+ annotateMode
50
+ />,
51
+ );
52
+
53
+ client.bus.get('beckon:focus').next({ annotationId: 'ann-7' });
54
+
55
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
56
+ expect(scrollSpy.mock.calls[0]?.[0]).toBe('ann-7');
57
+ });
58
+ });
@@ -109,5 +109,38 @@ describe('browse-renderers — annotation + session forwarding (dispatch contrac
109
109
  expect(props.existingAnnotations).toEqual([shapeAnnotation()]); // pinned — worked before
110
110
  expect(props.drawingMode).toBeNull();
111
111
  expect(props.session).toBe(session); // NEW — was a session-less no-op
112
+ // PDF-CONTINUOUS-SCROLL S1: browse asks for the scrolling column.
113
+ expect(props.pageLayout).toBe('scroll');
114
+ });
115
+
116
+ // S2. Annotate is the mode people actually work in
117
+ // (.plans/PDF-CONTINUOUS-SCROLL.md D3, second correction), so it gets the
118
+ // column too — leaving it on Previous/Next made the feature invisible to
119
+ // its primary audience.
120
+ it('the annotate registry asks for the column as well, with the live tool', async () => {
121
+ const session = fakeSession();
122
+ const { AnnotateView } = await import('../AnnotateView');
123
+ const { container } = render(
124
+ <AnnotateView
125
+ content="blob:pdf-url"
126
+ mimeType="application/pdf"
127
+ resourceUri="res-1"
128
+ annotations={emptyAnnotations}
129
+ uiState={{
130
+ selectedMotivation: 'highlighting',
131
+ selectedClick: 'detail',
132
+ selectedShape: 'rectangle',
133
+ hoveredAnnotationId: null,
134
+ scrollToAnnotationId: null,
135
+ }}
136
+ annotateMode
137
+ session={session}
138
+ />,
139
+ );
140
+
141
+ await within(container).findByText('pdf-canvas-mock');
142
+ const props = captured.pdf.at(-1)!;
143
+ expect(props.pageLayout).toBe('scroll');
144
+ expect(props.drawingMode).toBe('rectangle'); // the tool still reaches the pages
112
145
  });
113
146
  });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The "Download File" affordance on the unsupported-media fallback, in BOTH
3
+ * resource views.
4
+ *
5
+ * `GET /api/resources/:id` is the browser-facing alias of the pipe, and it
6
+ * exists only as an auth affordance for `<img>`, PDF.js and download links: a
7
+ * plain `<a download href>` sends neither an Authorization header nor a cookie,
8
+ * so the alias takes bearer + `?token=` only. Both views shipped a bare
9
+ * `/api/resources/${id}` — tokenless, so a 401, AND relative, so under
10
+ * bring-your-own-session embedding it resolves against the HOST app's origin
11
+ * rather than the backend's.
12
+ *
13
+ * Started RED (both hrefs were `/api/resources/res-1`).
14
+ */
15
+ import { describe, it, expect, vi } from 'vitest';
16
+ import { render, screen, waitFor } from '@testing-library/react';
17
+ import '@testing-library/jest-dom';
18
+ import type { SemiontSession } from '@semiont/sdk';
19
+ import type { AnnotationUIState } from '../../../types/annotation-props';
20
+ import { AnnotateView } from '../AnnotateView';
21
+ import { BrowseView } from '../BrowseView';
22
+
23
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
24
+ vi.mock('../../CodeMirrorRenderer', () => ({ CodeMirrorRenderer: () => null }));
25
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({ SvgDrawingCanvas: () => null }));
26
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
27
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
28
+
29
+ const BACKEND = 'http://backend.test:4000';
30
+ const UNSUPPORTED = 'application/octet-stream';
31
+
32
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
33
+ const uiState: AnnotationUIState = {
34
+ selectedMotivation: null,
35
+ selectedClick: 'detail',
36
+ selectedShape: 'rectangle',
37
+ hoveredAnnotationId: null,
38
+ scrollToAnnotationId: null,
39
+ };
40
+
41
+ /** A session whose client mints `token` — the batteries-included case. */
42
+ function sessionMinting(token: string): SemiontSession {
43
+ return fakeSession({ auth: { mediaToken: vi.fn(async () => ({ token })) } });
44
+ }
45
+
46
+ /** A session whose token request never settles — the in-flight case. */
47
+ function sessionPending(): SemiontSession {
48
+ return fakeSession({ auth: { mediaToken: vi.fn(() => new Promise<never>(() => {})) } });
49
+ }
50
+
51
+ /**
52
+ * A transport-only client: `SemiontClient.auth` is `AuthNamespace | undefined`,
53
+ * and a host wiring a bare transport (no `IBackendOperations`) has no `auth`.
54
+ */
55
+ function sessionWithoutAuth(): SemiontSession {
56
+ return fakeSession({});
57
+ }
58
+
59
+ function fakeSession(clientExtras: Record<string, unknown>): SemiontSession {
60
+ return {
61
+ client: {
62
+ baseUrl: BACKEND,
63
+ mark: { request: vi.fn() },
64
+ browse: { click: vi.fn() },
65
+ beckon: { hover: vi.fn() },
66
+ ...clientExtras,
67
+ },
68
+ subscribe: () => () => {},
69
+ } as unknown as SemiontSession;
70
+ }
71
+
72
+ const annotateProps = (session: SemiontSession) => ({
73
+ content: '',
74
+ mimeType: UNSUPPORTED,
75
+ resourceUri: 'res-1',
76
+ annotations: emptyAnnotations,
77
+ uiState,
78
+ annotateMode: false,
79
+ session,
80
+ });
81
+
82
+ const browseProps = (session: SemiontSession) => ({
83
+ content: '',
84
+ mimeType: UNSUPPORTED,
85
+ resourceUri: 'res-1',
86
+ annotations: emptyAnnotations,
87
+ annotateMode: false,
88
+ session,
89
+ });
90
+
91
+ const VIEWS = [
92
+ { name: 'AnnotateView', render: (s: SemiontSession) => render(<AnnotateView {...annotateProps(s)} />) },
93
+ { name: 'BrowseView', render: (s: SemiontSession) => render(<BrowseView {...browseProps(s)} />) },
94
+ ] as const;
95
+
96
+ describe.each(VIEWS)('$name — unsupported-media download link', ({ render: renderView }) => {
97
+ it('points at the backend origin and carries the media token', async () => {
98
+ renderView(sessionMinting('tok-abc'));
99
+
100
+ const link = await screen.findByRole('link', { name: 'Download File' });
101
+ expect(link).toHaveAttribute('href', `${BACKEND}/api/resources/res-1?token=tok-abc`);
102
+ expect(link).toHaveAttribute('download');
103
+ });
104
+
105
+ it('renders no href while the token is still in flight', async () => {
106
+ renderView(sessionPending());
107
+
108
+ // The affordance stays on screen — it is just not yet actionable. What it
109
+ // must NOT do is offer a URL already known to fail.
110
+ expect(await screen.findByText('Download File')).toBeInTheDocument();
111
+ expect(screen.queryByRole('link', { name: 'Download File' })).not.toBeInTheDocument();
112
+ });
113
+
114
+ it('renders no href when the client has no auth namespace', async () => {
115
+ renderView(sessionWithoutAuth());
116
+
117
+ expect(await screen.findByText('Download File')).toBeInTheDocument();
118
+ await waitFor(() => {
119
+ expect(screen.queryByRole('link', { name: 'Download File' })).not.toBeInTheDocument();
120
+ });
121
+ });
122
+
123
+ it('never builds a host-relative URL', async () => {
124
+ renderView(sessionMinting('tok-abc'));
125
+
126
+ const link = await screen.findByRole('link', { name: 'Download File' });
127
+ expect(link.getAttribute('href')).not.toMatch(/^\//);
128
+ });
129
+ });
@@ -7,10 +7,18 @@ import { SvgDrawingCanvas } from '../image-annotation/SvgDrawingCanvas';
7
7
  import { CodeMirrorRenderer } from '../CodeMirrorRenderer';
8
8
  import { segmentTextWithAnnotations } from '../../lib/text-segmentation';
9
9
  import type { SelectionMotivation, ShapeType } from '../annotation/AnnotateToolbar';
10
+ import { useTranslations } from '../../contexts/TranslationContext';
10
11
 
11
12
  // Lazy-load the PDF component to avoid SSR issues with browser PDF.js loading.
12
13
  // Kept lazy here deliberately: hoisting it to a static import would make it
13
14
  // eager again and reintroduce the SSR problem this indirection exists for.
15
+ /** The lazy chunk's fallback needs its own component: a hook cannot run
16
+ * inside a `fallback` prop expression. */
17
+ function PdfViewerLoading() {
18
+ const t = useTranslations('PdfViewer');
19
+ return <>{t('viewerLoading')}</>;
20
+ }
21
+
14
22
  const PdfAnnotationCanvas = lazy(() =>
15
23
  import('../pdf-annotation/PdfAnnotationCanvas.client').then((mod) => ({ default: mod.PdfAnnotationCanvas })),
16
24
  );
@@ -127,7 +135,7 @@ export function PdfAnnotateRenderer({
127
135
  }: AnnotateMediaRendererProps) {
128
136
  if (!content) return null;
129
137
  return (
130
- <Suspense fallback={<div className="semiont-annotate-view__loading">Loading PDF viewer...</div>}>
138
+ <Suspense fallback={<div className="semiont-annotate-view__loading"><PdfViewerLoading /></div>}>
131
139
  <PdfAnnotationCanvas
132
140
  pdfUrl={content}
133
141
  resourceUri={resourceUri}
@@ -137,6 +145,7 @@ export function PdfAnnotateRenderer({
137
145
  session={session}
138
146
  hoveredAnnotationId={hoveredAnnotationId || null}
139
147
  hoverDelayMs={hoverDelayMs}
148
+ pageLayout="scroll"
140
149
  />
141
150
  </Suspense>
142
151
  );
@@ -6,8 +6,16 @@ import remarkGfm from 'remark-gfm';
6
6
  import type { Annotation } from '@semiont/core';
7
7
  import type { SemiontSession } from '@semiont/sdk';
8
8
  import { SvgDrawingCanvas } from '../image-annotation/SvgDrawingCanvas';
9
+ import { useTranslations } from '../../contexts/TranslationContext';
9
10
 
10
11
  // Lazy-load the PDF component to avoid SSR issues with browser PDF.js loading.
12
+ /** The lazy chunk's fallback needs its own component: a hook cannot run
13
+ * inside a `fallback` prop expression. */
14
+ function PdfViewerLoading() {
15
+ const t = useTranslations('PdfViewer');
16
+ return <>{t('viewerLoading')}</>;
17
+ }
18
+
11
19
  const PdfAnnotationCanvas = lazy(() => import('../pdf-annotation/PdfAnnotationCanvas.client').then(mod => ({ default: mod.PdfAnnotationCanvas })));
12
20
 
13
21
  /**
@@ -60,7 +68,7 @@ export function ImageBrowseRenderer({ content, resourceUri, annotations, session
60
68
 
61
69
  export function PdfBrowseRenderer({ content, resourceUri, annotations, session }: MediaRendererProps) {
62
70
  return (
63
- <Suspense fallback={<div className="semiont-browse-view__loading">Loading PDF viewer...</div>}>
71
+ <Suspense fallback={<div className="semiont-browse-view__loading"><PdfViewerLoading /></div>}>
64
72
  <PdfAnnotationCanvas
65
73
  pdfUrl={content}
66
74
  resourceUri={resourceUri}
@@ -68,6 +76,7 @@ export function PdfBrowseRenderer({ content, resourceUri, annotations, session }
68
76
  drawingMode={null}
69
77
  selectedMotivation={null}
70
78
  session={session}
79
+ pageLayout="scroll"
71
80
  />
72
81
  </Suspense>
73
82
  );
@@ -126,49 +126,6 @@
126
126
  Detection Log (Completed Results)
127
127
  ============================================ */
128
128
 
129
- .semiont-assist-widget__log {
130
- background-color: var(--semiont-bg-primary);
131
- border-radius: 0.5rem;
132
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
133
- padding: 1rem;
134
- display: flex;
135
- flex-direction: column;
136
- gap: 0.75rem;
137
- }
138
-
139
- [data-theme="dark"] .semiont-assist-widget__log {
140
- background-color: var(--semiont-color-gray-800);
141
- }
142
-
143
- .semiont-assist-widget__log-items {
144
- display: flex;
145
- flex-direction: column;
146
- gap: 0.25rem;
147
- }
148
-
149
- .semiont-assist-widget__log-item {
150
- font-size: 0.75rem;
151
- color: var(--semiont-color-gray-600);
152
- display: flex;
153
- align-items: center;
154
- gap: 0.5rem;
155
- }
156
-
157
- [data-theme="dark"] .semiont-assist-widget__log-item {
158
- color: var(--semiont-color-gray-400);
159
- }
160
-
161
- .semiont-assist-widget__log-check {
162
- color: var(--semiont-color-green-600);
163
- }
164
-
165
- [data-theme="dark"] .semiont-assist-widget__log-check {
166
- color: var(--semiont-color-green-400);
167
- }
168
-
169
- .semiont-assist-widget__log-type {
170
- font-weight: 500;
171
- }
172
129
 
173
130
  /* ============================================
174
131
  Detection Progress Display
@@ -43,6 +43,7 @@ export function AssistSection({
43
43
  annotationType === 'assessment' ? 'AssessmentPanel' :
44
44
  'CommentsPanel';
45
45
  const t = useTranslations(panelName);
46
+ const ta = useTranslations('AssistProgress');
46
47
  const [instructions, setInstructions] = useState('');
47
48
  type ToneValue = 'scholarly' | 'explanatory' | 'conversational' | 'technical' | 'analytical' | 'critical' | 'balanced' | 'constructive' | '';
48
49
  const [tone, setTone] = useState<ToneValue>('');
@@ -89,7 +90,15 @@ export function AssistSection({
89
90
  progress={progress}
90
91
  progressProps={{
91
92
  onDismiss: handleDismissProgress,
92
- translations: { close: t('closeProgress') },
93
+ translations: {
94
+ cancel: t('cancel'),
95
+ inProgress: t('annotating'),
96
+ complete: t('complete'),
97
+ failed: t('failed'),
98
+ paramsTitle: ta('paramsTitle'),
99
+ processing: (label) => ta('processing', { label }),
100
+ close: ta('close'),
101
+ },
93
102
  }}
94
103
  form={
95
104
  <>
@@ -157,7 +166,7 @@ export function AssistSection({
157
166
  <span>{t('densityLabel')}</span>
158
167
  </label>
159
168
  {useDensity && (
160
- <span className="semiont-form-field__info">{density} per 2000 words</span>
169
+ <span className="semiont-form-field__info">{t('densityPerWords', { density })}</span>
161
170
  )}
162
171
  </div>
163
172
 
@@ -19,8 +19,12 @@ export interface AssistShellProps {
19
19
  * Pass-through config for the progress renderer (cancel/dismiss wiring,
20
20
  * translations, percent bar). Dismiss policy lives HERE: the shell forwards
21
21
  * `onDismiss` only once the assist is no longer running.
22
+ *
23
+ * Required because `translations` is: the shell renders the progress display
24
+ * itself, so a caller that omitted this would render untranslated chrome —
25
+ * the failure Lane A exists to make impossible.
22
26
  */
23
- progressProps?: Omit<AssistProgressProps, 'progress' | 'dataType'>;
27
+ progressProps: Omit<AssistProgressProps, 'progress' | 'dataType'>;
24
28
  }
25
29
 
26
30
  /**
@@ -6,6 +6,7 @@ import type { SemiontSession } from '@semiont/sdk';
6
6
  import { useSessionEventSubscriptions } from '../../../hooks/useSessionEventSubscriptions';
7
7
  import type { RouteBuilder, LinkComponentProps } from '../../../contexts/RoutingContext';
8
8
  import { AssistShell } from './AssistShell';
9
+ import { EntityFoundLog } from '../../EntityFoundLog';
9
10
  import { ReferenceEntry } from './ReferenceEntry';
10
11
  import type { components, Selector } from '@semiont/core';
11
12
  import { getTextPositionSelector, getTargetSelector } from '@semiont/core';
@@ -121,6 +122,9 @@ export function ReferencesPanel({
121
122
  sourceLanguage,
122
123
  }: Props) {
123
124
  const t = useTranslations('ReferencesPanel');
125
+ // Chrome shared by every assist surface (heading, current-work line, dismiss)
126
+ // lives in one namespace so the same words aren't re-translated per panel.
127
+ const ta = useTranslations('AssistProgress');
124
128
  const [selectedEntityTypes, setSelectedEntityTypes] = useState<string[]>([]);
125
129
  const [lastAnnotationLog, setLastDetectionLog] = useState<Array<{ entityType: string; foundCount: number }> | null>(null);
126
130
  const [pendingEntityTypes, setPendingEntityTypes] = useState<string[]>([]);
@@ -375,24 +379,19 @@ export function ReferencesPanel({
375
379
  failed: t('failed'),
376
380
  found: (count) => t('found', { count }),
377
381
  current: (entityType) => t('current', { entityType }),
378
- close: t('closeProgress'),
382
+ paramsTitle: ta('paramsTitle'),
383
+ processing: (label) => ta('processing', { label }),
384
+ close: ta('close'),
379
385
  },
380
386
  }}
381
387
  form={
382
388
  <>
383
389
  {/* Completed annotation log - shown after completion */}
384
- {lastAnnotationLog && lastAnnotationLog.length > 0 && (
385
- <div className="semiont-assist-widget__log">
386
- <div className="semiont-assist-widget__log-items">
387
- {lastAnnotationLog.map((item, index) => (
388
- <div key={index} className="semiont-assist-widget__log-item">
389
- <span className="semiont-assist-widget__log-check">✓</span>
390
- <span className="semiont-assist-widget__log-type">{item.entityType}:</span>
391
- <span>{t('found', { count: item.foundCount })}</span>
392
- </div>
393
- ))}
394
- </div>
395
- </div>
390
+ {lastAnnotationLog && (
391
+ <EntityFoundLog
392
+ entries={lastAnnotationLog}
393
+ formatFound={(count) => t('found', { count })}
394
+ />
396
395
  )}
397
396
 
398
397
  {/* Entity Types Selection */}