@semiont/react-ui 0.5.10 → 0.5.12

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 (81) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-75GY2EDR.js} +15 -6
  3. package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +1 -0
  4. package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
  5. package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
  6. package/dist/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
  7. package/dist/chunk-VXASXU4K.js.map +1 -0
  8. package/dist/index.css +150 -112
  9. package/dist/index.css.map +1 -1
  10. package/dist/index.d.ts +334 -48
  11. package/dist/index.js +2494 -2201
  12. package/dist/index.js.map +1 -1
  13. package/dist/test-utils.d.ts +5 -1
  14. package/dist/test-utils.js +10 -4
  15. package/dist/test-utils.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/components/ResourceTagsInline.tsx +94 -11
  18. package/src/components/StatusDisplay.css +36 -0
  19. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  20. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  21. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  22. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  23. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  24. package/src/components/image-annotation/SvgDrawingCanvas.tsx +17 -5
  25. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
  26. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
  27. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +17 -4
  28. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +143 -7
  29. package/src/components/resource/AnnotateView.tsx +59 -38
  30. package/src/components/resource/BrowseView.tsx +172 -138
  31. package/src/components/resource/ResourceViewer.tsx +123 -130
  32. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  33. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  34. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  35. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  36. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  37. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  38. package/src/components/resource/__tests__/BrowseView.test.tsx +133 -53
  39. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +102 -0
  40. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  41. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  42. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  43. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
  44. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  45. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  46. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  47. package/src/components/resource/browse-renderers.tsx +85 -0
  48. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  49. package/src/components/resource/panels/AssessmentPanel.tsx +12 -5
  50. package/src/components/resource/panels/AssistSection.tsx +4 -3
  51. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  52. package/src/components/resource/panels/CommentsPanel.tsx +12 -5
  53. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  54. package/src/components/resource/panels/HighlightPanel.tsx +12 -5
  55. package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
  56. package/src/components/resource/panels/ReferencesPanel.tsx +15 -6
  57. package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
  58. package/src/components/resource/panels/TagEntry.tsx +5 -3
  59. package/src/components/resource/panels/TaggingPanel.tsx +11 -4
  60. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +15 -2
  61. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
  62. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +39 -28
  63. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
  64. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
  65. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +54 -24
  66. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
  67. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
  68. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
  69. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
  70. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
  71. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +78 -59
  72. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
  73. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
  74. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +16 -7
  75. package/src/components/toolbar/Toolbar.css +14 -0
  76. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  77. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +43 -17
  78. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
  79. package/src/styles/features/resource-viewer.css +19 -0
  80. package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
  81. package/dist/chunk-O2MD7TGE.js.map +0 -1
@@ -19,14 +19,10 @@ import { TranslationProvider } from '../../../contexts/TranslationContext';
19
19
  import { ResourceAnnotationsProvider } from '../../../contexts/ResourceAnnotationsContext';
20
20
  import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
21
21
 
22
- // Mock dependencies
23
- vi.mock('../../../hooks/useObservableBrowse', () => ({
24
- useObservableExternalNavigation: () => vi.fn(),
25
- }));
26
-
27
- // ResourceViewer (and ResourceAnnotationsContext) now resolve the client via
22
+ // ResourceViewer now takes its session as a prop; its children (BrowseView /
23
+ // AnnotateView) and ResourceAnnotationsContext still resolve the client via
28
24
  // useSemiont(). Mock useSemiont to emit a minimal session carrying a stub
29
- // client with the methods the subject component touches. The session also
25
+ // client with the methods those children touch. The session also
30
26
  // exposes `on` and `emit` stubs so useEventSubscription(s) don't explode.
