@glossarist/concept-browser 0.7.44 → 0.7.46

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 (56) hide show
  1. package/package.json +2 -2
  2. package/scripts/generate-data.mjs +22 -13
  3. package/scripts/lib/build/image-assets.mjs +190 -0
  4. package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
  5. package/src/__tests__/bibliography-adapter.test.ts +79 -0
  6. package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
  7. package/src/__tests__/locale.test.ts +46 -0
  8. package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
  9. package/src/__tests__/non-verbal-anchor.test.ts +33 -0
  10. package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
  11. package/src/__tests__/non-verbal-highlight.test.ts +56 -0
  12. package/src/__tests__/non-verbal-kind.test.ts +77 -0
  13. package/src/__tests__/non-verbal-list.test.ts +67 -0
  14. package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
  15. package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
  16. package/src/__tests__/use-concept-entities.test.ts +76 -0
  17. package/src/adapters/bibliography-adapter.ts +49 -0
  18. package/src/adapters/factory.ts +14 -0
  19. package/src/adapters/model-bridge.ts +51 -0
  20. package/src/adapters/non-verbal/figure-bridge.ts +101 -0
  21. package/src/adapters/non-verbal/formula-bridge.ts +48 -0
  22. package/src/adapters/non-verbal/index.ts +55 -0
  23. package/src/adapters/non-verbal/kind.ts +46 -0
  24. package/src/adapters/non-verbal/prefix.ts +67 -0
  25. package/src/adapters/non-verbal/source-bridge.ts +81 -0
  26. package/src/adapters/non-verbal/table-bridge.ts +98 -0
  27. package/src/adapters/non-verbal/types.ts +133 -0
  28. package/src/adapters/non-verbal-resolver.ts +101 -0
  29. package/src/components/ConceptDetail.vue +17 -4
  30. package/src/components/LanguageDetail.vue +0 -3
  31. package/src/components/NonVerbalRepDisplay.vue +82 -24
  32. package/src/components/figure/FigureDisplay.vue +132 -0
  33. package/src/components/figure/FigureImages.vue +111 -0
  34. package/src/components/figure/figure-image-pick.ts +56 -0
  35. package/src/components/figure/figure-layout.ts +26 -0
  36. package/src/components/formula/FormulaDisplay.vue +90 -0
  37. package/src/components/formula/FormulaExpression.vue +70 -0
  38. package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
  39. package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
  40. package/src/components/non-verbal/NonVerbalList.vue +118 -0
  41. package/src/components/non-verbal/NonVerbalSources.vue +61 -0
  42. package/src/components/table/TableDisplay.vue +99 -0
  43. package/src/components/table/TableMarkup.vue +63 -0
  44. package/src/components/table/TableStructured.vue +66 -0
  45. package/src/composables/use-concept-entities.ts +70 -0
  46. package/src/composables/use-non-verbal-cross-ref.ts +79 -0
  47. package/src/composables/use-non-verbal-entity.ts +58 -0
  48. package/src/composables/use-reduced-motion.ts +26 -0
  49. package/src/composables/use-render-options.ts +30 -33
  50. package/src/router/index.ts +3 -0
  51. package/src/router/non-verbal-scroll-guard.ts +56 -0
  52. package/src/style.css +17 -0
  53. package/src/utils/content-renderer.ts +76 -64
  54. package/src/utils/locale.ts +92 -0
  55. package/src/utils/non-verbal-anchor.ts +51 -0
  56. package/src/utils/non-verbal-highlight.ts +27 -0
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalList — renders the structural non-verbal entity refs
4
+ * (`figures[]`, `tables[]`, `formulas[]` from the concept) as a compact
5
+ * list of in-page anchor links.
6
+ *
7
+ * This is the "Figures / Tables / Formulas" section of a concept page.
8
+ * Inline mentions in prose are handled separately by the content
9
+ * renderer; this list surfaces entities the author declared as
10
+ * structural members of the concept regardless of whether they are
11
+ * also mentioned inline.
12
+ *
13
+ * Click handling is delegated to `useNonVerbalCrossRef` (a single
14
+ * document-level handler), so the links here are plain `<a href="#…">`.
15
+ */
16
+ import { computed } from 'vue';
17
+ import type { StructuralEntityRef } from '../../composables/use-concept-entities';
18
+ import type { NonVerbalKind } from '../../adapters/non-verbal/types';
19
+
20
+ const props = defineProps<{
21
+ refs: StructuralEntityRef[];
22
+ }>();
23
+
24
+ const KIND_LABEL: Readonly<Record<NonVerbalKind, string>> = {
25
+ figure: 'Figure',
26
+ table: 'Table',
27
+ formula: 'Formula',
28
+ };
29
+
30
+ const KIND_BADGE_CLASS: Readonly<Record<NonVerbalKind, string>> = {
31
+ figure: 'bg-violet-50 text-violet-700',
32
+ table: 'bg-sky-50 text-sky-700',
33
+ formula: 'bg-amber-50 text-amber-700',
34
+ };
35
+
36
+ interface GroupedRefs {
37
+ kind: NonVerbalKind;
38
+ label: string;
39
+ badgeClass: string;
40
+ items: StructuralEntityRef[];
41
+ }
42
+
43
+ const grouped = computed<GroupedRefs[]>(() => {
44
+ const map = new Map<NonVerbalKind, StructuralEntityRef[]>();
45
+ for (const r of props.refs) {
46
+ const bucket = map.get(r.kind) ?? [];
47
+ bucket.push(r);
48
+ map.set(r.kind, bucket);
49
+ }
50
+ const out: GroupedRefs[] = [];
51
+ for (const [kind, items] of map) {
52
+ out.push({
53
+ kind,
54
+ label: KIND_LABEL[kind],
55
+ badgeClass: KIND_BADGE_CLASS[kind],
56
+ items,
57
+ });
58
+ }
59
+ return out;
60
+ });
61
+
62
+ function labelOf(r: StructuralEntityRef): string {
63
+ return r.display ?? r.entityId;
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <div v-if="refs.length" class="space-y-3">
69
+ <div class="section-label">Non-verbal entities</div>
70
+ <div
71
+ v-for="group in grouped"
72
+ :key="group.kind"
73
+ class="card p-3 space-y-1.5"
74
+ >
75
+ <div class="flex items-center gap-2">
76
+ <span class="badge text-[10px]" :class="group.badgeClass">{{ group.label }}s</span>
77
+ <span class="text-xs text-ink-400">{{ group.items.length }}</span>
78
+ </div>
79
+ <ol class="space-y-1">
80
+ <li v-for="r in group.items" :key="r.anchor">
81
+ <a
82
+ :href="`#${r.anchor}`"
83
+ class="nv-list__link"
84
+ >
85
+ <span class="nv-list__label">{{ labelOf(r) }}</span>
86
+ <span v-if="r.display" class="nv-list__id">{{ r.entityId }}</span>
87
+ </a>
88
+ </li>
89
+ </ol>
90
+ </div>
91
+ </div>
92
+ </template>
93
+
94
+ <style scoped>
95
+ .nv-list__link {
96
+ display: flex;
97
+ align-items: baseline;
98
+ gap: 0.5rem;
99
+ padding: 0.25rem 0.375rem;
100
+ border-radius: 0.25rem;
101
+ font-size: 0.8125rem;
102
+ color: var(--ink-700, #444);
103
+ text-decoration: none;
104
+ transition: background-color 120ms ease;
105
+ }
106
+ .nv-list__link:hover {
107
+ background: var(--ink-50, #f5f5f5);
108
+ color: var(--ink-900, #111);
109
+ }
110
+ .nv-list__label {
111
+ font-weight: 500;
112
+ }
113
+ .nv-list__id {
114
+ font-family: 'JetBrains Mono', monospace;
115
+ font-size: 0.6875rem;
116
+ color: var(--ink-400, #888);
117
+ }
118
+ </style>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalSources — shared source list for all three entity kinds.
4
+ *
5
+ * Reuses the existing CitationDisplay component for individual citations
6
+ * so the rendering matches the rest of the app. Each source may carry a
7
+ * modification note (e.g. "Adapted.") which is rendered alongside.
8
+ */
9
+ import type { Citation } from 'glossarist';
10
+ import type { NonVerbalSource } from '../../adapters/non-verbal/types';
11
+ import CitationDisplay from '../CitationDisplay.vue';
12
+
13
+ defineProps<{
14
+ sources: NonVerbalSource[];
15
+ }>();
16
+
17
+ // CitationDisplay expects glossarist's Citation class. NonVerbalSource.origin
18
+ // is wire-compatible at runtime but typed differently on the consumer side;
19
+ // cast once at this boundary.
20
+ function asCitation(origin: NonVerbalSource['origin']): Citation | null {
21
+ return (origin as unknown as Citation) ?? null;
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div v-if="sources.length" class="nv-sources">
27
+ <div class="nv-sources__label">Sources</div>
28
+ <ol class="nv-sources__list">
29
+ <li v-for="(src, i) in sources" :key="i" class="nv-source">
30
+ <CitationDisplay v-if="asCitation(src.origin)" :citation="asCitation(src.origin)!" />
31
+ <span v-if="src.modification" class="nv-source__modification">
32
+ — {{ src.modification }}
33
+ </span>
34
+ </li>
35
+ </ol>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .nv-sources {
41
+ font-size: 0.75rem;
42
+ color: var(--ink-500, #666);
43
+ margin-top: 0.5rem;
44
+ }
45
+ .nv-sources__label {
46
+ font-weight: 600;
47
+ text-transform: uppercase;
48
+ letter-spacing: 0.04em;
49
+ margin-bottom: 0.25rem;
50
+ }
51
+ .nv-sources__list {
52
+ list-style: decimal inside;
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.25rem;
56
+ }
57
+ .nv-source__modification {
58
+ color: var(--ink-400, #888);
59
+ font-style: italic;
60
+ }
61
+ </style>
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TableDisplay — renders a dataset-level Table entity.
4
+ *
5
+ * Delegates content rendering to TableStructured (headers + rows) or
6
+ * TableMarkup (HTML / Markdown / AsciiDoc) based on `content.kind`.
7
+ */
8
+ import { computed } from 'vue';
9
+ import type { Table } from '../../adapters/non-verbal/types';
10
+ import { useNonVerbalEntity } from '../../composables/use-non-verbal-entity';
11
+ import { resolveFallbackChain } from '../../utils/locale';
12
+ import { anchorId } from '../../utils/non-verbal-anchor';
13
+ import NonVerbalCaption from '../non-verbal/NonVerbalCaption.vue';
14
+ import NonVerbalSources from '../non-verbal/NonVerbalSources.vue';
15
+ import NonVerbalFallback from '../non-verbal/NonVerbalFallback.vue';
16
+ import TableStructured from './TableStructured.vue';
17
+ import TableMarkup from './TableMarkup.vue';
18
+
19
+ const props = defineProps<{
20
+ datasetId: string;
21
+ entityId: string;
22
+ locale: string;
23
+ datasetLocales?: readonly string[];
24
+ }>();
25
+
26
+ const k = () => 'table' as const;
27
+ const { entity, state, error } = useNonVerbalEntity(k, () => props.datasetId, () => props.entityId);
28
+
29
+ const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
30
+ const anchor = computed(() => anchorId('table', props.datasetId, props.entityId));
31
+ const descriptionId = computed(() => `${anchor.value}-desc`);
32
+
33
+ const table = computed<Table | null>(() => entity.value as Table | null);
34
+ const structuredContent = computed(() => {
35
+ const c = table.value?.content;
36
+ return c && c.kind === 'structured' ? c : null;
37
+ });
38
+ const markup = computed(() => {
39
+ const c = table.value?.content;
40
+ return c && c.kind === 'markup' ? c.markup : null;
41
+ });
42
+ </script>
43
+
44
+ <template>
45
+ <figure
46
+ v-if="table && state === 'loaded'"
47
+ :id="anchor"
48
+ class="table-entity"
49
+ >
50
+ <TableStructured
51
+ v-if="structuredContent"
52
+ :content="structuredContent"
53
+ :locale="locale"
54
+ :fallback-chain="fallbackChain"
55
+ />
56
+ <TableMarkup
57
+ v-else-if="markup"
58
+ :content="markup"
59
+ :format="table.format"
60
+ :locale="locale"
61
+ :fallback-chain="fallbackChain"
62
+ />
63
+
64
+ <NonVerbalCaption
65
+ :identifier="table.identifier"
66
+ :caption="table.caption"
67
+ :description="table.description"
68
+ :locale="locale"
69
+ :fallback-chain="fallbackChain"
70
+ :description-id="descriptionId"
71
+ />
72
+
73
+ <NonVerbalSources
74
+ v-if="table.sources?.length"
75
+ :sources="table.sources"
76
+ />
77
+ </figure>
78
+
79
+ <NonVerbalFallback
80
+ v-else-if="state === 'loading' || state === 'not-found' || state === 'error'"
81
+ :state="state"
82
+ kind="table"
83
+ :entity-id="entityId"
84
+ :message="error ?? undefined"
85
+ />
86
+ </template>
87
+
88
+ <style scoped>
89
+ .table-entity {
90
+ margin: 1rem 0;
91
+ display: flex;
92
+ flex-direction: column;
93
+ gap: 0.5rem;
94
+ }
95
+ .table-entity:focus-visible {
96
+ outline: 2px solid var(--blue-500, #3b82f6);
97
+ outline-offset: 4px;
98
+ }
99
+ </style>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TableMarkup — renders a markup table (HTML / Markdown / AsciiDoc).
4
+ *
5
+ * The content is a LocalizedString — one markup string per locale. The
6
+ * renderer picks the locale via the SSOT, then applies the format-specific
7
+ * transform:
8
+ * - `html` → render as-is (sanitized by the DOMPurify-driven
9
+ * v-html trust boundary upstream — V1 trusts authored HTML)
10
+ * - `markdown` → render via the lightweight markdown utility
11
+ * - `asciidoc` → render via the lightweight asciidoc utility
12
+ */
13
+ import { computed } from 'vue';
14
+ import type { LocalizedString, TableFormat } from '../../adapters/non-verbal/types';
15
+ import { pickLocaleMap } from '../../utils/locale';
16
+ import { renderMarkdown } from '../../utils/markdown-lite';
17
+ import { renderAsciiDocLite } from '../../utils/asciidoc-lite';
18
+
19
+ const props = defineProps<{
20
+ content: LocalizedString;
21
+ format?: TableFormat;
22
+ locale: string;
23
+ fallbackChain?: readonly string[];
24
+ }>();
25
+
26
+ const resolved = computed(() => pickLocaleMap(props.content, props.locale, props.fallbackChain));
27
+
28
+ const html = computed(() => {
29
+ const r = resolved.value;
30
+ if (!r) return '';
31
+ const fmt = props.format ?? 'html';
32
+ if (fmt === 'markdown') return renderMarkdown(r.text);
33
+ if (fmt === 'asciidoc') return renderAsciiDocLite(r.text);
34
+ return r.text;
35
+ });
36
+
37
+ const lang = computed(() => resolved.value?.locale);
38
+ </script>
39
+
40
+ <template>
41
+ <div class="nv-table nv-table--markup" :lang="lang" v-html="html"></div>
42
+ </template>
43
+
44
+ <style scoped>
45
+ .nv-table {
46
+ width: 100%;
47
+ overflow-x: auto;
48
+ font-size: 0.875rem;
49
+ }
50
+ .nv-table :deep(table) {
51
+ width: 100%;
52
+ border-collapse: collapse;
53
+ }
54
+ .nv-table :deep(th),
55
+ .nv-table :deep(td) {
56
+ padding: 0.5rem 0.75rem;
57
+ border: 1px solid var(--ink-100, #e5e5e5);
58
+ }
59
+ .nv-table :deep(th) {
60
+ background: var(--surface-alt, #f5f5f5);
61
+ font-weight: 600;
62
+ }
63
+ </style>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TableStructured — renders a structured table (headers + rows).
4
+ *
5
+ * Each cell is a LocalizedString. Headers are LocalizedString[] and rows
6
+ * are LocalizedString[][]. The locale SSOT picks one language per cell —
7
+ * cells can fall back to English independently.
8
+ */
9
+ import { computed } from 'vue';
10
+ import type { LocalizedString } from '../../adapters/non-verbal/types';
11
+ import { pickLocaleText } from '../../utils/locale';
12
+
13
+ interface StructuredContent {
14
+ kind: 'structured';
15
+ headers: LocalizedString[];
16
+ rows: LocalizedString[][];
17
+ }
18
+
19
+ const props = defineProps<{
20
+ content: StructuredContent;
21
+ locale: string;
22
+ fallbackChain?: readonly string[];
23
+ }>();
24
+
25
+ const headerTexts = computed(() =>
26
+ props.content.headers.map(h => pickLocaleText(h, props.locale, props.fallbackChain)),
27
+ );
28
+
29
+ const rowTexts = computed(() =>
30
+ props.content.rows.map(r =>
31
+ r.map(cell => pickLocaleText(cell, props.locale, props.fallbackChain)),
32
+ ),
33
+ );
34
+ </script>
35
+
36
+ <template>
37
+ <table class="nv-table nv-table--structured">
38
+ <thead v-if="headerTexts.length">
39
+ <tr>
40
+ <th v-for="(h, i) in headerTexts" :key="i" scope="col">{{ h }}</th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ <tr v-for="(row, ri) in rowTexts" :key="ri">
45
+ <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
46
+ </tr>
47
+ </tbody>
48
+ </table>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .nv-table {
53
+ width: 100%;
54
+ border-collapse: collapse;
55
+ font-size: 0.875rem;
56
+ }
57
+ .nv-table th, .nv-table td {
58
+ padding: 0.5rem 0.75rem;
59
+ border: 1px solid var(--ink-100, #e5e5e5);
60
+ text-align: left;
61
+ }
62
+ .nv-table th {
63
+ background: var(--surface-alt, #f5f5f5);
64
+ font-weight: 600;
65
+ }
66
+ </style>
@@ -0,0 +1,70 @@
1
+ /**
2
+ * useConceptEntities — pulls the structural non-verbal entity refs
3
+ * (`figures`, `tables`, `formulas`) from a Concept model instance and
4
+ * projects them into a uniform `{ kind, entityId, display }` shape that
5
+ * the list component can render without knowing about each kind.
6
+ *
7
+ * Per plan 02: structural refs live on `ManagedConceptData` (concept
8
+ * level), not on `LocalizedConcept`. The Concept model owns the raw
9
+ * arrays and the lazy getters that materialize them — this composable
10
+ * is a pure projection layer, not a source of truth.
11
+ *
12
+ * MECE: this composable reads; it does not fetch (the resolver does),
13
+ * does not render (the component does), and does not own the wire
14
+ * format (the bridge does).
15
+ */
16
+ import { computed, type ComputedRef } from 'vue';
17
+ import type { Concept } from 'glossarist';
18
+ import type { NonVerbalKind } from '../adapters/non-verbal/types';
19
+ import { anchorId } from '../utils/non-verbal-anchor';
20
+
21
+ export interface StructuralEntityRef {
22
+ kind: NonVerbalKind;
23
+ entityId: string;
24
+ display: string | null;
25
+ anchor: string;
26
+ }
27
+
28
+ const KIND_TO_CONCEPT_FIELD: Readonly<Record<NonVerbalKind, 'figures' | 'tables' | 'formulas'>> = {
29
+ figure: 'figures',
30
+ table: 'tables',
31
+ formula: 'formulas',
32
+ };
33
+
34
+ /**
35
+ * glossarist-js's published `.d.ts` omits Concept's lazy getters for
36
+ * structural refs (`figures`, `tables`, `formulas`), but the runtime
37
+ * exposes them per TODO.figures/02. Cast through `unknown` once at
38
+ * this boundary so consumers see a typed shape.
39
+ */
40
+ interface ConceptWithEntityRefs {
41
+ readonly figures: ReadonlyArray<{ entityId: string | null; display: string | null }>;
42
+ readonly tables: ReadonlyArray<{ entityId: string | null; display: string | null }>;
43
+ readonly formulas: ReadonlyArray<{ entityId: string | null; display: string | null }>;
44
+ }
45
+
46
+ export function useConceptEntities(
47
+ concept: ComputedRef<Concept>,
48
+ datasetId: ComputedRef<string>,
49
+ ): ComputedRef<StructuralEntityRef[]> {
50
+ return computed(() => {
51
+ const ds = datasetId.value;
52
+ const c = concept.value as unknown as ConceptWithEntityRefs;
53
+ const out: StructuralEntityRef[] = [];
54
+ for (const kind of Object.keys(KIND_TO_CONCEPT_FIELD) as NonVerbalKind[]) {
55
+ const refs = c[KIND_TO_CONCEPT_FIELD[kind]];
56
+ if (!refs || refs.length === 0) continue;
57
+ for (const r of refs) {
58
+ const entityId = r.entityId;
59
+ if (!entityId) continue;
60
+ out.push({
61
+ kind,
62
+ entityId,
63
+ display: r.display ?? null,
64
+ anchor: anchorId(kind, ds, entityId),
65
+ });
66
+ }
67
+ }
68
+ return out;
69
+ });
70
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * useNonVerbalCrossRef — wires renderer-emitted entity anchors
3
+ * (`<a href="#figure-{ds}-{id}">`) to in-page scroll, highlight, focus,
4
+ * and URL-hash update.
5
+ *
6
+ * Why a delegated document-level click handler? Entity references are
7
+ * rendered into `v-html` by the content-renderer; Vue `@click` does
8
+ * not bind to children of `v-html`. A single capture-phase handler on
9
+ * `document` is the correct pattern, and it stays in one place
10
+ * regardless of how many mentions the page contains.
11
+ *
12
+ * The behavior is identical across figure/table/formula — only the
13
+ * anchor prefix differs. `ANCHOR_KIND_SELECTORS` (from the anchor SSOT)
14
+ * is the single source for which prefixes count.
15
+ */
16
+ import { onMounted, onBeforeUnmount } from 'vue';
17
+ import { useReducedMotion } from './use-reduced-motion';
18
+ import { ANCHOR_KIND_SELECTORS } from '../utils/non-verbal-anchor';
19
+ import { highlightEntity, scrollToEntity } from '../utils/non-verbal-highlight';
20
+
21
+ const ENTITY_LINK_SELECTOR = ANCHOR_KIND_SELECTORS.join(', ');
22
+
23
+ export interface NonVerbalCrossRefOptions {
24
+ /** Override the document (injectable for tests). */
25
+ document?: Document;
26
+ /** Override the history API (injectable for tests). */
27
+ history?: History;
28
+ }
29
+
30
+ export interface NonVerbalCrossRef {
31
+ /** Programmatically activate an entity (e.g. from a list click). */
32
+ navigateToEntity: (anchorId: string) => void;
33
+ }
34
+
35
+ export function useNonVerbalCrossRef(
36
+ opts: NonVerbalCrossRefOptions = {},
37
+ ): NonVerbalCrossRef {
38
+ const doc = opts.document ?? (typeof document !== 'undefined' ? document : null);
39
+ const hist = opts.history ?? (typeof history !== 'undefined' ? history : null);
40
+ const reducedMotion = useReducedMotion();
41
+
42
+ function onGlobalClick(event: MouseEvent): void {
43
+ if (!doc) return;
44
+ const target = (event.target as HTMLElement | null)?.closest<HTMLAnchorElement>(
45
+ ENTITY_LINK_SELECTOR,
46
+ );
47
+ if (!target) return;
48
+ const href = target.getAttribute('href') ?? '';
49
+ if (!href.startsWith('#')) return;
50
+ const anchorId = href.slice(1);
51
+ const el = doc.getElementById(anchorId);
52
+ if (!el) return;
53
+ event.preventDefault();
54
+ activate(el, anchorId);
55
+ }
56
+
57
+ function activate(el: HTMLElement, anchorId: string): void {
58
+ scrollToEntity(el, !reducedMotion.value);
59
+ highlightEntity(el);
60
+ if (hist && doc && doc.location.hash !== `#${anchorId}`) {
61
+ hist.pushState(null, '', `#${anchorId}`);
62
+ }
63
+ }
64
+
65
+ function navigateToEntity(anchorId: string): void {
66
+ const el = doc?.getElementById(anchorId);
67
+ if (!el) return;
68
+ activate(el, anchorId);
69
+ }
70
+
71
+ onMounted(() => {
72
+ doc?.addEventListener('click', onGlobalClick, { capture: true });
73
+ });
74
+ onBeforeUnmount(() => {
75
+ doc?.removeEventListener('click', onGlobalClick);
76
+ });
77
+
78
+ return { navigateToEntity };
79
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * useNonVerbalEntity — composable that fetches one non-verbal entity
3
+ * through the resolver and tracks loading / not-found / error state.
4
+ *
5
+ * Components call this; they never touch `fetch()` directly. Locale is
6
+ * reactive — changing it does NOT refetch (the resolver caches the raw
7
+ * JSON-LD; localization happens at render time via the locale SSOT).
8
+ */
9
+ import { ref, watch, shallowRef } from 'vue';
10
+ import { getFactory } from '../adapters/factory';
11
+ import type { NonVerbalEntity, NonVerbalKind } from '../adapters/non-verbal/types';
12
+
13
+ export type LoadState = 'loading' | 'loaded' | 'not-found' | 'error';
14
+
15
+ export interface UseNonVerbalEntityOptions {
16
+ immediate?: boolean;
17
+ }
18
+
19
+ export function useNonVerbalEntity(
20
+ kind: () => NonVerbalKind,
21
+ datasetId: () => string,
22
+ entityId: () => string,
23
+ _opts: UseNonVerbalEntityOptions = {},
24
+ ) {
25
+ const entity = shallowRef<NonVerbalEntity | null>(null);
26
+ const state = ref<LoadState>('loading');
27
+ const error = ref<string | null>(null);
28
+
29
+ async function load() {
30
+ const k = kind();
31
+ const ds = datasetId();
32
+ const id = entityId();
33
+ if (!k || !ds || !id) {
34
+ state.value = 'not-found';
35
+ entity.value = null;
36
+ return;
37
+ }
38
+ state.value = 'loading';
39
+ error.value = null;
40
+ try {
41
+ const e = await getFactory().nonVerbalResolver.resolve(k, ds, id);
42
+ entity.value = e;
43
+ state.value = e ? 'loaded' : 'not-found';
44
+ } catch (err) {
45
+ state.value = 'error';
46
+ error.value = err instanceof Error ? err.message : String(err);
47
+ entity.value = null;
48
+ }
49
+ }
50
+
51
+ watch(
52
+ [kind, datasetId, entityId],
53
+ () => { void load(); },
54
+ { immediate: true },
55
+ );
56
+
57
+ return { entity, state, error, reload: load };
58
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Generic reduced-motion watcher. Shared across feature modules that need
3
+ * to respect the user's `prefers-reduced-motion` setting.
4
+ */
5
+ import { ref, onMounted, onBeforeUnmount } from 'vue';
6
+
7
+ export function useReducedMotion() {
8
+ const reduced = ref(false);
9
+ let mql: MediaQueryList | null = null;
10
+
11
+ const handler = (e: MediaQueryListEvent) => { reduced.value = e.matches; };
12
+
13
+ onMounted(() => {
14
+ if (typeof window === 'undefined' || !window.matchMedia) return;
15
+ mql = window.matchMedia('(prefers-reduced-motion: reduce)');
16
+ reduced.value = mql.matches;
17
+ mql.addEventListener('change', handler);
18
+ });
19
+
20
+ onBeforeUnmount(() => {
21
+ if (mql) mql.removeEventListener('change', handler);
22
+ mql = null;
23
+ });
24
+
25
+ return reduced;
26
+ }