@semiont/react-ui 0.5.18 → 0.5.20

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 (33) hide show
  1. package/README.md +4 -0
  2. package/dist/{PdfAnnotationCanvas.client-75GY2EDR.js → PdfAnnotationCanvas.client-V7Q3BEEQ.js} +42 -45
  3. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +1 -0
  4. package/dist/index.d.ts +231 -55
  5. package/dist/index.js +2115 -2161
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/AssistProgress.tsx +170 -0
  9. package/src/components/__tests__/AssistProgress.test.tsx +164 -0
  10. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +23 -54
  11. package/src/components/pdf-annotation/__tests__/rects-for-page.test.ts +96 -0
  12. package/src/components/pdf-annotation/rects-for-page.ts +47 -0
  13. package/src/components/resource/ResourceViewer.tsx +23 -55
  14. package/src/components/resource/panels/AssessmentPanel.tsx +1 -1
  15. package/src/components/resource/panels/AssistSection.tsx +111 -180
  16. package/src/components/resource/panels/AssistShell.tsx +77 -0
  17. package/src/components/resource/panels/CommentsPanel.tsx +1 -1
  18. package/src/components/resource/panels/HighlightPanel.tsx +1 -1
  19. package/src/components/resource/panels/ReferenceEntry.tsx +8 -1
  20. package/src/components/resource/panels/ReferencesPanel.tsx +101 -132
  21. package/src/components/resource/panels/TaggingPanel.tsx +23 -86
  22. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +6 -14
  23. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +59 -0
  24. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +20 -0
  25. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +0 -1
  26. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +14 -20
  27. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +30 -0
  28. package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -1
  29. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +24 -64
  30. package/src/styles/features/compose.css +7 -0
  31. package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +0 -1
  32. package/src/components/AnnotateReferencesProgressWidget.tsx +0 -125
  33. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +0 -101
@@ -5,7 +5,7 @@ import { useTranslations } from '../../../contexts/TranslationContext';
5
5
  import type { SemiontSession } from '@semiont/sdk';
6
6
  import { useSessionEventSubscriptions } from '../../../hooks/useSessionEventSubscriptions';
7
7
  import type { RouteBuilder, LinkComponentProps } from '../../../contexts/RoutingContext';
8
- import { AnnotateReferencesProgressWidget } from '../../AnnotateReferencesProgressWidget';
8
+ import { AssistShell } from './AssistShell';
9
9
  import { ReferenceEntry } from './ReferenceEntry';
10
10
  import type { components, Selector } from '@semiont/core';
11
11
  import { getTextPositionSelector, getTargetSelector } from '@semiont/core';