31
27
  const stubClient = {
32
28
  browse: { invalidateAnnotationList: vi.fn() },
@@ -115,6 +111,7 @@ describe('ResourceViewer - Mode Switching', () => {
115
111
  <ResourceViewer
116
112
  resource={mockResource}
117
113
  annotations={mockAnnotations}
114
+ session={null}
118
115
  />
119
116
  </TestWrapper>
120
117
  );
@@ -128,6 +125,7 @@ describe('ResourceViewer - Mode Switching', () => {
128
125
  <ResourceViewer
129
126
  resource={mockResource}
130
127
  annotations={mockAnnotations}
128
+ session={null}
131
129
  />
132
130
  </TestWrapper>
133
131
  );
@@ -144,6 +142,7 @@ describe('ResourceViewer - Mode Switching', () => {
144
142
  <ResourceViewer
145
143
  resource={mockResource}
146
144
  annotations={mockAnnotations}
145
+ session={null}
147
146
  />
148
147
  </TestWrapper>
149
148
  );
@@ -158,6 +157,7 @@ describe('ResourceViewer - Mode Switching', () => {
158
157
  <ResourceViewer
159
158
  resource={mockResource}
160
159
  annotations={mockAnnotations}
160
+ session={null}
161
161
  />
162
162
  </TestWrapper>
163
163
  );
@@ -169,6 +169,7 @@ describe('ResourceViewer - Mode Switching', () => {
169
169
  <ResourceViewer
170
170
  resource={mockResource}
171
171
  annotations={mockAnnotations}
172
+ session={null}
172
173
  />
173
174
  </TestWrapper>
174
175
  );
@@ -181,6 +182,7 @@ describe('ResourceViewer - Mode Switching', () => {
181
182
  <ResourceViewer
182
183
  resource={mockResource}
183
184
  annotations={mockAnnotations}
185
+ session={null}
184
186
  />
185
187
  </TestWrapper>
186
188
  );
@@ -193,6 +195,7 @@ describe('ResourceViewer - Mode Switching', () => {
193
195
  <ResourceViewer
194
196
  resource={mockResource}
195
197
  annotations={mockAnnotations}
198
+ session={null}
196
199
  />
197
200
  </TestWrapper>
198
201
  );
@@ -209,6 +212,7 @@ describe('ResourceViewer - Mode Switching', () => {
209
212
  <ResourceViewer
210
213
  resource={mockResource}
211
214
  annotations={mockAnnotations}
215
+ session={null}
212
216
  />
213
217
  </TestWrapper>
214
218
  );
@@ -221,6 +225,7 @@ describe('ResourceViewer - Mode Switching', () => {
221
225
  <ResourceViewer
222
226
  resource={mockResource}
223
227
  annotations={mockAnnotations}
228
+ session={null}
224
229
  />
225
230
  </TestWrapper>
226
231
  );
@@ -0,0 +1,135 @@
1
+ /**
2
+ * VIEWER-TOOLBAR-OPT-OUT — `showToolbar={false}` is a supported opt-out.
3
+ *
4
+ * Tier-2 hosts (controlled props, host-composed controls) hide the built-in
5
+ * bar by CONTRACT, not by CSS-ing react-ui's internals. Hiding the bar must
6
+ * not disable any seam: the keystone here proves annotate-mode selection
7
+ * capture still emits mark.request with the bar gone.
8
+ *
9
+ * Started RED (the prop doesn't exist; bars render everywhere) → GREEN.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+ import { render, fireEvent, screen, within } from '@testing-library/react';
13
+ import '@testing-library/jest-dom';
14
+ import type { SemiontSession } from '@semiont/sdk';
15
+ import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
16
+ import { ResourceViewer } from '../ResourceViewer';
17
+
18
+ // Render the real content so jsdom text selection can target it.
19
+ vi.mock('../../CodeMirrorRenderer', () => ({
20
+ CodeMirrorRenderer: ({ content }: { content: string }) => <div className="codemirror-renderer">{content}</div>,
21
+ }));
22
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({ SvgDrawingCanvas: () => <div>svg-mock</div> }));
23
+ vi.mock('../../pdf-annotation/PdfAnnotationCanvas.client', () => ({ PdfAnnotationCanvas: () => <div>pdf-mock</div> }));
24
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
25
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
26
+
27
+ const CONTENT = 'hello world selectme end';
28
+
29
+ function makeResource(mediaType = 'text/plain'): SemiontResource & { content: string } {
30
+ return {
31
+ '@context': 'https://www.w3.org/ns/activitystreams',
32
+ '@id': 'res-1' as ResourceId,
33
+ name: 'Doc',
34
+ created: '2024-01-01T00:00:00Z',
35
+ entityTypes: [],
36
+ archived: false,
37
+ representations: [{ mediaType, byteSize: 10 }],
38
+ content: CONTENT,
39
+ };
40
+ }
41
+
42
+ const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
43
+
44
+ function fakeSession() {
45
+ const client = {
46
+ browse: { click: vi.fn(), invalidateAnnotationList: vi.fn() },
47
+ beckon: { hover: vi.fn() },
48
+ mark: { request: vi.fn(), delete: vi.fn() },
49
+ };
50
+ return { session: { client, subscribe: () => () => {} } as unknown as SemiontSession, client };
51
+ }
52
+
53
+ const bars = (c: HTMLElement) => c.querySelectorAll('.semiont-annotate-toolbar').length;
54
+
55
+ describe('ResourceViewer — showToolbar opt-out', () => {
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ window.getSelection()?.removeAllRanges();
59
+ });
60
+
61
+ it('default pins today’s behavior: the bar renders in browse and annotate modes', () => {
62
+ const { session } = fakeSession();
63
+ const browse = render(
64
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations} />,
65
+ );
66
+ expect(bars(browse.container)).toBe(1);
67
+
68
+ const annotate = render(
69
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
70
+ annotateMode={true} onAnnotateModeChange={vi.fn()} />,
71
+ );
72
+ expect(bars(annotate.container)).toBe(1);
73
+ });
74
+
75
+ it('showToolbar={false}: no bar in browse mode', () => {
76
+ const { session } = fakeSession();
77
+ const { container } = render(
78
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
79
+ showToolbar={false} />,
80
+ );
81
+ expect(bars(container)).toBe(0);
82
+ expect(screen.getByText(CONTENT)).toBeInTheDocument(); // content still renders
83
+ });
84
+
85
+ it('showToolbar={false}: no bar in any annotate render mode (text / image / pdf)', async () => {
86
+ const { session } = fakeSession();
87
+ const text = render(
88
+ <ResourceViewer session={session} resource={makeResource('text/plain')} annotations={annotations}
89
+ annotateMode={true} onAnnotateModeChange={vi.fn()} showToolbar={false} />,
90
+ );
91
+ expect(bars(text.container)).toBe(0);
92
+
93
+ const image = render(
94
+ <ResourceViewer session={session} resource={makeResource('image/png')} annotations={annotations}
95
+ annotateMode={true} onAnnotateModeChange={vi.fn()} showToolbar={false} />,
96
+ );
97
+ expect(within(image.container).getByText('svg-mock')).toBeInTheDocument();
98
+ expect(bars(image.container)).toBe(0);
99
+
100
+ const pdf = render(
101
+ <ResourceViewer session={session} resource={makeResource('application/pdf')} annotations={annotations}
102
+ annotateMode={true} onAnnotateModeChange={vi.fn()} showToolbar={false} />,
103
+ );
104
+ await within(pdf.container).findByText('pdf-mock'); // flush the lazy canvas
105
+ expect(bars(pdf.container)).toBe(0);
106
+ });
107
+
108
+ it('keystone: with the bar hidden, annotate-mode selection still emits mark.request', () => {
109
+ const { session, client } = fakeSession();
110
+ const { container } = render(
111
+ <ResourceViewer session={session} resource={makeResource('text/plain')} annotations={annotations}
112
+ annotateMode={true} onAnnotateModeChange={vi.fn()}
113
+ selectionMotivation="highlighting" onSelectionMotivationChange={vi.fn()}
114
+ showToolbar={false} />,
115
+ );
116
+ expect(bars(container)).toBe(0);
117
+
118
+ // Select "world" (offsets 6..11) inside the rendered content, then mouseup.
119
+ const contentEl = within(container).getByText(CONTENT);
120
+ const range = document.createRange();
121
+ range.setStart(contentEl.firstChild!, 6);
122
+ range.setEnd(contentEl.firstChild!, 11);
123
+ const selection = window.getSelection()!;
124
+ selection.removeAllRanges();
125
+ selection.addRange(range);
126
+
127
+ fireEvent.mouseUp(contentEl);
128
+
129
+ expect(client.mark.request).toHaveBeenCalledTimes(1);
130
+ const [source, selectors, motivation] = client.mark.request.mock.calls[0]!;
131
+ expect(String(source)).toBe('res-1');
132
+ expect(selectors).toBeTruthy();
133
+ expect(motivation).toBe('highlighting');
134
+ });
135
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * TOOLBAR-PREFS-AS-PROPS — keystone + per-pref specs.
3
+ *
4
+ * Preferences are state, not events: each of the four toolbar prefs (mode, click
5
+ * action, selection motivation, shape) is a controlled/uncontrolled prop pair.
6
+ * Controlled instances render the given value and report intents via the callback —
7
+ * never self-mutate, never touch localStorage, never hear other instances.
8
+ * Uncontrolled instances hold a plain internal default (false / 'detail' /
9
+ * 'linking' / 'rectangle') — NOT the legacy localStorage+bus behavior (that lives
10
+ * in the useToolbarPrefs() policy layer).
11
+ *
12
+ * RED ledger: authored `it.fails` (observed: 5 expected fail), flipped to `it` at
13
+ * Phase 1 GREEN. Provider-free; real AnnotateToolbar (its controls are the subject).
14
+ */
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import { render, fireEvent, within, waitFor } from '@testing-library/react';
17
+ import '@testing-library/jest-dom';
18
+ import type { SemiontSession } from '@semiont/sdk';
19
+ import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
20
+ import { ResourceViewer } from '../ResourceViewer';
21
+
22
+ vi.mock('../../CodeMirrorRenderer', () => ({ CodeMirrorRenderer: () => <div>cm-mock</div> }));
23
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({ SvgDrawingCanvas: () => <div>svg-mock</div> }));
24
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
25
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
26
+
27
+ function makeResource(mediaType = 'text/plain'): SemiontResource & { content: string } {
28
+ return {
29
+ '@context': 'https://www.w3.org/ns/activitystreams',
30
+ '@id': 'res-1' as ResourceId,
31
+ name: 'Doc',
32
+ created: '2024-01-01T00:00:00Z',
33
+ entityTypes: [],
34
+ archived: false,
35
+ representations: [{ mediaType, byteSize: 10 }],
36
+ content: 'Prefs content.',
37
+ };
38
+ }
39
+
40
+ const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
41
+
42
+ /** Session double with a live subscribe registry so specs can fire legacy broadcasts. */
43
+ function liveSession() {
44
+ const handlers = new Map<string, Set<(p: unknown) => void>>();
45
+ const client = {
46
+ browse: { click: vi.fn(), invalidateAnnotationList: vi.fn() },
47
+ beckon: { hover: vi.fn() },
48
+ mark: {
49
+ toggleMode: vi.fn(), delete: vi.fn(), request: vi.fn(),
50
+ changeSelection: vi.fn(), changeClick: vi.fn(), changeShape: vi.fn(),
51
+ },
52
+ };
53
+ const session = {
54
+ client,
55
+ subscribe: (channel: string, handler: (p: unknown) => void) => {
56
+ if (!handlers.has(channel)) handlers.set(channel, new Set());
57
+ handlers.get(channel)!.add(handler);
58
+ return () => { handlers.get(channel)!.delete(handler); };
59
+ },
60
+ } as unknown as SemiontSession;
61
+ const fire = (channel: string, payload?: unknown) =>
62
+ handlers.get(channel)?.forEach((h) => h(payload));
63
+ return { session, client, fire };
64
+ }
65
+
66
+ const isAnnotate = (c: HTMLElement) => !!c.querySelector('.semiont-annotate-view');
67
+ const isBrowse = (c: HTMLElement) => !!c.querySelector('.semiont-browse-view');
68
+
69
+ /** Open a bar dropdown group by aria-label and click an option in its menu. */
70
+ async function pick(container: HTMLElement, group: string, option: string) {
71
+ const trigger = within(container).getByLabelText(group);
72
+ fireEvent.mouseEnter(trigger.parentElement!);
73
+ await waitFor(() => {
74
+ const menu = within(container).getByRole('menu');
75
+ fireEvent.click(within(menu).getByText(option));
76
+ });
77
+ }
78
+
79
+ const PREF_KEYS = ['annotateMode', 'semiont-toolbar-click', 'semiont-toolbar-selection'];
80
+ let getSpy: ReturnType<typeof vi.spyOn>;
81
+ let setSpy: ReturnType<typeof vi.spyOn>;
82
+ const prefKeyTouched = (spy: ReturnType<typeof vi.spyOn>) =>
83
+ spy.mock.calls.some((call: unknown[]) => {
84
+ const key = String(call[0]);
85
+ return PREF_KEYS.includes(key) || key.includes('shape');
86
+ });
87
+
88
+ describe('ResourceViewer — toolbar prefs as props', () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ localStorage.clear();
92
+ getSpy = vi.spyOn(Storage.prototype, 'getItem');
93
+ setSpy = vi.spyOn(Storage.prototype, 'setItem');
94
+ });
95
+ afterEach(() => {
96
+ getSpy.mockRestore();
97
+ setSpy.mockRestore();
98
+ });
99
+
100
+ // ── Keystone: per-instance isolation across three viewers on one session ──
101
+ it('keystone: three viewers — controlled A/B independent, C uncontrolled; B\'s Mode click fires only B\'s callback', async () => {
102
+ const { session, client } = liveSession();
103
+ const onB = vi.fn();
104
+ const a = render(
105
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
106
+ annotateMode={true} clickAction="follow" onAnnotateModeChange={vi.fn()} onClickActionChange={vi.fn()} />,
107
+ );
108
+ const b = render(
109
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
110
+ annotateMode={false} onAnnotateModeChange={onB} />,
111
+ );
112
+ const c = render(
113
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations} />,
114
+ );
115
+
116
+ expect(isAnnotate(a.container)).toBe(true); // A renders its controlled mode
117
+ expect(isBrowse(b.container)).toBe(true);
118
+ expect(isBrowse(c.container)).toBe(true); // C: plain default (false)
119
+
120
+ await pick(b.container, 'Mode', 'Annotate');
121
+
122
+ expect(onB).toHaveBeenCalledWith(true); // B reports intent…
123
+ expect(isBrowse(b.container)).toBe(true); // …and does not self-flip
124
+ expect(isAnnotate(a.container)).toBe(true); // A unchanged
125
+ expect(isBrowse(c.container)).toBe(true); // C unchanged
126
+ expect(client.mark.toggleMode).not.toHaveBeenCalled(); // no global emit
127
+ expect(prefKeyTouched(getSpy)).toBe(false); // no localStorage traffic
128
+ expect(prefKeyTouched(setSpy)).toBe(false);
129
+ });
130
+
131
+ // ── Per-pref: mode ──
132
+ it('mode: uncontrolled uses the plain default and is inert to the legacy broadcast', () => {
133
+ localStorage.setItem('annotateMode', 'true'); // legacy key must be IGNORED
134
+ getSpy.mockClear(); setSpy.mockClear();
135
+ const { session, fire } = liveSession();
136
+ const u = render(
137
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations} />,
138
+ );
139
+ expect(isBrowse(u.container)).toBe(true); // plain default, not the stored 'true'
140
+ fire('mark:mode-toggled');
141
+ expect(isBrowse(u.container)).toBe(true); // inert: preference events are gone
142
+ expect(prefKeyTouched(getSpy)).toBe(false);
143
+ expect(prefKeyTouched(setSpy)).toBe(false);
144
+ });
145
+
146
+ // ── Per-pref: click action ──
147
+ it('clickAction: controlled renders the value; the bar control reports and does not mutate', async () => {
148
+ const { session, client } = liveSession();
149
+ const onChange = vi.fn();
150
+ const v = render(
151
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
152
+ clickAction="follow" onClickActionChange={onChange} />,
153
+ );
154
+ const bar = v.container.querySelector('.semiont-annotate-toolbar') as HTMLElement;
155
+ expect(within(bar).getByText('Follow')).toBeInTheDocument(); // renders the prop value
156
+
157
+ await pick(v.container, 'Click', 'Detail');
158
+ expect(onChange).toHaveBeenCalledWith('detail'); // reports intent
159
+ expect(within(bar).getByText('Follow')).toBeInTheDocument(); // does not self-mutate
160
+ expect(client.mark.changeClick).not.toHaveBeenCalled();
161
+ });
162
+
163
+ // ── Per-pref: selection motivation (annotate-mode bar) ──
164
+ it('selectionMotivation: controlled renders the value; picking reports; re-picking the current reports null', async () => {
165
+ const { session, client } = liveSession();
166
+ const onChange = vi.fn();
167
+ const v = render(
168
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
169
+ annotateMode={true} onAnnotateModeChange={vi.fn()}
170
+ selectionMotivation="highlighting" onSelectionMotivationChange={onChange} />,
171
+ );
172
+ const bar = v.container.querySelector('.semiont-annotate-toolbar') as HTMLElement;
173
+ expect(within(bar).getByText('Highlight')).toBeInTheDocument(); // renders the prop value
174
+
175
+ await pick(v.container, 'Motivation', 'Comment');
176
+ expect(onChange).toHaveBeenCalledWith('commenting');
177
+
178
+ await pick(v.container, 'Motivation', 'Highlight'); // current value → toggles off
179
+ expect(onChange).toHaveBeenCalledWith(null);
180
+ expect(client.mark.changeSelection).not.toHaveBeenCalled();
181
+ });
182
+
183
+ // ── Per-pref: shape (media-gated; image annotate bar) ──
184
+ it('shape: controlled renders the value; picking reports and does not mutate', async () => {
185
+ const { session, client } = liveSession();
186
+ const onChange = vi.fn();
187
+ const v = render(
188
+ <ResourceViewer session={session} resource={makeResource('image/png')} annotations={annotations}
189
+ annotateMode={true} onAnnotateModeChange={vi.fn()}
190
+ shape="circle" onShapeChange={onChange} />,
191
+ );
192
+ const bar = v.container.querySelector('.semiont-annotate-toolbar') as HTMLElement;
193
+ expect(within(bar).getByText('Circle')).toBeInTheDocument(); // renders the prop value
194
+
195
+ await pick(v.container, 'Shape', 'Polygon');
196
+ expect(onChange).toHaveBeenCalledWith('polygon');
197
+ expect(within(bar).getByText('Circle')).toBeInTheDocument(); // does not self-mutate
198
+ expect(client.mark.changeShape).not.toHaveBeenCalled();
199
+ });
200
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * BUG: image-browse-renderer-drops-annotations — the default renderers must
3
+ * forward what BrowseView hands them.
4
+ *
5
+ * ImageBrowseRenderer destructured only content/mimeType and mounted a bare
6
+ * ImageViewer: shape annotations invisible in browse mode (annotate mode
7
+ * proves the data). Fix: the read-only annotation canvas (drawingMode=null),
8
+ * WITH the session extension on MediaRendererProps — clicks/hover route in
9
+ * browse mode too, and the PDF renderer's pre-existing session-less click gap
10
+ * heals in the same motion (per the fork note in the bug doc).
11
+ *
12
+ * Prop-capturing canvas mocks pin the contract: mounted, given the
13
+ * annotations, read-only, session threaded. Started RED (image: no canvas at
14
+ * all; pdf: no session) → GREEN.
15
+ */
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+ import { render, within } from '@testing-library/react';
18
+ import '@testing-library/jest-dom';
19
+ import type { SemiontSession } from '@semiont/sdk';
20
+ import type { Annotation, AnnotationId } from '@semiont/core';
21
+ import { BrowseView } from '../BrowseView';
22
+
23
+ const captured = vi.hoisted(() => ({
24
+ svg: [] as Record<string, unknown>[],
25
+ pdf: [] as Record<string, unknown>[],
26
+ }));
27
+
28
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({
29
+ SvgDrawingCanvas: (props: Record<string, unknown>) => {
30
+ captured.svg.push(props);
31
+ return <div className="semiont-svg-drawing-canvas">svg-canvas-mock</div>;
32
+ },
33
+ }));
34
+ vi.mock('../../pdf-annotation/PdfAnnotationCanvas.client', () => ({
35
+ PdfAnnotationCanvas: (props: Record<string, unknown>) => {
36
+ captured.pdf.push(props);
37
+ return <div>pdf-canvas-mock</div>;
38
+ },
39
+ }));
40
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
41
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
42
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
43
+
44
+ function fakeSession(): SemiontSession {
45
+ return {
46
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
47
+ subscribe: () => () => {},
48
+ } as unknown as SemiontSession;
49
+ }
50
+
51
+ /** A shape annotation (SvgSelector region) — the image-annotation shape. */
52
+ function shapeAnnotation(): Annotation {
53
+ return {
54
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
55
+ id: 'shape-1' as AnnotationId,
56
+ type: 'Annotation',
57
+ motivation: 'highlighting',
58
+ creator: { '@type': 'Person', name: 'user@example.com' },
59
+ created: '2026-07-14T00:00:00Z',
60
+ target: {
61
+ source: 'res-1',
62
+ selector: { type: 'SvgSelector', value: '<svg><rect x="1" y="1" width="5" height="5"/></svg>' },
63
+ },
64
+ } as unknown as Annotation;
65
+ }
66
+
67
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
68
+
69
+ const baseProps = {
70
+ resourceUri: 'res-1',
71
+ annotateMode: false,
72
+ };
73
+
74
+ describe('browse-renderers — annotation + session forwarding (dispatch contract)', () => {
75
+ beforeEach(() => {
76
+ vi.clearAllMocks();
77
+ captured.svg.length = 0;
78
+ captured.pdf.length = 0;
79
+ });
80
+
81
+ it('image browse mounts the read-only annotation canvas with the annotations and session', () => {
82
+ const session = fakeSession();
83
+ const annotations = { ...emptyAnnotations, highlights: [shapeAnnotation()] };
84
+ const { container } = render(
85
+ <BrowseView {...baseProps} content="blob:image-url" mimeType="image/png"
86
+ annotations={annotations} session={session} />,
87
+ );
88
+
89
+ expect(within(container).getByText('svg-canvas-mock')).toBeInTheDocument();
90
+ expect(captured.svg).toHaveLength(1);
91
+ const props = captured.svg[0]!;
92
+ expect(props.imageUrl).toBe('blob:image-url');
93
+ expect(props.existingAnnotations).toEqual([shapeAnnotation()]); // the dropped prop
94
+ expect(props.drawingMode).toBeNull(); // read-only in browse
95
+ expect(props.session).toBe(session); // interaction parity
96
+ });
97
+
98
+ it('pdf browse keeps its annotations (pinned) and gains the session (the healed click gap)', async () => {
99
+ const session = fakeSession();
100
+ const annotations = { ...emptyAnnotations, highlights: [shapeAnnotation()] };
101
+ const { container } = render(
102
+ <BrowseView {...baseProps} content="blob:pdf-url" mimeType="application/pdf"
103
+ annotations={annotations} session={session} />,
104
+ );
105
+
106
+ await within(container).findByText('pdf-canvas-mock'); // flush the lazy canvas
107
+ expect(captured.pdf).toHaveLength(1);
108
+ const props = captured.pdf[0]!;
109
+ expect(props.existingAnnotations).toEqual([shapeAnnotation()]); // pinned — worked before
110
+ expect(props.drawingMode).toBeNull();
111
+ expect(props.session).toBe(session); // NEW — was a session-less no-op
112
+ });
113
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * EMBEDDABLE-VIEWER-COMPLETION Phase 0 — regression: the browse `text` default
3
+ * renders markdown as **formatted prose**, not raw source.
4
+ *
5
+ * The other BrowseView suites mock `react-markdown` away for simplicity, so
6
+ * nothing currently pins that the default text renderer actually *formats*
7
+ * markdown. This spec uses the real renderer and guards against a regression to
8
+ * raw/source rendering.
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { render } from '@testing-library/react';
12
+ import '@testing-library/jest-dom';
13
+ import { TextBrowseRenderer, defaultBrowseRenderers } from '../browse-renderers';
14
+
15
+ const base = { mimeType: 'text/markdown', resourceUri: 'res-1', annotations: [] };
16
+
17
+ describe('browse-renderers — markdown-as-prose (Phase 0 regression)', () => {
18
+ it('TextBrowseRenderer is the default `text` renderer', () => {
19
+ expect(defaultBrowseRenderers.text).toBe(TextBrowseRenderer);
20
+ });
21
+
22
+ it('renders markdown as formatted HTML, not raw source', () => {
23
+ const { container } = render(
24
+ <TextBrowseRenderer content={'# Big Heading\n\nsome **bold** and a [link](https://x.test).'} {...base} />,
25
+ );
26
+ // Formatted: heading / strong / anchor elements exist (react-markdown output).
27
+ expect(container.querySelector('h1')?.textContent).toBe('Big Heading');
28
+ expect(container.querySelector('strong')?.textContent).toBe('bold');
29
+ expect(container.querySelector('a')?.getAttribute('href')).toBe('https://x.test');
30
+ // NOT raw: the literal markdown syntax must not survive to the text content.
31
+ expect(container.textContent).not.toContain('# Big Heading');
32
+ expect(container.textContent).not.toContain('**bold**');
33
+ });
34
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * EMBEDDABLE-VIEWER-COMPLETION Phase 1 — inline embedding.
3
+ *
4
+ * `inline` is a display variant (default: today's pane behavior, frontend
5
+ * untouched): the viewer renders at content height in a bare container — no
6
+ * inner scroll container, no pane chrome. jsdom can't compute stylesheet
7
+ * layout, so this spec pins the two testable seams: (1) the components emit
8
+ * the `--inline` modifier classes; (2) the stylesheet contains the inline
9
+ * overrides (height:auto / overflow:visible / padding drop). The visual
10
+ * auto-height check is the plan's live smoke-test.
11
+ *
12
+ * Started RED (no `inline` prop) and GREEN once Phase 1 lands.
13
+ */
14
+ import { describe, it, expect, vi } from 'vitest';
15
+ import { render } from '@testing-library/react';
16
+ import '@testing-library/jest-dom';
17
+ import { readFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import type { SemiontSession } from '@semiont/sdk';
20
+ import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
21
+ import { BrowseView } from '../BrowseView';
22
+ import { ResourceViewer } from '../ResourceViewer';
23
+
24
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
25
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
26
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
27
+
28
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
29
+
30
+ function fakeSession(): SemiontSession {
31
+ return {
32
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
33
+ subscribe: () => () => {},
34
+ } as unknown as SemiontSession;
35
+ }
36
+
37
+ const resource: SemiontResource & { content: string } = {
38
+ '@context': 'https://www.w3.org/ns/activitystreams',
39
+ '@id': 'res-1' as ResourceId,
40
+ name: 'Doc',
41
+ created: '2024-01-01T00:00:00Z',
42
+ entityTypes: [],
43
+ archived: false,
44
+ representations: [{ mediaType: 'text/plain', byteSize: 10 }],
45
+ content: 'Inline content.',
46
+ };
47
+
48
+ describe('inline embedding (Phase 1)', () => {
49
+ it('BrowseView: `inline` adds the modifier class; default does not', () => {
50
+ const props = {
51
+ content: 'x', mimeType: 'text/plain', resourceUri: 'res-1',
52
+ annotations: emptyAnnotations, annotateMode: false, session: fakeSession(),
53
+ };
54
+ const { container: pane } = render(<BrowseView {...props} />);
55
+ expect(pane.querySelector('.semiont-browse-view')).not.toHaveClass('semiont-browse-view--inline');
56
+
57
+ const { container } = render(<BrowseView {...props} inline />);
58
+ expect(container.querySelector('.semiont-browse-view')).toHaveClass('semiont-browse-view--inline');
59
+ });
60
+
61
+ it('ResourceViewer: `inline` marks its root and threads to BrowseView', () => {
62
+ const { container } = render(
63
+ <ResourceViewer
64
+ resource={resource} annotations={emptyAnnotations}
65
+ session={fakeSession()} inline
66
+ />,
67
+ );
68
+ expect(container.querySelector('.semiont-resource-viewer')).toHaveClass('semiont-resource-viewer--inline');
69
+ expect(container.querySelector('.semiont-browse-view')).toHaveClass('semiont-browse-view--inline');
70
+ });
71
+
72
+ it('stylesheet carries the inline overrides (static gate — layout itself is the live check)', () => {
73
+ // vitest runs with cwd = the package root (same in CI's per-workspace run).
74
+ const css = readFileSync(resolve(process.cwd(), 'src/styles/features/resource-viewer.css'), 'utf8');
75
+ // Auto-height roots, and the content area stops being a scroll container / drops the pane gutter.
76
+ expect(css).toMatch(/\.semiont-resource-viewer--inline\s*\{[^}]*height:\s*auto/);
77
+ expect(css).toMatch(/\.semiont-browse-view--inline\s*\{[^}]*height:\s*auto/);
78
+ expect(css).toMatch(/\.semiont-browse-view--inline\s+\.semiont-browse-view__content\s*\{[^}]*overflow:\s*visible/);
79
+ expect(css).toMatch(/\.semiont-browse-view--inline\s+\.semiont-browse-view__content\s*\{[^}]*padding:\s*0/);
80
+ });
81
+ });