@glossarist/concept-browser 0.7.107 → 0.7.108

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/README.md CHANGED
@@ -109,6 +109,93 @@ branding:
109
109
 
110
110
  If no logo is configured, the Glossarist logo is shown by default. The footer always shows the Glossarist logo ("Powered by Glossarist").
111
111
 
112
+ ### Fonts
113
+
114
+ Brand typography is fully slot-based and category-agnostic. There are **four slots** — `title`, `heading`, `body`, `mono` — and each slot accepts **any category** (`serif`, `sans-serif`, `monospace`). Nothing dictates that "headings must be serif" or "body must be sans-serif". Pick whatever combination matches your brand.
115
+
116
+ #### Slots
117
+
118
+ | Slot | Applies to | Default family | Default category |
119
+ |---|---|---|---|
120
+ | `title` | The single most-prominent text on each page (home hero, concept name on detail page, dataset/group title) | DM Serif Display | serif |
121
+ | `heading` | h2–h6 section headings | DM Serif Display | serif |
122
+ | `body` | Paragraph text, lists, table cells | DM Sans | sans-serif |
123
+ | `mono` | Code blocks, inline code, kbd | JetBrains Mono | monospace |
124
+
125
+ The defaults preserve the Glossarist visual identity — override any of them to match your brand.
126
+
127
+ #### Per-slot category override
128
+
129
+ Set `category` on any slot to control the fallback chain (the browser uses it when the primary family fails to load or is still loading):
130
+
131
+ ```yaml
132
+ branding:
133
+ fonts:
134
+ title:
135
+ family: Inter
136
+ source: google
137
+ category: sans-serif # sans-serif title (was serif by default)
138
+ weights: [400, 600, 700]
139
+ heading:
140
+ family: Inter
141
+ source: google
142
+ category: sans-serif
143
+ weights: [600, 700]
144
+ body:
145
+ family: Merriweather
146
+ source: google
147
+ category: serif # serif body (was sans-serif by default)
148
+ weights: [400, 700]
149
+ mono:
150
+ family: Fira Code
151
+ source: google
152
+ category: monospace
153
+ weights: [400, 500]
154
+ ```
155
+
156
+ This produces:
157
+
158
+ | Slot | CSS variable | Stack |
159
+ |---|---|---|
160
+ | title | `--font-title` | `'Inter', system-ui, sans-serif` |
161
+ | heading | `--font-heading` (and `--font-header` for backward compat) | `'Inter', system-ui, sans-serif` |
162
+ | body | `--font-body` | `'Merriweather', Georgia, serif` |
163
+ | mono | `--font-mono` | `'Fira Code', ui-monospace, "JetBrains Mono", Menlo, Monaco, monospace` |
164
+
165
+ #### Source options
166
+
167
+ | `source` | Behavior |
168
+ |---|---|
169
+ | `google` | Build emits a Google Fonts CSS `@import` for the declared `family` + `weights`. |
170
+ | `url` | Build emits a `@font-face` block loading from `url`. |
171
+ | `local` | Consumer ships the font files in `public/`; no build-time fetch. |
172
+
173
+ #### Backward compatibility
174
+
175
+ The legacy `branding.fonts.header` slot still works — it's a deprecated alias for `branding.fonts.heading`. Existing configs don't break. The CSS variable `--font-header` is still emitted (as an alias for `--font-heading`) so existing stylesheets that reference it continue to work.
176
+
177
+ #### Mixed-category example: all sans-serif
178
+
179
+ ```yaml
180
+ branding:
181
+ fonts:
182
+ title: { family: Inter, source: google, category: sans-serif }
183
+ heading: { family: Inter, source: google, category: sans-serif }
184
+ body: { family: Inter, source: google, category: sans-serif }
185
+ mono: { family: JetBrains Mono, source: google, category: monospace }
186
+ ```
187
+
188
+ #### Mixed-category example: traditional serif
189
+
190
+ ```yaml
191
+ branding:
192
+ fonts:
193
+ title: { family: Playfair Display, source: google, category: serif }
194
+ heading: { family: Lora, source: google, category: serif }
195
+ body: { family: Source Sans Pro, source: google, category: sans-serif }
196
+ mono: { family: Source Code Pro, source: google, category: monospace }
197
+ ```
198
+
112
199
  ### Favicons
113
200
 
114
201
  `branding.favicon` accepts two shapes — a legacy **string** form and an **object** form that lets consumers provide their own canonical favicon set without writing a post-build script.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.107",
