@glossarist/concept-browser 0.7.43 → 0.7.45

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 (60) hide show
  1. package/cli/index.mjs +12 -13
  2. package/package.json +3 -2
  3. package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
  4. package/scripts/fetch-datasets.mjs +53 -51
  5. package/scripts/generate-data.mjs +41 -19
  6. package/scripts/lib/build/image-assets.mjs +190 -0
  7. package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
  8. package/scripts/lib/local-path-safety.mjs +68 -0
  9. package/src/__tests__/bibliography-adapter.test.ts +79 -0
  10. package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
  11. package/src/__tests__/locale.test.ts +46 -0
  12. package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
  13. package/src/__tests__/non-verbal-anchor.test.ts +33 -0
  14. package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
  15. package/src/__tests__/non-verbal-highlight.test.ts +56 -0
  16. package/src/__tests__/non-verbal-kind.test.ts +77 -0
  17. package/src/__tests__/non-verbal-list.test.ts +67 -0
  18. package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
  19. package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
  20. package/src/__tests__/use-concept-entities.test.ts +76 -0
  21. package/src/adapters/bibliography-adapter.ts +49 -0
  22. package/src/adapters/factory.ts +14 -0
  23. package/src/adapters/model-bridge.ts +51 -0
  24. package/src/adapters/non-verbal/figure-bridge.ts +101 -0
  25. package/src/adapters/non-verbal/formula-bridge.ts +48 -0
  26. package/src/adapters/non-verbal/index.ts +55 -0
  27. package/src/adapters/non-verbal/kind.ts +46 -0
  28. package/src/adapters/non-verbal/prefix.ts +67 -0
  29. package/src/adapters/non-verbal/source-bridge.ts +81 -0
  30. package/src/adapters/non-verbal/table-bridge.ts +98 -0
  31. package/src/adapters/non-verbal/types.ts +133 -0
  32. package/src/adapters/non-verbal-resolver.ts +101 -0
  33. package/src/components/ConceptDetail.vue +17 -4
  34. package/src/components/LanguageDetail.vue +0 -3
  35. package/src/components/NonVerbalRepDisplay.vue +82 -24
  36. package/src/components/figure/FigureDisplay.vue +132 -0
  37. package/src/components/figure/FigureImages.vue +111 -0
  38. package/src/components/figure/figure-image-pick.ts +56 -0
  39. package/src/components/figure/figure-layout.ts +26 -0
  40. package/src/components/formula/FormulaDisplay.vue +90 -0
  41. package/src/components/formula/FormulaExpression.vue +70 -0
  42. package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
  43. package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
  44. package/src/components/non-verbal/NonVerbalList.vue +118 -0
  45. package/src/components/non-verbal/NonVerbalSources.vue +61 -0
  46. package/src/components/table/TableDisplay.vue +99 -0
  47. package/src/components/table/TableMarkup.vue +63 -0
  48. package/src/components/table/TableStructured.vue +66 -0
  49. package/src/composables/use-concept-entities.ts +70 -0
  50. package/src/composables/use-non-verbal-cross-ref.ts +79 -0
  51. package/src/composables/use-non-verbal-entity.ts +58 -0
  52. package/src/composables/use-reduced-motion.ts +26 -0
  53. package/src/composables/use-render-options.ts +30 -33
  54. package/src/router/index.ts +3 -0
  55. package/src/router/non-verbal-scroll-guard.ts +56 -0
  56. package/src/style.css +17 -0
  57. package/src/utils/content-renderer.ts +76 -64
  58. package/src/utils/locale.ts +92 -0
  59. package/src/utils/non-verbal-anchor.ts +51 -0
  60. package/src/utils/non-verbal-highlight.ts +27 -0
@@ -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
+ }
@@ -1,55 +1,52 @@
1
- import { ref, watch } from 'vue';
2
- import type { RenderOptions, BibResolver, FigResolver } from '../utils/content-renderer';
1
+ import { ref } from 'vue';
2
+ import type { RenderOptions, BibResolver, NonVerbalRefResolver } from '../utils/content-renderer';
3
+ import type { NonVerbalKind } from '../adapters/non-verbal/types';
3
4
  import { getFactory } from '../adapters/factory';
