@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.
- package/LICENSE.md +135 -0
- package/package.json +22 -0
- package/src/aside-style.ts +70 -0
- package/src/assistant-limits.test.ts +49 -0
- package/src/assistant-limits.ts +41 -0
- package/src/display-fonts.ts +483 -0
- package/src/docs-labels.test.ts +28 -0
- package/src/docs-labels.ts +25 -0
- package/src/highlight-colors.ts +19 -0
- package/src/index.ts +2324 -0
- package/src/journey-format.ts +260 -0
- package/src/lib/event-time.test.ts +85 -0
- package/src/lib/event-time.ts +152 -0
- package/src/lib/format-bytes.ts +50 -0
- package/src/lib/format-datetime.ts +88 -0
- package/src/lib/safe-download.test.ts +58 -0
- package/src/lib/safe-download.ts +97 -0
- package/src/mermaid-theme.ts +167 -0
- package/src/model-choices.ts +118 -0
- package/src/runners-types.ts +195 -0
- package/src/search-query.test.ts +86 -0
- package/src/search-query.ts +103 -0
- package/src/slugify.test.ts +159 -0
- package/src/slugify.ts +71 -0
- package/src/text-colors.ts +18 -0
- package/src/traces-format.ts +77 -0
- package/src/turn-streaming.ts +37 -0
- package/src/types/integrity.ts +164 -0
- package/src/types/maintenance.ts +78 -0
- package/src/types/sanity.ts +53 -0
- package/src/version.ts +58 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -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
|
+
}
|