@glossarist/concept-browser 0.7.110 → 0.7.112
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.
- package/package.json +2 -2
- package/src/__tests__/utils/external-detection.test.ts +139 -36
- package/src/__tests__/utils/font-variables.test.ts +202 -0
- package/src/components/LanguageDetail.vue +38 -0
- package/src/components/PartitiveRelationList.vue +51 -3
- package/src/config/use-site-config.ts +15 -20
- package/src/layouts/Default.astro +8 -14
- package/src/styles/tailwind.css +30 -12
- package/src/utils/external-detection.ts +71 -44
- package/src/utils/font-variables.ts +97 -0
- package/tailwind.config.js +5 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.112",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"astro": "^7.0.7",
|
|
39
39
|
"d3": "^7.9.0",
|
|
40
40
|
"favicons": "^7.2.0",
|
|
41
|
-
"glossarist": "^0.4.
|
|
41
|
+
"glossarist": "^0.4.52",
|
|
42
42
|
"js-yaml": "^4.1.0",
|
|
43
43
|
"jszip": "^3.10.1",
|
|
44
44
|
"pinia": "^2.3.1",
|
|
@@ -1,79 +1,155 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
isExternalConcept,
|
|
4
|
+
isExternalMember,
|
|
5
|
+
isExternalComprehensive,
|
|
6
|
+
getExternalMembers,
|
|
4
7
|
hasProvidedBy,
|
|
5
|
-
|
|
8
|
+
hasDanglingExternal,
|
|
6
9
|
formatExternalLabel,
|
|
7
|
-
type
|
|
10
|
+
type ConceptStore,
|
|
11
|
+
type ExternalConceptLike,
|
|
8
12
|
} from '../../utils/external-detection';
|
|
13
|
+
import { checkExtensionalCompleteness } from 'glossarist';
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
/**
|
|
16
|
+
* External-detection + extensional-completeness contract tests.
|
|
17
|
+
*
|
|
18
|
+
* glossarist@0.4.52 promoted these utilities from concept-browser's
|
|
19
|
+
* local src/utils/external-detection.ts into the upstream library.
|
|
20
|
+
* This spec pins:
|
|
21
|
+
* 1. The local module re-exports the upstream functions (no drift).
|
|
22
|
+
* 2. The upstream signatures work as documented.
|
|
23
|
+
* 3. `formatExternalLabel` (the UI-only helper) stays local.
|
|
24
|
+
* 4. `checkExtensionalCompleteness` flags ISO 704:2022 §6.4.5.1
|
|
25
|
+
* open-ended patterns in extensional definitions.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const extResolved: ExternalConceptLike = { status: 'external', related: [{ type: 'provided_by' }] };
|
|
29
|
+
const extDangling: ExternalConceptLike = { status: 'external', related: [] };
|
|
30
|
+
const internal: ExternalConceptLike = { status: 'valid', related: [] };
|
|
31
|
+
|
|
32
|
+
const store: ConceptStore = {
|
|
11
33
|
lookup(ref) {
|
|
12
|
-
if (ref?.id === 'ext-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (ref?.id === 'ext-2') {
|
|
16
|
-
return { status: 'external', relatedConcepts: [] };
|
|
17
|
-
}
|
|
18
|
-
if (ref?.id === 'int-1') {
|
|
19
|
-
return { status: 'valid', relatedConcepts: [] };
|
|
20
|
-
}
|
|
34
|
+
if (ref?.id === 'ext-resolved') return extResolved;
|
|
35
|
+
if (ref?.id === 'ext-dangling') return extDangling;
|
|
36
|
+
if (ref?.id === 'int-1') return internal;
|
|
21
37
|
return null;
|
|
22
38
|
},
|
|
23
39
|
};
|
|
24
40
|
|
|
25
|
-
describe('external-detection', () => {
|
|
41
|
+
describe('external-detection (re-exports from glossarist@0.4.52)', () => {
|
|
26
42
|
describe('isExternalConcept', () => {
|
|
27
|
-
it('returns true when
|
|
28
|
-
expect(isExternalConcept(
|
|
43
|
+
it('returns true when concept.status === "external"', () => {
|
|
44
|
+
expect(isExternalConcept(extResolved)).toBe(true);
|
|
45
|
+
expect(isExternalConcept(extDangling)).toBe(true);
|
|
29
46
|
});
|
|
30
47
|
|
|
31
|
-
it('returns false
|
|
32
|
-
expect(isExternalConcept(
|
|
48
|
+
it('returns false for internal concepts', () => {
|
|
49
|
+
expect(isExternalConcept(internal)).toBe(false);
|
|
33
50
|
});
|
|
34
51
|
|
|
35
|
-
it('returns false
|
|
36
|
-
expect(isExternalConcept(
|
|
52
|
+
it('returns false for null/undefined', () => {
|
|
53
|
+
expect(isExternalConcept(null)).toBe(false);
|
|
54
|
+
expect(isExternalConcept(undefined)).toBe(false);
|
|
37
55
|
});
|
|
56
|
+
});
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
describe('isExternalMember', () => {
|
|
59
|
+
it('returns true when the member ref resolves to an external concept', () => {
|
|
60
|
+
expect(isExternalMember({ ref: { source: 's', id: 'ext-resolved' } }, store)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns false when the member ref resolves to an internal concept', () => {
|
|
64
|
+
expect(isExternalMember({ ref: { source: 's', id: 'int-1' } }, store)).toBe(false);
|
|
41
65
|
});
|
|
42
66
|
|
|
43
67
|
it('returns false when store is null', () => {
|
|
44
|
-
expect(
|
|
68
|
+
expect(isExternalMember({ ref: { source: 's', id: 'ext-resolved' } }, null)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns false when ref is missing', () => {
|
|
72
|
+
expect(isExternalMember({}, store)).toBe(false);
|
|
73
|
+
expect(isExternalMember(null, store)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('isExternalComprehensive', () => {
|
|
78
|
+
it('returns true when the comprehensive ref resolves to external', () => {
|
|
79
|
+
const hyperedge = {
|
|
80
|
+
comprehensive: { source: 's', id: 'ext-resolved' },
|
|
81
|
+
members: [],
|
|
82
|
+
};
|
|
83
|
+
expect(isExternalComprehensive(hyperedge, store)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns false when the comprehensive ref resolves to internal', () => {
|
|
87
|
+
const hyperedge = {
|
|
88
|
+
comprehensive: { source: 's', id: 'int-1' },
|
|
89
|
+
members: [],
|
|
90
|
+
};
|
|
91
|
+
expect(isExternalComprehensive(hyperedge, store)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('getExternalMembers', () => {
|
|
96
|
+
it('returns only the members whose refs resolve to external', () => {
|
|
97
|
+
const hyperedge = {
|
|
98
|
+
comprehensive: { source: 's', id: 'int-1' },
|
|
99
|
+
members: [
|
|
100
|
+
{ ref: { source: 's', id: 'ext-resolved' } },
|
|
101
|
+
{ ref: { source: 's', id: 'int-1' } },
|
|
102
|
+
{ ref: { source: 's', id: 'ext-dangling' } },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const external = getExternalMembers(hyperedge, store);
|
|
106
|
+
expect(external).toHaveLength(2);
|
|
107
|
+
expect(external.map(m => (m.ref as { id?: string } | undefined)?.id)).toEqual(['ext-resolved', 'ext-dangling']);
|
|
45
108
|
});
|
|
46
109
|
});
|
|
47
110
|
|
|
48
111
|
describe('hasProvidedBy', () => {
|
|
49
|
-
it('returns true when the
|
|
50
|
-
expect(hasProvidedBy(
|
|
112
|
+
it('returns true when the concept has a provided_by edge', () => {
|
|
113
|
+
expect(hasProvidedBy(extResolved)).toBe(true);
|
|
51
114
|
});
|
|
52
115
|
|
|
53
|
-
it('returns false when the
|
|
54
|
-
expect(hasProvidedBy(
|
|
116
|
+
it('returns false when the concept has no provided_by edge', () => {
|
|
117
|
+
expect(hasProvidedBy(extDangling)).toBe(false);
|
|
55
118
|
});
|
|
56
119
|
|
|
57
|
-
it('returns false
|
|
58
|
-
expect(hasProvidedBy(
|
|
120
|
+
it('returns false for null/undefined', () => {
|
|
121
|
+
expect(hasProvidedBy(null)).toBe(false);
|
|
122
|
+
expect(hasProvidedBy(undefined)).toBe(false);
|
|
59
123
|
});
|
|
60
124
|
});
|
|
61
125
|
|
|
62
|
-
describe('
|
|
63
|
-
it('returns
|
|
64
|
-
|
|
126
|
+
describe('hasDanglingExternal (hyperedge-level)', () => {
|
|
127
|
+
it('returns true when any external ref lacks a provided_by edge', () => {
|
|
128
|
+
const hyperedge = {
|
|
129
|
+
comprehensive: { source: 's', id: 'ext-dangling' },
|
|
130
|
+
members: [],
|
|
131
|
+
};
|
|
132
|
+
expect(hasDanglingExternal(hyperedge, store)).toBe(true);
|
|
65
133
|
});
|
|
66
134
|
|
|
67
|
-
it('returns
|
|
68
|
-
|
|
135
|
+
it('returns false when every external ref is resolved', () => {
|
|
136
|
+
const hyperedge = {
|
|
137
|
+
comprehensive: { source: 's', id: 'ext-resolved' },
|
|
138
|
+
members: [{ ref: { source: 's', id: 'ext-resolved' } }],
|
|
139
|
+
};
|
|
140
|
+
expect(hasDanglingExternal(hyperedge, store)).toBe(false);
|
|
69
141
|
});
|
|
70
142
|
|
|
71
|
-
it('returns false
|
|
72
|
-
|
|
143
|
+
it('returns false when no refs are external', () => {
|
|
144
|
+
const hyperedge = {
|
|
145
|
+
comprehensive: { source: 's', id: 'int-1' },
|
|
146
|
+
members: [{ ref: { source: 's', id: 'int-1' } }],
|
|
147
|
+
};
|
|
148
|
+
expect(hasDanglingExternal(hyperedge, store)).toBe(false);
|
|
73
149
|
});
|
|
74
150
|
});
|
|
75
151
|
|
|
76
|
-
describe('formatExternalLabel', () => {
|
|
152
|
+
describe('formatExternalLabel (local UI helper, not in upstream)', () => {
|
|
77
153
|
it('wraps the label in parentheses for external concepts', () => {
|
|
78
154
|
expect(formatExternalLabel('precision condition', true)).toBe('(precision condition)');
|
|
79
155
|
});
|
|
@@ -83,3 +159,30 @@ describe('external-detection', () => {
|
|
|
83
159
|
});
|
|
84
160
|
});
|
|
85
161
|
});
|
|
162
|
+
|
|
163
|
+
describe('checkExtensionalCompleteness (ISO 704:2022 §6.4.5.1)', () => {
|
|
164
|
+
it('returns null for non-extensional definitions', () => {
|
|
165
|
+
expect(checkExtensionalCompleteness({ type: 'intensional', content: '...' })).toBeNull();
|
|
166
|
+
expect(checkExtensionalCompleteness({ type: null, content: '...' })).toBeNull();
|
|
167
|
+
expect(checkExtensionalCompleteness(null)).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns null for an extensional definition that lists every member', () => {
|
|
171
|
+
expect(checkExtensionalCompleteness({
|
|
172
|
+
type: 'extensional',
|
|
173
|
+
content: '1, 2, 3',
|
|
174
|
+
})).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns a warning when an extensional definition uses an open-ended pattern', () => {
|
|
178
|
+
// Open-ended patterns: "etc.", "...", "and so on", "includes but is not limited to"
|
|
179
|
+
const issue = checkExtensionalCompleteness({
|
|
180
|
+
type: 'extensional',
|
|
181
|
+
content: 'length, mass, time, etc.',
|
|
182
|
+
});
|
|
183
|
+
expect(issue).not.toBeNull();
|
|
184
|
+
expect(issue?.severity).toBe('warning');
|
|
185
|
+
expect(issue?.rule).toBe('ISO-704-6.4.5.1');
|
|
186
|
+
expect(issue?.message).toMatch(/completeness|extensional/i);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
buildFontVariables,
|
|
6
|
+
renderFontVariablesInline,
|
|
7
|
+
FONT_SLOTS,
|
|
8
|
+
type BrandingFontsLike,
|
|
9
|
+
} from '../../utils/font-variables';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Single-source-of-truth contract for branding-driven CSS font variables.
|
|
13
|
+
*
|
|
14
|
+
* Bug history (2026-08-01):
|
|
15
|
+
* - 0.7.106–0.7.110: brand fonts were silently clobbered for
|
|
16
|
+
* `--font-serif`/`--font-sans`/`--font-mono` because the tailwind.css
|
|
17
|
+
* `@theme` block emitted `:root, :host { ... }` declarations into the
|
|
18
|
+
* CSS bundle, which loaded AFTER Default.astro's inline `<style>` and
|
|
19
|
+
* overrode the brand values per CSS cascade (equal specificity, later
|
|
20
|
+
* source order wins).
|
|
21
|
+
* - 0.7.111 contributor patch fixed `:root` block but missed `@theme`.
|
|
22
|
+
*
|
|
23
|
+
* Post-refactor: `buildFontVariables()` is the only emitter. Both
|
|
24
|
+
* `Default.astro` (build-time inline) and `use-site-config.ts` (runtime
|
|
25
|
+
* overrides) call it. The output is the canonical slot-named shape —
|
|
26
|
+
* no `--font-serif`/`--font-sans` category-named variables that bake in
|
|
27
|
+
* serif↔heading / sans↔body assumptions.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
describe('buildFontVariables — shape contract', () => {
|
|
31
|
+
it('returns exactly the four slot-named variables', () => {
|
|
32
|
+
const vars = buildFontVariables(null);
|
|
33
|
+
expect(Object.keys(vars).sort()).toEqual(
|
|
34
|
+
['--font-body', '--font-heading', '--font-mono', '--font-title'],
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('FONT_SLOTS constant is the canonical slot list', () => {
|
|
39
|
+
expect(FONT_SLOTS).toEqual(['title', 'heading', 'body', 'mono']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('never emits --font-serif or --font-sans (category-named variables are forbidden)', () => {
|
|
43
|
+
const vars = buildFontVariables({
|
|
44
|
+
title: { family: 'Raleway', category: 'sans-serif' },
|
|
45
|
+
});
|
|
46
|
+
expect(vars).not.toHaveProperty('--font-serif');
|
|
47
|
+
expect(vars).not.toHaveProperty('--font-sans');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('never emits --font-header as a separate variable (legacy alias handled by CSS, not JS)', () => {
|
|
51
|
+
const vars = buildFontVariables({ header: { family: 'Raleway' } });
|
|
52
|
+
expect(vars).not.toHaveProperty('--font-header');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('buildFontVariables — defaults preserve Glossarist visual identity', () => {
|
|
57
|
+
it('falls back to DM Serif Display + DM Sans + JetBrains Mono when no branding', () => {
|
|
58
|
+
const vars = buildFontVariables(null);
|
|
59
|
+
expect(vars['--font-title']).toContain('DM Serif Display');
|
|
60
|
+
expect(vars['--font-heading']).toContain('DM Serif Display');
|
|
61
|
+
expect(vars['--font-body']).toContain('DM Sans');
|
|
62
|
+
expect(vars['--font-mono']).toContain('JetBrains Mono');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps working when individual slots are missing', () => {
|
|
66
|
+
const vars = buildFontVariables({ title: { family: 'Raleway' } });
|
|
67
|
+
expect(vars['--font-title']).toContain('Raleway');
|
|
68
|
+
expect(vars['--font-heading']).toContain('DM Serif Display');
|
|
69
|
+
expect(vars['--font-body']).toContain('DM Sans');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('buildFontVariables — brand overrides', () => {
|
|
74
|
+
const raleway: BrandingFontsLike = {
|
|
75
|
+
title: { family: 'Raleway', category: 'sans-serif' },
|
|
76
|
+
heading: { family: 'Raleway', category: 'sans-serif' },
|
|
77
|
+
body: { family: 'Raleway', category: 'sans-serif' },
|
|
78
|
+
mono: { family: 'Fira Code', category: 'monospace' },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
it('produces Raleway stacks for every text slot when consumer sets all four', () => {
|
|
82
|
+
const vars = buildFontVariables(raleway);
|
|
83
|
+
expect(vars['--font-title']).toBe("'Raleway', system-ui, sans-serif");
|
|
84
|
+
expect(vars['--font-heading']).toBe("'Raleway', system-ui, sans-serif");
|
|
85
|
+
expect(vars['--font-body']).toBe("'Raleway', system-ui, sans-serif");
|
|
86
|
+
expect(vars['--font-mono']).toBe("'Fira Code', ui-monospace, \"JetBrains Mono\", Menlo, Monaco, monospace");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('honors `header` legacy alias as input for the heading slot', () => {
|
|
90
|
+
const vars = buildFontVariables({ header: { family: 'Inter', category: 'sans-serif' } });
|
|
91
|
+
expect(vars['--font-heading']).toBe("'Inter', system-ui, sans-serif");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('`heading` slot wins over legacy `header` alias when both are set', () => {
|
|
95
|
+
const vars = buildFontVariables({
|
|
96
|
+
heading: { family: 'Heading', category: 'sans-serif' },
|
|
97
|
+
header: { family: 'Header', category: 'sans-serif' },
|
|
98
|
+
});
|
|
99
|
+
expect(vars['--font-heading']).toContain('Heading');
|
|
100
|
+
expect(vars['--font-heading']).not.toContain('Header');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('renderFontVariablesInline — serialization', () => {
|
|
105
|
+
it('produces a `:root { ... }` block with every slot variable', () => {
|
|
106
|
+
const css = renderFontVariablesInline({ title: { family: 'Raleway', category: 'sans-serif' } });
|
|
107
|
+
expect(css.startsWith(':root{')).toBe(true);
|
|
108
|
+
expect(css.endsWith('}')).toBe(true);
|
|
109
|
+
expect(css).toContain('--font-title:');
|
|
110
|
+
expect(css).toContain('--font-heading:');
|
|
111
|
+
expect(css).toContain('--font-body:');
|
|
112
|
+
expect(css).toContain('--font-mono:');
|
|
113
|
+
expect(css).toContain("'Raleway'");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('separates declarations with semicolons (no trailing junk)', () => {
|
|
117
|
+
const css = renderFontVariablesInline(null);
|
|
118
|
+
// Each declaration is `--font-X: value`, separated by `;`.
|
|
119
|
+
const declCount = css.match(/--font-[a-z]+:/g)!.length;
|
|
120
|
+
const sepCount = css.match(/;/g)!.length;
|
|
121
|
+
expect(declCount).toBe(4);
|
|
122
|
+
expect(sepCount).toBe(3); // 4 declarations = 3 separators (no trailing)
|
|
123
|
+
expect(css).not.toMatch(/;;/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('emits only slot-named variables — never --font-serif / --font-sans', () => {
|
|
127
|
+
const css = renderFontVariablesInline(null);
|
|
128
|
+
expect(css).not.toMatch(/--font-serif/);
|
|
129
|
+
expect(css).not.toMatch(/--font-sans/);
|
|
130
|
+
expect(css).not.toMatch(/--font-header/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* THE LOAD-BEARING REGRESSION TEST.
|
|
136
|
+
*
|
|
137
|
+
* The bundled CSS must NOT contain `:root, :host { --font-... }` declarations
|
|
138
|
+
* for any slot variable. If it does, the bundle loads after the inline
|
|
139
|
+
* `<style>` and clobbers the brand values per CSS cascade.
|
|
140
|
+
*
|
|
141
|
+
* This test scans the built Default.*.css artifact (produced by
|
|
142
|
+
* `npm run build:astro`) and asserts no font variable appears in a
|
|
143
|
+
* `:root` selector. Skipped when dist/ doesn't exist (fresh clone,
|
|
144
|
+
* before first build).
|
|
145
|
+
*/
|
|
146
|
+
function findBundleDefaultCss(): string | null {
|
|
147
|
+
const astroDir = resolve(process.cwd(), 'dist/_astro');
|
|
148
|
+
if (!existsSync(astroDir)) return null;
|
|
149
|
+
for (const f of readdirSync(astroDir)) {
|
|
150
|
+
if (f.startsWith('Default.') && f.endsWith('.css')) {
|
|
151
|
+
return readFileSync(resolve(astroDir, f), 'utf-8');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe('bundle drift regression — Default.*.css must not clobber brand font vars', () => {
|
|
158
|
+
it('bundle has no `:root,:host{...--font-X: <literal>}` declarations', () => {
|
|
159
|
+
// The bundle's `:root, :host { ... }` block (from @theme) is allowed
|
|
160
|
+
// to define font variables as `var()` references to the slot variables
|
|
161
|
+
// — that's an alias, not a literal. A literal value (e.g.,
|
|
162
|
+
// `--font-serif: "DM Serif Display"`) would clobber the inline <style>.
|
|
163
|
+
const css = findBundleDefaultCss();
|
|
164
|
+
if (css == null) {
|
|
165
|
+
console.warn('Skipping bundle drift test — dist/_astro/Default.*.css not found.');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Find any `--font-X: <value>` declaration in a `:root,:host{...}` block.
|
|
169
|
+
// Reject if the value is a literal (not a var() reference).
|
|
170
|
+
// NOTE: --font-weight-* is a Tailwind font-WEIGHT scale, not a
|
|
171
|
+
// font-family variable — excluded from this drift check.
|
|
172
|
+
const rootBlocks = css.match(/:root[^{]*\{([^}]*)\}/g) ?? [];
|
|
173
|
+
const offenders: string[] = [];
|
|
174
|
+
for (const block of rootBlocks) {
|
|
175
|
+
const fontDecls = block.matchAll(/--font-(title|heading|header|body|mono|serif|sans)\s*:\s*([^;}]+)/g);
|
|
176
|
+
for (const m of fontDecls) {
|
|
177
|
+
const name = m[1];
|
|
178
|
+
const value = m[2].trim();
|
|
179
|
+
if (value.startsWith('var(')) continue; // alias — OK
|
|
180
|
+
if (value === 'initial') continue; // explicit disable — OK
|
|
181
|
+
offenders.push(`--font-${name}: ${value}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
expect(
|
|
185
|
+
offenders,
|
|
186
|
+
'bundle must not emit literal font-family values at :root (they clobber inline style)',
|
|
187
|
+
).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('bundle aliases map to slot variables (no category-named sources)', () => {
|
|
191
|
+
const css = findBundleDefaultCss();
|
|
192
|
+
if (css == null) {
|
|
193
|
+
console.warn('Skipping bundle drift test — dist/_astro/Default.*.css not found.');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Verify the alias wiring is in place: --font-serif → --font-heading,
|
|
197
|
+
// --font-sans → --font-body. These ensure Tailwind's default utility
|
|
198
|
+
// names still work in templates that haven't migrated to slot names.
|
|
199
|
+
expect(css).toMatch(/--font-serif:\s*var\(--font-heading\)/);
|
|
200
|
+
expect(css).toMatch(/--font-sans:\s*var\(--font-body\)/);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Concept, LocalizedConcept, Designation, Expression, Abbreviation as AbbreviationType } from 'glossarist';
|
|
3
|
+
import { checkExtensionalCompleteness, type ExtensionalCompletenessIssue } from 'glossarist';
|
|
3
4
|
import { computed } from 'vue';
|
|
4
5
|
import { langName, langLabel } from '../utils/lang';
|
|
5
6
|
import { renderContent } from '../utils/content-renderer';
|
|
@@ -33,6 +34,33 @@ const definition = computed(() => {
|
|
|
33
34
|
const content = lc.value.definitions.map(d => d.content).filter(Boolean).join('\n\n');
|
|
34
35
|
return content;
|
|
35
36
|
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ISO 704:2022 §6.4.5.1 extensional-completeness warnings.
|
|
40
|
+
*
|
|
41
|
+
* An extensional definition enumerates every member of the concept's
|
|
42
|
+
* extension. Open-ended phrasing ("etc.", "...", "and so on") signals
|
|
43
|
+
* that the enumeration is incomplete — the definition is invalid per
|
|
44
|
+
* ISO 704. Surface each warning inline so editors can fix the source.
|
|
45
|
+
*
|
|
46
|
+
* The validator gates on `definition.type === 'extensional'`. Today
|
|
47
|
+
* concept-browser's DetailedDefinition model doesn't carry that field,
|
|
48
|
+
* so this returns [] — the wiring is in place for the moment glossarist
|
|
49
|
+
* exposes definition-type tracking (or for consumers that annotate
|
|
50
|
+
* definitions themselves).
|
|
51
|
+
*/
|
|
52
|
+
const extensionalIssues = computed<ExtensionalCompletenessIssue[]>(() => {
|
|
53
|
+
if (!lc.value) return [];
|
|
54
|
+
const issues: ExtensionalCompletenessIssue[] = [];
|
|
55
|
+
for (const d of lc.value.definitions) {
|
|
56
|
+
const issue = checkExtensionalCompleteness(
|
|
57
|
+
d as unknown as { type?: string | null; content?: string | null },
|
|
58
|
+
);
|
|
59
|
+
if (issue) issues.push(issue);
|
|
60
|
+
}
|
|
61
|
+
return issues;
|
|
62
|
+
});
|
|
63
|
+
|
|
36
64
|
const notes = computed(() => lc.value?.notes.map(n => n.content).filter(Boolean) ?? []);
|
|
37
65
|
const examples = computed(() => lc.value?.examples.map(e => e.content).filter(Boolean) ?? []);
|
|
38
66
|
const sources = computed(() => lc.value?.sources ?? []);
|
|
@@ -140,6 +168,16 @@ function handleContentClick(e: MouseEvent) {
|
|
|
140
168
|
<div v-if="definition" class="card p-5">
|
|
141
169
|
<div class="section-label">{{ t('concept.definition') }}</div>
|
|
142
170
|
<div class="text-ink-800 leading-relaxed mt-3" v-html="renderContent(definition, renderOpts)"></div>
|
|
171
|
+
<!-- ISO 704:2022 §6.4.5.1 extensional-completeness warnings -->
|
|
172
|
+
<div
|
|
173
|
+
v-for="(issue, i) in extensionalIssues"
|
|
174
|
+
:key="'ext'+i"
|
|
175
|
+
class="mt-3 p-2 rounded border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-900 dark:text-amber-100"
|
|
176
|
+
:title="`Rule: ${issue.rule}`"
|
|
177
|
+
>
|
|
178
|
+
<span class="font-semibold">⚠ {{ t('concept.extensionalWarning') || 'Extensional completeness' }}:</span>
|
|
179
|
+
{{ issue.message }}
|
|
180
|
+
</div>
|
|
143
181
|
</div>
|
|
144
182
|
|
|
145
183
|
<!-- Notes -->
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* drop's line style encodes its multiplicity + is_delimiting per
|
|
10
10
|
* ISO 704:2022 (5 multiplicity values × 2 delimiting states).
|
|
11
11
|
*
|
|
12
|
+
* External concepts (status: 'external') are wrapped in parentheses per
|
|
13
|
+
* ISO 704:2022 §5.5.4.3.1, using the upstream `isExternalMember` /
|
|
14
|
+
* `isExternalComprehensive` detection from glossarist@0.4.52.
|
|
15
|
+
*
|
|
12
16
|
* Header badges communicate metadata that the diagram's line styles
|
|
13
17
|
* also encode — for users who can't perceive the visual difference
|
|
14
18
|
* (screen readers, low vision, monochrome displays).
|
|
@@ -21,6 +25,7 @@ import { useI18n, locale } from '../i18n';
|
|
|
21
25
|
import { completenessLabel } from '../utils/partitive-relation-styling';
|
|
22
26
|
import { presenceLabel, countLabel } from '../utils/partitive-multiplicity';
|
|
23
27
|
import { resolveDesignation } from '../utils/resolve-designation';
|
|
28
|
+
import { isExternalMember, isExternalComprehensive, formatExternalLabel } from '../utils/external-detection';
|
|
24
29
|
import PartitiveRelationDiagram, {
|
|
25
30
|
type PartitiveMemberLabeled,
|
|
26
31
|
} from './PartitiveRelationDiagram.vue';
|
|
@@ -36,8 +41,51 @@ const store = useVocabularyStore();
|
|
|
36
41
|
const factory = getFactory();
|
|
37
42
|
const { t } = useI18n();
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
/** Per-URI cache of external-status lookups, so the rake doesn't re-resolve
|
|
45
|
+
* the same concept on every render pass. */
|
|
46
|
+
const externalCache = new Map<string, boolean>();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bridge the concept-browser ref shape to glossarist@0.4.52's ConceptStore
|
|
50
|
+
* interface. The store exposes concepts via `lookup({ source, id })` and
|
|
51
|
+
* returns an object with `.status` + `.related[]` — exactly what the
|
|
52
|
+
* upstream external-detection utilities need.
|
|
53
|
+
*/
|
|
54
|
+
const externalStore = {
|
|
55
|
+
lookup(ref: { source?: string | null; id?: string | null }) {
|
|
56
|
+
if (!ref?.source && !ref?.id) return null;
|
|
57
|
+
// Find the matching concept by traversing the loaded datasets.
|
|
58
|
+
for (const adapter of store.datasets.values()) {
|
|
59
|
+
for (const entry of adapter.getConcepts() ?? []) {
|
|
60
|
+
if (entry && entry.id === ref.id) {
|
|
61
|
+
return {
|
|
62
|
+
status: entry.status,
|
|
63
|
+
related: (entry as any).relatedConcepts ?? (entry as any).related ?? [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function isExternalUri(uri: string, kind: 'member' | 'comprehensive'): boolean {
|
|
73
|
+
if (externalCache.has(uri)) return externalCache.get(uri)!;
|
|
74
|
+
// Build a member/hyperedge shape the upstream utilities expect.
|
|
75
|
+
const ref = { source: uri, id: uri.split('/').pop() ?? uri };
|
|
76
|
+
let isExt: boolean;
|
|
77
|
+
if (kind === 'comprehensive') {
|
|
78
|
+
isExt = isExternalComprehensive({ comprehensive: ref, members: [] }, externalStore);
|
|
79
|
+
} else {
|
|
80
|
+
isExt = isExternalMember({ ref }, externalStore);
|
|
81
|
+
}
|
|
82
|
+
externalCache.set(uri, isExt);
|
|
83
|
+
return isExt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function designationFor(uri: string, kind: 'member' | 'comprehensive' = 'member'): string {
|
|
87
|
+
const label = resolveDesignation(uri, store, factory, locale.value);
|
|
88
|
+
return formatExternalLabel(label, isExternalUri(uri, kind));
|
|
41
89
|
}
|
|
42
90
|
|
|
43
91
|
function navigate(uri: string) {
|
|
@@ -67,7 +115,7 @@ function criterionText(criterion?: Record<string, string>): string | null {
|
|
|
67
115
|
function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
|
|
68
116
|
return rel.partitives.map(m => ({
|
|
69
117
|
uri: m.uri,
|
|
70
|
-
label: designationFor(m.uri),
|
|
118
|
+
label: designationFor(m.uri, 'member'),
|
|
71
119
|
presence: m.presence,
|
|
72
120
|
count: m.count,
|
|
73
121
|
isDelimiting: m.isDelimiting,
|
|
@@ -3,7 +3,7 @@ import type { PageConfig, SiteColors } from './types';
|
|
|
3
3
|
import type { DatasetGroup } from './types';
|
|
4
4
|
import { synthesizePages } from './page-types';
|
|
5
5
|
import { locale } from '../i18n';
|
|
6
|
-
import {
|
|
6
|
+
import { buildFontVariables, type BrandingFontsLike } from '../utils/font-variables';
|
|
7
7
|
|
|
8
8
|
export interface RuntimeSiteConfig {
|
|
9
9
|
id: string;
|
|
@@ -99,25 +99,20 @@ function applyBranding(config: RuntimeSiteConfig) {
|
|
|
99
99
|
root.style.setProperty('--brand-dark', b.darkColor);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
if (b.fonts
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
loadFont(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
root.style.setProperty('--font-body', fontStack(b.fonts.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body);
|
|
117
|
-
}
|
|
118
|
-
if (b.fonts?.mono) {
|
|
119
|
-
loadFont(b.fonts.mono);
|
|
120
|
-
root.style.setProperty('--font-mono', fontStack(b.fonts.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono);
|
|
102
|
+
if (b.fonts && (b.fonts.title || b.fonts.heading || b.fonts.header || b.fonts.body || b.fonts.mono)) {
|
|
103
|
+
// Load declared Google Fonts first so the brand stack resolves
|
|
104
|
+
// without flashing the fallback.
|
|
105
|
+
if (b.fonts.title) loadFont(b.fonts.title);
|
|
106
|
+
const headingFont = b.fonts.heading ?? b.fonts.header;
|
|
107
|
+
if (headingFont) loadFont(headingFont);
|
|
108
|
+
if (b.fonts.body) loadFont(b.fonts.body);
|
|
109
|
+
if (b.fonts.mono) loadFont(b.fonts.mono);
|
|
110
|
+
|
|
111
|
+
// Single SSOT — same shape as Default.astro's inline <style>.
|
|
112
|
+
const vars = buildFontVariables(b.fonts as BrandingFontsLike);
|
|
113
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
114
|
+
root.style.setProperty(k, v);
|
|
115
|
+
}
|
|
121
116
|
}
|
|
122
117
|
}
|
|
123
118
|
|
|
@@ -2,12 +2,8 @@
|
|
|
2
2
|
import '@/styles/tailwind.css';
|
|
3
3
|
import AppIsland from '../islands/AppIsland.vue';
|
|
4
4
|
import { getSiteConfig } from '../lib/site-config';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
DEFAULT_FONTS,
|
|
8
|
-
DEFAULT_CATEGORY,
|
|
9
|
-
type FontConfigLike,
|
|
10
|
-
} from '../utils/font-stack';
|
|
5
|
+
import { type FontConfigLike } from '../utils/font-stack';
|
|
6
|
+
import { renderFontVariablesInline } from '../utils/font-variables';
|
|
11
7
|
import { resolveFaviconBase } from '../utils/favicon-base';
|
|
12
8
|
import { buildFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
|
|
13
9
|
|
|
@@ -44,13 +40,11 @@ const fontsUrl = fontParts.length > 0
|
|
|
44
40
|
? `https://fonts.googleapis.com/css2?${fontParts.join('&')}&display=swap`
|
|
45
41
|
: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap';
|
|
46
42
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// and
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const fontBody = fontStack(brandingFonts?.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body;
|
|
53
|
-
const fontMono = fontStack(brandingFonts?.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono;
|
|
43
|
+
// Single source of truth for the inline font-variable block.
|
|
44
|
+
// renderFontVariablesInline() is shared with use-site-config.ts so the
|
|
45
|
+
// build-time SSG and runtime override paths can never drift.
|
|
46
|
+
// See src/utils/font-variables.ts for the contract + regression tests.
|
|
47
|
+
const fontVarsCss = renderFontVariablesInline(brandingFonts);
|
|
54
48
|
|
|
55
49
|
// Favicon configuration (issues #173, #177, and the data-driven refactor).
|
|
56
50
|
//
|
|
@@ -137,7 +131,7 @@ const faviconLinks = buildFaviconLinks({
|
|
|
137
131
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
138
132
|
<link rel="preload" as="style" href={fontsUrl} onload="this.rel='stylesheet'">
|
|
139
133
|
<noscript><link href={fontsUrl} rel="stylesheet"></noscript>
|
|
140
|
-
<Fragment set:html={`<style
|
|
134
|
+
<Fragment set:html={`<style>${fontVarsCss}</style>`} />
|
|
141
135
|
<script is:inline>
|
|
142
136
|
(function() {
|
|
143
137
|
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('theme='));
|
package/src/styles/tailwind.css
CHANGED
|
@@ -38,9 +38,19 @@
|
|
|
38
38
|
--color-group-family: #6366f1;
|
|
39
39
|
--color-group-collection: #0891b2;
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/* Tailwind font utilities. Defined as `var()` references to the slot
|
|
42
|
+
variables so the utilities exist (templates still reference
|
|
43
|
+
`.font-serif`, `.font-sans`, `.font-mono`) but the bundle's
|
|
44
|
+
`:root, :host { ... }` block emits ALIASES, not literal values —
|
|
45
|
+
meaning it cannot clobber whatever the inline `<style>` sets.
|
|
46
|
+
|
|
47
|
+
The inline style (Default.astro) emits ONLY slot-named variables
|
|
48
|
+
(--font-title, --font-heading, --font-body, --font-mono) via
|
|
49
|
+
buildFontVariables() in src/utils/font-variables.ts. The aliases
|
|
50
|
+
here resolve to those slot variables through the cascade. */
|
|
51
|
+
--font-serif: var(--font-heading);
|
|
52
|
+
--font-sans: var(--font-body);
|
|
53
|
+
--font-mono: var(--font-mono);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
:root {
|
|
@@ -60,10 +70,6 @@
|
|
|
60
70
|
--brand-primary: #2563eb;
|
|
61
71
|
--brand-primary-rgb: 37, 99, 235;
|
|
62
72
|
--brand-dark: #1e293b;
|
|
63
|
-
--font-title: "DM Serif Display", Georgia, serif;
|
|
64
|
-
--font-heading: "DM Serif Display", Georgia, serif;
|
|
65
|
-
--font-header: "DM Serif Display", Georgia, serif;
|
|
66
|
-
--font-body: "DM Sans", system-ui, sans-serif;
|
|
67
73
|
--gold-accent: #B8935A;
|
|
68
74
|
--gold-accent-deep: #8C6A3A;
|
|
69
75
|
}
|
|
@@ -75,11 +81,23 @@
|
|
|
75
81
|
-webkit-font-smoothing: antialiased;
|
|
76
82
|
-moz-osx-font-smoothing: grayscale;
|
|
77
83
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
font-family: var(--font-
|
|
84
|
+
/* Slot-named utilities — the canonical API. Each maps to a CSS variable
|
|
85
|
+
emitted by buildFontVariables() in src/utils/font-variables.ts. */
|
|
86
|
+
.font-title { font-family: var(--font-title); }
|
|
87
|
+
.font-heading { font-family: var(--font-heading); }
|
|
88
|
+
.font-body { font-family: var(--font-body); }
|
|
89
|
+
/* Backward-compat aliases. Existing templates use `.font-serif` and
|
|
90
|
+
`.font-sans`; map them to slot variables so brand fonts propagate
|
|
91
|
+
without forcing every consumer to rewrite markup. These do NOT
|
|
92
|
+
bake in serif↔heading / sans↔body — the slot variable holds
|
|
93
|
+
whatever category the consumer declared. */
|
|
94
|
+
.font-serif { font-family: var(--font-heading); }
|
|
95
|
+
.font-sans { font-family: var(--font-body); }
|
|
96
|
+
/* Legacy alias for `.font-header` (renamed to `.font-heading`). */
|
|
97
|
+
.font-header { font-family: var(--font-heading); }
|
|
98
|
+
/* Default heading typography — applies unless overridden by a slot class. */
|
|
99
|
+
h1, h2, h3, h4, h5, h6 {
|
|
100
|
+
font-family: var(--font-heading);
|
|
83
101
|
}
|
|
84
102
|
code, pre, .font-mono {
|
|
85
103
|
font-family: var(--font-mono);
|
|
@@ -1,70 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* External concept detection — ISO 704:2022 external concepts.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The detection logic now lives upstream in glossarist@0.4.52 — this
|
|
5
|
+
* file is a thin re-export so consumers don't have to know which package
|
|
6
|
+
* the functions live in. The signature is also richer upstream: it
|
|
7
|
+
* understands `MemberLike` (with nested `.ref`) and `HyperedgeLike`
|
|
8
|
+
* shapes, matching the actual glossarist-js model.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The only thing kept locally is `formatExternalLabel` — it's a pure
|
|
11
|
+
* UI concern (parentheses per ISO 704 §5.5.4.3.1) and doesn't belong
|
|
12
|
+
* in the model library.
|
|
13
|
+
*
|
|
14
|
+
* ISO 704:2022 §5.5.4.3.1: external concepts are shown in parentheses
|
|
15
|
+
* with a dashed border in diagrams.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
import {
|
|
19
|
+
isExternalConcept,
|
|
20
|
+
isExternalMember as glsIsExternalMember,
|
|
21
|
+
isExternalComprehensive as glsIsExternalComprehensive,
|
|
22
|
+
getExternalMembers as glsGetExternalMembers,
|
|
23
|
+
hasProvidedBy,
|
|
24
|
+
hasDanglingExternal as glsHasDanglingExternal,
|
|
25
|
+
} from 'glossarist';
|
|
26
|
+
import type {
|
|
27
|
+
ExternalConceptLike,
|
|
28
|
+
ConceptStore,
|
|
29
|
+
} from 'glossarist';
|
|
18
30
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
export type {
|
|
32
|
+
ExternalConceptLike,
|
|
33
|
+
ConceptStore,
|
|
34
|
+
};
|
|
22
35
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
36
|
+
export {
|
|
37
|
+
isExternalConcept,
|
|
38
|
+
hasProvidedBy,
|
|
39
|
+
};
|
|
27
40
|
|
|
28
41
|
/**
|
|
29
|
-
*
|
|
42
|
+
* Defensive wrappers around the upstream hyperedge-aware queries.
|
|
43
|
+
* glossarist@0.4.52 throws on null `store` (it indexes into it
|
|
44
|
+
* unconditionally). UI consumers may have no store available (e.g.,
|
|
45
|
+
* during initial render before the vocabulary store is loaded) —
|
|
46
|
+
* return false / [] instead of propagating the throw.
|
|
30
47
|
*/
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
store:
|
|
48
|
+
export function isExternalMember(
|
|
49
|
+
member: { ref?: unknown } | null | undefined,
|
|
50
|
+
store: ConceptStore | null | undefined,
|
|
34
51
|
): boolean {
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
return concept?.status === 'external';
|
|
52
|
+
if (!member || !store) return false;
|
|
53
|
+
return glsIsExternalMember(member as Parameters<typeof glsIsExternalMember>[0], store);
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* incomplete.
|
|
44
|
-
*/
|
|
45
|
-
export function hasProvidedBy(
|
|
46
|
-
ref: RefLike | null | undefined,
|
|
47
|
-
store: ConceptStoreLike | null | undefined,
|
|
56
|
+
export function isExternalComprehensive(
|
|
57
|
+
hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
|
|
58
|
+
store: ConceptStore | null | undefined,
|
|
48
59
|
): boolean {
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
if (!hyperedge || !store) return false;
|
|
61
|
+
return glsIsExternalComprehensive(
|
|
62
|
+
hyperedge as Parameters<typeof glsIsExternalComprehensive>[0],
|
|
63
|
+
store,
|
|
64
|
+
);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
export function getExternalMembers(
|
|
68
|
+
hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
|
|
69
|
+
store: ConceptStore | null | undefined,
|
|
70
|
+
): { ref?: unknown }[] {
|
|
71
|
+
if (!hyperedge || !store) return [];
|
|
72
|
+
return glsGetExternalMembers(
|
|
73
|
+
hyperedge as Parameters<typeof glsGetExternalMembers>[0],
|
|
74
|
+
store,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function hasDanglingExternal(
|
|
79
|
+
hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
|
|
80
|
+
store: ConceptStore | null | undefined,
|
|
61
81
|
): boolean {
|
|
62
|
-
|
|
82
|
+
if (!hyperedge || !store) return false;
|
|
83
|
+
return glsHasDanglingExternal(
|
|
84
|
+
hyperedge as Parameters<typeof glsHasDanglingExternal>[0],
|
|
85
|
+
store,
|
|
86
|
+
);
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
/**
|
|
66
90
|
* Format a label for an external concept — parenthetical per ISO 704.
|
|
67
91
|
* Returns `"(label)"` for external concepts, `label` for internal.
|
|
92
|
+
*
|
|
93
|
+
* Kept locally because it's a pure UI concern (rendering decision), not
|
|
94
|
+
* a model-layer classification.
|
|
68
95
|
*/
|
|
69
96
|
export function formatExternalLabel(
|
|
70
97
|
label: string,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for branding-driven CSS font variables.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
*
|
|
6
|
+
* Pre-refactor (0.7.106–0.7.111), font variables came from FOUR places:
|
|
7
|
+
* 1. `src/utils/font-stack.ts` (DEFAULT_FONTS)
|
|
8
|
+
* 2. `src/styles/tailwind.css` `:root { ... }` block
|
|
9
|
+
* 3. `src/styles/tailwind.css` `@theme { ... }` block (generated
|
|
10
|
+
* `:root, :host { ... }` in the CSS bundle)
|
|
11
|
+
* 4. `tailwind.config.js` `fontFamily.*` utility fallback strings
|
|
12
|
+
*
|
|
13
|
+
* The @theme block was the load-bearing bug. Its `:root, :host { ... }`
|
|
14
|
+
* declaration in the CSS bundle loaded AFTER `Default.astro`'s inline
|
|
15
|
+
* `<style>`, so by CSS cascade (equal specificity, later source order
|
|
16
|
+
* wins) the brand values were silently clobbered for `--font-serif`,
|
|
17
|
+
* `--font-sans`, `--font-mono`.
|
|
18
|
+
*
|
|
19
|
+
* Post-refactor: this module is the ONLY place that decides which CSS
|
|
20
|
+
* font variables exist and what their values are. Both
|
|
21
|
+
* `Default.astro` (build-time SSG inline `<style>`) and
|
|
22
|
+
* `use-site-config.ts` (runtime `root.style.setProperty` overrides)
|
|
23
|
+
* call `buildFontVariables()` and emit the same shape.
|
|
24
|
+
*
|
|
25
|
+
* DESIGN
|
|
26
|
+
*
|
|
27
|
+
* Slot-named variables only. No category-named variables
|
|
28
|
+
* (`--font-serif`, `--font-sans`) — those bake in "serif↔heading,
|
|
29
|
+
* sans↔body" assumptions that break when a consumer picks a different
|
|
30
|
+
* category for a slot. The Tailwind utilities `.font-serif` and
|
|
31
|
+
* `.font-sans` are kept as backward-compat aliases defined in
|
|
32
|
+
* `@layer base`, mapped to slot variables.
|
|
33
|
+
*
|
|
34
|
+
* Output keys:
|
|
35
|
+
* --font-title — most prominent display text
|
|
36
|
+
* --font-heading — h2–h6 section headings
|
|
37
|
+
* --font-body — paragraph text
|
|
38
|
+
* --font-mono — code, pre, kbd
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { fontStack, DEFAULT_FONTS, DEFAULT_CATEGORY, type FontConfigLike } from './font-stack';
|
|
42
|
+
|
|
43
|
+
/** Canonical slot names. Any CSS variable not in this list is a bug. */
|
|
44
|
+
export const FONT_SLOTS = ['title', 'heading', 'body', 'mono'] as const;
|
|
45
|
+
export type FontSlot = (typeof FONT_SLOTS)[number];
|
|
46
|
+
|
|
47
|
+
/** Branding fonts shape — accepts both `header` (legacy alias) and `heading`. */
|
|
48
|
+
export interface BrandingFontsLike {
|
|
49
|
+
title?: FontConfigLike;
|
|
50
|
+
heading?: FontConfigLike;
|
|
51
|
+
/** @deprecated Legacy alias for `heading`. */
|
|
52
|
+
header?: FontConfigLike;
|
|
53
|
+
body?: FontConfigLike;
|
|
54
|
+
mono?: FontConfigLike;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the canonical `:root` CSS font-variable map from a branding
|
|
59
|
+
* config. Every variable the system relies on is present in the output
|
|
60
|
+
* — no need for fallback definitions elsewhere.
|
|
61
|
+
*
|
|
62
|
+
* Values are pre-formed CSS font-family stacks (e.g.
|
|
63
|
+
* `'Raleway', system-ui, sans-serif`). The caller is responsible for
|
|
64
|
+
* serializing into a CSS `:root { ... }` block.
|
|
65
|
+
*
|
|
66
|
+
* `header` (legacy alias) is read but not emitted as a separate
|
|
67
|
+
* variable. Its value flows into `--font-heading`. Consumers that
|
|
68
|
+
* still reference `--font-header` in their CSS get the heading value
|
|
69
|
+
* via the alias declaration in `@layer base` of tailwind.css.
|
|
70
|
+
*/
|
|
71
|
+
export function buildFontVariables(
|
|
72
|
+
fonts: BrandingFontsLike | null | undefined,
|
|
73
|
+
): Record<`--font-${FontSlot}`, string> {
|
|
74
|
+
const f = fonts ?? {};
|
|
75
|
+
const heading = f.heading ?? f.header;
|
|
76
|
+
return {
|
|
77
|
+
'--font-title': fontStack(f.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title,
|
|
78
|
+
'--font-heading': fontStack(heading, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading,
|
|
79
|
+
'--font-body': fontStack(f.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body,
|
|
80
|
+
'--font-mono': fontStack(f.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Serialize the variable map to an inline `:root { ... }` CSS string,
|
|
86
|
+
* safe to drop into a `<style>` tag.
|
|
87
|
+
*
|
|
88
|
+
* Escapes nothing (font stacks come from a controlled source) but
|
|
89
|
+
* joins with proper CSS syntax. Use this from Default.astro.
|
|
90
|
+
*/
|
|
91
|
+
export function renderFontVariablesInline(
|
|
92
|
+
fonts: BrandingFontsLike | null | undefined,
|
|
93
|
+
): string {
|
|
94
|
+
const vars = buildFontVariables(fonts);
|
|
95
|
+
const body = FONT_SLOTS.map(slot => `--font-${slot}:${vars[`--font-${slot}`]}`).join(';');
|
|
96
|
+
return `:root{${body}}`;
|
|
97
|
+
}
|
package/tailwind.config.js
CHANGED
|
@@ -12,13 +12,11 @@ export default {
|
|
|
12
12
|
],
|
|
13
13
|
theme: {
|
|
14
14
|
extend: {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
mono: ['var(--font-mono, "JetBrains Mono")', 'ui-monospace', 'monospace'],
|
|
21
|
-
},
|
|
15
|
+
// Font utilities are defined in src/styles/tailwind.css @layer base.
|
|
16
|
+
// They reference slot CSS variables (--font-title, --font-heading,
|
|
17
|
+
// --font-body, --font-mono) emitted by buildFontVariables() in
|
|
18
|
+
// src/utils/font-variables.ts. Configuring them here would create
|
|
19
|
+
// a second source of truth that can drift.
|
|
22
20
|
colors: {
|
|
23
21
|
ink: {
|
|
24
22
|
DEFAULT: '#1a1b2e',
|
|
@@ -39,11 +37,6 @@ export default {
|
|
|
39
37
|
raised: '#ffffff',
|
|
40
38
|
},
|
|
41
39
|
},
|
|
42
|
-
typography: {
|
|
43
|
-
serif: {
|
|
44
|
-
'font-family': 'var(--font-header, "DM Serif Display"), Georgia, serif',
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
40
|
},
|
|
48
41
|
},
|
|
49
42
|
plugins: [],
|