@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.
- package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js +677 -0
- package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js.map +1 -0
- package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css +180 -0
- package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css.map +1 -0
- package/dist/{ar-JRTKCMTF.js → ar-YTUUGK2O.js} +32 -6
- package/dist/ar-YTUUGK2O.js.map +1 -0
- package/dist/{bn-LO6EDSCC.js → bn-ASGNGPMD.js} +32 -6
- package/dist/bn-ASGNGPMD.js.map +1 -0
- package/dist/{chunk-4QMTEPQ3.js → chunk-BTUG3VJJ.js} +18 -298
- package/dist/chunk-BTUG3VJJ.js.map +1 -0
- package/dist/{chunk-5Y5JDCKC.js → chunk-L3WKRWP3.js} +32 -6
- package/dist/chunk-L3WKRWP3.js.map +1 -0
- package/dist/chunk-OBQOZCJE.js +287 -0
- package/dist/chunk-OBQOZCJE.js.map +1 -0
- package/dist/{cs-LCHHOR4O.js → cs-WMYVAOZY.js} +32 -6
- package/dist/cs-WMYVAOZY.js.map +1 -0
- package/dist/{da-TARGPMJR.js → da-FSHHN4D6.js} +32 -6
- package/dist/da-FSHHN4D6.js.map +1 -0
- package/dist/{de-SBH4QBPJ.js → de-FJFO3TPT.js} +32 -6
- package/dist/de-FJFO3TPT.js.map +1 -0
- package/dist/{el-2D2OLGWB.js → el-XOH4QIAG.js} +32 -6
- package/dist/el-XOH4QIAG.js.map +1 -0
- package/dist/{en-QFIEG3CA.js → en-3FCBW3YD.js} +6 -2
- package/dist/{es-U6YK2LX6.js → es-HDX4QI7E.js} +32 -6
- package/dist/es-HDX4QI7E.js.map +1 -0
- package/dist/{fa-S7SZE37M.js → fa-DJ6WTFTJ.js} +32 -6
- package/dist/fa-DJ6WTFTJ.js.map +1 -0
- package/dist/{fi-CBL3NIXN.js → fi-4S7H7W7U.js} +32 -6
- package/dist/fi-4S7H7W7U.js.map +1 -0
- package/dist/{fr-EABUHV6S.js → fr-6UJSYHXC.js} +32 -6
- package/dist/fr-6UJSYHXC.js.map +1 -0
- package/dist/{he-THWD244Y.js → he-ZZJOHJX2.js} +32 -6
- package/dist/he-ZZJOHJX2.js.map +1 -0
- package/dist/{hi-GHGFZJ44.js → hi-HZJTAW7U.js} +32 -6
- package/dist/hi-HZJTAW7U.js.map +1 -0
- package/dist/{id-POZOUMDW.js → id-E3I6ILEM.js} +32 -6
- package/dist/id-E3I6ILEM.js.map +1 -0
- package/dist/index.css +140 -94
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +85 -24
- package/dist/index.js +1782 -1697
- package/dist/index.js.map +1 -1
- package/dist/{it-KDYJADSH.js → it-ULAPAD7U.js} +32 -6
- package/dist/it-ULAPAD7U.js.map +1 -0
- package/dist/{ja-Z5Z6ZF3P.js → ja-SAHZQ4CZ.js} +32 -6
- package/dist/ja-SAHZQ4CZ.js.map +1 -0
- package/dist/{ko-7X3XUXRN.js → ko-DFU2ADQ4.js} +32 -6
- package/dist/ko-DFU2ADQ4.js.map +1 -0
- package/dist/{ms-BTWEIW43.js → ms-NWL76XNT.js} +32 -6
- package/dist/ms-NWL76XNT.js.map +1 -0
- package/dist/{nl-IS2CGF5I.js → nl-ATCM76WN.js} +32 -6
- package/dist/nl-ATCM76WN.js.map +1 -0
- package/dist/{no-AAMEVA26.js → no-UIAQ5RJF.js} +32 -6
- package/dist/no-UIAQ5RJF.js.map +1 -0
- package/dist/{pl-PVXUQYFT.js → pl-NOECQDXD.js} +32 -6
- package/dist/pl-NOECQDXD.js.map +1 -0
- package/dist/{pt-3ITQ76TL.js → pt-SVWIIUPC.js} +32 -6
- package/dist/pt-SVWIIUPC.js.map +1 -0
- package/dist/{ro-FRB4IIVS.js → ro-NR2DRTZG.js} +32 -6
- package/dist/ro-NR2DRTZG.js.map +1 -0
- package/dist/{sv-DS3RO24M.js → sv-STVJ37JP.js} +32 -6
- package/dist/sv-STVJ37JP.js.map +1 -0
- package/dist/test-utils.js +5 -3
- package/dist/test-utils.js.map +1 -1
- package/dist/{th-AX66MWWB.js → th-UWQR4K54.js} +32 -6
- package/dist/th-UWQR4K54.js.map +1 -0
- package/dist/{tr-7KTB67TT.js → tr-WNXZSD27.js} +32 -6
- package/dist/tr-WNXZSD27.js.map +1 -0
- package/dist/{uk-SMQS2MAQ.js → uk-SJRMB47Y.js} +32 -6
- package/dist/uk-SJRMB47Y.js.map +1 -0
- package/dist/{vi-D3JXL2EZ.js → vi-TMRQFES2.js} +32 -6
- package/dist/vi-TMRQFES2.js.map +1 -0
- package/dist/{zh-S754AWFS.js → zh-QOL3LYEM.js} +32 -6
- package/dist/zh-QOL3LYEM.js.map +1 -0
- package/package.json +8 -8
- package/src/components/AssistProgress.tsx +42 -30
- package/src/components/EntityFoundLog.tsx +38 -0
- package/src/components/StatusDisplay.css +35 -35
- package/src/components/__tests__/AssistProgress.test.tsx +86 -28
- package/src/components/annotation/annotations.css +1 -1
- package/src/components/modals/SearchModal.css +5 -5
- package/src/components/pdf-annotation/PdfAnnotationCanvas.css +143 -6
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +689 -198
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +1008 -3
- package/src/components/pdf-annotation/__tests__/estimate-slot-height.test.ts +53 -0
- package/src/components/pdf-annotation/estimate-slot-height.ts +31 -0
- package/src/components/resource/AnnotateView.tsx +14 -7
- package/src/components/resource/BrowseView.tsx +2 -7
- package/src/components/resource/DownloadFileLink.tsx +49 -0
- package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +58 -0
- package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +33 -0
- package/src/components/resource/__tests__/download-link.test.tsx +129 -0
- package/src/components/resource/annotate-renderers.tsx +10 -1
- package/src/components/resource/browse-renderers.tsx +10 -1
- package/src/components/resource/panels/AssistSection.css +0 -43
- package/src/components/resource/panels/AssistSection.tsx +11 -2
- package/src/components/resource/panels/AssistShell.tsx +5 -1
- package/src/components/resource/panels/ReferencesPanel.tsx +12 -13
- package/src/components/resource/panels/TaggingPanel.tsx +21 -1
- package/src/components/resource/panels/__tests__/AssistSection.test.tsx +2 -2
- package/src/components/resource/panels/__tests__/AssistShell.test.tsx +9 -4
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +1 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +26 -0
- package/src/features/auth/auth.css +10 -10
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +58 -3
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +18 -7
- package/src/styles/core/inputs.css +1 -1
- package/src/styles/features/compose.css +1 -1
- package/src/styles/features/resource-viewer.css +1 -1
- package/src/styles/features/static-pages.css +11 -11
- package/src/styles/motivations/motivation-comment.css +2 -2
- package/src/styles/motivations/motivation-reference.css +2 -2
- package/src/styles/patterns/panels-base.css +4 -4
- package/src/styles/utilities/motion.css +15 -15
- package/src/styles/variables.css +29 -1
- package/translations/ar.json +27 -5
- package/translations/bn.json +27 -5
- package/translations/cs.json +27 -5
- package/translations/da.json +27 -5
- package/translations/de.json +27 -5
- package/translations/el.json +27 -5
- package/translations/en.json +27 -5
- package/translations/es.json +27 -5
- package/translations/fa.json +27 -5
- package/translations/fi.json +27 -5
- package/translations/fr.json +27 -5
- package/translations/he.json +27 -5
- package/translations/hi.json +27 -5
- package/translations/id.json +27 -5
- package/translations/it.json +27 -5
- package/translations/ja.json +27 -5
- package/translations/ko.json +27 -5
- package/translations/ms.json +27 -5
- package/translations/nl.json +27 -5
- package/translations/no.json +27 -5
- package/translations/pl.json +27 -5
- package/translations/pt.json +27 -5
- package/translations/ro.json +27 -5
- package/translations/sv.json +27 -5
- package/translations/th.json +27 -5
- package/translations/tr.json +27 -5
- package/translations/uk.json +27 -5
- package/translations/vi.json +27 -5
- package/translations/zh.json +27 -5
- package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css +0 -98
- package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css.map +0 -1
- package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js +0 -427
- package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +0 -1
- package/dist/ar-JRTKCMTF.js.map +0 -1
- package/dist/bn-LO6EDSCC.js.map +0 -1
- package/dist/chunk-4QMTEPQ3.js.map +0 -1
- package/dist/chunk-5Y5JDCKC.js.map +0 -1
- package/dist/cs-LCHHOR4O.js.map +0 -1
- package/dist/da-TARGPMJR.js.map +0 -1
- package/dist/de-SBH4QBPJ.js.map +0 -1
- package/dist/el-2D2OLGWB.js.map +0 -1
- package/dist/es-U6YK2LX6.js.map +0 -1
- package/dist/fa-S7SZE37M.js.map +0 -1
- package/dist/fi-CBL3NIXN.js.map +0 -1
- package/dist/fr-EABUHV6S.js.map +0 -1
- package/dist/he-THWD244Y.js.map +0 -1
- package/dist/hi-GHGFZJ44.js.map +0 -1
- package/dist/id-POZOUMDW.js.map +0 -1
- package/dist/it-KDYJADSH.js.map +0 -1
- package/dist/ja-Z5Z6ZF3P.js.map +0 -1
- package/dist/ko-7X3XUXRN.js.map +0 -1
- package/dist/ms-BTWEIW43.js.map +0 -1
- package/dist/nl-IS2CGF5I.js.map +0 -1
- package/dist/no-AAMEVA26.js.map +0 -1
- package/dist/pl-PVXUQYFT.js.map +0 -1
- package/dist/pt-3ITQ76TL.js.map +0 -1
- package/dist/ro-FRB4IIVS.js.map +0 -1
- package/dist/sv-DS3RO24M.js.map +0 -1
- package/dist/th-AX66MWWB.js.map +0 -1
- package/dist/tr-7KTB67TT.js.map +0 -1
- package/dist/uk-SMQS2MAQ.js.map +0 -1
- package/dist/vi-D3JXL2EZ.js.map +0 -1
- package/dist/zh-S754AWFS.js.map +0 -1
- /package/dist/{en-QFIEG3CA.js.map → en-3FCBW3YD.js.map} +0 -0
|
@@ -82,6 +82,14 @@ export function TaggingPanel({
|
|
|
82
82
|
sourceLanguage,
|
|
83
83
|
}: TaggingPanelProps) {
|
|
84
84
|
const t = useTranslations('TaggingPanel');
|
|
85
|
+
const ta = useTranslations('AssistProgress');
|
|
86
|
+
|
|
87
|
+
// Dismiss parity: tag was the one assist surface with no way to clear a
|
|
88
|
+
// finished progress display, because it had no `closeProgress` key to
|
|
89
|
+
// label the control. Same wiring as every other surface.
|
|
90
|
+
const handleDismissProgress = useCallback(() => {
|
|
91
|
+
session?.client.mark.dismissProgress();
|
|
92
|
+
}, [session]);
|
|
85
93
|
|
|
86
94
|
// Subscribe to the per-KB tag-schema registry. Schemas are runtime-
|
|
87
95
|
// registered by the KB at session start (see frame.addTagSchema).
|
|
@@ -355,7 +363,19 @@ export function TaggingPanel({
|
|
|
355
363
|
title={t('annotateTags')}
|
|
356
364
|
isAssisting={isAssisting}
|
|
357
365
|
progress={progress}
|
|
358
|
-
progressProps={{
|
|
366
|
+
progressProps={{
|
|
367
|
+
showPercentBar: true,
|
|
368
|
+
onDismiss: handleDismissProgress,
|
|
369
|
+
translations: {
|
|
370
|
+
cancel: t('cancel'),
|
|
371
|
+
inProgress: t('annotating'),
|
|
372
|
+
complete: t('complete'),
|
|
373
|
+
failed: t('failed'),
|
|
374
|
+
paramsTitle: ta('paramsTitle'),
|
|
375
|
+
processing: (label) => ta('processing', { label }),
|
|
376
|
+
close: ta('close'),
|
|
377
|
+
},
|
|
378
|
+
}}
|
|
359
379
|
form={
|
|
360
380
|
<>
|
|
361
381
|
{/* Empty-state — registry has resolved with no schemas. */}
|
|
@@ -126,7 +126,7 @@ describe('AssistSection', () => {
|
|
|
126
126
|
/>
|
|
127
127
|
);
|
|
128
128
|
|
|
129
|
-
expect(screen.getByText('
|
|
129
|
+
expect(screen.getByText('paramsTitle')).toBeInTheDocument();
|
|
130
130
|
expect(screen.getByText('Find important points')).toBeInTheDocument();
|
|
131
131
|
expect(screen.getByText(/Instructions:/)).toBeInTheDocument();
|
|
132
132
|
expect(screen.getByText('5')).toBeInTheDocument();
|
|
@@ -506,7 +506,7 @@ describe('AssistSection', () => {
|
|
|
506
506
|
/>
|
|
507
507
|
);
|
|
508
508
|
|
|
509
|
-
expect(screen.queryByText('
|
|
509
|
+
expect(screen.queryByText('paramsTitle')).not.toBeInTheDocument();
|
|
510
510
|
});
|
|
511
511
|
});
|
|
512
512
|
});
|
|
@@ -13,6 +13,11 @@ import { AssistShell } from '../AssistShell';
|
|
|
13
13
|
|
|
14
14
|
const progress = { stage: 'analyzing', percentage: 50, message: 'working' };
|
|
15
15
|
|
|
16
|
+
/** AssistProgress requires a full translation set; the shell just passes it through. */
|
|
17
|
+
const TR = { cancel: 'tr.cancel', inProgress: 'tr.inProgress', complete: 'tr.complete',
|
|
18
|
+
failed: 'tr.failed', close: 'tr.close', paramsTitle: 'tr.paramsTitle',
|
|
19
|
+
processing: (l: string) => `tr.processing(${l})` };
|
|
20
|
+
|
|
16
21
|
describe('AssistShell', () => {
|
|
17
22
|
beforeEach(() => {
|
|
18
23
|
localStorage.clear();
|
|
@@ -21,12 +26,12 @@ describe('AssistShell', () => {
|
|
|
21
26
|
it('renders the form when there is no progress, the progress when there is', () => {
|
|
22
27
|
const { rerender } = render(
|
|
23
28
|
<AssistShell assistType="tag" title="Annotate Tags" isAssisting={false} progress={null}
|
|
24
|
-
form={<button type="button">the form</button>} />,
|
|
29
|
+
form={<button type="button">the form</button>} progressProps={{ translations: TR }} />,
|
|
25
30
|
);
|
|
26
31
|
expect(screen.getByText('the form')).toBeInTheDocument();
|
|
27
32
|
rerender(
|
|
28
33
|
<AssistShell assistType="tag" title="Annotate Tags" isAssisting={true} progress={progress}
|
|
29
|
-
form={<button type="button">the form</button>} />,
|
|
34
|
+
form={<button type="button">the form</button>} progressProps={{ translations: TR }} />,
|
|
30
35
|
);
|
|
31
36
|
expect(screen.queryByText('the form')).not.toBeInTheDocument();
|
|
32
37
|
expect(screen.getByText('working')).toBeInTheDocument();
|
|
@@ -37,7 +42,7 @@ describe('AssistShell', () => {
|
|
|
37
42
|
const props = {
|
|
38
43
|
assistType: 'highlight', title: 'Annotate Highlights', progress,
|
|
39
44
|
form: <span>form</span>,
|
|
40
|
-
progressProps: { onDismiss, translations: { close: 'Close' } },
|
|
45
|
+
progressProps: { onDismiss, translations: { ...TR, close: 'Close' } },
|
|
41
46
|
};
|
|
42
47
|
const { rerender } = render(<AssistShell {...props} isAssisting={true} />);
|
|
43
48
|
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument();
|
|
@@ -50,7 +55,7 @@ describe('AssistShell', () => {
|
|
|
50
55
|
it('persists the expand state per assist type', async () => {
|
|
51
56
|
render(
|
|
52
57
|
<AssistShell assistType="reference" title="Annotate References" isAssisting={false} progress={null}
|
|
53
|
-
form={<span>form</span>} />,
|
|
58
|
+
form={<span>form</span>} progressProps={{ translations: TR }} />,
|
|
54
59
|
);
|
|
55
60
|
await userEvent.click(screen.getByRole('button', { name: /Annotate References/ }));
|
|
56
61
|
expect(screen.queryByText('form')).not.toBeInTheDocument();
|
|
@@ -169,7 +169,7 @@ describe('HighlightPanel + AssistSection Integration', () => {
|
|
|
169
169
|
/>
|
|
170
170
|
);
|
|
171
171
|
|
|
172
|
-
expect(screen.getByText('
|
|
172
|
+
expect(screen.getByText('paramsTitle')).toBeInTheDocument();
|
|
173
173
|
expect(screen.getByText('Find important points')).toBeInTheDocument();
|
|
174
174
|
expect(screen.getByText('5')).toBeInTheDocument();
|
|
175
175
|
});
|
|
@@ -409,6 +409,32 @@ describe('ReferencesPanel Component', () => {
|
|
|
409
409
|
expect(screen.getByText('Organization:')).toBeInTheDocument();
|
|
410
410
|
});
|
|
411
411
|
|
|
412
|
+
it('renders the entity log with the SAME markup the progress display uses', () => {
|
|
413
|
+
// ASSIST-SURFACE-WARTS Lane B: this form-side log and AssistProgress's
|
|
414
|
+
// completed-entity log are the same concept. They had two class families
|
|
415
|
+
// (semiont-assist-widget__log* here, semiont-annotation-log* there) one
|
|
416
|
+
// panel apart — one concept, one markup.
|
|
417
|
+
const { container, rerender } = renderWithEventBus(
|
|
418
|
+
<ReferencesPanel
|
|
419
|
+
{...panelProps()}
|
|
420
|
+
isAssisting={false}
|
|
421
|
+
progress={{
|
|
422
|
+
stage: 'complete',
|
|
423
|
+
percentage: 100,
|
|
424
|
+
message: 'Annotation complete',
|
|
425
|
+
completedEntityTypes: [{ entityType: 'Person', foundCount: 5 }],
|
|
426
|
+
}}
|
|
427
|
+
/>
|
|
428
|
+
);
|
|
429
|
+
rerender(
|
|
430
|
+
<ReferencesPanel {...panelProps()} isAssisting={false} progress={null} />
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
expect(container.querySelector('.semiont-annotation-log')).toBeInTheDocument();
|
|
434
|
+
expect(container.querySelector('.semiont-annotation-log-item')).toBeInTheDocument();
|
|
435
|
+
expect(container.querySelector('.semiont-assist-widget__log')).not.toBeInTheDocument();
|
|
436
|
+
});
|
|
437
|
+
|
|
412
438
|
it('should hide entity type selection during detection', () => {
|
|
413
439
|
renderWithEventBus(
|
|
414
440
|
<ReferencesPanel
|
|
@@ -316,12 +316,12 @@
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
.semiont-form__input-row:focus-visible {
|
|
319
|
-
outline: 2px solid var(--semiont-focus-ring
|
|
319
|
+
outline: 2px solid var(--semiont-focus-ring);
|
|
320
320
|
outline-offset: 2px;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
[data-theme="dark"] .semiont-form__input-row:focus-visible {
|
|
324
|
-
outline-color: var(--semiont-focus-ring
|
|
324
|
+
outline-color: var(--semiont-focus-ring);
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
.semiont-form__input-row .semiont-input {
|
|
@@ -329,12 +329,12 @@
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
.semiont-form__input-row .semiont-input:focus-visible {
|
|
332
|
-
outline: 2px solid var(--semiont-focus-ring
|
|
332
|
+
outline: 2px solid var(--semiont-focus-ring);
|
|
333
333
|
outline-offset: 2px;
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
[data-theme="dark"] .semiont-form__input-row .semiont-input:focus-visible {
|
|
337
|
-
outline-color: var(--semiont-focus-ring
|
|
337
|
+
outline-color: var(--semiont-focus-ring);
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
.semiont-form__url-status {
|
|
@@ -345,27 +345,27 @@
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
.semiont-form__url-status--checking {
|
|
348
|
-
color: var(--semiont-text-
|
|
348
|
+
color: var(--semiont-text-tertiary);
|
|
349
349
|
animation: spin 1s linear infinite;
|
|
350
350
|
display: inline-block;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
[data-theme="dark"] .semiont-form__url-status--checking {
|
|
354
|
-
color: var(--semiont-text-
|
|
354
|
+
color: var(--semiont-text-tertiary);
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
.semiont-form__url-status--ok {
|
|
358
|
-
color: var(--semiont-success
|
|
358
|
+
color: var(--semiont-color-success);
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
[data-theme="dark"] .semiont-form__url-status--ok {
|
|
362
|
-
color: var(--semiont-success
|
|
362
|
+
color: var(--semiont-color-success);
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
.semiont-form__url-status--error {
|
|
366
|
-
color: var(--semiont-error
|
|
366
|
+
color: var(--semiont-color-error);
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
[data-theme="dark"] .semiont-form__url-status--error {
|
|
370
|
-
color: var(--semiont-error
|
|
370
|
+
color: var(--semiont-color-error);
|
|
371
371
|
}
|
|
@@ -41,7 +41,7 @@ vi.mock('../../../hooks/useResourceContent', () => ({
|
|
|
41
41
|
// SemiontClient (wired to a dummy baseUrl). The real client surface lets
|
|
42
42
|
// createResourceViewerPageStateUnit run against the full namespace API without us
|
|
43
43
|
// hand-stubbing every method it touches.
|
|
44
|
-
const { stubBrowser } = vi.hoisted(() => {
|
|
44
|
+
const { stubBrowser, stubClient } = vi.hoisted(() => {
|
|
45
45
|
const { BehaviorSubject } = require('rxjs');
|
|
46
46
|
const { SemiontClient, HttpTransport, HttpContentTransport } = require('@semiont/sdk');
|
|
47
47
|
const { baseUrl } = require('@semiont/core');
|
|
@@ -63,7 +63,7 @@ const { stubBrowser } = vi.hoisted(() => {
|
|
|
63
63
|
on: vi.fn(() => () => {}),
|
|
64
64
|
stream: vi.fn(() => ({ subscribe: () => ({ unsubscribe: () => {} }) })),
|
|
65
65
|
};
|
|
66
|
-
return { stubBrowser };
|
|
66
|
+
return { stubBrowser, stubClient: client };
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
vi.mock('../../../session/SemiontProvider', async () => {
|
|
@@ -76,6 +76,8 @@ vi.mock('../../../session/SemiontProvider', async () => {
|
|
|
76
76
|
};
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
const capturedHistory = vi.hoisted(() => ({ props: null as any }));
|
|
80
|
+
|
|
79
81
|
vi.mock('@semiont/react-ui', async () => {
|
|
80
82
|
const actual = await vi.importActual('@semiont/react-ui');
|
|
81
83
|
return {
|
|
@@ -84,7 +86,10 @@ vi.mock('@semiont/react-ui', async () => {
|
|
|
84
86
|
Toolbar: () => <div data-testid="toolbar">Toolbar</div>,
|
|
85
87
|
ToolbarPanels: ({ children }: any) => <div data-testid="toolbar-panels">{children}</div>,
|
|
86
88
|
UnifiedAnnotationsPanel: () => <div data-testid="annotations-panel">Annotations</div>,
|
|
87
|
-
AnnotationHistory: () =>
|
|
89
|
+
AnnotationHistory: (props: any) => {
|
|
90
|
+
capturedHistory.props = props;
|
|
91
|
+
return <div data-testid="history-panel">History</div>;
|
|
92
|
+
},
|
|
88
93
|
ResourceInfoPanel: () => <div data-testid="info-panel">Info</div>,
|
|
89
94
|
CollaborationPanel: () => <div data-testid="collaboration-panel">Collaboration</div>,
|
|
90
95
|
JsonLdPanel: () => <div data-testid="jsonld-panel">JSON-LD</div>,
|
|
@@ -118,6 +123,15 @@ vi.mock('../../../contexts/ResourceAnnotationsContext', () => ({
|
|
|
118
123
|
ResourceAnnotationsProvider: ({ children }: any) => children,
|
|
119
124
|
}));
|
|
120
125
|
|
|
126
|
+
// Capture what ResourceViewerPage passes to useOutcomeToasts. The real hook is a
|
|
127
|
+
// pure event subscription, so standing it in costs the other specs nothing.
|
|
128
|
+
const outcomeToastsCalls = vi.hoisted(() => [] as unknown[]);
|
|
129
|
+
vi.mock('../../../hooks/useOutcomeToasts', () => ({
|
|
130
|
+
useOutcomeToasts: (resourceId: unknown) => {
|
|
131
|
+
outcomeToastsCalls.push(resourceId);
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
|
|
121
135
|
// Mock useEventSubscription at the direct path used by ResourceViewerPage
|
|
122
136
|
// (the barrel export mock doesn't intercept direct context imports)
|
|
123
137
|
const mockUseEventSubscriptions = vi.fn();
|
|
@@ -175,6 +189,23 @@ const renderWithProviders = (ui: React.ReactElement) => {
|
|
|
175
189
|
);
|
|
176
190
|
};
|
|
177
191
|
|
|
192
|
+
describe('ResourceViewerPage — outcome toasts reach the user', () => {
|
|
193
|
+
// The decline/success toast was dropped for EVERY resource:
|
|
194
|
+
// `useOutcomeToasts(resource.id as string)` read a property
|
|
195
|
+
// `ResourceDescriptor` does not declare (it carries `@id`). The descriptor's
|
|
196
|
+
// open index signature made the access legal and the `as string` silenced its
|
|
197
|
+
// `unknown` type, so the hook's `event.resourceId !== resourceId` guard
|
|
198
|
+
// compared a real id against `undefined` and returned early every time.
|
|
199
|
+
it('subscribes with the resource id, not a property the descriptor lacks', () => {
|
|
200
|
+
outcomeToastsCalls.length = 0;
|
|
201
|
+
renderWithProviders(<ResourceViewerPage {...createMockProps()} />);
|
|
202
|
+
|
|
203
|
+
expect(outcomeToastsCalls).not.toHaveLength(0);
|
|
204
|
+
// Must be the resource's identity — `undefined` here silences every toast.
|
|
205
|
+
expect(outcomeToastsCalls[0]).toBe('test-123');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
178
209
|
describe('ResourceViewerPage', () => {
|
|
179
210
|
beforeEach(() => {
|
|
180
211
|
});
|
|
@@ -228,6 +259,30 @@ describe('ResourceViewerPage', () => {
|
|
|
228
259
|
localStorage.clear();
|
|
229
260
|
});
|
|
230
261
|
|
|
262
|
+
it('clicking a history event focuses that annotation in the content', () => {
|
|
263
|
+
// ASSIST-SURFACE-WARTS Lane D. `handleEventClick` used to be a no-op with
|
|
264
|
+
// a stale comment, while HistoryEvent still rendered a focusable button
|
|
265
|
+
// labelled "View annotation" — a promise to screen-reader users that
|
|
266
|
+
// nothing kept. `beckon:focus` is the existing "scroll to and highlight"
|
|
267
|
+
// contract (BrowseView already subscribes); this makes the page a producer.
|
|
268
|
+
localStorage.setItem('activeToolbarPanel', 'history');
|
|
269
|
+
capturedHistory.props = null;
|
|
270
|
+
const props = createMockProps();
|
|
271
|
+
renderWithProviders(<ResourceViewerPage {...props} />);
|
|
272
|
+
|
|
273
|
+
expect(capturedHistory.props).not.toBeNull();
|
|
274
|
+
|
|
275
|
+
const focused: Array<string | null> = [];
|
|
276
|
+
const sub = stubClient.bus.get('beckon:focus').subscribe(({ annotationId }: any) => focused.push(annotationId));
|
|
277
|
+
|
|
278
|
+
expect(capturedHistory.props?.onEventClick).toBeTypeOf('function');
|
|
279
|
+
capturedHistory.props!.onEventClick('ann-42');
|
|
280
|
+
expect(focused).toEqual(['ann-42']);
|
|
281
|
+
|
|
282
|
+
sub.unsubscribe();
|
|
283
|
+
localStorage.clear();
|
|
284
|
+
});
|
|
285
|
+
|
|
231
286
|
it('shows history panel when activePanel is history', () => {
|
|
232
287
|
localStorage.setItem('activeToolbarPanel', 'history');
|
|
233
288
|
const props = createMockProps();
|
|
@@ -24,6 +24,7 @@ import type { BrowseMediaRenderers, AnnotateMediaRenderers } from '@semiont/reac
|
|
|
24
24
|
import { useObservable } from '@semiont/react-ui';
|
|
25
25
|
import { useResourceContent } from '../../../hooks/useResourceContent';
|
|
26
26
|
import { useMediaToken } from '../../../hooks/useMediaToken';
|
|
27
|
+
import { mediaUrl } from '../../../lib/media-url';
|
|
27
28
|
import { useToast } from '../../../components/Toast';
|
|
28
29
|
import { useOutcomeToasts } from '../../../hooks/useOutcomeToasts';
|
|
29
30
|
import { useTheme } from '../../../contexts/ThemeContext';
|
|
@@ -139,6 +140,7 @@ export function ResourceViewerPage({
|
|
|
139
140
|
// Translations
|
|
140
141
|
const tw = useTranslations('ReferenceWizard');
|
|
141
142
|
const tg = useTranslations('ResourceGenerate');
|
|
143
|
+
const ta = useTranslations('AssistProgress');
|
|
142
144
|
|
|
143
145
|
const browser = useSemiont();
|
|
144
146
|
const session = useObservable(browser.activeSession$);
|
|
@@ -185,9 +187,7 @@ export function ResourceViewerPage({
|
|
|
185
187
|
|
|
186
188
|
// Binary path: fetch short-lived media token, construct URL
|
|
187
189
|
const { token: mediaToken, loading: mediaTokenLoading } = useMediaToken(semiont ?? null, rUri);
|
|
188
|
-
const binaryContent = (isBinary
|
|
189
|
-
? `${semiont.baseUrl}/api/resources/${rUri}?token=${mediaToken}`
|
|
190
|
-
: '';
|
|
190
|
+
const binaryContent = (isBinary ? mediaUrl(semiont, rUri, mediaToken) : undefined) ?? '';
|
|
191
191
|
|
|
192
192
|
const content = isBinary ? binaryContent : textContent;
|
|
193
193
|
const contentLoading = isBinary ? mediaTokenLoading : textLoading;
|
|
@@ -396,7 +396,7 @@ export function ResourceViewerPage({
|
|
|
396
396
|
// assist timed-out) live in useOutcomeToasts — they need only the resource id
|
|
397
397
|
// and the toast surface. The registration below keeps the handlers that need
|
|
398
398
|
// page-local dependencies (SDK actions, sparkles, settings, navigation).
|
|
399
|
-
useOutcomeToasts(
|
|
399
|
+
useOutcomeToasts(rUri);
|
|
400
400
|
|
|
401
401
|
// Single useEventSubscriptions call per file (enforced by
|
|
402
402
|
// scripts/compliance/audit-hooks-ordering.ts); hooks like useOutcomeToasts
|
|
@@ -444,9 +444,17 @@ export function ResourceViewerPage({
|
|
|
444
444
|
}
|
|
445
445
|
}, [session]);
|
|
446
446
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
447
|
+
// Clicking a history row reveals that annotation in the content. HistoryEvent
|
|
448
|
+
// renders these rows as buttons labelled "View annotation", so this used to be
|
|
449
|
+
// a focusable, screen-reader-announced control wired to a no-op.
|
|
450
|
+
// `beckon:focus` is the existing "scroll to and highlight" contract rather
|
|
451
|
+
// than a new prop chain — BrowseView already subscribed to it, AnnotateView
|
|
452
|
+
// now does too. See .plans/ASSIST-SURFACE-WARTS.md Lane D.
|
|
453
|
+
const handleEventClick = useCallback((id: string | null) => {
|
|
454
|
+
if (id) {
|
|
455
|
+
stateUnit?.beckon.focus(annotationId(id));
|
|
456
|
+
}
|
|
457
|
+
}, [stateUnit]);
|
|
450
458
|
|
|
451
459
|
// Document rendering
|
|
452
460
|
return (
|
|
@@ -475,6 +483,9 @@ export function ResourceViewerPage({
|
|
|
475
483
|
inProgress: tg('progressInProgress'),
|
|
476
484
|
complete: tg('progressComplete'),
|
|
477
485
|
failed: tg('progressFailed'),
|
|
486
|
+
paramsTitle: ta('paramsTitle'),
|
|
487
|
+
processing: (label) => ta('processing', { label }),
|
|
488
|
+
close: ta('close'),
|
|
478
489
|
}}
|
|
479
490
|
/>
|
|
480
491
|
)}
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
align-items: center;
|
|
138
138
|
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
|
139
139
|
font-size: var(--semiont-text-sm);
|
|
140
|
-
font-family: var(--semiont-font-mono
|
|
140
|
+
font-family: var(--semiont-font-mono);
|
|
141
141
|
color: var(--semiont-text-tertiary);
|
|
142
142
|
background-color: var(--semiont-color-gray-50);
|
|
143
143
|
border-right: 1px solid var(--semiont-color-gray-300);
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
highlight while a drag hovers. */
|
|
119
119
|
.semiont-form__upload-dropzone[data-drag-over="true"] {
|
|
120
120
|
border-color: var(--semiont-color-primary-500);
|
|
121
|
-
background: var(--semiont-color-primary-50
|
|
121
|
+
background: var(--semiont-color-primary-50);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/* Dark mode needs its own tint: primary-50 is a near-white wash that would
|
|
@@ -324,7 +324,7 @@
|
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
[data-theme="dark"] .semiont-browse-view__content tr:nth-child(even) td {
|
|
327
|
-
background: var(--semiont-color-gray-
|
|
327
|
+
background: var(--semiont-color-gray-800);
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
/* GFM task list checkboxes */
|
|
@@ -412,8 +412,8 @@
|
|
|
412
412
|
}
|
|
413
413
|
|
|
414
414
|
.semiont-static-warning-box {
|
|
415
|
-
background: var(--semiont-color-
|
|
416
|
-
border-left: 4px solid var(--semiont-color-
|
|
415
|
+
background: var(--semiont-color-amber-50);
|
|
416
|
+
border-left: 4px solid var(--semiont-color-amber-500);
|
|
417
417
|
border-radius: 0.375rem;
|
|
418
418
|
padding: 1rem 1.25rem;
|
|
419
419
|
margin: 1.5rem 0;
|
|
@@ -427,7 +427,7 @@
|
|
|
427
427
|
|
|
428
428
|
[data-theme="dark"] .semiont-static-warning-box {
|
|
429
429
|
background: rgba(251, 146, 60, 0.1);
|
|
430
|
-
border-left-color: var(--semiont-color-
|
|
430
|
+
border-left-color: var(--semiont-color-amber-400);
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
/* ========================================
|
|
@@ -447,27 +447,27 @@
|
|
|
447
447
|
|
|
448
448
|
.semiont-static-badge-planned {
|
|
449
449
|
background: rgba(251, 146, 60, 0.1);
|
|
450
|
-
color: var(--semiont-color-
|
|
451
|
-
border: 1px solid var(--semiont-color-
|
|
450
|
+
color: var(--semiont-color-amber-700);
|
|
451
|
+
border: 1px solid var(--semiont-color-amber-200);
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
.semiont-static-badge-active {
|
|
455
455
|
background: rgba(34, 197, 94, 0.1);
|
|
456
|
-
color: var(--semiont-color-
|
|
457
|
-
border: 1px solid var(--semiont-color-
|
|
456
|
+
color: var(--semiont-color-green-700);
|
|
457
|
+
border: 1px solid var(--semiont-color-green-200);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
460
|
/* Dark mode */
|
|
461
461
|
[data-theme="dark"] .semiont-static-badge-planned {
|
|
462
462
|
background: rgba(251, 146, 60, 0.15);
|
|
463
|
-
color: var(--semiont-color-
|
|
464
|
-
border-color: var(--semiont-color-
|
|
463
|
+
color: var(--semiont-color-amber-400);
|
|
464
|
+
border-color: var(--semiont-color-amber-600);
|
|
465
465
|
}
|
|
466
466
|
|
|
467
467
|
[data-theme="dark"] .semiont-static-badge-active {
|
|
468
468
|
background: rgba(34, 197, 94, 0.15);
|
|
469
|
-
color: var(--semiont-color-
|
|
470
|
-
border-color: var(--semiont-color-
|
|
469
|
+
color: var(--semiont-color-green-400);
|
|
470
|
+
border-color: var(--semiont-color-green-600);
|
|
471
471
|
}
|
|
472
472
|
|
|
473
473
|
/* ========================================
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
--semiont-motivation-comment-panel-bg-dark: rgba(55, 65, 81, 0.2);
|
|
34
34
|
|
|
35
35
|
/* Button gradients */
|
|
36
|
-
--semiont-motivation-comment-button-bg: linear-gradient(to right, var(--semiont-color-purple-600), var(--semiont-color-
|
|
37
|
-
--semiont-motivation-comment-button-bg-hover: linear-gradient(to right, var(--semiont-color-purple-700), var(--semiont-color-
|
|
36
|
+
--semiont-motivation-comment-button-bg: linear-gradient(to right, var(--semiont-color-purple-600), var(--semiont-color-blue-600));
|
|
37
|
+
--semiont-motivation-comment-button-bg-hover: linear-gradient(to right, var(--semiont-color-purple-700), var(--semiont-color-blue-700));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/* ============================================
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
/* Light theme colors */
|
|
15
15
|
--semiont-motivation-reference-primary: var(--semiont-color-cyan-600);
|
|
16
16
|
--semiont-motivation-reference-secondary: var(--semiont-color-blue-600);
|
|
17
|
-
--semiont-motivation-reference-bg: linear-gradient(to right, var(--semiont-color-cyan-200
|
|
18
|
-
--semiont-motivation-reference-bg-hover: linear-gradient(to right, var(--semiont-color-cyan-300
|
|
17
|
+
--semiont-motivation-reference-bg: linear-gradient(to right, var(--semiont-color-cyan-200), var(--semiont-color-blue-200));
|
|
18
|
+
--semiont-motivation-reference-bg-hover: linear-gradient(to right, var(--semiont-color-cyan-300), var(--semiont-color-blue-300));
|
|
19
19
|
--semiont-motivation-reference-border: rgba(6, 182, 212, 0.3);
|
|
20
20
|
--semiont-motivation-reference-border-hover: rgba(6, 182, 212, 0.5);
|
|
21
21
|
--semiont-motivation-reference-text: var(--semiont-color-gray-900);
|
|
@@ -405,12 +405,12 @@
|
|
|
405
405
|
|
|
406
406
|
/* Login form pulsing border for first-launch nudge */
|
|
407
407
|
@keyframes semiont-pulse-border {
|
|
408
|
-
0%, 100% { border-color: var(--semiont-color-primary-300
|
|
409
|
-
50% { border-color: var(--semiont-color-primary-600
|
|
408
|
+
0%, 100% { border-color: var(--semiont-color-primary-300); }
|
|
409
|
+
50% { border-color: var(--semiont-color-primary-600); }
|
|
410
410
|
}
|
|
411
411
|
|
|
412
412
|
.semiont-panel__login-form--pulsing {
|
|
413
|
-
border: 2px solid var(--semiont-color-primary-400
|
|
414
|
-
border-radius: var(--semiont-panel-border-radius
|
|
413
|
+
border: 2px solid var(--semiont-color-primary-400);
|
|
414
|
+
border-radius: var(--semiont-panel-border-radius);
|
|
415
415
|
animation: semiont-pulse-border 2s ease-in-out infinite;
|
|
416
416
|
}
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
.semiont-transition {
|
|
125
125
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
|
126
126
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
127
|
-
transition-duration: var(--semiont-duration-base
|
|
127
|
+
transition-duration: var(--semiont-duration-base);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
.semiont-transition-none {
|
|
@@ -134,58 +134,58 @@
|
|
|
134
134
|
.semiont-transition-all {
|
|
135
135
|
transition-property: all;
|
|
136
136
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
137
|
-
transition-duration: var(--semiont-duration-base
|
|
137
|
+
transition-duration: var(--semiont-duration-base);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
.semiont-transition-colors {
|
|
141
141
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
|
142
142
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
143
|
-
transition-duration: var(--semiont-duration-base
|
|
143
|
+
transition-duration: var(--semiont-duration-base);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
.semiont-transition-opacity {
|
|
147
147
|
transition-property: opacity;
|
|
148
148
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
149
|
-
transition-duration: var(--semiont-duration-base
|
|
149
|
+
transition-duration: var(--semiont-duration-base);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
.semiont-transition-transform {
|
|
153
153
|
transition-property: transform;
|
|
154
154
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
155
|
-
transition-duration: var(--semiont-duration-base
|
|
155
|
+
transition-duration: var(--semiont-duration-base);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/* Animation classes */
|
|
159
159
|
.semiont-animate-fade-in {
|
|
160
|
-
animation: fadeIn var(--semiont-duration-base
|
|
160
|
+
animation: fadeIn var(--semiont-duration-base) ease-in-out;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
.semiont-animate-fade-out {
|
|
164
|
-
animation: fadeOut var(--semiont-duration-base
|
|
164
|
+
animation: fadeOut var(--semiont-duration-base) ease-in-out;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
.semiont-animate-slide-in-up {
|
|
168
|
-
animation: slideInUp var(--semiont-duration-
|
|
168
|
+
animation: slideInUp var(--semiont-duration-base) ease-out;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
.semiont-animate-slide-in-down {
|
|
172
|
-
animation: slideInDown var(--semiont-duration-
|
|
172
|
+
animation: slideInDown var(--semiont-duration-base) ease-out;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
.semiont-animate-slide-in-left {
|
|
176
|
-
animation: slideInLeft var(--semiont-duration-
|
|
176
|
+
animation: slideInLeft var(--semiont-duration-base) ease-out;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
.semiont-animate-slide-in-right {
|
|
180
|
-
animation: slideInRight var(--semiont-duration-
|
|
180
|
+
animation: slideInRight var(--semiont-duration-base) ease-out;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
.semiont-animate-scale-in {
|
|
184
|
-
animation: scaleIn var(--semiont-duration-base
|
|
184
|
+
animation: scaleIn var(--semiont-duration-base) ease-out;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
.semiont-animate-scale-out {
|
|
188
|
-
animation: scaleOut var(--semiont-duration-base
|
|
188
|
+
animation: scaleOut var(--semiont-duration-base) ease-in;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
.semiont-animate-spin {
|
|
@@ -299,13 +299,13 @@
|
|
|
299
299
|
.semiont-motion-safe-transition-all {
|
|
300
300
|
transition-property: all;
|
|
301
301
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
302
|
-
transition-duration: var(--semiont-duration-base
|
|
302
|
+
transition-duration: var(--semiont-duration-base);
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
.semiont-motion-safe-transition-transform {
|
|
306
306
|
transition-property: transform;
|
|
307
307
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
308
|
-
transition-duration: var(--semiont-duration-base
|
|
308
|
+
transition-duration: var(--semiont-duration-base);
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|