3
+ "version": "0.7.108",
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": {
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveFaviconBase } from '../../utils/favicon-base';
3
+
4
+ /**
5
+ * Regression test for the sub-path deployment 404 bug.
6
+ *
7
+ * Bug (filed by user, 2026-08-01): deployments at sub-paths
8
+ * (e.g. `/cie-eilv/`) saw console 404s for `favicon-32x32.png`,
9
+ * `favicon-16x16.png`, `favicon-48x48.png` because the emitted
10
+ * `<link href>` pointed at the ROOT (`/favicon-32x32.png`) instead
11
+ * of the deployment sub-path (`/cie-eilv/favicon-32x32.png`).
12
+ *
13
+ * Root cause: `Default.astro` hardcoded `const faviconBase = '/'`.
14
+ * Fix: cascade through configuration sources to find the right prefix.
15
+ */
16
+ describe('resolveFaviconBase', () => {
17
+ it('returns / when nothing is configured (root deployment)', () => {
18
+ expect(resolveFaviconBase({})).toBe('/');
19
+ });
20
+
21
+ it('returns / when all candidates are empty', () => {
22
+ expect(resolveFaviconBase({
23
+ brandingFaviconBasePath: '',
24
+ siteConfigBasePath: '',
25
+ astroBaseUrl: '',
26
+ })).toBe('/');
27
+ });
28
+
29
+ it('uses branding.favicon.base_path when set (highest priority)', () => {
30
+ expect(resolveFaviconBase({
31
+ brandingFaviconBasePath: '/my-brand/',
32
+ siteConfigBasePath: '/should-be-ignored/',
33
+ astroBaseUrl: '/should-be-ignored/',
34
+ })).toBe('/my-brand/');
35
+ });
36
+
37
+ it('uses siteConfig.basePath when branding.favicon.base_path is absent', () => {
38
+ expect(resolveFaviconBase({
39
+ siteConfigBasePath: '/cie-eilv/',
40
+ astroBaseUrl: '/',
41
+ })).toBe('/cie-eilv/');
42
+ });
43
+
44
+ it('uses import.meta.env.BASE_URL when siteConfig.basePath is absent', () => {
45
+ expect(resolveFaviconBase({
46
+ astroBaseUrl: '/vocab/',
47
+ })).toBe('/vocab/');
48
+ });
49
+
50
+ /**
51
+ * THE LOAD-BEARING REGRESSION TEST.
52
+ *
53
+ * Reproduction of the bug: cie-eilv is deployed at /cie-eilv/, but
54
+ * the live site emitted <link href="/favicon-32x32.png">. The fix
55
+ * must produce /cie-eilv/favicon-32x32.png.
56
+ */
57
+ it('regression: resolves /cie-eilv/ when that is the configured basePath', () => {
58
+ const base = resolveFaviconBase({
59
+ siteConfigBasePath: '/cie-eilv/',
60
+ astroBaseUrl: '/',
61
+ });
62
+ expect(base).toBe('/cie-eilv/');
63
+ // Pin the full URL construction so a future regression that breaks
64
+ // the trailing slash also fails this test.
65
+ expect(`${base}favicon-32x32.png`).toBe('/cie-eilv/favicon-32x32.png');
66
+ expect(`${base}favicon-16x16.png`).toBe('/cie-eilv/favicon-16x16.png');
67
+ expect(`${base}favicon-48x48.png`).toBe('/cie-eilv/favicon-48x48.png');
68
+ });
69
+
70
+ it('appends trailing slash if consumer omitted it', () => {
71
+ expect(resolveFaviconBase({
72
+ siteConfigBasePath: '/cie-eilv', // no trailing slash
73
+ })).toBe('/cie-eilv/');
74
+ });
75
+
76
+ it('does not double-slash if consumer includes trailing slash', () => {
77
+ expect(resolveFaviconBase({
78
+ siteConfigBasePath: '/cie-eilv/',
79
+ })).toBe('/cie-eilv/');
80
+ });
81
+
82
+ it('handles deep sub-path deployments', () => {
83
+ expect(resolveFaviconBase({
84
+ siteConfigBasePath: '/vocab/iala/',
85
+ })).toBe('/vocab/iala/');
86
+ expect(`${resolveFaviconBase({ siteConfigBasePath: '/vocab/iala/' })}favicon.ico`)
87
+ .toBe('/vocab/iala/favicon.ico');
88
+ });
89
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildDefaultFaviconLinks, renderFaviconLinkTag } from '../../utils/favicon-links';
3
+
4
+ /**
5
+ * Regression test for the sub-path deployment 404 bug.
6
+ *
7
+ * Bug (filed by user, 2026-08-01): deployments at sub-paths
8
+ * (e.g. `/cie-eilv/`) saw console 404s for `favicon-32x32.png`,
9
+ * `favicon-16x16.png`, `favicon-48x48.png` because the emitted
10
+ * `<link href>` pointed at the ROOT (`/favicon-32x32.png`) instead
11
+ * of the deployment sub-path (`/cie-eilv/favicon-32x32.png`).
12
+ *
13
+ * Root cause: Default.astro hardcoded `const faviconBase = '/'`.
14
+ * Fix: cascade through configuration sources (favicon-base.ts) and
15
+ * emit links with the resolved prefix.
16
+ *
17
+ * This test exercises the full link-emission path: config in →
18
+ * resolved base → link tags with correct hrefs.
19
+ */
20
+ describe('buildDefaultFaviconLinks — BASE_PATH regression', () => {
21
+ it('emits /-prefixed hrefs for root deployment', () => {
22
+ const links = buildDefaultFaviconLinks({ astroBaseUrl: '/' });
23
+ const png16 = links.find(l => l.sizes === '16x16');
24
+ const png32 = links.find(l => l.sizes === '32x32');
25
+ const png48 = links.find(l => l.sizes === '48x48');
26
+ expect(png16?.href).toBe('/favicon-16x16.png');
27
+ expect(png32?.href).toBe('/favicon-32x32.png');
28
+ expect(png48?.href).toBe('/favicon-48x48.png');
29
+ });
30
+
31
+ /**
32
+ * THE LOAD-BEARING TEST — pins the exact bug the user reported.
33
+ *
34
+ * Reproduction: cie-eilv deployment at /cie-eilv/. The 0.7.106/0.7.107
35
+ * build emitted href="/favicon-32x32.png" — wrong. The fix must emit
36
+ * href="/cie-eilv/favicon-32x32.png".
37
+ */
38
+ it('emits /cie-eilv/-prefixed hrefs for sub-path deployment', () => {
39
+ const links = buildDefaultFaviconLinks({
40
+ siteConfigBasePath: '/cie-eilv/',
41
+ astroBaseUrl: '/',
42
+ });
43
+ const png16 = links.find(l => l.sizes === '16x16');
44
+ const png32 = links.find(l => l.sizes === '32x32');
45
+ const png48 = links.find(l => l.sizes === '48x48');
46
+ expect(png16?.href).toBe('/cie-eilv/favicon-16x16.png');
47
+ expect(png32?.href).toBe('/cie-eilv/favicon-32x32.png');
48
+ expect(png48?.href).toBe('/cie-eilv/favicon-48x48.png');
49
+ });
50
+
51
+ it('handles deep sub-paths', () => {
52
+ const links = buildDefaultFaviconLinks({
53
+ siteConfigBasePath: '/vocab/iala/',
54
+ });
55
+ const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
56
+ expect(icon?.href).toBe('/vocab/iala/favicon.ico');
57
+ });
58
+
59
+ it('handles basePath without trailing slash', () => {
60
+ const links = buildDefaultFaviconLinks({
61
+ siteConfigBasePath: '/cie-eilv', // no trailing slash
62
+ });
63
+ const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
64
+ expect(icon?.href).toBe('/cie-eilv/favicon.ico');
65
+ });
66
+
67
+ it('never produces a bare "/" href', () => {
68
+ // Regression guard: a future bug that drops the filename would leave
69
+ // just the prefix. This test fails loudly if that happens.
70
+ const scenarios = [
71
+ { astroBaseUrl: '/' },
72
+ { astroBaseUrl: '/vocab/' },
73
+ { siteConfigBasePath: '/cie-eilv/' },
74
+ { brandingFaviconBasePath: '/custom/' },
75
+ ];
76
+ for (const input of scenarios) {
77
+ const links = buildDefaultFaviconLinks(input);
78
+ for (const link of links) {
79
+ expect(link.href, `bare href for input ${JSON.stringify(input)}`).not.toBe('/');
80
+ expect(link.href.length, `empty href for input ${JSON.stringify(input)}`).toBeGreaterThan(1);
81
+ }
82
+ }
83
+ });
84
+
85
+ it('emits the canonical 16 default favicon tags', () => {
86
+ const links = buildDefaultFaviconLinks({ astroBaseUrl: '/' });
87
+ // 5 icons (ico + svg + 3 PNG sizes) + 11 apple-touch-icon sizes = 16
88
+ expect(links.length).toBe(16);
89
+ expect(links.filter(l => l.rel === 'icon').length).toBe(5);
90
+ expect(links.filter(l => l.rel === 'apple-touch-icon').length).toBe(11);
91
+ });
92
+ });
93
+
94
+ describe('renderFaviconLinkTag', () => {
95
+ it('renders all attributes in deterministic order', () => {
96
+ const html = renderFaviconLinkTag({
97
+ rel: 'icon',
98
+ type: 'image/png',
99
+ sizes: '32x32',
100
+ href: '/favicon-32x32.png',
101
+ });
102
+ expect(html).toBe('<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />');
103
+ });
104
+
105
+ it('omits optional attributes when not set', () => {
106
+ const html = renderFaviconLinkTag({
107
+ rel: 'apple-touch-icon',
108
+ sizes: '180x180',
109
+ href: '/apple-touch-icon.png',
110
+ });
111
+ expect(html).toBe('<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />');
112
+ });
113
+
114
+ it('never emits an empty href', () => {
115
+ // Build a complete link tag and verify the href attribute has content.
116
+ const links = buildDefaultFaviconLinks({ siteConfigBasePath: '/sub-path/' });
117
+ for (const link of links) {
118
+ const html = renderFaviconLinkTag(link);
119
+ const hrefMatch = html.match(/href="([^"]+)"/);
120
+ expect(hrefMatch, `no href in: ${html}`).not.toBeNull();
121
+ expect(hrefMatch![1].length, `empty href in: ${html}`).toBeGreaterThan(0);
122
+ }
123
+ });
124
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ fontStack,
4
+ DEFAULT_FONTS,
5
+ DEFAULT_CATEGORY,
6
+ type FontConfigLike,
7
+ } from '../../utils/font-stack';
8
+
9
+ /**
10
+ * Font stack builder — guarantees that NO slot dictates a category.
11
+ * Consumers can pick serif, sans-serif, or monospace for title, heading,
12
+ * body, or mono independently.
13
+ */
14
+ describe('font-stack', () => {
15
+ describe('fontStack', () => {
16
+ it('returns undefined when font is null/undefined/empty', () => {
17
+ expect(fontStack(null, 'serif')).toBeUndefined();
18
+ expect(fontStack(undefined, 'serif')).toBeUndefined();
19
+ expect(fontStack({ family: '' }, 'serif')).toBeUndefined();
20
+ });
21
+
22
+ it('uses declared category for the fallback chain', () => {
23
+ const font: FontConfigLike = { family: 'Inter', category: 'sans-serif' };
24
+ expect(fontStack(font, 'serif')).toBe("'Inter', system-ui, sans-serif");
25
+ });
26
+
27
+ it('falls back to defaultCategory when category is omitted (backward compat)', () => {
28
+ const font: FontConfigLike = { family: 'Inter' };
29
+ // Default category is serif (header slot's prior behavior).
30
+ expect(fontStack(font, 'serif')).toBe("'Inter', Georgia, serif");
31
+ });
32
+
33
+ it('lets a sans-serif font be installed in the title slot', () => {
34
+ // The whole point of the redesign: titles don't have to be serif.
35
+ const font: FontConfigLike = { family: 'Inter', category: 'sans-serif' };
36
+ expect(fontStack(font, DEFAULT_CATEGORY.title)).toBe("'Inter', system-ui, sans-serif");
37
+ });
38
+
39
+ it('lets a serif font be installed in the body slot', () => {
40
+ const font: FontConfigLike = { family: 'Merriweather', category: 'serif' };
41
+ expect(fontStack(font, DEFAULT_CATEGORY.body)).toBe("'Merriweather', Georgia, serif");
42
+ });
43
+
44
+ it('supports monospace category for any slot', () => {
45
+ const font: FontConfigLike = { family: 'JetBrains Mono', category: 'monospace' };
46
+ expect(fontStack(font, DEFAULT_CATEGORY.mono)).toBe(
47
+ "'JetBrains Mono', ui-monospace, \"JetBrains Mono\", Menlo, Monaco, monospace",
48
+ );
49
+ });
50
+ });
51
+
52
+ describe('DEFAULT_FONTS', () => {
53
+ it('preserves the Glossarist visual identity (serif title + serif heading + sans body + mono)', () => {
54
+ // These defaults are NOT a dictate — consumers can override any slot
55
+ // to any category. They exist so existing deployments don't change
56
+ // appearance on upgrade.
57
+ expect(DEFAULT_FONTS.title).toContain('DM Serif Display');
58
+ expect(DEFAULT_FONTS.heading).toContain('DM Serif Display');
59
+ expect(DEFAULT_FONTS.body).toContain('DM Sans');
60
+ expect(DEFAULT_FONTS.mono).toContain('JetBrains Mono');
61
+ });
62
+
63
+ it('every default stack includes the appropriate fallback category', () => {
64
+ expect(DEFAULT_FONTS.title).toMatch(/Georgia, serif/);
65
+ expect(DEFAULT_FONTS.heading).toMatch(/Georgia, serif/);
66
+ expect(DEFAULT_FONTS.body).toMatch(/system-ui, sans-serif/);
67
+ expect(DEFAULT_FONTS.mono).toMatch(/monospace/);
68
+ });
69
+ });
70
+
71
+ describe('DEFAULT_CATEGORY', () => {
72
+ it('does NOT force title to be serif — it is only the default', () => {
73
+ // The default for title IS serif (visual identity), but consumers
74
+ // can override to any category. The default is just a fallback
75
+ // when `category` is omitted on the FontConfig.
76
+ expect(DEFAULT_CATEGORY.title).toBe('serif');
77
+ expect(DEFAULT_CATEGORY.heading).toBe('serif');
78
+ expect(DEFAULT_CATEGORY.body).toBe('sans-serif');
79
+ expect(DEFAULT_CATEGORY.mono).toBe('monospace');
80
+ });
81
+ });
82
+
83
+ describe('regression — every slot can be every category', () => {
84
+ // Pin the contract: the consumer can install any category in any slot.
85
+ // This is the answer to "What do you have to dictate that headers must
86
+ // be serif?" — nothing, anymore.
87
+ const categories: Array<['serif' | 'sans-serif' | 'monospace', RegExp]> = [
88
+ ['serif', /Georgia, serif/],
89
+ ['sans-serif', /system-ui, sans-serif/],
90
+ ['monospace', /monospace/],
91
+ ];
92
+ const slots = ['title', 'heading', 'body', 'mono'] as const;
93
+
94
+ for (const slot of slots) {
95
+ for (const [category, expectedFallback] of categories) {
96
+ it(`slot=${slot} with category=${category} → fallback matches`, () => {
97
+ const font: FontConfigLike = { family: 'TestFont', category };
98
+ const stack = fontStack(font, DEFAULT_CATEGORY[slot])!;
99
+ expect(stack).toContain("'TestFont'");
100
+ expect(stack).toMatch(expectedFallback);
101
+ });
102
+ }
103
+ }
104
+ });
105
+ });
@@ -297,7 +297,7 @@ const nonVerbalReps = computed(() => {
297
297
  </button>
298
298
  </div>
299
299
  </div>
300
- <h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderedPrimaryTerm"></h1>
300
+ <h1 class="font-title text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderedPrimaryTerm"></h1>
301
301
  <div class="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4 sm:flex-wrap sm:overflow-visible sm:mx-0 sm:pb-0 scrollbar-none">
302
302
  <span class="badge badge-blue font-mono">{{ conceptId }}</span>
303
303
  <span
@@ -1,10 +1,22 @@
1
1
  // === Branding ===
2
2
 
3
+ export type FontCategory = 'serif' | 'sans-serif' | 'monospace';
4
+
3
5
  export interface FontConfig {
4
6
  family: string;
5
7
  source: 'google' | 'url' | 'local';
6
8
  weights?: number[];
7
9
  url?: string;
10
+ /** Font category — controls the fallback chain when the primary family
11
+ * fails to load. Defaults preserve prior behavior: `'serif'` for header,
12
+ * `'sans-serif'` for body, `'monospace'` for mono.
13
+ *
14
+ * Set explicitly when the consumer's chosen family doesn't match the
15
+ * slot's default category — e.g. set `header: { family: 'Inter',
16
+ * category: 'sans-serif' }` so the fallback chain is
17
+ * `'Inter', system-ui, sans-serif` instead of `'Inter', Georgia, serif`.
18
+ */
19
+ category?: FontCategory;
8
20
  }
9
21
 
10
22
  export interface LogoConfig {
@@ -38,8 +50,18 @@ export interface SiteBranding {
38
50
  primaryColor?: string;
39
51
  darkColor?: string;
40
52
  fonts?: {
53
+ /** Most prominent display text on each page — home hero, concept name
54
+ * on the detail page, group/dataset title. No category is dictated;
55
+ * pick `serif`, `sans-serif`, or `monospace` for any slot. */
56
+ title?: FontConfig;
57
+ /** Section headings (h2–h6). */
58
+ heading?: FontConfig;
59
+ /** Backward-compat alias for `heading` (deprecated). */
41
60
  header?: FontConfig;
61
+ /** Paragraph text, lists, table cells. */
42
62
  body?: FontConfig;
63
+ /** Code blocks, inline code, kbd. */
64
+ mono?: FontConfig;
43
65
  };
44
66
  logo?: LogoConfig;
45
67
  footerLogo?: LogoConfig;
@@ -3,6 +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 { fontStack, DEFAULT_FONTS, DEFAULT_CATEGORY } from '../utils/font-stack';
6
7
 
7
8
  export interface RuntimeSiteConfig {
8
9
  id: string;
@@ -21,8 +22,11 @@ export interface RuntimeSiteConfig {
21
22
  primaryColor?: string;
22
23
  darkColor?: string;
23
24
  fonts?: {
24
- header?: { family: string; source: string; weights?: number[]; url?: string };
25
- body?: { family: string; source: string; weights?: number[]; url?: string };
25
+ title?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
26
+ heading?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
27
+ header?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' }; // deprecated alias for heading
28
+ body?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
29
+ mono?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
26
30
  };
27
31
  logo?: { path: string; alt: string; url?: string; light?: string; dark?: string };
28
32
  footerLogo?: { path: string; alt: string; url?: string; light?: string; dark?: string };
@@ -95,13 +99,25 @@ function applyBranding(config: RuntimeSiteConfig) {
95
99
  root.style.setProperty('--brand-dark', b.darkColor);
96
100
  }
97
101
 
98
- if (b.fonts?.header) {
99
- loadFont(b.fonts.header);
100
- root.style.setProperty('--font-header', `'${b.fonts.header.family}', Georgia, serif`);
102
+ if (b.fonts?.title) {
103
+ loadFont(b.fonts.title);
104
+ root.style.setProperty('--font-title', fontStack(b.fonts.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title);
105
+ }
106
+ // heading is the new slot name; header is the backward-compat alias.
107
+ const headingFont = b.fonts?.heading ?? b.fonts?.header;
108
+ if (headingFont) {
109
+ loadFont(headingFont);
110
+ const stack = fontStack(headingFont, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading;
111
+ root.style.setProperty('--font-heading', stack);
112
+ root.style.setProperty('--font-header', stack);
101
113
  }
102
114
  if (b.fonts?.body) {
103
115
  loadFont(b.fonts.body);
104
- root.style.setProperty('--font-body', `'${b.fonts.body.family}', system-ui, sans-serif`);
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);
105
121
  }
106
122
  }
107
123
 
@@ -2,6 +2,14 @@
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
+ fontStack,
7
+ DEFAULT_FONTS,
8
+ DEFAULT_CATEGORY,
9
+ type FontConfigLike,
10
+ } from '../utils/font-stack';
11
+ import { resolveFaviconBase } from '../utils/favicon-base';
12
+ import { buildDefaultFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
5
13
 
6
14
  interface Props {
7
15
  title: string;
@@ -14,28 +22,35 @@ const { title, description = '', conceptTerm, conceptDefinition } = Astro.props;
14
22
  const siteConfig = await getSiteConfig();
15
23
 
16
24
  // Derive Google Fonts URL from siteConfig branding (build-time SSG).
17
- // Falls back to the default DM Sans / DM Serif Display / JetBrains Mono
18
- // when no branding.fonts is declared.
25
+ // Includes every slot the consumer declared (title / heading / body / mono,
26
+ // plus the deprecated `header` alias). Falls back to the default
27
+ // DM Sans / DM Serif Display / JetBrains Mono when nothing is declared.
19
28
  const brandingFonts = siteConfig?.branding?.fonts;
29
+ const headingFont = brandingFonts?.heading ?? brandingFonts?.header; // header = deprecated alias
30
+ const fontSlots = [
31
+ brandingFonts?.title,
32
+ headingFont,
33
+ brandingFonts?.body,
34
+ brandingFonts?.mono,
35
+ ];
20
36
  const fontParts: string[] = [];
21
- if (brandingFonts?.header?.source === 'google' && brandingFonts.header.family) {
22
- const w = (brandingFonts.header.weights || [400, 700]).join(';');
23
- fontParts.push(`family=${brandingFonts.header.family.replace(/ /g, '+')}:wght@${w}`);
24
- }
25
- if (brandingFonts?.body?.source === 'google' && brandingFonts.body.family) {
26
- const w = (brandingFonts.body.weights || [400, 700]).join(';');
27
- fontParts.push(`family=${brandingFonts.body.family.replace(/ /g, '+')}:wght@${w}`);
37
+ for (const f of fontSlots) {
38
+ if (f && (f as FontConfigLike).source === 'google' && (f as FontConfigLike).family) {
39
+ const w = (f.weights || [400, 700]).join(';');
40
+ fontParts.push(`family=${(f as FontConfigLike).family.replace(/ /g, '+')}:wght@${w}`);
41
+ }
28
42
  }
29
43
  const fontsUrl = fontParts.length > 0
30
44
  ? `https://fonts.googleapis.com/css2?${fontParts.join('&')}&display=swap`
31
45
  : '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';
32
46
 
33
- const fontHeader = brandingFonts?.header?.family
34
- ? `'${brandingFonts.header.family}', Georgia, serif`
35
- : `'DM Serif Display', Georgia, serif`;
36
- const fontBody = brandingFonts?.body?.family
37
- ? `'${brandingFonts.body.family}', system-ui, sans-serif`
38
- : `'DM Sans', system-ui, sans-serif`;
47
+ // Build CSS font-family stacks via the shared SSOT. Consumers can pick
48
+ // any category for any slot — e.g. sans-serif title + serif heading —
49
+ // and the fallback chain follows the declared category.
50
+ const fontTitle = fontStack(brandingFonts?.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title;
51
+ const fontHeading = fontStack(headingFont, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading;
52
+ const fontBody = fontStack(brandingFonts?.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body;
53
+ const fontMono = fontStack(brandingFonts?.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono;
39
54
 
40
55
  // Favicon configuration (issue #173).
41
56
  //
@@ -55,7 +70,20 @@ const fontBody = brandingFonts?.body?.family
55
70
  const faviconCfg = siteConfig?.branding?.favicon ?? {};
56
71
  const skipDefaultFaviconLinks = faviconCfg.skip_default_links === true;
57
72
  const customFaviconLinks: string = faviconCfg.links_html ?? '';
58
- const faviconBase = faviconCfg.base_path ?? '/';
73
+
74
+ // BASE_PATH-aware favicon URL prefix. Walks the configuration cascade:
75
+ // branding.favicon.base_path → siteConfig.basePath → import.meta.env.BASE_URL → '/'.
76
+ // Always ends with '/' so `${faviconBase}favicon.ico` resolves correctly
77
+ // whether the deployment is at '/' or '/cie-eilv/'.
78
+ //
79
+ // See src/utils/favicon-base.ts for the shared resolver + regression tests.
80
+ const faviconBaseInput = {
81
+ brandingFaviconBasePath: typeof faviconCfg === 'object' ? faviconCfg.base_path : undefined,
82
+ siteConfigBasePath: siteConfig?.basePath,
83
+ astroBaseUrl: import.meta.env.BASE_URL,
84
+ };
85
+ const faviconBase = resolveFaviconBase(faviconBaseInput);
86
+ const faviconLinks = buildDefaultFaviconLinks(faviconBaseInput).map(renderFaviconLinkTag);
59
87
  ---
60
88
 
61
89
  <!DOCTYPE html>
@@ -63,24 +91,7 @@ const faviconBase = faviconCfg.base_path ?? '/';
63
91
  <head>
64
92
  <meta charset="utf-8" />
65
93
  {!skipDefaultFaviconLinks && (
66
- <>
67
- <link rel="icon" type="image/x-icon" href={`${faviconBase}favicon.ico`} />
68
- <link rel="icon" type="image/svg+xml" href={`${faviconBase}favicon.svg`} />
69
- <link rel="icon" type="image/png" sizes="16x16" href={`${faviconBase}favicon-16x16.png`} />
70
- <link rel="icon" type="image/png" sizes="32x32" href={`${faviconBase}favicon-32x32.png`} />
71
- <link rel="icon" type="image/png" sizes="48x48" href={`${faviconBase}favicon-48x48.png`} />
72
- <link rel="apple-touch-icon" sizes="57x57" href={`${faviconBase}apple-touch-icon-57x57.png`} />
73
- <link rel="apple-touch-icon" sizes="60x60" href={`${faviconBase}apple-touch-icon-60x60.png`} />
74
- <link rel="apple-touch-icon" sizes="72x72" href={`${faviconBase}apple-touch-icon-72x72.png`} />
75
- <link rel="apple-touch-icon" sizes="76x76" href={`${faviconBase}apple-touch-icon-76x76.png`} />
76
- <link rel="apple-touch-icon" sizes="114x114" href={`${faviconBase}apple-touch-icon-114x114.png`} />
77
- <link rel="apple-touch-icon" sizes="120x120" href={`${faviconBase}apple-touch-icon-120x120.png`} />
78
- <link rel="apple-touch-icon" sizes="144x144" href={`${faviconBase}apple-touch-icon-144x144.png`} />
79
- <link rel="apple-touch-icon" sizes="152x152" href={`${faviconBase}apple-touch-icon-152x152.png`} />
80
- <link rel="apple-touch-icon" sizes="167x167" href={`${faviconBase}apple-touch-icon-167x167.png`} />
81
- <link rel="apple-touch-icon" sizes="180x180" href={`${faviconBase}apple-touch-icon-180x180.png`} />
82
- <link rel="apple-touch-icon" sizes="1024x1024" href={`${faviconBase}apple-touch-icon-1024x1024.png`} />
83
- </>
94
+ <Fragment set:html={faviconLinks.join('\n ')} />
84
95
  )}
85
96
  {customFaviconLinks && <Fragment set:html={customFaviconLinks} />}
86
97
  <meta name="apple-mobile-web-app-capable" content="yes" />
@@ -93,7 +104,7 @@ const faviconBase = faviconCfg.base_path ?? '/';
93
104
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
94
105
  <link rel="preload" as="style" href={fontsUrl} onload="this.rel='stylesheet'">
95
106
  <noscript><link href={fontsUrl} rel="stylesheet"></noscript>
96
- <Fragment set:html={`<style>:root{--font-header:${fontHeader};--font-body:${fontBody}}</style>`} />
107
+ <Fragment set:html={`<style>:root{--font-title:${fontTitle};--font-heading:${fontHeading};--font-header:${fontHeading};--font-body:${fontBody};--font-mono:${fontMono}}</style>`} />
97
108
  <script is:inline>
98
109
  (function() {
99
110
  const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('theme='));
@@ -60,6 +60,8 @@
60
60
  --brand-primary: #2563eb;
61
61
  --brand-primary-rgb: 37, 99, 235;
62
62
  --brand-dark: #1e293b;
63
+ --font-title: "DM Serif Display", Georgia, serif;
64
+ --font-heading: "DM Serif Display", Georgia, serif;
63
65
  --font-header: "DM Serif Display", Georgia, serif;
64
66
  --font-body: "DM Sans", system-ui, sans-serif;
65
67
  --gold-accent: #B8935A;
@@ -73,6 +75,9 @@
73
75
  -webkit-font-smoothing: antialiased;
74
76
  -moz-osx-font-smoothing: grayscale;
75
77
  }
78
+ .font-title {
79
+ font-family: var(--font-title);
80
+ }
76
81
  h1, h2, h3, .font-serif {
77
82
  font-family: var(--font-header);
78
83
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Favicon base path resolver — shared SSOT for the URL prefix that
3
+ * goes in front of every favicon `<link href="...">`.
4
+ *
5
+ * Used by:
6
+ * - `src/layouts/Default.astro` (build-time SSG <link> emission)
7
+ * - `cli/index.mjs` / `cli/index.ts` (runtime <link> rewriting)
8
+ *
9
+ * Why this exists: a deployment at `/cie-eilv/` has its favicon files
10
+ * at `/cie-eilv/favicon.ico`, NOT `/favicon.ico`. Hardcoding `/` as
11
+ * the URL prefix (which 0.7.106 did) caused 404s on every sub-path
12
+ * deployment. This resolver walks the configuration cascade to find
13
+ * the right prefix.
14
+ */
15
+
16
+ export interface FaviconBaseInput {
17
+ /** Explicit consumer override via `branding.favicon.base_path`. */
18
+ brandingFaviconBasePath?: string;
19
+ /** Consumer's declared sub-path via `site-config.yml:basePath`. */
20
+ siteConfigBasePath?: string;
21
+ /** Build-time BASE_PATH from Astro/Vite (`import.meta.env.BASE_URL`). */
22
+ astroBaseUrl?: string;
23
+ }
24
+
25
+ /**
26
+ * Resolve the URL prefix for favicon links. Always returns a string
27
+ * ending with `/` so callers can do `${base}favicon.ico` without
28
+ * worrying about the separator.
29
+ *
30
+ * Cascade (first non-empty wins):
31
+ * 1. branding.favicon.base_path (explicit per-deployment override)
32
+ * 2. siteConfig.basePath (consumer's declared sub-path)
33
+ * 3. import.meta.env.BASE_URL (build-time BASE_PATH)
34
+ * 4. '/' (last resort — root deployment)
35
+ */
36
+ export function resolveFaviconBase(input: FaviconBaseInput): string {
37
+ const candidates = [
38
+ input.brandingFaviconBasePath,
39
+ input.siteConfigBasePath,
40
+ input.astroBaseUrl,
41
+ '/',
42
+ ];
43
+ const raw = candidates.find(c => c != null && c.length > 0) ?? '/';
44
+ return raw.endsWith('/') ? raw : `${raw}/`;
45
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Favicon link-tag builder — shared SSOT for the `<link rel="icon">`
3
+ * block emitted in `<head>`.
4
+ *
5
+ * Used by:
6
+ * - `src/layouts/Default.astro` (build-time SSG)
7
+ *
8
+ * Why this exists: a deployment at `/cie-eilv/` has its favicon files
9
+ * at `/cie-eilv/favicon.ico`, NOT `/favicon.ico`. Hardcoding `/` as
10
+ * the URL prefix (which 0.7.106 did) caused 404s on every sub-path
11
+ * deployment. This builder walks the configuration cascade via
12
+ * `resolveFaviconBase` and emits correctly-prefixed link tags.
13
+ */
14
+
15
+ import { resolveFaviconBase } from './favicon-base';
16
+
17
+ export interface FaviconLinkTag {
18
+ rel: string;
19
+ type?: string;
20
+ sizes?: string;
21
+ href: string;
22
+ }
23
+
24
+ /** Default favicon file set emitted by the CLI's `favicons` pipeline. */
25
+ const DEFAULT_FAVICON_FILES: readonly FaviconLinkTag[] = [
26
+ { rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' },
27
+ { rel: 'icon', type: 'image/svg+xml', href: 'favicon.svg' },
28
+ { rel: 'icon', type: 'image/png', sizes: '16x16', href: 'favicon-16x16.png' },
29
+ { rel: 'icon', type: 'image/png', sizes: '32x32', href: 'favicon-32x32.png' },
30
+ { rel: 'icon', type: 'image/png', sizes: '48x48', href: 'favicon-48x48.png' },
31
+ { rel: 'apple-touch-icon', sizes: '57x57', href: 'apple-touch-icon-57x57.png' },
32
+ { rel: 'apple-touch-icon', sizes: '60x60', href: 'apple-touch-icon-60x60.png' },
33
+ { rel: 'apple-touch-icon', sizes: '72x72', href: 'apple-touch-icon-72x72.png' },
34
+ { rel: 'apple-touch-icon', sizes: '76x76', href: 'apple-touch-icon-76x76.png' },
35
+ { rel: 'apple-touch-icon', sizes: '114x114',href: 'apple-touch-icon-114x114.png' },
36
+ { rel: 'apple-touch-icon', sizes: '120x120',href: 'apple-touch-icon-120x120.png' },
37
+ { rel: 'apple-touch-icon', sizes: '144x144',href: 'apple-touch-icon-144x144.png' },
38
+ { rel: 'apple-touch-icon', sizes: '152x152',href: 'apple-touch-icon-152x152.png' },
39
+ { rel: 'apple-touch-icon', sizes: '167x167',href: 'apple-touch-icon-167x167.png' },
40
+ { rel: 'apple-touch-icon', sizes: '180x180',href: 'apple-touch-icon-180x180.png' },
41
+ { rel: 'apple-touch-icon', sizes: '1024x1024',href: 'apple-touch-icon-1024x1024.png' },
42
+ ];
43
+
44
+ export interface BuildFaviconLinksInput {
45
+ /** Explicit consumer override via `branding.favicon.base_path`. */
46
+ brandingFaviconBasePath?: string;
47
+ /** Consumer's declared sub-path via `site-config.yml:basePath`. */
48
+ siteConfigBasePath?: string;
49
+ /** Build-time BASE_PATH from Astro/Vite (`import.meta.env.BASE_URL`). */
50
+ astroBaseUrl?: string;
51
+ }
52
+
53
+ /**
54
+ * Build the list of default favicon link tags with BASE_PATH-aware URLs.
55
+ *
56
+ * Returns the tag descriptors; the caller (Default.astro) renders them
57
+ * into <link> elements.
58
+ */
59
+ export function buildDefaultFaviconLinks(input: BuildFaviconLinksInput): FaviconLinkTag[] {
60
+ const base = resolveFaviconBase(input);
61
+ return DEFAULT_FAVICON_FILES.map(tag => ({
62
+ ...tag,
63
+ href: `${base}${tag.href}`,
64
+ }));
65
+ }
66
+
67
+ /**
68
+ * Render a single FaviconLinkTag to an HTML <link> string.
69
+ *
70
+ * Attribute order: rel, type (if present), sizes (if present), href.
71
+ * Deterministic for snapshot testing.
72
+ */
73
+ export function renderFaviconLinkTag(tag: FaviconLinkTag): string {
74
+ const attrs: string[] = [`rel="${tag.rel}"`];
75
+ if (tag.type) attrs.push(`type="${tag.type}"`);
76
+ if (tag.sizes) attrs.push(`sizes="${tag.sizes}"`);
77
+ attrs.push(`href="${tag.href}"`);
78
+ return `<link ${attrs.join(' ')} />`;
79
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Font stack builder — shared SSOT for `branding.fonts.*` fallback chains.
3
+ *
4
+ * Used by:
5
+ * - `src/layouts/Default.astro` (build-time SSG CSS variables)
6
+ * - `src/config/use-site-config.ts` (runtime CSS variable overrides)
7
+ *
8
+ * Design principle: NO slot dictates a font category. The consumer picks
9
+ * `serif`, `sans-serif`, or `monospace` for each slot independently. The
10
+ * defaults below preserve the Glossarist visual identity (serif title +
11
+ * serif heading + sans-serif body + monospace code) but every slot can
12
+ * be overridden to any category.
13
+ *
14
+ * Slot semantics:
15
+ * - title : single most-prominent display text on the page
16
+ * (home hero h1, concept name on detail page, group title)
17
+ * - heading : h2–h6 section headings
18
+ * - body : paragraph text, lists, table cells
19
+ * - mono : code blocks, inline code, kbd
20
+ *
21
+ * Backward compat:
22
+ * - `branding.fonts.header` is accepted as a deprecated alias for
23
+ * `branding.fonts.heading` (both write to `--font-heading`, which
24
+ * also surfaces as `--font-header` for legacy CSS).
25
+ */
26
+
27
+ export type FontCategory = 'serif' | 'sans-serif' | 'monospace';
28
+
29
+ export interface FontConfigLike {
30
+ family: string;
31
+ category?: FontCategory;
32
+ }
33
+
34
+ const FALLBACKS: Readonly<Record<FontCategory, string>> = {
35
+ serif: 'Georgia, serif',
36
+ 'sans-serif': 'system-ui, sans-serif',
37
+ monospace: 'ui-monospace, "JetBrains Mono", Menlo, Monaco, monospace',
38
+ };
39
+
40
+ /**
41
+ * Build a CSS font-family stack from a FontConfig-like object.
42
+ *
43
+ * - If `font` is null/undefined or has no family, returns `undefined`
44
+ * and the caller applies its slot-specific default.
45
+ * - If `font.category` is omitted, falls back to `defaultCategory`
46
+ * so existing configs preserve prior behavior.
47
+ */
48
+ export function fontStack(
49
+ font: FontConfigLike | null | undefined,
50
+ defaultCategory: FontCategory,
51
+ ): string | undefined {
52
+ if (!font?.family) return undefined;
53
+ const category: FontCategory = font.category ?? defaultCategory;
54
+ const fallback = FALLBACKS[category];
55
+ return `'${font.family}', ${fallback}`;
56
+ }
57
+
58
+ /** Default font stacks (used when the consumer declares nothing).
59
+ * Preserves the Glossarist visual identity. NOT a dictate — every
60
+ * slot can be overridden to any category via branding.fonts.*. */
61
+ export const DEFAULT_FONTS = {
62
+ title: `'DM Serif Display', ${FALLBACKS.serif}`,
63
+ heading: `'DM Serif Display', ${FALLBACKS.serif}`,
64
+ body: `'DM Sans', ${FALLBACKS['sans-serif']}`,
65
+ mono: `'JetBrains Mono', ${FALLBACKS.monospace}`,
66
+ } as const;
67
+
68
+ /** Slot → default category, used when the consumer omits `category`.
69
+ * These defaults preserve prior behavior and are NOT a constraint —
70
+ * consumers can pick any category for any slot. */
71
+ export const DEFAULT_CATEGORY = {
72
+ title: 'serif' as FontCategory,
73
+ heading: 'serif' as FontCategory,
74
+ body: 'sans-serif' as FontCategory,
75
+ mono: 'monospace' as FontCategory,
76
+ } as const;
77
+
@@ -321,7 +321,7 @@ function clearSection() {
321
321
 
322
322
  <!-- Header -->
323
323
  <div v-if="manifest" class="mb-8">
324
- <h1 class="font-serif text-3xl text-ink-800 mb-2">{{ localizedTitle }}</h1>
324
+ <h1 class="font-title text-3xl text-ink-800 mb-2">{{ localizedTitle }}</h1>
325
325
  <p class="text-ink-400 leading-relaxed max-w-2xl">{{ localizedDescription }}</p>
326
326
  <div class="flex flex-wrap gap-2 mt-4">
327
327
  <span class="badge" :style="conceptCountBadgeStyle">{{ manifest.conceptCount.toLocaleString() }} {{ t('dataset.concepts') }}</span>
@@ -96,7 +96,7 @@ function openDataset(id: string) {
96
96
  <template v-else-if="group">
97
97
  <div class="flex items-baseline gap-3 mb-2">
98
98
  <span class="text-2xl">{{ groupMeta.glyph }}</span>
99
- <h1 class="font-serif text-3xl text-ink-800 dark:text-ink-50">
99
+ <h1 class="font-title text-3xl text-ink-800 dark:text-ink-50">
100
100
  {{ group.label }}
101
101
  </h1>
102
102
  </div>
@@ -77,7 +77,7 @@ function goToGraph() { router.push({ name: 'graph' }); }
77
77
  <span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300 hidden sm:inline">{{ localizedSubtitle }}</span>
78
78
  </template>
79
79
  </div>
80
- <h1 class="font-serif text-[2rem] sm:text-[2.75rem] text-ink-800 leading-[1.1] mb-4 tracking-tight">
80
+ <h1 class="font-title text-[2rem] sm:text-[2.75rem] text-ink-800 leading-[1.1] mb-4 tracking-tight">
81
81
  {{ localizedTitle }}
82
82
  <template v-if="localizedSubtitle"><br class="hidden sm:block" /> {{ localizedSubtitle }}</template>
83
83
  </h1>
@@ -13,9 +13,11 @@ export default {
13
13
  theme: {
14
14
  extend: {
15
15
  fontFamily: {
16
+ title: ['var(--font-title, "DM Serif Display")', 'Georgia', 'serif'],
17
+ heading: ['var(--font-heading, "DM Serif Display")', 'Georgia', 'serif'],
16
18
  serif: ['var(--font-header, "DM Serif Display")', 'Georgia', 'serif'],
17
19
  sans: ['var(--font-body, "DM Sans")', 'system-ui', 'sans-serif'],
18
- mono: ['"JetBrains Mono"', 'monospace'],
20
+ mono: ['var(--font-mono, "JetBrains Mono")', 'ui-monospace', 'monospace'],
19
21
  },
20
22
  colors: {
21
23
  ink: {