5
+ import { anchorId } from '../utils/non-verbal-anchor';
4
6
  import { escapeAttr } from '../utils/escape';
5
7
 
6
- interface BibEntry {
7
- reference: string;
8
- title?: string;
9
- link?: string;
10
- }
11
-
12
- const bibCache = new Map<string, Record<string, BibEntry>>();
13
-
14
- async function loadBibliography(registerId: string): Promise<Record<string, BibEntry> | null> {
15
- if (bibCache.has(registerId)) return bibCache.get(registerId)!;
16
- try {
17
- const resp = await fetch(`${import.meta.env.BASE_URL}data/${registerId}/bibliography.json`);
18
- if (!resp.ok) return null;
19
- const data = await resp.json();
20
- bibCache.set(registerId, data);
21
- return data;
22
- } catch {
23
- return null;
24
- }
25
- }
26
-
27
8
  export function useRenderOptions(registerId: () => string) {
28
- const bibData = ref<Record<string, BibEntry> | null>(null);
9
+ const ready = ref(false);
29
10
 
30
11
  async function ensureBibLoaded() {
31
12
  const id = registerId();
32
13
  if (!id) return;
33
- bibData.value = await loadBibliography(id);
14
+ await getFactory().bibliography(id).load();
15
+ ready.value = true;
34
16
  }
35
17
 
36
18
  const bibResolver: BibResolver = (refId, title) => {
37
- const entry = bibData.value?.[refId];
19
+ const id = registerId();
20
+ const entry = id ? getFactory().bibliography(id).findById(refId) : null;
38
21
  if (!entry) {
39
22
  return `<span class="bib-ref">${escapeAttr(title)}</span>`;
40
23
  }
41
- const display = title || entry.reference;
24
+ const display = title || entry.reference || refId;
42
25
  if (entry.link) {
43
- return `<a href="${escapeAttr(entry.link)}" target="_blank" rel="noopener" class="bib-link" title="${escapeAttr(entry.title || '')}">${escapeAttr(display)}</a>`;
26
+ return `<a href="${escapeAttr(entry.link)}" target="_blank" rel="noopener" class="bib-link" title="${escapeAttr(entry.title ?? '')}">${escapeAttr(display)}</a>`;
44
27
  }
45
- return `<span class="bib-ref" title="${escapeAttr(entry.title || '')}">${escapeAttr(display)}</span>`;
28
+ return `<span class="bib-ref" title="${escapeAttr(entry.title ?? '')}">${escapeAttr(display)}</span>`;
46
29
  };
47
30
 
48
- const figResolver: FigResolver = (figId) => {
31
+ const nonVerbalRefResolver: NonVerbalRefResolver = (kind: NonVerbalKind, entityId, display) => {
49
32
  const id = registerId();
50
- const imgSrc = `${import.meta.env.BASE_URL}data/${id}/images/${figId}.png`;
51
- return `<span class="fig-ref"><a href="${escapeAttr(imgSrc)}" target="_blank" rel="noopener">${escapeAttr(figId)}</a></span>`;
33
+ if (!id) {
34
+ const label = display ?? entityId;
35
+ return `<span class="nv-ref nv-ref--${kind}">${escapeAttr(label)}</span>`;
36
+ }
37
+ const href = `#${anchorId(kind, id, entityId)}`;
38
+ const label = display ?? defaultLabelForKind(kind, entityId);
39
+ const cls = `nv-ref nv-ref--${kind}`;
40
+ return `<a href="${href}" class="${cls}" data-nv-kind="${escapeAttr(kind)}" data-nv-dataset="${escapeAttr(id)}" data-nv-entity="${escapeAttr(entityId)}">${escapeAttr(label)}</a>`;
52
41
  };
53
42
 
54
- return { bibData, ensureBibLoaded, bibResolver, figResolver };
43
+ return { ready, ensureBibLoaded, bibResolver, nonVerbalRefResolver };
44
+ }
45
+
46
+ function defaultLabelForKind(kind: NonVerbalKind, entityId: string): string {
47
+ switch (kind) {
48
+ case 'figure': return `Figure ${entityId}`;
49
+ case 'table': return `Table ${entityId}`;
50
+ case 'formula': return `Formula ${entityId}`;
51
+ }
55
52
  }
@@ -1,4 +1,5 @@
1
1
  import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
2
+ import { installNonVerbalScroll } from './non-verbal-scroll-guard';
2
3
 
3
4
  export const routes: RouteRecordRaw[] = [
4
5
  {
@@ -109,4 +110,6 @@ const router = createRouter({
109
110
  routes,
110
111
  });
111
112
 
113
+ installNonVerbalScroll(router);
114
+
112
115
  export default router;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * installNonVerbalScroll — router hook that turns entity-hash deep-links
3
+ * (`/concept/X#figure-{ds}-{id}`) into scroll + highlight on arrival.
4
+ *
5
+ * Why polling instead of an event? Entity components fetch their
6
+ * JSON-LD asynchronously via the resolver; the target `<figure>` does
7
+ * not exist in the DOM until that fetch resolves. Polling for up to
8
+ * `timeoutMs` covers the slow-network worst case without coupling the
9
+ * router to the resolver.
10
+ *
11
+ * The anchor id format is owned by `utils/non-verbal-anchor.ts`; this
12
+ * guard only matches the hash prefix and hands off to the same
13
+ * highlight utility used by click-driven navigation, so the user sees
14
+ * identical behavior whether they arrived by click or by URL.
15
+ */
16
+ import type { Router } from 'vue-router';
17
+ import { highlightEntity, scrollToEntity } from '../utils/non-verbal-highlight';
18
+
19
+ const DEFAULT_TIMEOUT_MS = 5000;
20
+ const POLL_INTERVAL_MS = 50;
21
+
22
+ const ENTITY_HASH_RE = /^#(?:figure|table|formula)-/;
23
+
24
+ export function installNonVerbalScroll(
25
+ router: Router,
26
+ options: { timeoutMs?: number } = {},
27
+ ): void {
28
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
+
30
+ router.afterEach((to) => {
31
+ if (!to.hash || !ENTITY_HASH_RE.test(to.hash)) return;
32
+ const anchorId = to.hash.slice(1);
33
+ const prefersReducedMotion = typeof window !== 'undefined'
34
+ && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
35
+
36
+ void waitForElement(anchorId, timeoutMs).then((el) => {
37
+ if (!el) return;
38
+ scrollToEntity(el, !prefersReducedMotion);
39
+ highlightEntity(el);
40
+ });
41
+ });
42
+ }
43
+
44
+ function waitForElement(id: string, timeoutMs: number): Promise<HTMLElement | null> {
45
+ return new Promise((resolve) => {
46
+ if (typeof document === 'undefined') return resolve(null);
47
+ const start = Date.now();
48
+ const tick = () => {
49
+ const el = document.getElementById(id);
50
+ if (el) return resolve(el);
51
+ if (Date.now() - start > timeoutMs) return resolve(null);
52
+ setTimeout(tick, POLL_INTERVAL_MS);
53
+ };
54
+ tick();
55
+ });
56
+ }
package/src/style.css CHANGED
@@ -133,6 +133,23 @@
133
133
  @apply text-ink-600 hover:text-ink-800 underline-offset-2 hover:underline transition-colors;
134
134
  }
135
135
 
136
+ /* Non-verbal entity highlight — single source of truth for the
137
+ cross-ref "you landed here" affordance. Toggled by
138
+ utils/non-verbal-highlight.ts; applies to figure/table/formula
139
+ root elements uniformly. */
140
+ .nv-entity--highlighted {
141
+ outline: 2px solid var(--brand-primary, #2563eb);
142
+ outline-offset: 4px;
143
+ animation: nv-entity-pulse 1.6s ease-out;
144
+ }
145
+ @keyframes nv-entity-pulse {
146
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
147
+ 20% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.25); }
148
+ }
149
+ @media (prefers-reduced-motion: reduce) {
150
+ .nv-entity--highlighted { animation: none; }
151
+ }
152
+
136
153
  /* Concept definition lists */
137
154
  .concept-list {
138
155
  list-style: disc;