@crossworks/client-types 0.230.43

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.
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Font registry — the SINGLE source of truth for every user-selectable face
3
+ * (Settings → Appearance).
4
+ *
5
+ * One list drives everything: the `@font-face` block (generated by
6
+ * `displayFontFaceCss`, injected once in the root layout and in the server-
7
+ * rendered share/print shell), the selection modal, and the runtime CSS-var
8
+ * overrides in `FontProvider`. There are FOUR slots that pick from it — the
9
+ * interface, the wordmark, the peer name, and Pages/Notes prose — and one
10
+ * library behind all four, because "which faces exist" and "where a face is
11
+ * used" are different questions and only the second one varies per slot.
12
+ *
13
+ * ── Every face here is a VARIABLE font with at least two axes ────────────────
14
+ *
15
+ * The ranges below are not typed by hand. They are read out of each file's
16
+ * `fvar` table by `scripts/fonts-import.mjs`, which also converts to woff2 and
17
+ * installs into both apps. That matters more than it sounds: a variable face
18
+ * declared as a single weight makes the browser SYNTHESISE bold, which shows up
19
+ * across the interface as smeared headings and buttons. Add a face by running
20
+ * the importer and pasting its row, never by editing a range by eye.
21
+ *
22
+ * Three axes have real `@font-face` descriptors and are declared per face:
23
+ * `wght` → font-weight, `wdth` → font-stretch, `slnt` → font-style: oblique.
24
+ * A face with a `slnt` axis therefore needs NO italic file: the one file leans
25
+ * on demand. Everything else a face carries (opsz, GRAD, WONK, SOFT, ROND, the
26
+ * Roboto Flex zoo) is recorded in `axes` as documentation only — `opsz` the
27
+ * browser applies on its own, the rest sit at their defaults because nothing
28
+ * sets font-variation-settings. They are listed so the next person can see what
29
+ * a face is capable of before wiring a control to it.
30
+ *
31
+ * Loading is lazy by construction: a browser fetches a font file only when it
32
+ * actually paints text in that family, so declaring ~20 `@font-face` rules costs
33
+ * nothing until a face is *selected* (or previewed while the modal is open). The
34
+ * runtime cost is the chosen faces, not the library. This is why we can ship
35
+ * variety without eager-bundling everything (unlike `next/font`, which is right
36
+ * for the always-loaded default UI face and wrong for an opt-in library).
37
+ *
38
+ * Two entries carry no library file. `inherit` means "follow the interface
39
+ * font", which is the sane default for the peer name and for prose. `inter` is
40
+ * the always-loaded next/font face (client/web/lib/fonts.ts, served from
41
+ * public/Inter) — it stays out of the library so its ~700K of roman+italic is
42
+ * not shipped twice.
43
+ */
44
+
45
+ export type FontFallback = 'sans-serif' | 'serif' | 'monospace';
46
+
47
+ /** Which shelf of the selection modal a face sits on. */
48
+ export type FontShelf = 'sans' | 'serif' | 'mono' | 'display';
49
+
50
+ export type FontFace = {
51
+ /** Stable slug stored in preferences + used as the file basename. */
52
+ key: string;
53
+ /** Human label shown in the modal. */
54
+ label: string;
55
+ /** CSS `font-family` name declared by our `@font-face` (null for the two
56
+ * entries that resolve to an existing family instead). */
57
+ family: string | null;
58
+ /** Generic fallback appended after the family. */
59
+ fallback: FontFallback;
60
+ /** The modal's grouping. */
61
+ shelf: FontShelf;
62
+ /** Public path to the upright face file, or null for `inherit` / `inter`. */
63
+ file: string | null;
64
+ /** Public path to a real italic file, when the family ships one and the face
65
+ * is one you would set a document in. A face with a `slnt` axis needs none
66
+ * (see `style`); a face with neither leaves the browser to synthesise. */
67
+ italicFile?: string;
68
+ /** `font-weight` descriptor — the file's real `wght` range. */
69
+ weight?: string;
70
+ /** `font-stretch` descriptor — the file's real `wdth` range. The older
71
+ * spelling of `font-width`, and the one every browser that can render Mantle
72
+ * already accepts. */
73
+ stretch?: string;
74
+ /** `font-style` descriptor for a `slnt` axis, as an oblique range. Note the
75
+ * sign flip the importer applies: OpenType measures slant counter-clockwise,
76
+ * CSS measures it clockwise. */
77
+ style?: string;
78
+ /** Axes with no CSS descriptor, for documentation. */
79
+ axes?: string[];
80
+ };
81
+
82
+ const LIB = '/fonts/library';
83
+
84
+ /** The default for each of the four slots. "Default" is the ABSENCE of the
85
+ * stored value and of the CSS var — see resolveFontVars. */
86
+ export const DEFAULT_UI_FONT = 'inter';
87
+ export const DEFAULT_LOGO_FONT = 'bricolage-grotesque';
88
+ export const DEFAULT_TITLE_FONT = 'inherit';
89
+ export const DEFAULT_PROSE_FONT = 'inherit';
90
+
91
+ /**
92
+ * Keys that used to exist and no longer do resolve to nothing, which every
93
+ * caller already treats as "fall back to the default" — trimming the library
94
+ * has always been safe. `sans` is the one exception worth mapping rather than
95
+ * dropping: it was the old name for "follow the interface font" and was the
96
+ * shipped default for the peer name, so a brain that stored it explicitly
97
+ * should keep the behaviour it chose, not silently acquire a concrete face.
98
+ */
99
+ const LEGACY_KEYS: Record<string, string> = { sans: 'inherit' };
100
+
101
+ export const FONT_LIBRARY: FontFace[] = [
102
+ // ── the two that resolve to an existing family ────────────────────────────
103
+ {
104
+ key: 'inherit',
105
+ label: 'Same as interface',
106
+ family: null,
107
+ fallback: 'sans-serif',
108
+ shelf: 'sans',
109
+ file: null,
110
+ },
111
+ {
112
+ key: 'inter',
113
+ label: 'Inter',
114
+ family: null,
115
+ fallback: 'sans-serif',
116
+ shelf: 'sans',
117
+ file: null,
118
+ },
119
+ // ── sans ──────────────────────────────────────────────────────────────────
120
+ {
121
+ key: 'afacad-flux',
122
+ label: 'Afacad Flux',
123
+ family: 'Afacad Flux',
124
+ fallback: 'sans-serif',
125
+ shelf: 'sans',
126
+ file: `${LIB}/afacad-flux.woff2`,
127
+ weight: '100 1000',
128
+ // Leans BOTH ways: the slnt axis runs -14 to +14, so this one file covers
129
+ // upright, italic, and a backslant nothing in the UI asks for.
130
+ style: 'oblique -14deg 14deg',
131
+ },
132
+ {
133
+ key: 'bricolage-grotesque',
134
+ label: 'Bricolage Grotesque',
135
+ family: 'Bricolage Grotesque',
136
+ fallback: 'sans-serif',
137
+ shelf: 'sans',
138
+ file: `${LIB}/bricolage-grotesque.woff2`,
139
+ weight: '200 800',
140
+ stretch: '75% 100%',
141
+ axes: ['opsz'],
142
+ },
143
+ {
144
+ key: 'dm-sans',
145
+ label: 'DM Sans',
146
+ family: 'DM Sans',
147
+ fallback: 'sans-serif',
148
+ shelf: 'sans',
149
+ file: `${LIB}/dm-sans.woff2`,
150
+ italicFile: `${LIB}/dm-sans-italic.woff2`,
151
+ weight: '100 1000',
152
+ axes: ['opsz'],
153
+ },
154
+ {
155
+ key: 'google-sans-flex',
156
+ label: 'Google Sans Flex',
157
+ family: 'Google Sans Flex',
158
+ fallback: 'sans-serif',
159
+ shelf: 'sans',
160
+ // By far the heaviest face in the library (~1.9M packed, six axes). It is
161
+ // here because it is genuinely the most flexible one, and lazy loading
162
+ // means only a brain that selects it ever pays for it.
163
+ file: `${LIB}/google-sans-flex.woff2`,
164
+ weight: '1 1000',
165
+ stretch: '25% 151%',
166
+ style: 'oblique 0deg 10deg',
167
+ axes: ['opsz', 'GRAD', 'ROND'],
168
+ },
169
+ {
170
+ key: 'instrument-sans',
171
+ label: 'Instrument Sans',
172
+ family: 'Instrument Sans',
173
+ fallback: 'sans-serif',
174
+ shelf: 'sans',
175
+ file: `${LIB}/instrument-sans.woff2`,
176
+ italicFile: `${LIB}/instrument-sans-italic.woff2`,
177
+ // A genuinely narrow weight range. Declared honestly rather than padded to
178
+ // 100-900: a range wider than the file's own is clamped, but a caller
179
+ // reading this row should see what it can actually do.
180
+ weight: '400 700',
181
+ stretch: '75% 100%',
182
+ },
183
+ {
184
+ key: 'nunito-sans',
185
+ label: 'Nunito Sans',
186
+ family: 'Nunito Sans',
187
+ fallback: 'sans-serif',
188
+ shelf: 'sans',
189
+ file: `${LIB}/nunito-sans.woff2`,
190
+ italicFile: `${LIB}/nunito-sans-italic.woff2`,
191
+ weight: '200 1000',
192
+ stretch: '75% 125%',
193
+ axes: ['opsz', 'YTLC'],
194
+ },
195
+ {
196
+ key: 'roboto-flex',
197
+ label: 'Roboto Flex',
198
+ family: 'Roboto Flex',
199
+ fallback: 'sans-serif',
200
+ shelf: 'sans',
201
+ file: `${LIB}/roboto-flex.woff2`,
202
+ weight: '100 1000',
203
+ stretch: '25% 151%',
204
+ style: 'oblique 0deg 10deg',
205
+ axes: ['opsz', 'GRAD', 'XOPQ', 'YOPQ', 'XTRA', 'YTUC', 'YTLC', 'YTAS', 'YTDE', 'YTFI'],
206
+ },
207
+ {
208
+ key: 'saira',
209
+ label: 'Saira',
210
+ family: 'Saira',
211
+ fallback: 'sans-serif',
212
+ shelf: 'sans',
213
+ file: `${LIB}/saira.woff2`,
214
+ italicFile: `${LIB}/saira-italic.woff2`,
215
+ weight: '100 900',
216
+ stretch: '50% 125%',
217
+ },
218
+ {
219
+ key: 'signika',
220
+ label: 'Signika',
221
+ family: 'Signika',
222
+ fallback: 'sans-serif',
223
+ shelf: 'sans',
224
+ file: `${LIB}/signika.woff2`,
225
+ weight: '300 700',
226
+ axes: ['GRAD'],
227
+ },
228
+ // ── serif ─────────────────────────────────────────────────────────────────
229
+ {
230
+ key: 'playfair',
231
+ label: 'Playfair',
232
+ family: 'Playfair',
233
+ fallback: 'serif',
234
+ shelf: 'serif',
235
+ file: `${LIB}/playfair.woff2`,
236
+ italicFile: `${LIB}/playfair-italic.woff2`,
237
+ weight: '300 900',
238
+ stretch: '87.5% 112.5%',
239
+ axes: ['opsz'],
240
+ },
241
+ {
242
+ key: 'fraunces',
243
+ label: 'Fraunces',
244
+ family: 'Fraunces',
245
+ fallback: 'serif',
246
+ shelf: 'serif',
247
+ file: `${LIB}/fraunces.woff2`,
248
+ italicFile: `${LIB}/fraunces-italic.woff2`,
249
+ weight: '100 900',
250
+ // SOFT and WONK are real axes on this face — the reason it is in the
251
+ // library at all. Unwired for now; see the note at the top of the file.
252
+ axes: ['opsz', 'SOFT', 'WONK'],
253
+ },
254
+ // ── mono ──────────────────────────────────────────────────────────────────
255
+ {
256
+ key: 'inconsolata',
257
+ label: 'Inconsolata',
258
+ family: 'Inconsolata',
259
+ fallback: 'monospace',
260
+ shelf: 'mono',
261
+ file: `${LIB}/inconsolata.woff2`,
262
+ weight: '200 900',
263
+ stretch: '50% 200%',
264
+ },
265
+ // ── display ───────────────────────────────────────────────────────────────
266
+ // Condensed and high-contrast faces: right for a wordmark or a peer name,
267
+ // wrong for a 13px table cell. They stay selectable for every slot anyway —
268
+ // the modal groups them so the choice is informed, and refusing a choice
269
+ // outright is not our job.
270
+ {
271
+ key: 'advent-pro',
272
+ label: 'Advent Pro',
273
+ family: 'Advent Pro',
274
+ fallback: 'sans-serif',
275
+ shelf: 'display',
276
+ file: `${LIB}/advent-pro.woff2`,
277
+ weight: '100 900',
278
+ // The wdth axis STARTS at its narrow default and only widens, so this face
279
+ // has no condensed end below 100%.
280
+ stretch: '100% 200%',
281
+ },
282
+ {
283
+ key: 'big-shoulders',
284
+ label: 'Big Shoulders',
285
+ family: 'Big Shoulders',
286
+ fallback: 'sans-serif',
287
+ shelf: 'display',
288
+ file: `${LIB}/big-shoulders.woff2`,
289
+ weight: '100 900',
290
+ axes: ['opsz'],
291
+ },
292
+ {
293
+ key: 'fredoka',
294
+ label: 'Fredoka',
295
+ family: 'Fredoka',
296
+ fallback: 'sans-serif',
297
+ shelf: 'display',
298
+ file: `${LIB}/fredoka.woff2`,
299
+ weight: '300 700',
300
+ stretch: '75% 125%',
301
+ },
302
+ ];
303
+
304
+ /** Modal shelf order + labels. Keys are the `shelf` values. */
305
+ export const FONT_SHELVES: Array<{ id: FontShelf; label: string }> = [
306
+ { id: 'sans', label: 'Sans' },
307
+ { id: 'serif', label: 'Serif' },
308
+ { id: 'mono', label: 'Mono' },
309
+ { id: 'display', label: 'Display' },
310
+ ];
311
+
312
+ /**
313
+ * ── Size ────────────────────────────────────────────────────────────────────
314
+ *
315
+ * One vocabulary for all four slots, applied two different ways.
316
+ *
317
+ * The INTERFACE size scales the ROOT font-size, and because the shell is
318
+ * rem-based (h-16 header, rem paddings, rem control heights) the whole
319
+ * interface scales rather than just the letters — the same thing OS display
320
+ * scaling does. Percentages, not px, so a visitor who has raised their
321
+ * browser's default font size keeps that as the baseline; hard-coding 18px
322
+ * would quietly OVERRIDE an accessibility setting with our own.
323
+ *
324
+ * The other three are local multipliers on one element's own size, published as
325
+ * CSS vars. A wordmark that scaled the shell would be a bug, not a feature.
326
+ */
327
+ export type FontSize = 'xsmall' | 'small' | 'medium' | 'large';
328
+
329
+ export const FONT_SIZES: Array<{ id: FontSize; label: string; hint: string }> = [
330
+ { id: 'xsmall', label: 'Extra small', hint: 'Densest' },
331
+ { id: 'small', label: 'Small', hint: 'Denser — more on screen' },
332
+ { id: 'medium', label: 'Medium', hint: 'Default' },
333
+ { id: 'large', label: 'Large', hint: 'Roomier — easier to read' },
334
+ ];
335
+
336
+ export const DEFAULT_FONT_SIZE: FontSize = 'medium';
337
+
338
+ /**
339
+ * All four sizes travel as `<html>` ATTRIBUTES, never as resolved numbers.
340
+ * app.css owns the multipliers, exactly as it already owned the root font-size
341
+ * percentages, so there is one place holding the scale and no chance of a TS
342
+ * copy drifting from the CSS that actually paints. "Default" is the absence of
343
+ * the attribute.
344
+ */
345
+ export function resolveFontSize(v: string | null | undefined): FontSize {
346
+ return v === 'xsmall' || v === 'small' || v === 'large' || v === 'medium' ? v : DEFAULT_FONT_SIZE;
347
+ }
348
+
349
+ // ── lookup ──────────────────────────────────────────────────────────────────
350
+
351
+ /** How many variable axes a face carries: the CSS-addressable descriptors
352
+ * (weight/stretch/style) plus the descriptorless axes. The dialog's "· N axes"
353
+ * label and the test's two-axis-floor assertion both count through here, so
354
+ * the number users see is the same one the invariant enforces. */
355
+ export function axisCount(f: FontFace): number {
356
+ return [f.weight, f.stretch, f.style].filter(Boolean).length + (f.axes?.length ?? 0);
357
+ }
358
+
359
+ const BY_KEY = new Map(FONT_LIBRARY.map((f) => [f.key, f]));
360
+
361
+ export function fontByKey(key: string | null | undefined): FontFace | undefined {
362
+ if (!key) return undefined;
363
+ return BY_KEY.get(LEGACY_KEYS[key] ?? key);
364
+ }
365
+
366
+ /**
367
+ * CSS `font-family` value for a font key. Unknown keys → null (caller keeps its
368
+ * default).
369
+ */
370
+ export function fontFamilyValue(key: string | null | undefined): string | null {
371
+ const f = fontByKey(key);
372
+ if (!f) return null;
373
+ // 'inherit' means "follow the interface font, whatever it is", so it resolves
374
+ // through the var the interface choice overrides.
375
+ if (f.key === 'inherit') return 'var(--font-sans, ui-sans-serif, sans-serif)';
376
+ // 'inter' is a CONCRETE face, not "the current one". It must NOT resolve
377
+ // through --font-sans: that var is what the interface choice overrides, so an
378
+ // Inter row would preview in whichever face is selected — Saira under a label
379
+ // saying Inter. `--font-sans-base` always holds the next/font Inter family,
380
+ // set unconditionally in the root layout precisely so this stays truthful.
381
+ if (f.key === DEFAULT_UI_FONT) return 'var(--font-sans-base, ui-sans-serif, sans-serif)';
382
+ if (!f.family) return null;
383
+ return `"${f.family}", ${f.fallback}`;
384
+ }
385
+
386
+ /**
387
+ * `@font-face` rules for every library face, injected once server-side (root
388
+ * layout + the share/print shell) so the declarations are present on first
389
+ * paint (no FOUT wait) yet the files stay lazily fetched. Deterministic (no
390
+ * runtime state) so it is safe to render into a <style>.
391
+ */
392
+ export function displayFontFaceCss(): string {
393
+ const rules: string[] = [];
394
+ for (const f of FONT_LIBRARY) {
395
+ if (!f.file || !f.family) continue;
396
+ rules.push(face(f, f.file, f.style ?? 'normal'));
397
+ // A real italic file is a SECOND face on the same family, distinguished
398
+ // only by font-style — that is how the browser picks it for italic text.
399
+ // Faces with a slnt axis get nothing extra: their one rule already
400
+ // declares an oblique range, which covers italic.
401
+ if (f.italicFile) rules.push(face(f, f.italicFile, 'italic'));
402
+ }
403
+ return rules.join('\n');
404
+ }
405
+
406
+ function face(f: FontFace, file: string, style: string): string {
407
+ return (
408
+ `@font-face{font-family:"${f.family}";` +
409
+ `src:url("${file}") format("${fileFormat(file)}");` +
410
+ `font-display:swap;` +
411
+ `font-style:${style};` +
412
+ (f.weight ? `font-weight:${f.weight};` : '') +
413
+ (f.stretch ? `font-stretch:${f.stretch};` : '') +
414
+ `}`
415
+ );
416
+ }
417
+
418
+ /** `format()` hint for a face file. A wrong hint makes the browser skip the
419
+ * face entirely, so it is derived from the filename, never assumed. */
420
+ function fileFormat(file: string): string {
421
+ if (file.endsWith('.woff2')) return 'woff2';
422
+ if (file.endsWith('.woff')) return 'woff';
423
+ if (file.endsWith('.otf')) return 'opentype';
424
+ return 'truetype';
425
+ }
426
+
427
+ /**
428
+ * Resolve the four stored font keys to their CSS `font-family` values, dropping
429
+ * defaults and unknown keys — the shared projection behind the server-rendered
430
+ * `<html>` appearance attributes (see appearance.ts). Values land as inline
431
+ * style on the root element; "default" is the absence of the var, so the
432
+ * elements' var() fallbacks win. There is deliberately NO localStorage /
433
+ * before-paint-script path: the document arrives correct, and the client
434
+ * providers read the rendered attributes back as their initial state.
435
+ */
436
+ export type ResolvedFontVars = {
437
+ wordmark?: string;
438
+ pageTitle?: string;
439
+ ui?: string;
440
+ prose?: string;
441
+ };
442
+
443
+ export function resolveFontVars(
444
+ logo: string | null | undefined,
445
+ title: string | null | undefined,
446
+ ui?: string | null | undefined,
447
+ prose?: string | null | undefined,
448
+ ): ResolvedFontVars {
449
+ const out: ResolvedFontVars = {};
450
+ if (logo && fontByKey(logo)?.key !== DEFAULT_LOGO_FONT) {
451
+ const v = fontFamilyValue(logo);
452
+ if (v) out.wordmark = v;
453
+ }
454
+ if (title && fontByKey(title)?.key !== DEFAULT_TITLE_FONT) {
455
+ const v = fontFamilyValue(title);
456
+ if (v) out.pageTitle = v;
457
+ }
458
+ // The UI font overrides `--font-sans` itself rather than introducing a var of
459
+ // its own, so everything that already resolves it — the `font-sans` utility
460
+ // and every element inheriting from the root — follows the choice with no
461
+ // further wiring. Custom properties inherit, so setting it once at the root
462
+ // is the whole mechanism.
463
+ //
464
+ // 'inherit' (and the legacy 'sans' that aliases to it) must be REJECTED for
465
+ // this slot, not resolved: its value is `var(--font-sans, …)`, and stamping
466
+ // that as --font-sans is a self-referential custom property — invalid at
467
+ // computed-value time regardless of the fallback, which drops the ENTIRE
468
+ // interface to the browser default font. The dialog never offers it for this
469
+ // slot, but the PUT route is shape-only validation, so the stored value can
470
+ // be anything.
471
+ if (ui) {
472
+ const key = fontByKey(ui)?.key;
473
+ if (key !== DEFAULT_UI_FONT && key !== 'inherit') {
474
+ const v = fontFamilyValue(ui);
475
+ if (v) out.ui = v;
476
+ }
477
+ }
478
+ if (prose && fontByKey(prose)?.key !== DEFAULT_PROSE_FONT) {
479
+ const v = fontFamilyValue(prose);
480
+ if (v) out.prose = v;
481
+ }
482
+ return out;
483
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { docLabelFromRelPath, prettifyDocLabel } from './docs-labels';
3
+
4
+ describe('prettifyDocLabel', () => {
5
+ it('strips ordering prefixes and title-cases', () => {
6
+ expect(prettifyDocLabel('00-index.md')).toBe('Index');
7
+ expect(prettifyDocLabel('02-concepts')).toBe('Concepts');
8
+ expect(prettifyDocLabel('the-brain.md')).toBe('The Brain');
9
+ });
10
+
11
+ it('keeps a version number whole (changelog entries)', () => {
12
+ // Without the version guard the prefix strip would mangle these: `0.100.0` → "100.0".
13
+ expect(prettifyDocLabel('0.100.0.md')).toBe('v0.100.0');
14
+ expect(prettifyDocLabel('0.20.68.md')).toBe('v0.20.68');
15
+ expect(prettifyDocLabel('v1.2.3.md')).toBe('v1.2.3');
16
+ });
17
+
18
+ it('does not treat prefixed doc names as versions', () => {
19
+ expect(prettifyDocLabel('01-getting-started.md')).toBe('Getting Started');
20
+ });
21
+ });
22
+
23
+ describe('docLabelFromRelPath', () => {
24
+ it('labels from the last path segment', () => {
25
+ expect(docLabelFromRelPath('guide/00-index.md')).toBe('Index');
26
+ expect(docLabelFromRelPath('0.109.0.md')).toBe('v0.109.0');
27
+ });
28
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pure label helpers for the docs reader, shared by the server data layer
3
+ * (prev/next labels) and the client nav (folder/file labels). No 'server-only'
4
+ * marker — safe to import from client components.
5
+ */
6
+
7
+ /** Prettify a filename or folder segment for display:
8
+ * strip a leading `NN-`/`NN_`/`NN.` ordering prefix, drop the `.md` extension,
9
+ * turn dashes/underscores into spaces, and title-case.
10
+ * `00-index.md` → "Index", `02-concepts` → "Concepts", `the-brain.md` → "The Brain".
11
+ * A pure version number (a changelog entry) is kept whole — the ordering-prefix
12
+ * strip would otherwise mangle it (`0.100.0` → "100.0"): `0.100.0.md` → "v0.100.0". */
13
+ export function prettifyDocLabel(name: string): string {
14
+ const base = name.replace(/\.(md|markdown)$/i, '');
15
+ if (/^v?\d+(\.\d+)+$/.test(base)) return base.startsWith('v') ? base : `v${base}`;
16
+ const noPrefix = base.replace(/^\d+[-_.]/, '');
17
+ const spaced = noPrefix.replace(/[-_]+/g, ' ').trim();
18
+ return spaced.replace(/\b\w/g, (c) => c.toUpperCase()) || base;
19
+ }
20
+
21
+ /** Display label for a doc, from the last segment of its collection-relative path. */
22
+ export function docLabelFromRelPath(relPath: string): string {
23
+ const last = relPath.split('/').pop() ?? relPath;
24
+ return prettifyDocLabel(last);
25
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Themed highlight palette. We store a TOKEN KEY (e.g. `chart-2`) on the
3
+ * highlight mark, never a raw colour, so highlights track the active theme +
4
+ * light/dark like the rest of the document. A null/unknown colour = the default
5
+ * highlight (primary tint, styled in globals.css). Pure (no React) → safe to
6
+ * import in the server-side public renderer.
7
+ */
8
+ export const HIGHLIGHT_TOKENS = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5'] as const;
9
+ export type HighlightToken = (typeof HIGHLIGHT_TOKENS)[number];
10
+
11
+ export function isHighlightToken(v: unknown): v is HighlightToken {
12
+ return typeof v === 'string' && (HIGHLIGHT_TOKENS as readonly string[]).includes(v);
13
+ }
14
+
15
+ /** CSS `background-color` for a highlight token, or null for the default tint. */
16
+ export function highlightColor(token: unknown): string | null {
17
+ if (!isHighlightToken(token)) return null;
18
+ return `color-mix(in oklab, var(--${token}) 30%, transparent)`;
19
+ }