@glossarist/concept-browser 0.7.44 → 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 (56) hide show
  1. package/package.json +2 -2
  2. package/scripts/generate-data.mjs +20 -11
  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,132 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FigureDisplay — main component for rendering a dataset-level Figure.
4
+ *
5
+ * Fetches the entity via the resolver, renders the image(s), caption,
6
+ * description, and sources. Composite figures (with subfigures) render
7
+ * recursively — each subfigure is itself a FigureDisplay.
8
+ *
9
+ * Self-anchoring: the outer `<figure>` element receives the anchor ID
10
+ * so `{{fig:id}}` mentions can scroll to it via the cross-ref composable.
11
+ */
12
+ import { computed, ref } from 'vue';
13
+ import type { Figure } from '../../adapters/non-verbal/types';
14
+ import { useNonVerbalEntity } from '../../composables/use-non-verbal-entity';
15
+ import { resolveFallbackChain } from '../../utils/locale';
16
+ import { anchorId } from '../../utils/non-verbal-anchor';
17
+ import { deriveLayout } from './figure-layout';
18
+ import NonVerbalCaption from '../non-verbal/NonVerbalCaption.vue';
19
+ import NonVerbalSources from '../non-verbal/NonVerbalSources.vue';
20
+ import NonVerbalFallback from '../non-verbal/NonVerbalFallback.vue';
21
+ import FigureImages from './FigureImages.vue';
22
+
23
+ const props = defineProps<{
24
+ datasetId: string;
25
+ entityId: string;
26
+ locale: string;
27
+ /** Languages configured on the dataset — drives fallback chain. */
28
+ datasetLocales?: readonly string[];
29
+ }>();
30
+
31
+ const k = () => 'figure' as const;
32
+ const ds = () => props.datasetId;
33
+ const id = () => props.entityId;
34
+ const { entity, state, error } = useNonVerbalEntity(k, ds, id);
35
+
36
+ const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
37
+ const layout = computed(() => entity.value ? deriveLayout(entity.value as Figure) : 'single');
38
+ const anchor = computed(() => anchorId('figure', props.datasetId, props.entityId));
39
+ const descriptionId = computed(() => `${anchor.value}-desc`);
40
+
41
+ const topLevelImages = computed(() => (entity.value as Figure | null)?.images ?? []);
42
+ </script>
43
+
44
+ <template>
45
+ <figure
46
+ v-if="entity && state === 'loaded'"
47
+ :id="anchor"
48
+ :class="`figure figure--${layout}`"
49
+ >
50
+ <div v-if="topLevelImages.length" :class="`figure__media figure__media--${layout}`">
51
+ <FigureImages
52
+ :images="topLevelImages"
53
+ :alt="(entity as Figure).alt"
54
+ :dataset-id="datasetId"
55
+ :locale="locale"
56
+ :fallback-chain="fallbackChain"
57
+ :hasDescription="!!(entity as Figure).description && Object.keys((entity as Figure).description ?? {}).length > 0"
58
+ :description-id="descriptionId"
59
+ entity-label="Figure"
60
+ />
61
+ </div>
62
+
63
+ <template v-if="(entity as Figure).subfigures?.length">
64
+ <div :class="`figure__subfigures figure__subfigures--${layout}`">
65
+ <FigureDisplay
66
+ v-for="sub in (entity as Figure).subfigures"
67
+ :key="sub.id"
68
+ :dataset-id="datasetId"
69
+ :entity-id="sub.id"
70
+ :locale="locale"
71
+ :dataset-locales="datasetLocales"
72
+ />
73
+ </div>
74
+ </template>
75
+
76
+ <NonVerbalCaption
77
+ :identifier="(entity as Figure).identifier"
78
+ :caption="(entity as Figure).caption"
79
+ :description="(entity as Figure).description"
80
+ :locale="locale"
81
+ :fallback-chain="fallbackChain"
82
+ :description-id="descriptionId"
83
+ />
84
+
85
+ <NonVerbalSources
86
+ v-if="(entity as Figure).sources?.length"
87
+ :sources="(entity as Figure).sources!"
88
+ />
89
+ </figure>
90
+
91
+ <NonVerbalFallback
92
+ v-else-if="state === 'loading' || state === 'not-found' || state === 'error'"
93
+ :state="state"
94
+ kind="figure"
95
+ :entity-id="entityId"
96
+ :message="error ?? undefined"
97
+ />
98
+ </template>
99
+
100
+ <style scoped>
101
+ .figure {
102
+ margin: 1rem 0;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 0.5rem;
106
+ }
107
+ .figure__media--row,
108
+ .figure__media--grid {
109
+ display: grid;
110
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
111
+ gap: 0.75rem;
112
+ }
113
+ .figure__media--column {
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 0.75rem;
117
+ }
118
+ .figure__subfigures--row { flex-direction: row; flex-wrap: wrap; gap: 0.75rem; }
119
+ .figure__subfigures--column { flex-direction: column; gap: 0.75rem; }
120
+ .figure__subfigures--grid {
121
+ display: grid;
122
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
123
+ gap: 0.75rem;
124
+ }
125
+
126
+ [dir='rtl'] .figure__subfigures--row { flex-direction: row-reverse; }
127
+
128
+ .figure:focus-visible {
129
+ outline: 2px solid var(--blue-500, #3b82f6);
130
+ outline-offset: 4px;
131
+ }
132
+ </style>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import type { FigureImage, LocalizedString } from '../../adapters/non-verbal/types';
4
+ import { pickLocaleText, hasLocale } from '../../utils/locale';
5
+ import { getFactory } from '../../adapters/factory';
6
+
7
+ const props = withDefaults(defineProps<{
8
+ images: FigureImage[];
9
+ alt?: LocalizedString;
10
+ datasetId: string;
11
+ locale: string;
12
+ fallbackChain?: readonly string[];
13
+ descriptionId?: string;
14
+ hasDescription?: boolean;
15
+ entityLabel?: string;
16
+ }>(), {
17
+ entityLabel: 'Figure',
18
+ });
19
+
20
+ const resolver = getFactory().nonVerbalResolver;
21
+
22
+ const altText = computed(() => pickLocaleText(props.alt, props.locale, props.fallbackChain));
23
+ const altMissing = computed(() => !props.alt || Object.keys(props.alt).length === 0);
24
+ const altEmpty = computed(() =>
25
+ !!props.alt && hasLocale(props.alt, props.locale) && props.alt[props.locale] === '',
26
+ );
27
+
28
+ interface SourceVariant { src: string; type: string; media?: string; }
29
+
30
+ const sourceVariants = computed<SourceVariant[]>(() => {
31
+ const out: SourceVariant[] = [];
32
+ for (const img of props.images) {
33
+ if (!img.role || img.role === 'vector' || img.role === 'raster') continue;
34
+ const src = resolver.resolveImageUrl(props.datasetId, img.src);
35
+ const type = img.format === 'svg' ? 'image/svg+xml' : `image/${img.format === 'jpg' ? 'jpeg' : img.format}`;
36
+ let media: string | undefined;
37
+ if (img.role === 'dark') media = '(prefers-color-scheme: dark)';
38
+ else if (img.role === 'light') media = '(prefers-color-scheme: light)';
39
+ else if (img.role === 'print') media = 'print';
40
+ out.push({ src, type, media });
41
+ }
42
+ return out;
43
+ });
44
+
45
+ const defaultImg = computed(() => {
46
+ const nonRole = props.images.find(i => !i.role || i.role === 'vector' || i.role === 'raster');
47
+ const chosen = nonRole ?? props.images[0];
48
+ if (!chosen) return null;
49
+ return {
50
+ src: resolver.resolveImageUrl(props.datasetId, chosen.src),
51
+ width: chosen.width,
52
+ height: chosen.height,
53
+ };
54
+ });
55
+ </script>
56
+
57
+ <template>
58
+ <div class="figure__images">
59
+ <picture v-if="!altMissing">
60
+ <source
61
+ v-for="(v, i) in sourceVariants"
62
+ :key="i"
63
+ :type="v.type"
64
+ :srcset="v.src"
65
+ :media="v.media"
66
+ />
67
+ <img
68
+ v-if="defaultImg"
69
+ :src="defaultImg.src"
70
+ :alt="altEmpty ? '' : altText"
71
+ :width="defaultImg.width"
72
+ :height="defaultImg.height"
73
+ loading="lazy"
74
+ :aria-describedby="hasDescription && descriptionId ? descriptionId : undefined"
75
+ class="figure__img"
76
+ >
77
+ </picture>
78
+ <div
79
+ v-else
80
+ class="figure__alt-missing"
81
+ role="img"
82
+ :aria-label="`${entityLabel} is missing alt text`"
83
+ >
84
+ <p>{{ entityLabel }}: alt text missing</p>
85
+ </div>
86
+ </div>
87
+ </template>
88
+
89
+ <style scoped>
90
+ .figure__images {
91
+ margin: 0;
92
+ }
93
+ .figure__img {
94
+ max-width: 100%;
95
+ height: auto;
96
+ display: block;
97
+ border-radius: 0.375rem;
98
+ }
99
+ .figure__alt-missing {
100
+ padding: 1rem;
101
+ background: #fef3c7;
102
+ border: 1px dashed #d97706;
103
+ border-radius: 0.375rem;
104
+ color: #92400e;
105
+ font-size: 0.8125rem;
106
+ }
107
+
108
+ @media (prefers-color-scheme: dark) {
109
+ .figure__img { background: var(--surface-alt, #222); }
110
+ }
111
+ </style>
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Figure image picker — single SSOT for choosing the best variant from
3
+ * a Figure's image array.
4
+ *
5
+ * Decision order:
6
+ * 1. If user prefers dark color scheme and a dark variant exists, use it.
7
+ * 2. If user prefers light color scheme and a light variant exists, use it.
8
+ * 3. If a vector (SVG) variant exists and the user prefers vector, use it.
9
+ * 4. Otherwise, fall back to the first declared variant.
10
+ *
11
+ * The `print` role is reserved for print stylesheets (selected via CSS
12
+ * @media print in FigureImages.vue), not picked here.
13
+ */
14
+ import type { FigureImage } from '../../adapters/non-verbal/types';
15
+
16
+ export interface PickOptions {
17
+ prefersDark?: boolean;
18
+ prefersVector?: boolean;
19
+ }
20
+
21
+ export function pickBestImage(images: FigureImage[], opts: PickOptions = {}): FigureImage | null {
22
+ if (images.length === 0) return null;
23
+ if (opts.prefersDark) {
24
+ const dark = images.find(i => i.role === 'dark');
25
+ if (dark) return dark;
26
+ }
27
+ if (!opts.prefersDark) {
28
+ const light = images.find(i => i.role === 'light');
29
+ if (light) return light;
30
+ }
31
+ if (opts.prefersVector) {
32
+ const vector = images.find(i => i.format === 'svg' || i.role === 'vector');
33
+ if (vector) return vector;
34
+ }
35
+ return images[0] ?? null;
36
+ }
37
+
38
+ /**
39
+ * Group image variants by role for the <picture> element. Returns the
40
+ * responsive sources in the order they should appear (most-specific
41
+ * first), plus the default `<img>` source.
42
+ */
43
+ export function groupImageVariants(images: FigureImage[]): {
44
+ sources: FigureImage[];
45
+ img: FigureImage | null;
46
+ print: FigureImage | null;
47
+ } {
48
+ const print = images.find(i => i.role === 'print') ?? null;
49
+ const dark = images.find(i => i.role === 'dark');
50
+ const light = images.find(i => i.role === 'light');
51
+ const sources: FigureImage[] = [];
52
+ if (dark) sources.push(dark);
53
+ if (light) sources.push(light);
54
+ const img = pickBestImage(images, {}) ?? images[0] ?? null;
55
+ return { sources, img, print };
56
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Figure layout derivation — derives a layout label from subfigure count.
3
+ *
4
+ * Per the architectural rule "derive layout, don't store it", this helper
5
+ * inspects a Figure's subfigures and returns the layout kind:
6
+ *
7
+ * - `single` — no subfigures
8
+ * - `row` — 2 subfigures side-by-side
9
+ * - `column` — 3+ subfigures in a vertical stack (default for many)
10
+ * - `grid` — 4+ subfigures in a 2-col grid
11
+ *
12
+ * Authors who want different behavior can split a composite figure into
13
+ * multiple top-level figures. V1 does not expose a `layout` field.
14
+ */
15
+ import type { Figure } from '../../adapters/non-verbal/types';
16
+
17
+ export type FigureLayout = 'single' | 'row' | 'column' | 'grid';
18
+
19
+ export function deriveLayout(fig: Figure): FigureLayout {
20
+ const count = fig.subfigures?.length ?? 0;
21
+ if (count === 0) return 'single';
22
+ if (count === 1) return 'column';
23
+ if (count === 2) return 'row';
24
+ if (count === 3) return 'column';
25
+ return 'grid';
26
+ }
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FormulaDisplay — renders a dataset-level Formula entity.
4
+ *
5
+ * Caption + expression + description + sources. The expression renders
6
+ * via Plurimath (LaTeX/MathML/AsciiMath). Self-anchoring — the outer
7
+ * `<figure>` receives the anchor ID for `{{formula:id}}` mentions.
8
+ */
9
+ import { computed } from 'vue';
10
+ import type { Formula } from '../../adapters/non-verbal/types';
11
+ import { useNonVerbalEntity } from '../../composables/use-non-verbal-entity';
12
+ import { resolveFallbackChain } from '../../utils/locale';
13
+ import { anchorId } from '../../utils/non-verbal-anchor';
14
+ import NonVerbalCaption from '../non-verbal/NonVerbalCaption.vue';
15
+ import NonVerbalSources from '../non-verbal/NonVerbalSources.vue';
16
+ import NonVerbalFallback from '../non-verbal/NonVerbalFallback.vue';
17
+ import FormulaExpression from './FormulaExpression.vue';
18
+
19
+ const props = defineProps<{
20
+ datasetId: string;
21
+ entityId: string;
22
+ locale: string;
23
+ datasetLocales?: readonly string[];
24
+ }>();
25
+
26
+ const k = () => 'formula' 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('formula', props.datasetId, props.entityId));
31
+ const descriptionId = computed(() => `${anchor.value}-desc`);
32
+ </script>
33
+
34
+ <template>
35
+ <figure
36
+ v-if="entity && state === 'loaded'"
37
+ :id="anchor"
38
+ class="formula-entity"
39
+ >
40
+ <div class="formula__expr-line">
41
+ <FormulaExpression
42
+ :expression="(entity as Formula).expression"
43
+ :notation="(entity as Formula).notation"
44
+ :locale="locale"
45
+ :fallback-chain="fallbackChain"
46
+ />
47
+ </div>
48
+
49
+ <NonVerbalCaption
50
+ :identifier="(entity as Formula).identifier"
51
+ :caption="(entity as Formula).caption"
52
+ :description="(entity as Formula).description"
53
+ :locale="locale"
54
+ :fallback-chain="fallbackChain"
55
+ :description-id="descriptionId"
56
+ />
57
+
58
+ <NonVerbalSources
59
+ v-if="(entity as Formula).sources?.length"
60
+ :sources="(entity as Formula).sources!"
61
+ />
62
+ </figure>
63
+
64
+ <NonVerbalFallback
65
+ v-else-if="state === 'loading' || state === 'not-found' || state === 'error'"
66
+ :state="state"
67
+ kind="formula"
68
+ :entity-id="entityId"
69
+ :message="error ?? undefined"
70
+ />
71
+ </template>
72
+
73
+ <style scoped>
74
+ .formula-entity {
75
+ margin: 1rem 0;
76
+ padding: 0.75rem 1rem;
77
+ border-left: 3px solid var(--ink-200, #e5e5e5);
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 0.5rem;
81
+ }
82
+ .formula__expr-line {
83
+ font-size: 1.125rem;
84
+ padding: 0.25rem 0;
85
+ }
86
+ .formula-entity:focus-visible {
87
+ outline: 2px solid var(--blue-500, #3b82f6);
88
+ outline-offset: 4px;
89
+ }
90
+ </style>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FormulaExpression — renders one formula expression in its notation.
4
+ *
5
+ * Uses Plurimath (the project's existing math library, NOT KaTeX) to
6
+ * render LaTeX / AsciiMath / MathML to MathML+HTML. Plurimath output
7
+ * includes semantic MathML for screen readers as a by-product of its
8
+ * internal pipeline, so no extra a11y work is needed.
9
+ *
10
+ * The expression is a LocalizedString — different locales can name
11
+ * variables in their local language while keeping the same notation type.
12
+ */
13
+ import { computed, ref, watch, onMounted } from 'vue';
14
+ import type { LocalizedString, FormulaNotation } from '../../adapters/non-verbal/types';
15
+ import { pickLocaleMap, localeToBcp47 } from '../../utils/locale';
16
+ import { loadPlurimath } from '../../utils/plurimath';
17
+
18
+ const props = defineProps<{
19
+ expression: LocalizedString;
20
+ notation: FormulaNotation;
21
+ locale: string;
22
+ fallbackChain?: readonly string[];
23
+ }>();
24
+
25
+ const resolved = computed(() => pickLocaleMap(props.expression, props.locale, props.fallbackChain));
26
+ const html = ref<string>('');
27
+ const lang = computed(() => resolved.value ? localeToBcp47(resolved.value.locale) : undefined);
28
+
29
+ const PLURIMATH_FORMAT: Record<FormulaNotation, string> = {
30
+ latex: 'latex',
31
+ asciimath: 'asciimath',
32
+ mathml: 'mathml',
33
+ };
34
+
35
+ async function render() {
36
+ const r = resolved.value;
37
+ if (!r) { html.value = ''; return; }
38
+ try {
39
+ const Plurimath = await loadPlurimath();
40
+ const p = new Plurimath(r.text, PLURIMATH_FORMAT[props.notation]);
41
+ html.value = p.toMathml().replace('display="block"', 'display="inline"').trim();
42
+ } catch {
43
+ html.value = `<code class="formula-fallback">${r.text}</code>`;
44
+ }
45
+ }
46
+
47
+ onMounted(render);
48
+ watch([resolved, () => props.notation], render);
49
+ </script>
50
+
51
+ <template>
52
+ <span class="formula__expression" :lang="lang">
53
+ <span v-if="html" v-html="html"></span>
54
+ <code v-else></code>
55
+ </span>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .formula__expression {
60
+ display: inline-block;
61
+ font-family: 'Latin Modern Math', 'STIX Two Math', serif;
62
+ }
63
+ .formula-fallback {
64
+ font-family: var(--font-mono, monospace);
65
+ background: var(--surface-alt, #f5f5f5);
66
+ padding: 0.125rem 0.375rem;
67
+ border-radius: 0.25rem;
68
+ font-size: 0.875rem;
69
+ }
70
+ </style>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalCaption — shared caption + identifier + description.
4
+ *
5
+ * Renders identically for Figure, Table, Formula. The shape:
6
+ *
7
+ * <figcaption>
8
+ * <span class="nv-caption__identifier">Figure 7c.</span>
9
+ * <span class="nv-caption__text" lang="en">Caption text.</span>
10
+ * <details>
11
+ * <summary>Detailed description</summary>
12
+ * <p lang="en">Long description text.</p>
13
+ * </details>
14
+ * </figcaption>
15
+ *
16
+ * The `lang` attribute reflects the ACTUAL resolved locale (not the
17
+ * requested one), so a French page with an English-only caption shows
18
+ * `lang="en"` on the caption — correct for screen readers.
19
+ */
20
+ import { computed } from 'vue';
21
+ import type { LocalizedString } from '../../adapters/non-verbal/types';
22
+ import { pickLocaleMap, localeToBcp47 } from '../../utils/locale';
23
+
24
+ const props = defineProps<{
25
+ identifier?: string;
26
+ caption?: LocalizedString;
27
+ description?: LocalizedString;
28
+ locale: string;
29
+ fallbackChain?: readonly string[];
30
+ descriptionId?: string;
31
+ }>();
32
+
33
+ const captionResolved = computed(() =>
34
+ pickLocaleMap(props.caption, props.locale, props.fallbackChain),
35
+ );
36
+
37
+ const descriptionResolved = computed(() =>
38
+ pickLocaleMap(props.description, props.locale, props.fallbackChain),
39
+ );
40
+
41
+ const captionLang = computed(() =>
42
+ captionResolved.value ? localeToBcp47(captionResolved.value.locale) : undefined,
43
+ );
44
+
45
+ const descriptionLang = computed(() =>
46
+ descriptionResolved.value ? localeToBcp47(descriptionResolved.value.locale) : undefined,
47
+ );
48
+ </script>
49
+
50
+ <template>
51
+ <figcaption class="nv-caption">
52
+ <span v-if="identifier" class="nv-caption__identifier">{{ identifier }}.</span>
53
+ <span
54
+ v-if="captionResolved"
55
+ class="nv-caption__text"
56
+ :lang="captionLang"
57
+ >{{ captionResolved.text }}</span>
58
+ <details
59
+ v-if="descriptionResolved"
60
+ :id="descriptionId"
61
+ class="nv-caption__desc"
62
+ >
63
+ <summary>Detailed description</summary>
64
+ <p :lang="descriptionLang">{{ descriptionResolved.text }}</p>
65
+ </details>
66
+ </figcaption>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .nv-caption {
71
+ font-size: 0.875rem;
72
+ color: var(--ink-700, #444);
73
+ line-height: 1.5;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 0.25rem;
77
+ }
78
+ .nv-caption__identifier {
79
+ font-weight: 600;
80
+ color: var(--ink-800, #222);
81
+ }
82
+ .nv-caption__text {
83
+ font-style: italic;
84
+ }
85
+ .nv-caption__desc {
86
+ margin-top: 0.25rem;
87
+ font-size: 0.8125rem;
88
+ color: var(--ink-500, #666);
89
+ }
90
+ .nv-caption__desc > summary {
91
+ cursor: pointer;
92
+ font-weight: 500;
93
+ color: var(--ink-600, #555);
94
+ }
95
+ .nv-caption__desc > p {
96
+ margin-top: 0.5rem;
97
+ line-height: 1.6;
98
+ }
99
+
100
+ @media (prefers-contrast: more) {
101
+ .nv-caption__text { font-weight: 600; }
102
+ .nv-caption__identifier { font-weight: 700; }
103
+ }
104
+ </style>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalFallback — shared loading / not-found / error state.
4
+ *
5
+ * Each entity display component renders its own fallback rather than
6
+ * hoisting errors to a global banner. Per the project rule
7
+ * "Error handling: local over global", a missing figure should not
8
+ * break the page — it shows a compact notice inline.
9
+ */
10
+ defineProps<{
11
+ state: 'loading' | 'not-found' | 'error';
12
+ kind: 'figure' | 'table' | 'formula';
13
+ entityId: string;
14
+ message?: string;
15
+ }>();
16
+ </script>
17
+
18
+ <template>
19
+ <div
20
+ class="nv-fallback"
21
+ :class="`nv-fallback--${state}`"
22
+ role="status"
23
+ :aria-live="state === 'error' ? 'assertive' : 'polite'"
24
+ >
25
+ <template v-if="state === 'loading'">
26
+ <span class="nv-fallback__spinner" aria-hidden="true"></span>
27
+ Loading {{ kind }} <code>{{ entityId }}</code>…
28
+ </template>
29
+ <template v-else-if="state === 'not-found'">
30
+ {{ kind }} <code>{{ entityId }}</code> not found
31
+ </template>
32
+ <template v-else>
33
+ Failed to load {{ kind }} <code>{{ entityId }}</code>
34
+ <span v-if="message" class="nv-fallback__detail">: {{ message }}</span>
35
+ </template>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .nv-fallback {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ padding: 0.5rem 0.75rem;
45
+ border-radius: 0.375rem;
46
+ font-size: 0.8125rem;
47
+ color: var(--ink-500, #666);
48
+ background: var(--surface-alt, #f5f5f5);
49
+ border: 1px dashed var(--ink-200, #ccc);
50
+ }
51
+ .nv-fallback--error {
52
+ color: #b91c1c;
53
+ background: #fef2f2;
54
+ border-color: #fecaca;
55
+ }
56
+ .nv-fallback__spinner {
57
+ width: 0.75rem;
58
+ height: 0.75rem;
59
+ border: 2px solid var(--ink-200, #ccc);
60
+ border-top-color: transparent;
61
+ border-radius: 50%;
62
+ animation: nv-spin 0.8s linear infinite;
63
+ }
64
+ @keyframes nv-spin { to { transform: rotate(360deg); } }
65
+
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .nv-fallback__spinner { animation: none; }
68
+ }
69
+ </style>