@@ -109,19 +109,6 @@ export function ReferencesPanel({
109
109
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
110
110
  const containerRef = useRef<HTMLDivElement>(null);
111
111
 
112
- // Collapsible detection section state - load from localStorage, default expanded
113
- const [isAssistExpanded, setIsDetectExpanded] = useState(() => {
114
- if (typeof window === 'undefined') return true;
115
- const stored = localStorage.getItem('assist-section-expanded-reference');
116
- return stored ? stored === 'true' : true;
117
- });
118
-
119
- // Persist detection section expanded state to localStorage
120
- useEffect(() => {
121
- if (typeof window === 'undefined') return;
122
- localStorage.setItem('assist-section-expanded-reference', String(isAssistExpanded));
123
- }, [isAssistExpanded]);
124
-
125
112
  // Direct ref management - replace useAnnotationPanel hook
126
113
  const entryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
127
114
 
@@ -351,120 +338,17 @@ export function ReferencesPanel({
351
338
 
352
339
  {/* Scrollable content area */}
353
340
  <div ref={containerRef} className="semiont-panel__content">
354
- {/* Assist Section - only in Annotate mode and for text resources */}
341
+ {/* Assist Section - only in Annotate mode; shown for any media type (AI detection is media-agnostic — text is resolved via the media-type registry, incl. PDF text layers) */}
355
342
  {annotateMode && (
356
- <div className="semiont-panel__section">
357
- <button
358
- onClick={() => setIsDetectExpanded(!isAssistExpanded)}
359
- className="semiont-panel__section-title semiont-panel__section-title--collapsible"
360
- aria-expanded={isAssistExpanded}
361
- type="button"
362
- >
363
- <span>{t('annotateReferences')}</span>
364
- <span className="semiont-panel__section-chevron" data-expanded={isAssistExpanded}>
365
-
366
- </span>
367
- </button>
368
- {isAssistExpanded && (
369
- <>
370
- {/* Show annotation UI when not actively assisting */}
371
- {!isAssisting && (
372
- <div className="semiont-assist-widget" data-type="reference">
373
- <>
374
- {/* Completed annotation log - shown after completion */}
375
- {lastAnnotationLog && lastAnnotationLog.length > 0 && (
376
- <div className="semiont-assist-widget__log">
377
- <div className="semiont-assist-widget__log-items">
378
- {lastAnnotationLog.map((item, index) => (
379
- <div key={index} className="semiont-assist-widget__log-item">
380
- <span className="semiont-assist-widget__log-check">✓</span>
381
- <span className="semiont-assist-widget__log-type">{item.entityType}:</span>
382
- <span>{t('found', { count: item.foundCount })}</span>
383
- </div>
384
- ))}
385
- </div>
386
- </div>
387
- )}
388
-
389
- {/* Entity Types Selection */}
390
- <div className="semiont-assist-widget__entity-types">
391
- <p className="semiont-assist-widget__label">
392
- {t('selectEntityTypes')}
393
- </p>
394
- <div className="semiont-assist-widget__chips">
395
- {allEntityTypes.length > 0 ? (
396
- allEntityTypes.map((type: string) => (
397
- <button
398
- key={type}
399
- onClick={() => {
400
- setSelectedEntityTypes(prev =>
401
- prev.includes(type)
402
- ? prev.filter(t => t !== type)
403
- : [...prev, type]
404
- );
405
- }}
406
- aria-pressed={selectedEntityTypes.includes(type)}
407
- aria-label={`${selectedEntityTypes.includes(type) ? t('deselect') : t('select')} ${type}`}
408
- className="semiont-chip semiont-chip--selectable"
409
- data-selected={selectedEntityTypes.includes(type)}
410
- >
411
- {type}
412
- </button>
413
- ))
414
- ) : (
415
- <p className="semiont-assist-widget__no-types">
416
- {t('noEntityTypes')}
417
- </p>
418
- )}
419
- </div>
420
- </div>
421
-
422
- {/* Selected Count */}
423
- {selectedEntityTypes.length > 0 && (
424
- <p className="semiont-assist-widget__count">
425
- {t('typesSelected', { count: selectedEntityTypes.length })}
426
- </p>
427
- )}
428
-
429
- {/* Include Descriptive References Checkbox */}
430
- <div className="semiont-assist-widget__checkbox-group">
431
- <label className="semiont-assist-widget__checkbox-label">
432
- <input
433
- type="checkbox"
434
- checked={includeDescriptiveReferences}
435
- onChange={(e) => setIncludeDescriptiveReferences(e.target.checked)}
436
- className="semiont-assist-widget__checkbox"
437
- />
438
- <span>{t('includeDescriptiveReferences')}</span>
439
- </label>
440
- <p className="semiont-assist-widget__checkbox-hint">
441
- {t('descriptiveReferencesTooltip')}
442
- </p>
443
- </div>
444
-
445
- {/* Start Assist Button */}
446
- <button
447
- onClick={handleAssist}
448
- disabled={selectedEntityTypes.length === 0}
449
- title={t('annotate')}
450
- className="semiont-button"
451
- data-variant="assist"
452
- data-type="reference"
453
- >
454
- <span className="semiont-button-icon">✨</span>
455
- <span>{t('annotate')}</span>
456
- </button>
457
- </>
458
- </div>
459
- )}
460
-
461
- {/* Annotation Progress - shown when active */}
462
- {isAssisting && progress && (
463
- <AnnotateReferencesProgressWidget
464
- progress={progress}
465
- annotationType="reference"
466
- cancelJobType="annotation"
467
- translations={{
343
+ <AssistShell
344
+ assistType="reference"
345
+ title={t('annotateReferences')}
346
+ isAssisting={isAssisting}
347
+ progress={progress}
348
+ progressProps={{
349
+ onCancel: () => session?.client.job.cancelRequest('annotation'),
350
+ onDismiss: () => session?.client.mark.dismissProgress(),
351
+ translations: {
468
352
  title: t('annotationProgressTitle'),
469
353
  cancel: t('cancelAnnotation'),
470
354
  inProgress: t('annotating'),
@@ -472,12 +356,97 @@ export function ReferencesPanel({
472
356
  failed: t('failed'),
473
357
  found: (count) => t('found', { count }),
474
358
  current: (entityType) => t('current', { entityType }),
475
- }}
476
- />
477
- )}
359
+ close: t('closeProgress'),
360
+ },
361
+ }}
362
+ form={
363
+ <>
364
+ {/* Completed annotation log - shown after completion */}
365
+ {lastAnnotationLog && lastAnnotationLog.length > 0 && (
366
+ <div className="semiont-assist-widget__log">
367
+ <div className="semiont-assist-widget__log-items">
368
+ {lastAnnotationLog.map((item, index) => (
369
+ <div key={index} className="semiont-assist-widget__log-item">
370
+ <span className="semiont-assist-widget__log-check">✓</span>
371
+ <span className="semiont-assist-widget__log-type">{item.entityType}:</span>
372
+ <span>{t('found', { count: item.foundCount })}</span>
373
+ </div>
374
+ ))}
375
+ </div>
376
+ </div>
377
+ )}
378
+
379
+ {/* Entity Types Selection */}
380
+ <div className="semiont-assist-widget__entity-types">
381
+ <p className="semiont-assist-widget__label">
382
+ {t('selectEntityTypes')}
383
+ </p>
384
+ <div className="semiont-assist-widget__chips">
385
+ {allEntityTypes.length > 0 ? (
386
+ allEntityTypes.map((type: string) => (
387
+ <button
388
+ key={type}
389
+ onClick={() => {
390
+ setSelectedEntityTypes(prev =>
391
+ prev.includes(type)
392
+ ? prev.filter(t => t !== type)
393
+ : [...prev, type]
394
+ );
395
+ }}
396
+ aria-pressed={selectedEntityTypes.includes(type)}
397
+ aria-label={`${selectedEntityTypes.includes(type) ? t('deselect') : t('select')} ${type}`}
398
+ className="semiont-chip semiont-chip--selectable"
399
+ data-selected={selectedEntityTypes.includes(type)}
400
+ >
401
+ {type}
402
+ </button>
403
+ ))
404
+ ) : (
405
+ <p className="semiont-assist-widget__no-types">
406
+ {t('noEntityTypes')}
407
+ </p>
408
+ )}
409
+ </div>
410
+ </div>
411
+
412
+ {/* Selected Count */}
413
+ {selectedEntityTypes.length > 0 && (
414
+ <p className="semiont-assist-widget__count">
415
+ {t('typesSelected', { count: selectedEntityTypes.length })}
416
+ </p>
417
+ )}
418
+
419
+ {/* Include Descriptive References Checkbox */}
420
+ <div className="semiont-assist-widget__checkbox-group">
421
+ <label className="semiont-assist-widget__checkbox-label">
422
+ <input
423
+ type="checkbox"
424
+ checked={includeDescriptiveReferences}
425
+ onChange={(e) => setIncludeDescriptiveReferences(e.target.checked)}
426
+ className="semiont-assist-widget__checkbox"
427
+ />
428
+ <span>{t('includeDescriptiveReferences')}</span>
429
+ </label>
430
+ <p className="semiont-assist-widget__checkbox-hint">
431
+ {t('descriptiveReferencesTooltip')}
432
+ </p>
433
+ </div>
434
+
435
+ {/* Start Assist Button */}
436
+ <button
437
+ onClick={handleAssist}
438
+ disabled={selectedEntityTypes.length === 0}
439
+ title={t('annotate')}
440
+ className="semiont-button"
441
+ data-variant="assist"
442
+ data-type="reference"
443
+ >
444
+ <span className="semiont-button-icon">✨</span>
445
+ <span>{t('annotate')}</span>
446
+ </button>
478
447
  </>
479
- )}
480
- </div>
448
+ }
449
+ />
481
450
  )}
482
451
 
483
452
  {/* References List Section */}
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
5
  import { useObservable } from '../../../hooks/useObservable';
6
6
  import type { SemiontSession } from '@semiont/sdk';
7
+ import { AssistShell } from './AssistShell';
7
8
  import { useSessionEventSubscriptions } from '../../../hooks/useSessionEventSubscriptions';
8
9
  import type { components, Selector } from '@semiont/core';
9
10
  import { getTextPositionSelector, getTargetSelector } from '@semiont/core';
@@ -111,19 +112,6 @@ export function TaggingPanel({
111
112
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
112
113
  const containerRef = useRef<HTMLDivElement>(null);
113
114
 
114
- // Collapsible detection section state - load from localStorage, default expanded
115
- const [isAssistExpanded, setIsDetectExpanded] = useState(() => {
116
- if (typeof window === 'undefined') return true;
117
- const stored = localStorage.getItem('assist-section-expanded-tag');
118
- return stored ? stored === 'true' : true;
119
- });
120
-
121
- // Persist detection section expanded state to localStorage
122
- useEffect(() => {
123
- if (typeof window === 'undefined') return;
124
- localStorage.setItem('assist-section-expanded-tag', String(isAssistExpanded));
125
- }, [isAssistExpanded]);
126
-
127
115
  // Subscribe to click events - update focused state
128
116
  // Event handler for annotation clicks (extracted to avoid inline arrow function)
129
117
  const handleAnnotationClick = useCallback(({ annotationId }: { annotationId: string }) => {
@@ -357,22 +345,14 @@ export function TaggingPanel({
357
345
 
358
346
  {/* Assist Section - only in Annotate mode */}
359
347
  {annotateMode && (
360
- <div className="semiont-panel__section">
361
- <button
362
- onClick={() => setIsDetectExpanded(!isAssistExpanded)}
363
- className="semiont-panel__section-title semiont-panel__section-title--collapsible"
364
- aria-expanded={isAssistExpanded}
365
- type="button"
366
- >
367
- <span>{t('annotateTags')}</span>
368
- <span className="semiont-panel__section-chevron" data-expanded={isAssistExpanded}>
369
-
370
- </span>
371
- </button>
372
- {isAssistExpanded && (
373
- <div className="semiont-assist-widget" data-assisting={isAssisting && progress ? 'true' : 'false'} data-type="tag">
374
- {!isAssisting && !progress && (
375
- <>
348
+ <AssistShell
349
+ assistType="tag"
350
+ title={t('annotateTags')}
351
+ isAssisting={isAssisting}
352
+ progress={progress}
353
+ progressProps={{ showPercentBar: true }}
354
+ form={
355
+ <>
376
356
  {/* Empty-state — registry has resolved with no schemas. */}
377
357
  {noSchemasRegistered && (
378
358
  <p className="semiont-form__help" data-type="tag-no-schemas">
@@ -461,64 +441,21 @@ export function TaggingPanel({
461
441
  </p>
462
442
  </div>
463
443
  )}
464
- </>
465
- )}
466
-
467
- {/* Assist Button - Always visible */}
468
- <button
469
- onClick={handleAssist}
470
- disabled={selectedCategories.size === 0 || isAssisting}
471
- className="semiont-button"
472
- data-variant="assist"
473
- data-type="tag"
474
- >
475
- <span className="semiont-button-icon">✨</span>
476
- <span>{t('annotate')}</span>
477
- </button>
478
-
479
- {/* Annotation Progress */}
480
- {isAssisting && progress && (
481
- <div className="semiont-annotation-progress" data-type="tag">
482
- {/* Request Parameters */}
483
- {progress.requestParams && progress.requestParams.length > 0 && (
484
- <div className="semiont-annotation-progress__params" data-type="tag">
485
- <div className="semiont-annotation-progress__params-title">Request Parameters:</div>
486
- {progress.requestParams.map((param, idx) => (
487
- <div key={idx} className="semiont-annotation-progress__param">
488
- <span className="semiont-annotation-progress__param-label">{param.label}:</span> {param.value}
489
- </div>
490
- ))}
491
- </div>
492
- )}
444
+ {/* Assist Button */}
445
+ <button
446
+ onClick={handleAssist}
447
+ disabled={selectedCategories.size === 0 || isAssisting}
448
+ className="semiont-button"
449
+ data-variant="assist"
450
+ data-type="tag"
451
+ >
452
+ <span className="semiont-button-icon">✨</span>
453
+ <span>{t('annotate')}</span>
454
+ </button>
493
455
 
494
- <div className="semiont-annotation-progress__status">
495
- <div className="semiont-annotation-progress__message">
496
- <span className="semiont-annotation-progress__icon">✨</span>
497
- <span>{progress.message}</span>
498
- </div>
499
- {progress.currentCategory && (
500
- <div className="semiont-annotation-progress__details">
501
- Processing: {progress.currentCategory}
502
- {progress.processedCategories !== undefined && progress.totalCategories !== undefined && (
503
- <> ({progress.processedCategories}/{progress.totalCategories})</>
504
- )}
505
- </div>
506
- )}
507
- </div>
508
- {progress.percentage !== undefined && (
509
- <div className="semiont-progress-bar">
510
- <div
511
- className="semiont-progress-bar__fill"
512
- data-type="tag"
513
- style={{ width: `${progress.percentage}%` }}
514
- />
515
- </div>
516
- )}
517
- </div>
518
- )}
519
- </div>
520
- )}
521
- </div>
456
+ </>
457
+ }
458
+ />
522
459
  )}
523
460
 
524
461
  {/* Tags list */}
@@ -6,7 +6,7 @@ import type { components, Selector } from '@semiont/core';
6
6
  type JobProgress = components['schemas']['JobProgress'];
7
7
  import type { RouteBuilder, LinkComponentProps } from '../../../contexts/RoutingContext';
8
8
  import type { SemiontSession } from '@semiont/sdk';
9
- import type { Annotator } from '../../../lib/annotation-registry';
9
+ import { annotatorKeyForMotivation, type Annotator } from '../../../lib/annotation-registry';
10
10
  import { StatisticsPanel } from './StatisticsPanel';
11
11
  import { HighlightPanel } from './HighlightPanel';
12
12
  import { ReferencesPanel } from './ReferencesPanel';
@@ -154,21 +154,13 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
154
154
  }
155
155
  }, [props.initialTabGeneration]); // Only watch generation counter, not the tab itself
156
156
 
157
- // Auto-switch to the appropriate tab when creating a new annotation
157
+ // Auto-switch to the appropriate tab when creating a new annotation. Tab
158
+ // keys are the annotator keys — derived from the registry, the single
159
+ // motivation↔annotator source, so this can't drift from ANNOTATORS.
158
160
  useEffect(() => {
159
161
  if (props.pendingAnnotation) {
160
- // Map motivation to tab (only for motivations with corresponding tabs)
161
- const motivationToTab: Partial<Record<Motivation, TabKey>> = {
162
- 'linking': 'reference',
163
- 'commenting': 'comment',
164
- 'tagging': 'tag',
165
- 'assessing': 'assessment',
166
- 'highlighting': 'highlight'
167
- };
168
- const tab = motivationToTab[props.pendingAnnotation.motivation];
169
- if (tab) {
170
- setActiveTab(tab);
171
- }
162
+ const tab = annotatorKeyForMotivation(props.pendingAnnotation.motivation);
163
+ if (tab) setActiveTab(tab);
172
164
  }
173
165
  }, [props.pendingAnnotation]);
174
166
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * AssistShell (#7) — the shared assist chrome. Pins the net behavior that
3
+ * holds across the isAssisting-prop relocation: the form/progress switch and
4
+ * the dismiss policy (dismiss is offered only once the assist is no longer
5
+ * running — the SHELL owns that policy; AssistProgress just renders whatever
6
+ * callback it is handed).
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
9
+ import { render, screen } from '@testing-library/react';
10
+ import userEvent from '@testing-library/user-event';
11
+ import '@testing-library/jest-dom';
12
+ import { AssistShell } from '../AssistShell';
13
+
14
+ const progress = { stage: 'analyzing', percentage: 50, message: 'working' };
15
+
16
+ describe('AssistShell', () => {
17
+ beforeEach(() => {
18
+ localStorage.clear();
19
+ });
20
+
21
+ it('renders the form when there is no progress, the progress when there is', () => {
22
+ const { rerender } = render(
23
+ <AssistShell assistType="tag" title="Annotate Tags" isAssisting={false} progress={null}
24
+ form={<button type="button">the form</button>} />,
25
+ );
26
+ expect(screen.getByText('the form')).toBeInTheDocument();
27
+ rerender(
28
+ <AssistShell assistType="tag" title="Annotate Tags" isAssisting={true} progress={progress}
29
+ form={<button type="button">the form</button>} />,
30
+ );
31
+ expect(screen.queryByText('the form')).not.toBeInTheDocument();
32
+ expect(screen.getByText('working')).toBeInTheDocument();
33
+ });
34
+
35
+ it('withholds dismiss while assisting, offers it once terminal', async () => {
36
+ const onDismiss = vi.fn();
37
+ const props = {
38
+ assistType: 'highlight', title: 'Annotate Highlights', progress,
39
+ form: <span>form</span>,
40
+ progressProps: { onDismiss, translations: { close: 'Close' } },
41
+ };
42
+ const { rerender } = render(<AssistShell {...props} isAssisting={true} />);
43
+ expect(screen.queryByLabelText('Close')).not.toBeInTheDocument();
44
+
45
+ rerender(<AssistShell {...props} isAssisting={false} />);
46
+ await userEvent.click(screen.getByLabelText('Close'));
47
+ expect(onDismiss).toHaveBeenCalledOnce();
48
+ });
49
+
50
+ it('persists the expand state per assist type', async () => {
51
+ render(
52
+ <AssistShell assistType="reference" title="Annotate References" isAssisting={false} progress={null}
53
+ form={<span>form</span>} />,
54
+ );
55
+ await userEvent.click(screen.getByRole('button', { name: /Annotate References/ }));
56
+ expect(screen.queryByText('form')).not.toBeInTheDocument();
57
+ expect(localStorage.getItem('assist-section-expanded-reference')).toBe('false');
58
+ });
59
+ });
@@ -306,6 +306,26 @@ describe('ReferenceEntry', () => {
306
306
 
307
307
  bindSpy.mockRestore();
308
308
  });
309
+
310
+ it('emits bind:body-error (resource-stamped, client-local) when unlink fails', async () => {
311
+ // This component has no toast surface — its catch emits the client-local
312
+ // bind error and useOutcomeToasts surfaces it. The raw
313
+ // bind:body-update-failed wire reply is busRequest plumbing, not UI.
314
+ mockIsBodyResolved.mockReturnValue(true);
315
+ mockGetBodySource.mockReturnValue('linked-doc');
316
+
317
+ const bindSpy = vi.spyOn(BindNamespace.prototype, 'body').mockRejectedValue(new Error('link is load-bearing'));
318
+ const errors: unknown[] = [];
319
+ eventBus.get('bind:body-error').subscribe(e => errors.push(e));
320
+
321
+ const { container } = renderEntry({ annotateMode: true });
322
+ await userEvent.click(container.querySelector('.semiont-reference-unlink')!);
323
+
324
+ await vi.waitFor(() => expect(errors).toHaveLength(1));
325
+ expect(errors[0]).toEqual({ resourceId: 'resource-1', message: 'link is load-bearing' });
326
+
327
+ bindSpy.mockRestore();
328
+ });
309
329
  });
310
330
 
311
331
  describe('Status icon — stub reference', () => {
@@ -51,7 +51,6 @@ vi.mock('../../../../contexts/TranslationContext', () => ({
51
51
 
52
52
  vi.mock('../AssistSection', () => ({
53
53
  AssistSection: () => null,
54
- AnnotateReferencesProgressWidget: () => null,
55
54
  }));
56
55
 
57
56
  const NINE_TYPES = [
@@ -77,16 +77,6 @@ vi.mock('../../../../contexts/TranslationContext', () => ({
77
77
  TranslationProvider: ({ children }: { children: React.ReactNode }) => children,
78
78
  }));
79
79
 
80
- // Mock AnnotateReferencesProgressWidget - simplified to avoid module import issues
81
- vi.mock('@/components/AnnotateReferencesProgressWidget', () => ({
82
- AnnotateReferencesProgressWidget: ({ progress }: any) => (
83
- <div data-testid="annotation-progress-widget">
84
- <div data-testid="progress-data">{JSON.stringify(progress)}</div>
85
- <button title="Cancel Annotation">Cancel</button>
86
- </div>
87
- ),
88
- }));
89
-
90
80
  describe('ReferencesPanel Component', () => {
91
81
  // Mock Link component
92
82
  const MockLink = ({ href, children, ...props }: any) => (
@@ -392,7 +382,7 @@ describe('ReferencesPanel Component', () => {
392
382
  />
393
383
  );
394
384
 
395
- expect(screen.getByTestId('annotation-progress-widget')).toBeInTheDocument();
385
+ expect(screen.getByText('Detecting references...')).toBeInTheDocument();
396
386
  });
397
387
 
398
388
  it('should pass progress data to widget', () => {
@@ -414,9 +404,9 @@ describe('ReferencesPanel Component', () => {
414
404
  />
415
405
  );
416
406
 
417
- const progressData = screen.getByTestId('progress-data');
418
- expect(progressData.textContent).toContain('Person');
419
- expect(progressData.textContent).toContain('Organization');
407
+ // Real AssistProgress renders the completed entity-type log.
408
+ expect(screen.getByText('Person:')).toBeInTheDocument();
409
+ expect(screen.getByText('Organization:')).toBeInTheDocument();
420
410
  });
421
411
 
422
412
  it('should hide entity type selection during detection', () => {
@@ -441,7 +431,9 @@ describe('ReferencesPanel Component', () => {
441
431
  />
442
432
  );
443
433
 
444
- const cancelButton = screen.getByTitle('Cancel Annotation');
434
+ // Real AssistProgress titles the cancel button with t('cancelAnnotation')
435
+ // (the mock translator echoes unknown keys).
436
+ const cancelButton = screen.getByTitle('cancelAnnotation');
445
437
  expect(cancelButton).toBeInTheDocument();
446
438
  });
447
439
  });
@@ -577,9 +569,11 @@ describe('ReferencesPanel Component', () => {
577
569
  />
578
570
  );
579
571
 
580
- // Should not show any log items (but selection UI should still be visible)
572
+ // Should not show any log items. Terminal progress (dismissable) is
573
+ // shown instead of the form — the AssistShell normalization (#7); the
574
+ // form returns once progress clears.
581
575
  expect(screen.queryByText('✓')).not.toBeInTheDocument();
582
- expect(screen.getByText('Select entity types')).toBeInTheDocument();
576
+ expect(screen.queryByText('Select entity types')).not.toBeInTheDocument();
583
577
  });
584
578
  });
585
579
 
@@ -600,7 +594,7 @@ describe('ReferencesPanel Component', () => {
600
594
  );
601
595
 
602
596
  // Detecting state
603
- expect(screen.getByTestId('annotation-progress-widget')).toBeInTheDocument();
597
+ expect(screen.getByText('Detecting references...')).toBeInTheDocument();
604
598
  expect(screen.queryByText('Select entity types')).not.toBeInTheDocument();
605
599
  });
606
600
 
@@ -614,7 +608,7 @@ describe('ReferencesPanel Component', () => {
614
608
  );
615
609
 
616
610
  // Detecting
617
- expect(screen.getByTestId('annotation-progress-widget')).toBeInTheDocument();
611
+ expect(screen.getByText('Detecting references...')).toBeInTheDocument();
618
612
 
619
613
  // Complete - first trigger useEffect to copy to lastDetectionLog
620
614
  rerender(
@@ -635,7 +629,7 @@ describe('ReferencesPanel Component', () => {
635
629
  <ReferencesPanel {...panelProps()} isAssisting={false} progress={null} />
636
630
  );
637
631
 
638
- expect(screen.queryByTestId('annotation-progress-widget')).not.toBeInTheDocument();
632
+ expect(screen.queryByText('Annotation complete')).not.toBeInTheDocument();
639
633
  // Both log and selection UI should be visible
640
634
  expect(screen.getByText('Person:')).toBeInTheDocument();
641
635
  expect(screen.getByText('Select entity types')).toBeInTheDocument();
@@ -311,6 +311,36 @@ describe('ResourceComposePage', () => {
311
311
 
312
312
  expect(screen.getByText('Drop file or click')).toBeInTheDocument();
313
313
  });
314
+
315
+ // The dropzone copy says "Drop file or click" — the drop half was never
316
+ // wired (the input is display:none, so native input drops can't land
317
+ // either): Chrome handled the drop by navigating to the file.
318
+ it('cancels dragover on the dropzone so the browser never takes the drop', () => {
319
+ const props = createMockProps();
320
+ const { container } = renderWithProviders(<ResourceComposePage {...props} />);
321
+
322
+ fireEvent.click(screen.getByText('Upload File').closest('button')!);
323
+ const dropzone = container.querySelector('.semiont-form__upload-dropzone')!;
324
+
325
+ // fireEvent returns false when the event was preventDefault-ed.
326
+ expect(fireEvent.dragOver(dropzone, { dataTransfer: { files: [] } })).toBe(false);
327
+ });
328
+
329
+ it('applies a dropped file: name shown, resource name defaulted', async () => {
330
+ const props = createMockProps();
331
+ const { container } = renderWithProviders(<ResourceComposePage {...props} />);
332
+
333
+ fireEvent.click(screen.getByText('Upload File').closest('button')!);
334
+ const dropzone = container.querySelector('.semiont-form__upload-dropzone')!;
335
+
336
+ const file = new File(['# notes'], 'field-notes.md', { type: 'text/markdown' });
337
+ fireEvent.drop(dropzone, { dataTransfer: { files: [file] } });
338
+
339
+ await waitFor(() => {
340
+ expect(screen.getByText('field-notes.md')).toBeInTheDocument();
341
+ });
342
+ expect(screen.getByLabelText('Resource Name')).toHaveValue('field-notes');
343
+ });
314
344
  });
315
345
 
316
346
  describe('Form Submission', () => {