@semiont/react-ui 0.5.2 → 0.5.3

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 (130) hide show
  1. package/dist/{ar-3W37O3R3.mjs → ar-UUMMNQKF.mjs} +2 -17
  2. package/dist/ar-UUMMNQKF.mjs.map +1 -0
  3. package/dist/{bn-JZTJLMVE.mjs → bn-AL5BJSR3.mjs} +2 -17
  4. package/dist/bn-AL5BJSR3.mjs.map +1 -0
  5. package/dist/{chunk-7VWNZ5YX.mjs → chunk-EBBL3VJI.mjs} +31 -31
  6. package/dist/{chunk-NOD3NCXE.mjs → chunk-OJSRLEER.mjs} +2 -17
  7. package/dist/chunk-OJSRLEER.mjs.map +1 -0
  8. package/dist/{cs-XYHH7HNE.mjs → cs-UMINALSU.mjs} +2 -17
  9. package/dist/cs-UMINALSU.mjs.map +1 -0
  10. package/dist/{da-MZKIECVT.mjs → da-FKUX6CDL.mjs} +2 -17
  11. package/dist/da-FKUX6CDL.mjs.map +1 -0
  12. package/dist/{de-AYXTMRQW.mjs → de-XSJ3E25S.mjs} +2 -17
  13. package/dist/de-XSJ3E25S.mjs.map +1 -0
  14. package/dist/{el-A6CVQWAW.mjs → el-UJXNRCBP.mjs} +2 -17
  15. package/dist/el-UJXNRCBP.mjs.map +1 -0
  16. package/dist/{en-YPQQBI4T.mjs → en-J5DHKLQ5.mjs} +2 -2
  17. package/dist/{es-M2HXLJGT.mjs → es-VURP62BU.mjs} +2 -17
  18. package/dist/es-VURP62BU.mjs.map +1 -0
  19. package/dist/{fa-V6JZJDYP.mjs → fa-TIT5ZPZY.mjs} +2 -17
  20. package/dist/fa-TIT5ZPZY.mjs.map +1 -0
  21. package/dist/{fi-ONDTZ5H7.mjs → fi-F7VTGT4H.mjs} +2 -17
  22. package/dist/fi-F7VTGT4H.mjs.map +1 -0
  23. package/dist/{fr-PAPV4H4G.mjs → fr-2ZR26VF7.mjs} +2 -17
  24. package/dist/fr-2ZR26VF7.mjs.map +1 -0
  25. package/dist/{he-F6VTLJLW.mjs → he-BXP2KYVZ.mjs} +2 -17
  26. package/dist/he-BXP2KYVZ.mjs.map +1 -0
  27. package/dist/{hi-CFUAV4BF.mjs → hi-PSWTP3NC.mjs} +2 -17
  28. package/dist/hi-PSWTP3NC.mjs.map +1 -0
  29. package/dist/{id-NBKLCCI7.mjs → id-HO6TXGTO.mjs} +2 -17
  30. package/dist/id-HO6TXGTO.mjs.map +1 -0
  31. package/dist/index.d.mts +1 -3
  32. package/dist/index.mjs +114 -261
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/{it-SLSOWVVU.mjs → it-AGTDMBL3.mjs} +2 -17
  35. package/dist/it-AGTDMBL3.mjs.map +1 -0
  36. package/dist/{ja-L5IG4ECE.mjs → ja-TTGOVF5K.mjs} +2 -17
  37. package/dist/ja-TTGOVF5K.mjs.map +1 -0
  38. package/dist/{ko-QYMTULKK.mjs → ko-FF77IQ7N.mjs} +2 -17
  39. package/dist/ko-FF77IQ7N.mjs.map +1 -0
  40. package/dist/{ms-5DGSFKM2.mjs → ms-UPQWWIL4.mjs} +2 -17
  41. package/dist/ms-UPQWWIL4.mjs.map +1 -0
  42. package/dist/{nl-VZPCGONO.mjs → nl-W75HEPFL.mjs} +2 -17
  43. package/dist/nl-W75HEPFL.mjs.map +1 -0
  44. package/dist/{no-MF6F352I.mjs → no-R4W7W7ZU.mjs} +2 -17
  45. package/dist/no-R4W7W7ZU.mjs.map +1 -0
  46. package/dist/{pl-WIK72JUO.mjs → pl-GQC2ELWO.mjs} +2 -17
  47. package/dist/pl-GQC2ELWO.mjs.map +1 -0
  48. package/dist/{pt-RRP5ZF6A.mjs → pt-YGVT62RU.mjs} +2 -17
  49. package/dist/pt-YGVT62RU.mjs.map +1 -0
  50. package/dist/{ro-XHQLC3T7.mjs → ro-TST6XS6X.mjs} +2 -17
  51. package/dist/ro-TST6XS6X.mjs.map +1 -0
  52. package/dist/{sv-EWULDN6E.mjs → sv-TQLF6HV7.mjs} +2 -17
  53. package/dist/sv-TQLF6HV7.mjs.map +1 -0
  54. package/dist/test-utils.mjs +2 -2
  55. package/dist/{th-TGOBHFG4.mjs → th-HJUIETVR.mjs} +2 -17
  56. package/dist/th-HJUIETVR.mjs.map +1 -0
  57. package/dist/{tr-LMMPBMV7.mjs → tr-CW3C46TW.mjs} +2 -17
  58. package/dist/tr-CW3C46TW.mjs.map +1 -0
  59. package/dist/{uk-IPGRRJY6.mjs → uk-WTHZQB2U.mjs} +2 -17
  60. package/dist/uk-WTHZQB2U.mjs.map +1 -0
  61. package/dist/{vi-Q676OJQS.mjs → vi-PHWHJLKP.mjs} +2 -17
  62. package/dist/vi-PHWHJLKP.mjs.map +1 -0
  63. package/dist/{zh-F3MTWQDX.mjs → zh-MO3FCUD6.mjs} +2 -17
  64. package/dist/zh-MO3FCUD6.mjs.map +1 -0
  65. package/package.json +1 -1
  66. package/src/components/resource/panels/TagEntry.tsx +13 -2
  67. package/src/components/resource/panels/TaggingPanel.tsx +83 -41
  68. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +26 -19
  69. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +128 -38
  70. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +3 -3
  71. package/translations/ar.json +1 -16
  72. package/translations/bn.json +1 -16
  73. package/translations/cs.json +1 -16
  74. package/translations/da.json +1 -16
  75. package/translations/de.json +1 -16
  76. package/translations/el.json +1 -16
  77. package/translations/en.json +1 -16
  78. package/translations/es.json +1 -16
  79. package/translations/fa.json +1 -16
  80. package/translations/fi.json +1 -16
  81. package/translations/fr.json +1 -16
  82. package/translations/he.json +1 -16
  83. package/translations/hi.json +1 -16
  84. package/translations/id.json +1 -16
  85. package/translations/it.json +1 -16
  86. package/translations/ja.json +1 -16
  87. package/translations/ko.json +1 -16
  88. package/translations/ms.json +1 -16
  89. package/translations/nl.json +1 -16
  90. package/translations/no.json +1 -16
  91. package/translations/pl.json +1 -16
  92. package/translations/pt.json +1 -16
  93. package/translations/ro.json +1 -16
  94. package/translations/sv.json +1 -16
  95. package/translations/th.json +1 -16
  96. package/translations/tr.json +1 -16
  97. package/translations/uk.json +1 -16
  98. package/translations/vi.json +1 -16
  99. package/translations/zh.json +1 -16
  100. package/dist/ar-3W37O3R3.mjs.map +0 -1
  101. package/dist/bn-JZTJLMVE.mjs.map +0 -1
  102. package/dist/chunk-NOD3NCXE.mjs.map +0 -1
  103. package/dist/cs-XYHH7HNE.mjs.map +0 -1
  104. package/dist/da-MZKIECVT.mjs.map +0 -1
  105. package/dist/de-AYXTMRQW.mjs.map +0 -1
  106. package/dist/el-A6CVQWAW.mjs.map +0 -1
  107. package/dist/es-M2HXLJGT.mjs.map +0 -1
  108. package/dist/fa-V6JZJDYP.mjs.map +0 -1
  109. package/dist/fi-ONDTZ5H7.mjs.map +0 -1
  110. package/dist/fr-PAPV4H4G.mjs.map +0 -1
  111. package/dist/he-F6VTLJLW.mjs.map +0 -1
  112. package/dist/hi-CFUAV4BF.mjs.map +0 -1
  113. package/dist/id-NBKLCCI7.mjs.map +0 -1
  114. package/dist/it-SLSOWVVU.mjs.map +0 -1
  115. package/dist/ja-L5IG4ECE.mjs.map +0 -1
  116. package/dist/ko-QYMTULKK.mjs.map +0 -1
  117. package/dist/ms-5DGSFKM2.mjs.map +0 -1
  118. package/dist/nl-VZPCGONO.mjs.map +0 -1
  119. package/dist/no-MF6F352I.mjs.map +0 -1
  120. package/dist/pl-WIK72JUO.mjs.map +0 -1
  121. package/dist/pt-RRP5ZF6A.mjs.map +0 -1
  122. package/dist/ro-XHQLC3T7.mjs.map +0 -1
  123. package/dist/sv-EWULDN6E.mjs.map +0 -1
  124. package/dist/th-TGOBHFG4.mjs.map +0 -1
  125. package/dist/tr-LMMPBMV7.mjs.map +0 -1
  126. package/dist/uk-IPGRRJY6.mjs.map +0 -1
  127. package/dist/vi-Q676OJQS.mjs.map +0 -1
  128. package/dist/zh-F3MTWQDX.mjs.map +0 -1
  129. /package/dist/{chunk-7VWNZ5YX.mjs.map → chunk-EBBL3VJI.mjs.map} +0 -0
  130. /package/dist/{en-YPQQBI4T.mjs.map → en-J5DHKLQ5.mjs.map} +0 -0
@@ -9,7 +9,6 @@ import type { components, Selector } from '@semiont/core';
9
9
  import { getTextPositionSelector, getTargetSelector } from '@semiont/core';
10
10
  import { TagEntry } from './TagEntry';
11
11
  import { PanelHeader } from './PanelHeader';
12
- import { getAllTagSchemas } from '../../../lib/tag-schemas';
13
12
  import './TaggingPanel.css';
14
13
 
15
14
  import type { Annotation } from '@semiont/core';
@@ -76,8 +75,34 @@ export function TaggingPanel({
76
75
  }: TaggingPanelProps) {
77
76
  const t = useTranslations('TaggingPanel');
78
77
  const session = useObservable(useSemiont().activeSession$);
79
- const [selectedSchemaId, setSelectedSchemaId] = useState<string>('legal-irac');
78
+
79
+ // Subscribe to the per-KB tag-schema registry. Schemas are runtime-
80
+ // registered by the KB at session start (see frame.addTagSchema).
81
+ // During the initial load the observable yields `undefined` — render an
82
+ // empty schemas list and let the picker render no options until the
83
+ // first emission lands.
84
+ const tagSchemas$ = useMemo(
85
+ () => session?.client.browse.tagSchemas() ?? null,
86
+ [session],
87
+ );
88
+ const schemasObserved = useObservable(tagSchemas$);
89
+ const schemas = schemasObserved ?? [];
90
+ // True only AFTER the registry has resolved AND it's empty — distinct
91
+ // from the initial-loading state (`schemasObserved === undefined`),
92
+ // which renders nothing rather than an empty-state message.
93
+ const noSchemasRegistered = schemasObserved !== undefined && schemasObserved.length === 0;
94
+
95
+ const [selectedSchemaId, setSelectedSchemaId] = useState<string>('');
80
96
  const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
97
+
98
+ // Default the schema selection to the first registered schema once
99
+ // the registry resolves. We don't reset on schemas changing to avoid
100
+ // clobbering an explicit user choice.
101
+ useEffect(() => {
102
+ if (!selectedSchemaId && schemas.length > 0) {
103
+ setSelectedSchemaId(schemas[0]!.id);
104
+ }
105
+ }, [schemas, selectedSchemaId]);
81
106
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
82
107
  const containerRef = useRef<HTMLDivElement>(null);
83
108
 
@@ -165,7 +190,6 @@ export function TaggingPanel({
165
190
  // Pulse effect is handled by isHovered prop on TagEntry
166
191
  }, [hoveredAnnotationId]);
167
192
 
168
- const schemas = getAllTagSchemas();
169
193
  const selectedSchema = schemas.find(s => s.id === selectedSchemaId);
170
194
 
171
195
  const handleSchemaChange = (schemaId: string) => {
@@ -248,23 +272,32 @@ export function TaggingPanel({
248
272
  </p>
249
273
  </div>
250
274
 
275
+ {/* Empty-state — registry has resolved with no schemas. */}
276
+ {noSchemasRegistered && (
277
+ <p className="semiont-form__help" data-type="tag-no-schemas">
278
+ {t('noSchemas')}
279
+ </p>
280
+ )}
281
+
251
282
  {/* Schema and Category Selection for Manual Tag */}
252
- <div className="semiont-form-field">
253
- <label className="semiont-form-field__label">
254
- {t('selectSchema')}
255
- </label>
256
- <select
257
- value={selectedSchemaId}
258
- onChange={(e) => handleSchemaChange(e.target.value)}
259
- className="semiont-select"
260
- >
261
- {schemas.map(schema => (
262
- <option key={schema.id} value={schema.id}>
263
- {t(`schema${schema.id === 'legal-irac' ? 'Legal' : schema.id === 'scientific-imrad' ? 'Scientific' : 'Argument'}`)}
264
- </option>
265
- ))}
266
- </select>
267
- </div>
283
+ {!noSchemasRegistered && (
284
+ <div className="semiont-form-field">
285
+ <label className="semiont-form-field__label">
286
+ {t('selectSchema')}
287
+ </label>
288
+ <select
289
+ value={selectedSchemaId}
290
+ onChange={(e) => handleSchemaChange(e.target.value)}
291
+ className="semiont-select"
292
+ >
293
+ {schemas.map(schema => (
294
+ <option key={schema.id} value={schema.id}>
295
+ {schema.name}
296
+ </option>
297
+ ))}
298
+ </select>
299
+ </div>
300
+ )}
268
301
 
269
302
  {selectedSchema && (
270
303
  <div className="semiont-form-field">
@@ -334,28 +367,37 @@ export function TaggingPanel({
334
367
  <div className="semiont-assist-widget" data-assisting={isAssisting && progress ? 'true' : 'false'} data-type="tag">
335
368
  {!isAssisting && !progress && (
336
369
  <>
370
+ {/* Empty-state — registry has resolved with no schemas. */}
371
+ {noSchemasRegistered && (
372
+ <p className="semiont-form__help" data-type="tag-no-schemas">
373
+ {t('noSchemas')}
374
+ </p>
375
+ )}
376
+
337
377
  {/* Schema Selector */}
338
- <div className="semiont-form-field">
339
- <label className="semiont-form-field__label">
340
- {t('selectSchema')}
341
- </label>
342
- <select
343
- value={selectedSchemaId}
344
- onChange={(e) => handleSchemaChange(e.target.value)}
345
- className="semiont-select"
346
- >
347
- {schemas.map(schema => (
348
- <option key={schema.id} value={schema.id}>
349
- {t(`schema${schema.id === 'legal-irac' ? 'Legal' : schema.id === 'scientific-imrad' ? 'Scientific' : 'Argument'}`)}
350
- </option>
351
- ))}
352
- </select>
353
- {selectedSchema && (
354
- <p className="semiont-form__help">
355
- {selectedSchema.description}
356
- </p>
357
- )}
358
- </div>
378
+ {!noSchemasRegistered && (
379
+ <div className="semiont-form-field">
380
+ <label className="semiont-form-field__label">
381
+ {t('selectSchema')}
382
+ </label>
383
+ <select
384
+ value={selectedSchemaId}
385
+ onChange={(e) => handleSchemaChange(e.target.value)}
386
+ className="semiont-select"
387
+ >
388
+ {schemas.map(schema => (
389
+ <option key={schema.id} value={schema.id}>
390
+ {schema.name}
391
+ </option>
392
+ ))}
393
+ </select>
394
+ {selectedSchema && (
395
+ <p className="semiont-form__help">
396
+ {selectedSchema.description}
397
+ </p>
398
+ )}
399
+ </div>
400
+ )}
359
401
 
360
402
  {/* Category Selector */}
361
403
  {selectedSchema && (
@@ -398,7 +440,7 @@ export function TaggingPanel({
398
440
  style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}
399
441
  >
400
442
  <span style={{ fontWeight: 500 }}>
401
- {t(`category${category.name.replace(/\s+/g, '')}`)}
443
+ {category.name}
402
444
  </span>
403
445
  <span style={{ fontSize: 'var(--semiont-text-xs)', color: 'var(--semiont-text-secondary)' }}>
404
446
  {category.description}
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import React from 'react';
3
- import { screen } from '@testing-library/react';
3
+ import { render, screen } from '@testing-library/react';
4
4
  import '@testing-library/jest-dom';
5
- import { renderWithProviders } from '../../../../test-utils';
5
+ import { of } from 'rxjs';
6
+ import { CacheObservable } from '@semiont/sdk';
7
+ import { renderWithProviders, createTestSemiontWrapper } from '../../../../test-utils';
6
8
  import userEvent from '@testing-library/user-event';
7
- import type { components } from '@semiont/core';
9
+ import type { components, TagSchema } from '@semiont/core';
8
10
 
9
11
  import type { Annotation } from '@semiont/core';
10
12
 
@@ -23,21 +25,15 @@ vi.mock('@semiont/ontology', () => ({
23
25
  getTagSchemaId: vi.fn(),
24
26
  }));
25
27
 
26
- // Mock tag-schemas
27
- vi.mock('../../../../lib/tag-schemas', () => ({
28
- getTagSchema: vi.fn(),
29
- }));
30
-
31
28
  import { getAnnotationExactText } from '@semiont/core';
32
29
  import { getTagCategory, getTagSchemaId } from '@semiont/ontology';
33
- import { getTagSchema } from '../../../../lib/tag-schemas';
34
30
  import type { MockedFunction } from 'vitest';
35
31
  import { TagEntry } from '../TagEntry';
36
32
 
37
33
  const mockGetAnnotationExactText = getAnnotationExactText as MockedFunction<typeof getAnnotationExactText>;
38
34
  const mockGetTagCategory = getTagCategory as MockedFunction<typeof getTagCategory>;
39
35
  const mockGetTagSchemaId = getTagSchemaId as MockedFunction<typeof getTagSchemaId>;
40
- const mockGetTagSchema = getTagSchema as MockedFunction<typeof getTagSchema>;
36
+
41
37
 
42
38
  const createMockTag = (overrides?: Partial<Annotation>): Annotation => ({
43
39
  '@context': 'http://www.w3.org/ns/anno.jsonld',
@@ -76,7 +72,6 @@ describe('TagEntry', () => {
76
72
  mockGetAnnotationExactText.mockReturnValue('Tagged text content');
77
73
  mockGetTagCategory.mockReturnValue('Entity');
78
74
  mockGetTagSchemaId.mockReturnValue(null);
79
- mockGetTagSchema.mockReturnValue(null);
80
75
  });
81
76
 
82
77
  describe('Rendering', () => {
@@ -115,24 +110,36 @@ describe('TagEntry', () => {
115
110
 
116
111
  it('should render schema name when available', () => {
117
112
  mockGetTagSchemaId.mockReturnValue('schema-ner-v1');
118
- mockGetTagSchema.mockReturnValue({
113
+ const NER_SCHEMA: TagSchema = {
119
114
  id: 'schema-ner-v1',
120
115
  name: 'Named Entity Recognition',
116
+ description: 'NER',
121
117
  domain: 'nlp',
122
- version: '1.0',
123
- categories: [],
124
- });
125
-
126
- renderWithProviders(<TagEntry {...defaultProps} />);
118
+ tags: [],
119
+ };
120
+
121
+ // Stub the cache to resolve immediately with the test schema —
122
+ // exercises the rendering path without round-tripping through the
123
+ // transport's HTTP plumbing.
124
+ const { SemiontWrapper, client } = createTestSemiontWrapper();
125
+ vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
126
+ CacheObservable.from(of([NER_SCHEMA]))
127
+ );
128
+ render(<TagEntry {...defaultProps} />, { wrapper: SemiontWrapper });
127
129
 
128
130
  expect(screen.getByText('Named Entity Recognition')).toBeInTheDocument();
129
131
  });
130
132
 
131
133
  it('should not render schema name when schema is not found', () => {
132
134
  mockGetTagSchemaId.mockReturnValue('unknown-schema');
133
- mockGetTagSchema.mockReturnValue(null);
134
135
 
135
- const { container } = renderWithProviders(<TagEntry {...defaultProps} />);
136
+ // Stub the cache to resolve to an empty list — the schema lookup
137
+ // misses, the schema-name `<span>` is not rendered.
138
+ const { SemiontWrapper, client } = createTestSemiontWrapper();
139
+ vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
140
+ CacheObservable.from(of([]))
141
+ );
142
+ const { container } = render(<TagEntry {...defaultProps} />, { wrapper: SemiontWrapper });
136
143
 
137
144
  expect(container.querySelector('.semiont-annotation-entry__meta')).not.toBeInTheDocument();
138
145
  });
@@ -4,8 +4,10 @@ import React from 'react';
4
4
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
6
  import '@testing-library/jest-dom';
7
+ import { of } from 'rxjs';
8
+ import { CacheObservable } from '@semiont/sdk';
7
9
  import { TaggingPanel } from '../TaggingPanel';
8
- import type { components, EventBus } from '@semiont/core';
10
+ import type { components, EventBus, TagSchema } from '@semiont/core';
9
11
  import { createTestSemiontWrapper } from '../../../../test-utils';
10
12
 
11
13
  import type { Annotation } from '@semiont/core';
@@ -32,8 +34,30 @@ function createEventTracker() {
32
34
  };
33
35
  }
34
36
 
37
+ // Test tag schemas — the panel subscribes to `client.browse.tagSchemas()`.
38
+ // We stub that method directly to return a `CacheObservable` that emits
39
+ // these schemas synchronously, mirroring the post-resolve cache state
40
+ // without the round-trip through bus/transport plumbing.
41
+ const TEST_TAG_SCHEMAS: TagSchema[] = [
42
+ {
43
+ id: 'legal-irac',
44
+ name: 'Legal (IRAC)',
45
+ description: 'Issue, Rule, Application, Conclusion framework for legal analysis',
46
+ domain: 'legal',
47
+ tags: [
48
+ { name: 'Issue', description: 'Legal question to be resolved', examples: [] },
49
+ { name: 'Rule', description: 'Legal principle or statute', examples: [] },
50
+ { name: 'Application', description: 'Application of rule to facts', examples: [] },
51
+ { name: 'Conclusion', description: 'Resolution of the issue', examples: [] },
52
+ ],
53
+ },
54
+ ];
55
+
35
56
  const renderWithEventBus = (component: React.ReactElement, tracker?: ReturnType<typeof createEventTracker>) => {
36
- const { SemiontWrapper, eventBus } = createTestSemiontWrapper();
57
+ const { SemiontWrapper, eventBus, client } = createTestSemiontWrapper();
58
+ vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
59
+ CacheObservable.from(of(TEST_TAG_SCHEMAS))
60
+ );
37
61
  if (tracker) tracker._attach(eventBus);
38
62
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
39
63
  <SemiontWrapper>{children}</SemiontWrapper>
@@ -41,20 +65,36 @@ const renderWithEventBus = (component: React.ReactElement, tracker?: ReturnType<
41
65
  return render(component, { wrapper: Wrapper });
42
66
  };
43
67
 
44
- // Mock TranslationContext
68
+ // Variant for the empty-registry case: the cache resolves to `[]`
69
+ // (post-bootstrap, no schemas registered). Distinct from the still-
70
+ // loading case where the observable yields `undefined`.
71
+ const renderWithEmptyRegistry = (component: React.ReactElement) => {
72
+ const { SemiontWrapper, client } = createTestSemiontWrapper();
73
+ vi.spyOn(client.browse, 'tagSchemas').mockReturnValue(
74
+ CacheObservable.from(of([]))
75
+ );
76
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
77
+ <SemiontWrapper>{children}</SemiontWrapper>
78
+ );
79
+ return render(component, { wrapper: Wrapper });
80
+ };
81
+
82
+ // Mock TranslationContext. The component now uses `schema.name` /
83
+ // `category.name` directly off the registered TagSchema objects (Stage 2.B
84
+ // of TAG-SCHEMAS-GAP), so the per-schema/per-category translation keys
85
+ // the older mock carried (`schemaLegal`, `categoryIssue`, etc.) are no
86
+ // longer referenced — kept the mock minimal.
45
87
  vi.mock('../../../../contexts/TranslationContext', () => ({
46
88
  useTranslations: vi.fn(() => (key: string, params?: Record<string, any>) => {
47
89
  const translations: Record<string, string> = {
48
90
  title: 'Tags',
49
91
  noTags: 'No tags yet. Select text to add a tag.',
92
+ noSchemas: 'No tag schemas registered for this knowledge base.',
50
93
  createTagForSelection: 'Create tag for selection',
51
94
  selectSchema: 'Select schema',
52
95
  selectCategory: 'Select category',
53
96
  selectCategories: 'Select categories',
54
97
  chooseCategory: 'Choose a category',
55
- schemaLegal: 'Legal (IRAC)',
56
- schemaScientific: 'Scientific (IMRAD)',
57
- schemaArgument: 'Argument',
58
98
  annotateTags: 'Annotate Tags',
59
99
  annotate: 'Annotate',
60
100
  cancel: 'Cancel',
@@ -62,10 +102,6 @@ vi.mock('../../../../contexts/TranslationContext', () => ({
62
102
  selectAll: 'Select All',
63
103
  deselectAll: 'Deselect All',
64
104
  categoriesSelected: '{count} categories selected',
65
- categoryIssue: 'Issue',
66
- categoryRule: 'Rule',
67
- categoryApplication: 'Application',
68
- categoryConclusion: 'Conclusion',
69
105
  };
70
106
  let result = translations[key] || key;
71
107
  if (params?.count !== undefined) {
@@ -95,23 +131,6 @@ vi.mock('../TagEntry', () => ({
95
131
  ),
96
132
  }));
97
133
 
98
- // Mock tag schemas
99
- vi.mock('../../../../lib/tag-schemas', () => ({
100
- getAllTagSchemas: vi.fn(() => [
101
- {
102
- id: 'legal-irac',
103
- name: 'Legal (IRAC)',
104
- description: 'Issue, Rule, Application, Conclusion framework for legal analysis',
105
- tags: [
106
- { name: 'Issue', description: 'Legal question to be resolved', color: '#3b82f6' },
107
- { name: 'Rule', description: 'Legal principle or statute', color: '#10b981' },
108
- { name: 'Application', description: 'Application of rule to facts', color: '#f59e0b' },
109
- { name: 'Conclusion', description: 'Resolution of the issue', color: '#ef4444' },
110
- ],
111
- },
112
- ]),
113
- }));
114
-
115
134
  import { getTextPositionSelector, getTargetSelector } from '@semiont/core';
116
135
  const mockGetTextPositionSelector = getTextPositionSelector as MockedFunction<typeof getTextPositionSelector>;
117
136
  const mockGetTargetSelector = getTargetSelector as MockedFunction<typeof getTargetSelector>;
@@ -307,7 +326,7 @@ describe('TaggingPanel Component', () => {
307
326
  expect(selects.length).toBeGreaterThan(0);
308
327
  });
309
328
 
310
- it('should show category selector in tag creation form', () => {
329
+ it('should show category selector in tag creation form', async () => {
311
330
  const pendingAnnotation = createPendingAnnotation('Selected text');
312
331
 
313
332
  renderWithEventBus(
@@ -317,7 +336,10 @@ describe('TaggingPanel Component', () => {
317
336
  />
318
337
  );
319
338
 
320
- expect(screen.getByText(/Select category/)).toBeInTheDocument();
339
+ // The category selector only renders once the schema list has
340
+ // resolved and the default schema (first registered) has been
341
+ // picked — async because the schemas come from `browse.tagSchemas()`.
342
+ expect(await screen.findByText(/Select category/)).toBeInTheDocument();
321
343
  });
322
344
 
323
345
  it('should emit mark:submitevent when category is selected', async () => {
@@ -332,7 +354,10 @@ describe('TaggingPanel Component', () => {
332
354
  tracker
333
355
  );
334
356
 
335
- // Find the category selector (the one in the pending annotation form)
357
+ // Wait for the schema list to load `browse.tagSchemas()` is
358
+ // async, so the category dropdown only renders after the bus
359
+ // response lands.
360
+ await screen.findByText(/Select category/);
336
361
  const categorySelects = screen.getAllByRole('combobox');
337
362
  const categorySelect = categorySelects.find(select =>
338
363
  select.querySelector('option[value=""]')?.textContent === 'Choose a category'
@@ -366,6 +391,7 @@ describe('TaggingPanel Component', () => {
366
391
  tracker
367
392
  );
368
393
 
394
+ await screen.findByText(/Select category/);
369
395
  const categorySelects = screen.getAllByRole('combobox');
370
396
  const categorySelect = categorySelects.find(select =>
371
397
  select.querySelector('option[value=""]')?.textContent === 'Choose a category'
@@ -474,7 +500,7 @@ describe('TaggingPanel Component', () => {
474
500
  expect(selects.length).toBeGreaterThan(0);
475
501
  });
476
502
 
477
- it('should show Select All and Deselect All buttons', () => {
503
+ it('should show Select All and Deselect All buttons', async () => {
478
504
  renderWithEventBus(
479
505
  <TaggingPanel
480
506
  {...defaultProps}
@@ -482,11 +508,11 @@ describe('TaggingPanel Component', () => {
482
508
  />
483
509
  );
484
510
 
485
- expect(screen.getByText('Select All')).toBeInTheDocument();
511
+ expect(await screen.findByText('Select All')).toBeInTheDocument();
486
512
  expect(screen.getByText('Deselect All')).toBeInTheDocument();
487
513
  });
488
514
 
489
- it('should show category checkboxes', () => {
515
+ it('should show category checkboxes', async () => {
490
516
  renderWithEventBus(
491
517
  <TaggingPanel
492
518
  {...defaultProps}
@@ -494,7 +520,9 @@ describe('TaggingPanel Component', () => {
494
520
  />
495
521
  );
496
522
 
497
- expect(screen.getByText('Issue')).toBeInTheDocument();
523
+ // Categories appear once `browse.tagSchemas()` resolves and the
524
+ // default schema is selected.
525
+ expect(await screen.findByText('Issue')).toBeInTheDocument();
498
526
  expect(screen.getByText('Rule')).toBeInTheDocument();
499
527
  expect(screen.getByText('Application')).toBeInTheDocument();
500
528
  expect(screen.getByText('Conclusion')).toBeInTheDocument();
@@ -520,7 +548,7 @@ describe('TaggingPanel Component', () => {
520
548
  />
521
549
  );
522
550
 
523
- const issueCheckbox = screen.getByLabelText(/Issue/);
551
+ const issueCheckbox = await screen.findByLabelText(/Issue/);
524
552
  await userEvent.click(issueCheckbox);
525
553
 
526
554
  const annotateButton = screen.getByRole('button', { name: /✨\s*Annotate/i });
@@ -537,7 +565,7 @@ describe('TaggingPanel Component', () => {
537
565
  tracker
538
566
  );
539
567
 
540
- const issueCheckbox = screen.getByLabelText(/Issue/);
568
+ const issueCheckbox = await screen.findByLabelText(/Issue/);
541
569
  const ruleCheckbox = screen.getByLabelText(/Rule/);
542
570
 
543
571
  await userEvent.click(issueCheckbox);
@@ -581,7 +609,7 @@ describe('TaggingPanel Component', () => {
581
609
  expect(headings[0]).toHaveClass('semiont-panel-header__text');
582
610
  });
583
611
 
584
- it('should have proper checkbox labels', () => {
612
+ it('should have proper checkbox labels', async () => {
585
613
  renderWithEventBus(
586
614
  <TaggingPanel
587
615
  {...defaultProps}
@@ -589,8 +617,70 @@ describe('TaggingPanel Component', () => {
589
617
  />
590
618
  );
591
619
 
592
- expect(screen.getByLabelText(/Issue/)).toBeInTheDocument();
620
+ expect(await screen.findByLabelText(/Issue/)).toBeInTheDocument();
593
621
  expect(screen.getByLabelText(/Rule/)).toBeInTheDocument();
594
622
  });
595
623
  });
624
+
625
+ describe('Empty registry (no tag schemas registered)', () => {
626
+ // The empty path: `browse.tagSchemas()` resolves to `[]` (KB has
627
+ // not run `register-tag-schemas` yet, no skill has registered a
628
+ // schema either). The panel should surface a clear message in
629
+ // both contexts where the schema picker would otherwise render —
630
+ // not just leave the dropdown empty.
631
+
632
+ it('shows the noSchemas message in the assist section instead of the picker', async () => {
633
+ renderWithEmptyRegistry(
634
+ <TaggingPanel {...defaultProps} annotateMode={true} />
635
+ );
636
+
637
+ // The empty-state message renders…
638
+ expect(
639
+ await screen.findByText(/No tag schemas registered for this knowledge base/i),
640
+ ).toBeInTheDocument();
641
+
642
+ // …and the picker UI does NOT (the form-field label `Select schema`
643
+ // is gated on `!noSchemasRegistered`).
644
+ expect(screen.queryByLabelText(/Select schema/i)).not.toBeInTheDocument();
645
+ });
646
+
647
+ it('shows the noSchemas message in the pending tag-creation form instead of the picker', async () => {
648
+ const pendingAnnotation = createPendingAnnotation('Selected text');
649
+
650
+ renderWithEmptyRegistry(
651
+ <TaggingPanel
652
+ {...defaultProps}
653
+ pendingAnnotation={pendingAnnotation}
654
+ annotateMode={false}
655
+ />
656
+ );
657
+
658
+ // The pending form opens. With annotateMode={false} the assist
659
+ // section is skipped so we get exactly one empty-state message —
660
+ // the one inside the pending form. (Default annotateMode=true
661
+ // renders the message in both places, which is the right product
662
+ // behavior; a separate test covers the assist-section path.)
663
+ expect(screen.getByText(/Create tag for selection/)).toBeInTheDocument();
664
+ expect(
665
+ await screen.findByText(/No tag schemas registered for this knowledge base/i),
666
+ ).toBeInTheDocument();
667
+ // No "Select category" label — the second dropdown renders only
668
+ // when `selectedSchema` exists, which requires a schema to be
669
+ // registered first.
670
+ expect(screen.queryByText(/Select category/i)).not.toBeInTheDocument();
671
+ });
672
+
673
+ it('keeps the panel rendering tags in the list section even with an empty registry', () => {
674
+ // The existing tag annotations on the resource still render —
675
+ // schema-registration is a write-side concern; reading existing
676
+ // tags doesn't depend on the registry being populated.
677
+ renderWithEmptyRegistry(
678
+ <TaggingPanel {...defaultProps} annotations={mockTags.multiple} />
679
+ );
680
+
681
+ expect(screen.getByTestId('tag-1')).toBeInTheDocument();
682
+ expect(screen.getByTestId('tag-2')).toBeInTheDocument();
683
+ expect(screen.getByTestId('tag-3')).toBeInTheDocument();
684
+ });
685
+ });
596
686
  });
@@ -12,7 +12,7 @@ import {
12
12
  LightBulbIcon
13
13
  } from '@heroicons/react/24/outline';
14
14
  import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
15
- import type { TagSchema } from '@semiont/react-ui';
15
+ import type { TagSchema } from '@semiont/sdk';
16
16
 
17
17
  export interface TagSchemasPageProps {
18
18
  // Data props
@@ -43,7 +43,7 @@ const domainIcons: Record<string, React.ComponentType<any>> = {
43
43
  general: LightBulbIcon
44
44
  };
45
45
 
46
- const domainClasses = {
46
+ const domainClasses: Record<string, string> = {
47
47
  legal: 'semiont-schema-domain--legal',
48
48
  scientific: 'semiont-schema-domain--scientific',
49
49
  general: 'semiont-schema-domain--general'
@@ -84,7 +84,7 @@ export function TagSchemasPage({
84
84
  <div className="semiont-card-grid semiont-card-grid--two-columns">
85
85
  {schemas.map((schema) => {
86
86
  const Icon = domainIcons[schema.domain] || LightBulbIcon;
87
- const domainClass = domainClasses[schema.domain] || domainClasses.general;
87
+ const domainClass = domainClasses[schema.domain] || domainClasses.general!;
88
88
 
89
89
  return (
90
90
  <div
@@ -148,25 +148,10 @@
148
148
  "TaggingPanel": {
149
149
  "title": "العلامات",
150
150
  "noTags": "لا توجد علامات بعد. استخدم التوضيح بالذكاء الاصطناعي لتحديد الأدوار الهيكلية.",
151
+ "noSchemas": "لا توجد مخططات وسوم مسجلة لقاعدة المعارف هذه. يجب على مسؤول قاعدة المعارف تسجيل مخطط تحليل هيكلي واحد على الأقل (مثلاً عبر مهارة `register-tag-schemas`) قبل استخدام الوسم.",
151
152
  "annotateTags": "توضيح العلامات",
152
153
  "selectSchema": "اختيار الإطار",
153
- "schemaLegal": "التحليل القانوني (IRAC)",
154
- "schemaScientific": "الورقة العلمية (IMRAD)",
155
- "schemaArgument": "بنية الحجة (Toulmin)",
156
154
  "selectCategories": "اختيار الفئات",
157
- "categoryIssue": "القضية",
158
- "categoryRule": "القاعدة",
159
- "categoryApplication": "التطبيق",
160
- "categoryConclusion": "الخاتمة",
161
- "categoryIntroduction": "المقدمة",
162
- "categoryMethods": "المناهج",
163
- "categoryResults": "النتائج",
164
- "categoryDiscussion": "المناقشة",
165
- "categoryClaim": "الادعاء",
166
- "categoryEvidence": "الدليل",
167
- "categoryWarrant": "المبرر",
168
- "categoryCounterargument": "الحجة المضادة",
169
- "categoryRebuttal": "الدحض",
170
155
  "annotate": "توضيح",
171
156
  "annotating": "جارٍ التوضيح...",
172
157
  "cancel": "إلغاء",
@@ -148,25 +148,10 @@
148
148
  "TaggingPanel": {
149
149
  "title": "ট্যাগসমূহ",
150
150
  "noTags": "এখনো কোনো ট্যাগ নেই। কাঠামোগত ভূমিকা শনাক্ত করতে AI টীকাকরণ ব্যবহার করুন।",
151
+ "noSchemas": "এই জ্ঞান ভাণ্ডারের জন্য কোনো ট্যাগ স্কিমা নিবন্ধিত নেই। ট্যাগিং ব্যবহার করার আগে একজন KB প্রশাসককে কমপক্ষে একটি কাঠামোগত-বিশ্লেষণ স্কিমা নিবন্ধন করতে হবে (যেমন `register-tag-schemas` দক্ষতার মাধ্যমে)।",
151
152
  "annotateTags": "ট্যাগ টীকাকরণ",
152
153
  "selectSchema": "ফ্রেমওয়ার্ক নির্বাচন করুন",
153
- "schemaLegal": "আইনি বিশ্লেষণ (IRAC)",
154
- "schemaScientific": "বৈজ্ঞানিক গবেষণাপত্র (IMRAD)",
155
- "schemaArgument": "যুক্তি কাঠামো (Toulmin)",
156
154
  "selectCategories": "বিভাগ নির্বাচন করুন",
157
- "categoryIssue": "বিষয়",
158
- "categoryRule": "নিয়ম",
159
- "categoryApplication": "প্রয়োগ",
160
- "categoryConclusion": "উপসংহার",
161
- "categoryIntroduction": "ভূমিকা",
162
- "categoryMethods": "পদ্ধতি",
163
- "categoryResults": "ফলাফল",
164
- "categoryDiscussion": "আলোচনা",
165
- "categoryClaim": "দাবি",
166
- "categoryEvidence": "প্রমাণ",
167
- "categoryWarrant": "যুক্তিসূত্র",
168
- "categoryCounterargument": "প্রতিযুক্তি",
169
- "categoryRebuttal": "খণ্ডন",
170
155
  "annotate": "টীকাকরণ",
171
156
  "annotating": "টীকাকরণ হচ্ছে...",
172
157
  "cancel": "বাতিল করুন",
@@ -148,25 +148,10 @@
148
148
  "TaggingPanel": {
149
149
  "title": "Štítky",
150
150
  "noTags": "Zatím žádné štítky. Použijte AI anotaci pro identifikaci strukturálních rolí.",
151
+ "noSchemas": "Pro tuto znalostní bázi nejsou registrována žádná schémata značek. Správce KB musí zaregistrovat alespoň jedno schéma strukturální analýzy (např. pomocí dovednosti `register-tag-schemas`), než bude možné používat značkování.",
151
152
  "annotateTags": "Anotovat štítky",
152
153
  "selectSchema": "Vybrat rámec",
153
- "schemaLegal": "Právní analýza (IRAC)",
154
- "schemaScientific": "Vědecký článek (IMRAD)",
155
- "schemaArgument": "Struktura argumentace (Toulmin)",
156
154
  "selectCategories": "Vybrat kategorie",
157
- "categoryIssue": "Otázka",
158
- "categoryRule": "Pravidlo",
159
- "categoryApplication": "Aplikace",
160
- "categoryConclusion": "Závěr",
161
- "categoryIntroduction": "Úvod",
162
- "categoryMethods": "Metody",
163
- "categoryResults": "Výsledky",
164
- "categoryDiscussion": "Diskuze",
165
- "categoryClaim": "Tvrzení",
166
- "categoryEvidence": "Důkaz",
167
- "categoryWarrant": "Odůvodnění",
168
- "categoryCounterargument": "Protiargument",
169
- "categoryRebuttal": "Vyvrácení",
170
155
  "annotate": "Anotovat",
171
156
  "annotating": "Anotování...",
172
157
  "cancel": "Zrušit",