@citisen/dsh-font 0.1.0 → 0.2.0

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/src/client.js CHANGED
@@ -54,6 +54,8 @@ const PLUGIN_ID = /* dsh:plugin-id */ 'dsh-font'
54
54
  /** Field names — must match the host schema in `lib/index.js`. */
55
55
  const UI_FONT_FAMILY_FIELD = 'uiFontFamily'
56
56
  const CODE_FONT_FAMILY_FIELD = 'codeFontFamily'
57
+ const CODE_FONT_WEIGHT_FIELD = 'codeFontWeight'
58
+ const UI_FONT_WEIGHT_FIELD = 'uiFontWeight'
57
59
  const UI_FONT_SCALE_FIELD = 'uiFontScale'
58
60
  const CONTENT_FONT_SIZE_FIELD = 'contentFontSize'
59
61
  const CODE_FONT_SIZE_FIELD = 'codeFontSize'
@@ -72,60 +74,1259 @@ const CONTENT_FONT_SIZE_MIN = 12
72
74
  const CONTENT_FONT_SIZE_MAX = 20
73
75
  const CODE_FONT_SIZE_MIN = 10
74
76
  const CODE_FONT_SIZE_MAX = 20
77
+ /** The shipped code weight — must match `DEFAULT_CODE_FONT_WEIGHT` in the host. */
78
+ const DEFAULT_CODE_FONT_WEIGHT = 400
79
+ /**
80
+ * The shipped interface weight — must match `DEFAULT_UI_FONT_WEIGHT` in the
81
+ * host. It is the design system's own base weight, so it is the "no override"
82
+ * value: the interface weight rule is emitted only for something else.
83
+ */
84
+ const DEFAULT_UI_FONT_WEIGHT = 400
75
85
 
76
86
  /** The interface text sizes the shipped components hard-code, in px. */
77
87
  const UI_TEXT_STEPS = [11, 12, 13, 14, 16, 20, 24]
78
88
 
79
- /** Curated family presets, offered as one-click fills for both inputs. */
80
- const UI_FAMILY_PRESETS = [
81
- { id: 'system', label: 'System', value: DEFAULT_UI_FONT_FAMILY },
82
- {
83
- id: 'inter',
84
- label: 'Inter',
85
- value: 'Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif',
86
- },
87
- {
88
- id: 'plex',
89
- label: 'IBM Plex Sans',
90
- value: '"IBM Plex Sans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
91
- },
92
- {
93
- id: 'noto',
94
- label: 'Noto Sans',
95
- value: '"Noto Sans", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
96
- },
89
+ // ─── the code weight: a closed vocabulary ───────────────────────────────────
90
+ //
91
+ // The design system ships no weight token at all: every `font:` declaration in
92
+ // the interface is a literal, and the code ladder is literally `400`. So the
93
+ // weight cannot ride the family token the way the family does — `font-family`
94
+ // has no slot for it, and `font-family: "Geist Mono" 500, monospace` is simply
95
+ // an invalid declaration that would drop the whole stack. It has to be a
96
+ // separate `font-weight`, which is what the field below carries.
97
+ //
98
+ // The list is closed rather than a free number on purpose: a `font-weight` a
99
+ // family does not have is synthesized by the browser, and offering faux-bold as
100
+ // a normal choice is worse than not offering it. The numbers are the CSS Fonts
101
+ // keyword scale, which is also the scale the Local Font Access API reports the
102
+ // individual faces of a family on.
103
+
104
+ /** Selectable code weights, in the order the picker shows them. */
105
+ const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900]
106
+
107
+ /** Locale key suffix per weight — `font.weight.medium` and friends. */
108
+ const WEIGHT_KEYS = {
109
+ 100: 'thin',
110
+ 200: 'extralight',
111
+ 300: 'light',
112
+ 400: 'regular',
113
+ 500: 'medium',
114
+ 600: 'semibold',
115
+ 700: 'bold',
116
+ 800: 'extrabold',
117
+ 900: 'black',
118
+ }
119
+
120
+ /**
121
+ * Weight words a typed family name may end with, mapped to their number.
122
+ *
123
+ * This is what makes `Geist Mono Medium` mean "Geist Mono at 500" rather than a
124
+ * literal family called `Geist Mono Medium`, which resolves to nothing. A
125
+ * variable font like Geist Mono has one family and the whole weight axis, so
126
+ * every one of these words — and each static `Light`/`Medium`/`Bold` face file
127
+ * — is a weight, never a family.
128
+ *
129
+ * Deliberately absent: the bare numbers. `400` is far too easy to hit as part
130
+ * of a real family name (`Mono 2`, `JBMono 400`), and the picker has a control
131
+ * for the weight, so a numeric suffix is left alone.
132
+ */
133
+ const WEIGHT_WORDS = {
134
+ thin: 100,
135
+ hairline: 100,
136
+ extralight: 200,
137
+ ultralight: 200,
138
+ light: 300,
139
+ book: 400,
140
+ normal: 400,
141
+ regular: 400,
142
+ roman: 400,
143
+ medium: 500,
144
+ demibold: 600,
145
+ semibold: 600,
146
+ bold: 700,
147
+ extrabold: 800,
148
+ ultrabold: 800,
149
+ black: 900,
150
+ heavy: 900,
151
+ extrablack: 900,
152
+ ultrablack: 900,
153
+ }
154
+
155
+ // ─── the font query: a small language over `font-family` ────────────────────
156
+ //
157
+ // What the settings row edits is a *query*, not a CSS value:
158
+ //
159
+ // Geist Mono medium, "Zhuque Fangsong (technical preview)", monospace
160
+ // └─────┬────┘ └──┬─┘
161
+ // family weight
162
+ //
163
+ // The grammar is the CSS font-family list plus the one thing the list cannot
164
+ // carry: the weight. `font-family: "Geist Mono" 500, monospace` is an invalid
165
+ // declaration that drops the whole stack, so the weight has to be its own
166
+ // `font-weight` — but it can still be *written* next to the family it belongs
167
+ // to, which is what makes one field able to describe both. A weight is chosen
168
+ // for a family; having to change them in two separate places is exactly the
169
+ // problem this language exists to remove.
170
+ //
171
+ // query := entry ("," entry)*
172
+ // entry := family | weight
173
+ // family := '"' … '"' | "'" … "'" | word (space word)*
174
+ // weight := a word from WEIGHT_WORDS
175
+ //
176
+ // Only the LAST word of an unquoted entry may be a weight word, and only when
177
+ // the whole entry is not itself a catalogued family: `Book Antiqua` and
178
+ // `Franklin Gothic Medium` are real families, and stripping either would
179
+ // silently retarget the stack at a font nobody picked. Quoting always means
180
+ // "this is the family name, verbatim", so a quoted entry never splits.
181
+ //
182
+ // The weight is one value for the whole axis, so where it is written does not
183
+ // change what it means. The canonical form puts it after the FIRST family —
184
+ // the one that is in effect — where it reads as a property of that family.
185
+ //
186
+ // The parser is forgiving on purpose. This text is typed by hand into a small
187
+ // box, not generated, so anything it cannot place is kept as written and
188
+ // reported as a diagnostic instead of being silently dropped.
189
+
190
+ /** One diagnostic code the reader can raise; the row localizes each one. */
191
+ const QUERY_DIAGNOSTIC = {
192
+ unknownFamily: 'unknown-family',
193
+ missingWeight: 'missing-weight',
194
+ duplicateWeight: 'duplicate-weight',
195
+ unclosedQuote: 'unclosed-quote',
196
+ trailingText: 'trailing-text',
197
+ }
198
+
199
+ /**
200
+ * The word a numeric weight is spelled with in a query: 500 -> `medium`.
201
+ * @param weight - a numeric weight, or anything `normalizeWeight` accepts.
202
+ * @returns the canonical word for it.
203
+ */
204
+ function weightWord(weight) {
205
+ return WEIGHT_KEYS[normalizeWeight(weight)] ?? String(weight)
206
+ }
207
+
208
+ /**
209
+ * The weights one family actually has, read off its faces' style names.
210
+ *
211
+ * The Local Font Access API reports a face's `style` (`Regular`, `SemiBold`,
212
+ * `Bold Italic`) rather than a number, so the mapping is the same vocabulary
213
+ * the query itself accepts — which is also why `Semi Bold` has to be folded
214
+ * back into one word before the lookup: the API's spelling is not guaranteed.
215
+ *
216
+ * An empty result means "unknown", not "none": enumerating faces is
217
+ * permission-gated and a family may report styles this vocabulary cannot read.
218
+ * Callers must treat it as the absence of evidence.
219
+ * @param styles - the face style names of one family.
220
+ * @returns the distinct weights, ascending.
221
+ */
222
+ function faceWeights(styles) {
223
+ if (!Array.isArray(styles)) return []
224
+ const found = new Set()
225
+ for (const style of styles) {
226
+ const text = String(style)
227
+ .toLowerCase()
228
+ .replace(/\b(semi|demi)\s+(?=[a-z])/g, 'semi')
229
+ .replace(/\b(extra|ultra)\s+(?=[a-z])/g, 'extra')
230
+ for (const word of text.split(/[^a-z]+/)) {
231
+ if (Object.hasOwn(WEIGHT_WORDS, word)) found.add(WEIGHT_WORDS[word])
232
+ }
233
+ }
234
+ return [...found].sort((left, right) => left - right)
235
+ }
236
+
237
+ /**
238
+ * Whether a lowercase name is a generic CSS family (`monospace`, `serif`, …).
239
+ * Generic families are always valid in a list, but picking one is a different
240
+ * intent from picking an installed font, so the language colours them apart.
241
+ * @param name - a lowercase family name.
242
+ * @returns whether it is generic.
243
+ */
244
+ function isGenericFamilyName(name) {
245
+ return GENERIC_FAMILIES.includes(name)
246
+ }
247
+
248
+ /**
249
+ * Split a query into its comma-separated entries, keeping every raw slice.
250
+ *
251
+ * The raw slices are what makes an edit able to rewrite the text without
252
+ * reformatting it: `lead + core + trail` is always exactly the entry as it was
253
+ * found, so a rewrite that only reorders entries cannot lose a space.
254
+ *
255
+ * Commas inside quotes are not separators. Backslash escapes are deliberately
256
+ * not interpreted — consistent with {@link parseFamilyList} — so a family name
257
+ * containing the quote character has to be written with the other quote style.
258
+ *
259
+ * @param text - the query.
260
+ * @returns one record per entry, in order; a blank query yields one empty entry.
261
+ */
262
+ function splitQueryEntries(text) {
263
+ const source = typeof text === 'string' ? text : ''
264
+ const bounds = []
265
+ let start = 0
266
+ let quote = ''
267
+ for (let index = 0; index < source.length; index += 1) {
268
+ const char = source[index]
269
+ if (quote !== '') {
270
+ if (char === quote) quote = ''
271
+ continue
272
+ }
273
+ if (char === '"' || char === "'") {
274
+ quote = char
275
+ continue
276
+ }
277
+ if (char === ',') {
278
+ bounds.push([start, index])
279
+ start = index + 1
280
+ }
281
+ }
282
+ bounds.push([start, source.length])
283
+
284
+ return bounds.map(([from, to]) => {
285
+ const text = source.slice(from, to)
286
+ const trimmed = text.trim()
287
+ const lead = trimmed === '' ? text : text.slice(0, text.indexOf(trimmed))
288
+ const core = trimmed
289
+ const trail = text.slice(lead.length + core.length)
290
+ const quoteChar = core[0]
291
+ const quoted = quoteChar === '"' || quoteChar === "'"
292
+ const close = quoted ? core.indexOf(quoteChar, 1) : -1
293
+ const closed = close > 0
294
+ // The family text: the quotes are punctuation, not part of the name, and
295
+ // anything after the closing quote (a weight word) still belongs to the
296
+ // entry, so it is kept — with the quote pair taken out of the middle.
297
+ const tail = closed ? core.slice(close + 1).trim() : ''
298
+ const inner = quoted
299
+ ? [closed ? core.slice(1, close) : core.slice(1), tail].filter((part) => part !== '').join(' ')
300
+ : core
301
+ return {
302
+ text,
303
+ start: from,
304
+ end: to,
305
+ lead,
306
+ core,
307
+ trail,
308
+ quoted,
309
+ closed,
310
+ inner,
311
+ }
312
+ })
313
+ }
314
+
315
+ /**
316
+ * Read one entry into the family it names, the weight word it carries, and the
317
+ * highlight kind of its family part.
318
+ *
319
+ * The two shapes a weight can take are both here: a bare entry whose last word
320
+ * is a weight (`Geist Mono medium`), and a quoted family followed by one
321
+ * (`"Geist Mono" medium` — which is what the canonical form writes, and why a
322
+ * quoted entry cannot simply mean "no weight"). A quoted name whose closing
323
+ * quote is the last character carries no weight, so `"Book Antiqua"` stays a
324
+ * name even though a bare `Book Antiqua` could split if it were not catalogued.
325
+ *
326
+ * @param entry - one record from {@link splitQueryEntries}.
327
+ * @param known - the lowercase catalogue.
328
+ * @param enumerated - whether the catalogue is authoritative.
329
+ * @returns `{ name, word, wordLength, kind, diagnostics }`.
330
+ */
331
+ function readQueryEntry(entry, known, enumerated) {
332
+ const core = entry.core
333
+ const diagnostics = []
334
+ if (core === '') return { name: '', word: undefined, wordLength: 0, kind: 'empty', diagnostics }
335
+
336
+ const quoteChar = core[0]
337
+ const quote = quoteChar === '"' || quoteChar === "'" ? quoteChar : ''
338
+ const lower = core.toLowerCase()
339
+ let name = core
340
+ let word
341
+ let wordLength = 0
342
+
343
+ if (quote !== '') {
344
+ const close = core.indexOf(quote, 1)
345
+ if (close < 0) {
346
+ diagnostics.push({ code: QUERY_DIAGNOSTIC.unclosedQuote })
347
+ name = core.slice(1).trim()
348
+ } else {
349
+ name = core.slice(1, close)
350
+ const rest = core.slice(close + 1).trim()
351
+ if (rest !== '') {
352
+ if (Object.hasOwn(WEIGHT_WORDS, rest.toLowerCase())) {
353
+ word = rest.toLowerCase()
354
+ wordLength = rest.length
355
+ } else {
356
+ diagnostics.push({ code: QUERY_DIAGNOSTIC.trailingText, text: rest })
357
+ }
358
+ }
359
+ }
360
+ } else if (isGenericFamilyName(lower) || known.has(lower)) {
361
+ // The whole entry is one family: there is nothing to strip off it.
362
+ } else if (Object.hasOwn(WEIGHT_WORDS, lower)) {
363
+ // A bare weight word stands on its own, without a family.
364
+ name = ''
365
+ word = lower
366
+ wordLength = core.length
367
+ } else {
368
+ const cut = lower.lastIndexOf(' ')
369
+ if (cut > 0 && Object.hasOwn(WEIGHT_WORDS, lower.slice(cut + 1))) {
370
+ word = lower.slice(cut + 1)
371
+ wordLength = word.length
372
+ name = core.slice(0, core.length - wordLength).trim()
373
+ }
374
+ }
375
+
376
+ const nameLower = name.trim().toLowerCase()
377
+ const generic = nameLower !== '' && isGenericFamilyName(nameLower)
378
+ const catalogued = nameLower !== '' && known.has(nameLower)
379
+ if (nameLower !== '' && !generic && !catalogued && enumerated) {
380
+ diagnostics.push({ code: QUERY_DIAGNOSTIC.unknownFamily, name: name.trim() })
381
+ }
382
+ return {
383
+ name: name.trim(),
384
+ word,
385
+ wordLength,
386
+ kind:
387
+ nameLower === ''
388
+ ? 'weight'
389
+ : generic
390
+ ? 'generic'
391
+ : catalogued || !enumerated
392
+ ? 'family'
393
+ : 'unknown',
394
+ diagnostics,
395
+ }
396
+ }
397
+
398
+ /** The diagnostic-free pieces a query resolves to. */
399
+ function emptyQueryRead() {
400
+ return {
401
+ families: [],
402
+ weight: undefined,
403
+ weightWord: undefined,
404
+ effective: -1,
405
+ diagnostics: [],
406
+ tokens: [],
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Read a query into families, a weight, and highlight tokens — the one pass
412
+ * behind both {@link parseFontQuery} and {@link fontQueryTokens}.
413
+ * @param text - the query.
414
+ * @param options - `{ catalogue, styles, enumerated }`. `enumerated` declares
415
+ * the catalogue authoritative (read from the machine), which is what makes an
416
+ * unrecognized family worth warning about.
417
+ * @returns `{ entries, families, weight, weightWord, effective, diagnostics, tokens }`.
418
+ */
419
+ function readFontQuery(text, options) {
420
+ const read = emptyQueryRead()
421
+ if (typeof text !== 'string' || text === '') return read
422
+
423
+ const catalogue = Array.isArray(options?.catalogue) ? options.catalogue : []
424
+ const known = new Set(catalogue.map((family) => String(family).toLowerCase()))
425
+ const styles = options?.styles ?? {}
426
+ const styleKeys = new Map()
427
+ for (const key of Object.keys(styles)) styleKeys.set(key.toLowerCase(), key)
428
+ const enumerated = options?.enumerated === true
429
+
430
+ const entries = splitQueryEntries(text)
431
+ let sawWeight = false
432
+
433
+ for (const entry of entries) {
434
+ const readEntry = readQueryEntry(entry, known, enumerated)
435
+ for (const diagnostic of readEntry.diagnostics) read.diagnostics.push(diagnostic)
436
+ if (readEntry.name !== '') read.families.push(readEntry.name)
437
+
438
+ if (readEntry.word !== undefined) {
439
+ if (!sawWeight) {
440
+ sawWeight = true
441
+ read.weight = WEIGHT_WORDS[readEntry.word]
442
+ read.weightWord = readEntry.word
443
+ } else {
444
+ read.diagnostics.push({ code: QUERY_DIAGNOSTIC.duplicateWeight, word: readEntry.word })
445
+ }
446
+ }
447
+
448
+ // ── the highlight tokens for this entry ─────────────────────────────────
449
+ // They concatenate back into the input exactly, which is what lets the
450
+ // painted layer and the (invisible) textarea stay aligned glyph for glyph.
451
+ if (entry.lead !== '') read.tokens.push({ text: entry.lead, kind: 'space' })
452
+ if (entry.core !== '') {
453
+ if (readEntry.wordLength > 0 && readEntry.name !== '') {
454
+ read.tokens.push({
455
+ text: entry.core.slice(0, entry.core.length - readEntry.wordLength),
456
+ kind: readEntry.kind,
457
+ family: readEntry.name,
458
+ })
459
+ read.tokens.push({
460
+ text: entry.core.slice(-readEntry.wordLength),
461
+ kind: 'weight',
462
+ family: readEntry.name,
463
+ })
464
+ } else {
465
+ read.tokens.push({ text: entry.core, kind: readEntry.kind, family: readEntry.name })
466
+ }
467
+ }
468
+ if (entry.trail !== '') read.tokens.push({ text: entry.trail, kind: 'space' })
469
+ if (entry.end < text.length) read.tokens.push({ text: ',', kind: 'comma' })
470
+ }
471
+
472
+ // ── the family that is actually in effect ────────────────────────────────
473
+ // With an authoritative catalogue the first *installed* family wins, because
474
+ // that is the one the browser will paint with. Without one the catalogue is
475
+ // only a suggestion list, so the parser cannot second-guess the first entry.
476
+ if (read.families.length > 0) {
477
+ if (!enumerated) {
478
+ read.effective = 0
479
+ } else {
480
+ for (let index = 0; index < read.families.length; index += 1) {
481
+ const lower = read.families[index].toLowerCase()
482
+ if (isGenericFamilyName(lower) || known.has(lower)) {
483
+ read.effective = index
484
+ break
485
+ }
486
+ }
487
+ }
488
+ }
489
+
490
+ // ── a weight the effective family does not have ──────────────────────────
491
+ // Only reported when the faces of that family are actually known: an empty
492
+ // face list means "not read", never "not installed".
493
+ if (read.weight !== undefined && read.effective >= 0) {
494
+ const family = read.families[read.effective]
495
+ const key = styleKeys.get(family.toLowerCase())
496
+ const weights = key === undefined ? [] : faceWeights(styles[key])
497
+ if (weights.length > 0 && !weights.includes(read.weight)) {
498
+ read.diagnostics.push({
499
+ code: QUERY_DIAGNOSTIC.missingWeight,
500
+ name: family,
501
+ weight: read.weight,
502
+ word: read.weightWord,
503
+ })
504
+ for (const token of read.tokens) {
505
+ if (token.kind === 'weight') token.kind = 'weightMissing'
506
+ }
507
+ }
508
+ }
509
+
510
+ return read
511
+ }
512
+
513
+ /**
514
+ * Parse a query into the two stored fields it stands for.
515
+ * @param text - the query.
516
+ * @param options - see {@link readFontQuery}.
517
+ * @returns `{ families, weight, weightWord, effective, diagnostics }`.
518
+ */
519
+ function parseFontQuery(text, options) {
520
+ const read = readFontQuery(text, options)
521
+ return {
522
+ families: read.families,
523
+ weight: read.weight,
524
+ weightWord: read.weightWord,
525
+ effective: read.effective,
526
+ diagnostics: read.diagnostics,
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Tokenize a query for the painted layer that sits behind the textarea.
532
+ * @param text - the query.
533
+ * @param options - see {@link readFontQuery}.
534
+ * @returns the tokens, whose `text` concatenates back into the input exactly.
535
+ */
536
+ function fontQueryTokens(text, options) {
537
+ return readFontQuery(text, options).tokens
538
+ }
539
+
540
+ /**
541
+ * Write families and a weight back out as the canonical query.
542
+ *
543
+ * The weight is attached to the first family — the one in effect — and is
544
+ * omitted entirely for `undefined`, which is how an axis says "no weight of my
545
+ * own". Everything else is quoted the way CSS requires.
546
+ * @param families - family names in priority order.
547
+ * @param weight - the numeric weight, or undefined.
548
+ * @returns the query text.
549
+ */
550
+ function serializeFontQuery(families, weight) {
551
+ const list = Array.isArray(families) ? families : []
552
+ const word = weight === undefined || weight === null ? undefined : weightWord(weight)
553
+ return list
554
+ .map((family, index) =>
555
+ index === 0 && word !== undefined ? `${quoteFamily(family)} ${word}` : quoteFamily(family),
556
+ )
557
+ .filter((token) => token !== '')
558
+ .join(', ')
559
+ }
560
+
561
+ /**
562
+ * Locate the entry a caret sits in, and the word inside it being typed.
563
+ *
564
+ * The replacement range is the WHOLE entry, not the word: a family name is
565
+ * several words (`Geist Mono`), so completing `geist mo` has to replace both.
566
+ * The word is still reported, because it is what tells a weight completion
567
+ * (`Geist Mono b` -> `bold`) from a family completion.
568
+ *
569
+ * @param text - the query.
570
+ * @param caret - the caret offset.
571
+ * @returns `{ start, end, core, inner, quoted, head, word, weightWord, familyEnd,
572
+ * caretInCore }`: `inner` is the entry with its quotes taken out, `head` the
573
+ * entry text before the word being typed (trimmed, unquoted), `weightWord` the
574
+ * complete weight word the entry already ends with, and `familyEnd` where the
575
+ * family part of the entry stops, in core offsets.
576
+ */
577
+ function queryContextAt(text, caret) {
578
+ const source = typeof text === 'string' ? text : ''
579
+ const position = Math.min(Math.max(Number.isFinite(caret) ? caret : 0, 0), source.length)
580
+ const entries = splitQueryEntries(source)
581
+ let entry = entries[entries.length - 1]
582
+ for (const candidate of entries) {
583
+ if (position <= candidate.end) {
584
+ entry = candidate
585
+ break
586
+ }
587
+ }
588
+ const caretInCore = Math.max(position - entry.start - entry.lead.length, 0)
589
+ const before = entry.core.slice(0, caretInCore)
590
+ const after = entry.core.slice(before.length)
591
+ const left = /\S*$/.exec(before)?.[0] ?? ''
592
+ const right = /^\S*/.exec(after)?.[0] ?? ''
593
+
594
+ // A trailing complete weight word is not part of the family name, so the
595
+ // family text ends before it: that is what keeps `"Geist Mono" medium`
596
+ // suggesting families and weights instead of being read as one long name.
597
+ const lower = entry.core.toLowerCase()
598
+ const cut = lower.lastIndexOf(' ')
599
+ const tail = cut > 0 ? lower.slice(cut + 1) : ''
600
+ const weightWord = Object.hasOwn(WEIGHT_WORDS, tail) ? tail : undefined
601
+
602
+ return {
603
+ start: entry.start,
604
+ end: entry.end,
605
+ core: entry.core,
606
+ inner: entry.inner,
607
+ quoted: entry.quoted,
608
+ head: unquoteName(before.slice(0, before.length - left.length).trim()),
609
+ word: `${left}${right}`,
610
+ weightWord,
611
+ familyEnd: weightWord === undefined ? entry.core.length : Math.max(cut, 0),
612
+ caretInCore,
613
+ }
614
+ }
615
+
616
+ /** Strip one matching pair of surrounding quotes from a name. */
617
+ function unquoteName(name) {
618
+ const text = String(name)
619
+ const quoteChar = text[0]
620
+ if ((quoteChar === '"' || quoteChar === "'") && text.length >= 2 && text.endsWith(quoteChar)) {
621
+ return text.slice(1, -1)
622
+ }
623
+ return text
624
+ }
625
+
626
+ /** Exact, case-insensitive lookup of one name in a suggestion space. */
627
+ function findExactName(names, needle) {
628
+ const lower = needle.toLowerCase()
629
+ return names.find((name) => name.toLowerCase() === lower)
630
+ }
631
+
632
+ /** One entry of the autocomplete list, in the order the popup shows them. */
633
+ const SUGGESTION_LIMIT = 40
634
+
635
+ /**
636
+ * The whole autocomplete decision, as a pure function of the text and caret.
637
+ *
638
+ * Candidates are family names, generic keywords, and — when the entry already
639
+ * names a family — that family's weight words, written as `<family> <weight>`
640
+ * so picking one both sets the family and the weight in a single edit. The
641
+ * weights come from the machine's faces when they could be read, and from the
642
+ * closed CSS vocabulary otherwise, so the weight is always discoverable even
643
+ * without the Local Font Access permission.
644
+ *
645
+ * @param context - the result of {@link queryContextAt}.
646
+ * @param options - `{ catalogue, styles, enumerated, shippedWeight }`.
647
+ * `shippedWeight` is the axis's implicit weight, offered as a row that takes
648
+ * the word away instead of writing it.
649
+ * @returns `{ start, end, at, items, custom }`; `items` are ordered for display.
650
+ */
651
+ function querySuggestions(context, options) {
652
+ const catalogue = Array.isArray(options?.catalogue) ? options.catalogue : []
653
+ const styles = options?.styles ?? {}
654
+ const space = [...new Set([...catalogue, ...GENERIC_FAMILIES])]
655
+ // The needle is the FAMILY part of the entry: a weight word the entry already
656
+ // carries would otherwise make `"Geist Mono" medium` match no family at all.
657
+ const inner = context.inner.trim()
658
+ const needle = context.weightWord === undefined ? inner : inner.slice(0, -(context.weightWord.length)).trim()
659
+ const exact = needle === '' ? undefined : findExactName(space, needle)
660
+ const head = context.head === '' ? undefined : findExactName(space, context.head)
661
+ const family = exact ?? head
662
+ const wordPrefix = (exact === undefined ? context.word : '').toLowerCase()
663
+
664
+ const matches = rankFamilyMatches(space, needle).slice(0, SUGGESTION_LIMIT)
665
+
666
+ const items = []
667
+ const families = matches.map((name) => ({
668
+ id: `family:${name}`,
669
+ kind: isGenericFamilyName(name.toLowerCase()) ? 'generic' : 'family',
670
+ // A completion never drops a weight the entry already states: the word
671
+ // travels with the pick, so swapping the family does not quietly reset the
672
+ // weight and picking the family already there changes nothing at all.
673
+ insert:
674
+ context.weightWord === undefined
675
+ ? quoteFamily(name)
676
+ : `${quoteFamily(name)} ${context.weightWord}`,
677
+ name,
678
+ }))
679
+
680
+ // A weight is only offered once the entry names a family, so the list never
681
+ // fills with weights while the user is still spelling the family out.
682
+ const weights = []
683
+ if (family !== undefined) {
684
+ const key = Object.keys(styles).find((name) => name.toLowerCase() === family.toLowerCase())
685
+ const detected = key === undefined ? [] : faceWeights(styles[key])
686
+ const pool = detected.length > 0 ? detected : FONT_WEIGHTS
687
+ for (const weight of pool) {
688
+ const word = weightWord(weight)
689
+ if (wordPrefix !== '' && !word.startsWith(wordPrefix)) continue
690
+ weights.push({
691
+ id: `weight:${family}:${String(weight)}`,
692
+ kind: 'weight',
693
+ // The axis's shipped weight is implicit, so picking it takes the word
694
+ // away rather than spelling out a value nobody chose.
695
+ insert: weight === options?.shippedWeight ? quoteFamily(family) : `${quoteFamily(family)} ${word}`,
696
+ family,
697
+ word,
698
+ weight,
699
+ })
700
+ }
701
+ // The weight the entry already states leads its own list, so an Enter that
702
+ // accepts the highlighted row re-applies what is written instead of
703
+ // silently moving a complete query to another weight.
704
+ const current = weights.findIndex((item) => item.word === context.weightWord)
705
+ if (current > 0) weights.unshift(...weights.splice(current, 1))
706
+ }
707
+
708
+ // The word being typed decides what leads: once the caret is past the family
709
+ // name, the weight is what the user is reaching for (`Geist Mono b` -> Bold),
710
+ // while a caret inside the name keeps the family list first (`inter t` ->
711
+ // Inter Tight, not Inter's Thin).
712
+ const atFamilyEnd = context.caretInCore >= context.familyEnd
713
+ const weightFirst =
714
+ family !== undefined && atFamilyEnd && (exact !== undefined || families.length === 0)
715
+
716
+ // Where the pick lands. A caret at the very start of a COMPLETE entry is a
717
+ // boundary, not an edit: the user put it before the family to place another
718
+ // one ahead of it — a fallback stays a fallback — which is how a family is
719
+ // promoted to the front without dragging anything. Anywhere else the entry
720
+ // under the caret is what is being written, so the pick replaces it. A weight
721
+ // never inserts a new entry: it completes the one it is next to, which
722
+ // {@link applySuggestion} enforces from the chosen row's kind.
723
+ const atBoundary = context.caretInCore === 0 && exact !== undefined
724
+
725
+ return {
726
+ start: context.start,
727
+ end: context.end,
728
+ at: atBoundary ? 'before' : 'entry',
729
+ items: [...(weightFirst ? weights : families), ...(weightFirst ? families : weights)].slice(
730
+ 0,
731
+ SUGGESTION_LIMIT,
732
+ ),
733
+ // The custom row inserts the entry AS TYPED — weight word included — while
734
+ // the ranking above used the family part alone.
735
+ custom:
736
+ needle !== '' && exact === undefined && !isGenericFamilyName(needle.toLowerCase())
737
+ ? inner
738
+ : undefined,
739
+ }
740
+ }
741
+
742
+ /** Clamp the highlighted index against a list that may have shrunk under it. */
743
+ function clampHighlight(items, active) {
744
+ return items.length === 0 ? -1 : Math.min(Math.max(active, 0), items.length - 1)
745
+ }
746
+
747
+ /**
748
+ * Place a chosen completion into the query.
749
+ *
750
+ * Two shapes, because the caret means two things. `at: 'entry'` replaces the
751
+ * entry under the caret — what completing a half-typed family needs, since a
752
+ * family name is several words. `at: 'before'` inserts a whole new entry ahead
753
+ * of the one under the caret, which is how a family is put in charge without
754
+ * touching the fallbacks behind it.
755
+ *
756
+ * A trailing `, ` is added after a family so the next fallback can be typed
757
+ * straight away — but not after a weight, which completes the entry instead of
758
+ * inviting another one. An empty entry at the end of the query is what the
759
+ * canonical form drops on commit, so the affordance never reaches the setting.
760
+ *
761
+ * @param text - the query.
762
+ * @param suggestion - `{ start, end, at }` from {@link querySuggestions}.
763
+ * @param insert - the text to place there.
764
+ * @param kind - the chosen item's kind, which decides the trailing comma.
765
+ * @returns `{ text, caret }`.
766
+ */
767
+ function applySuggestion(text, suggestion, insert, kind) {
768
+ const source = typeof text === 'string' ? text : ''
769
+ const start = Math.min(Math.max(suggestion.start, 0), source.length)
770
+ const end = Math.min(Math.max(suggestion.end, start), source.length)
771
+ const before = source.slice(0, start)
772
+ // Inserting ahead of the entry keeps the whole entry; replacing it keeps only
773
+ // what follows it. A weight is never an insertion, whatever the caret says.
774
+ const insertingBefore = suggestion.at === 'before' && kind !== 'weight'
775
+ const after = source.slice(insertingBefore ? start : end)
776
+
777
+ if (insertingBefore) {
778
+ // The space the entry carried belonged to the comma before it, so it is
779
+ // restored rather than doubled — and the entry itself is kept verbatim.
780
+ const rest = after.replace(/^\s+/, '')
781
+ const head = before !== '' && !/\s$/.test(before) ? `${before} ` : before
782
+ return { text: `${head}${insert}, ${rest}`, caret: head.length + insert.length + 2 }
783
+ }
784
+
785
+ const trailing = after === '' && kind !== 'weight' ? ', ' : ''
786
+ return {
787
+ text: `${before}${insert}${trailing}${after}`,
788
+ caret: before.length + insert.length + trailing.length,
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Move the entry a caret sits in one slot earlier or later.
794
+ *
795
+ * This is the editor's answer to drag-and-drop: order is still significant (the
796
+ * first installed family wins), but it is edited as text, so the keyboard never
797
+ * has to reach for a pointer. Only the entries swap; every space and comma
798
+ * stays exactly where it was.
799
+ *
800
+ * @param text - the query.
801
+ * @param caret - the caret offset, which selects the entry to move.
802
+ * @param delta - `-1` for earlier, `1` for later.
803
+ * @returns `{ text, caret }`; unchanged when the move leaves the list.
804
+ */
805
+ function moveFontQueryEntry(text, caret, delta) {
806
+ const source = typeof text === 'string' ? text : ''
807
+ const entries = splitQueryEntries(source)
808
+ if (entries.length < 2) return { text: source, caret }
809
+
810
+ let index = entries.length - 1
811
+ for (let position = 0; position < entries.length; position += 1) {
812
+ if (caret <= entries[position].end) {
813
+ index = position
814
+ break
815
+ }
816
+ }
817
+ const target = index + delta
818
+ if (target < 0 || target >= entries.length) return { text: source, caret }
819
+
820
+ const cores = entries.map((entry) => entry.core)
821
+ const moved = cores[index]
822
+ cores[index] = cores[target]
823
+ cores[target] = moved
824
+
825
+ const parts = entries.map((entry, position) => `${entry.lead}${cores[position]}${entry.trail}`)
826
+ const starts = []
827
+ let cursor = 0
828
+ for (const part of parts) {
829
+ starts.push(cursor)
830
+ cursor += part.length + 1 // the comma this part is joined with
831
+ }
832
+ const offset = Math.min(
833
+ Math.max(caret - (entries[index].start + entries[index].lead.length), 0),
834
+ cores[target].length,
835
+ )
836
+ return {
837
+ text: parts.join(','),
838
+ caret: starts[target] + entries[target].lead.length + offset,
839
+ }
840
+ }
841
+
842
+ /**
843
+ * The weight an interface step keeps when the whole interface is moved.
844
+ *
845
+ * The interface has a weight hierarchy — headings 700, table heads 500, body
846
+ * 400 — and setting the interface weight has to preserve the *contrast* between
847
+ * those steps rather than flatten it, or every heading would sink below the body
848
+ * once the base goes past it. So each step keeps its shipped distance from 400
849
+ * and is never allowed below the base.
850
+ *
851
+ * @param shipped - the step's shipped weight.
852
+ * @param weight - the interface weight.
853
+ * @returns the weight that step takes.
854
+ */
855
+ function emphasisWeight(shipped, weight) {
856
+ if (weight === DEFAULT_UI_FONT_WEIGHT) return shipped
857
+ return Math.min(900, Math.max(shipped, shipped + weight - DEFAULT_UI_FONT_WEIGHT))
858
+ }
859
+
860
+ /**
861
+ * Whether a diagnostic is a syntax error or a note about the machine.
862
+ *
863
+ * Text the reader cannot make sense of is marked as an error — the red line the
864
+ * user is owed when their input is wrong — while a family or a weight that is
865
+ * merely not installed here stays a warning: it is still a valid query, and the
866
+ * browser may well resolve it.
867
+ * @param code - a `QUERY_DIAGNOSTIC` code.
868
+ * @returns `'error'` or `'warn'`.
869
+ */
870
+ function diagnosticKind(code) {
871
+ return code === QUERY_DIAGNOSTIC.unclosedQuote || code === QUERY_DIAGNOSTIC.trailingText
872
+ ? 'error'
873
+ : 'warn'
874
+ }
875
+
876
+ /** Substitute `{name}`-style placeholders in one locale template. */
877
+ function fillTemplate(template, values) {
878
+ return String(template).replace(/\{(\w+)\}/g, (match, key) =>
879
+ Object.hasOwn(values, key) ? String(values[key]) : match,
880
+ )
881
+ }
882
+
883
+ /**
884
+ * Localize one query diagnostic.
885
+ * @param diagnostic - a record from {@link parseFontQuery}'s `diagnostics`.
886
+ * @param labels - the row's copy: `unknownFamily`, `missingWeight`,
887
+ * `duplicateWeight`, `unclosedQuote`.
888
+ * @returns the message to show, or `''` for a code this build does not know.
889
+ */
890
+ function describeDiagnostic(diagnostic, labels) {
891
+ switch (diagnostic.code) {
892
+ case QUERY_DIAGNOSTIC.unknownFamily:
893
+ return fillTemplate(labels.unknownFamily, { name: diagnostic.name })
894
+ case QUERY_DIAGNOSTIC.missingWeight:
895
+ return fillTemplate(labels.missingWeight, {
896
+ family: diagnostic.name,
897
+ weight: diagnostic.weight,
898
+ word: diagnostic.word,
899
+ })
900
+ case QUERY_DIAGNOSTIC.duplicateWeight:
901
+ return fillTemplate(labels.duplicateWeight, { word: diagnostic.word })
902
+ case QUERY_DIAGNOSTIC.unclosedQuote:
903
+ return labels.unclosedQuote
904
+ case QUERY_DIAGNOSTIC.trailingText:
905
+ return fillTemplate(labels.trailingText, { text: diagnostic.text })
906
+ default:
907
+ return ''
908
+ }
909
+ }
910
+
911
+ /**
912
+ * The number a stored weight token stands for.
913
+ * @param weight - a stored value or a weight word.
914
+ * @returns the numeric weight, falling back to the shipped one.
915
+ */
916
+ function normalizeWeight(weight) {
917
+ if (typeof weight === 'number' && FONT_WEIGHTS.includes(weight)) return weight
918
+ if (typeof weight === 'string' && Object.hasOwn(WEIGHT_WORDS, weight.toLowerCase())) {
919
+ return WEIGHT_WORDS[weight.toLowerCase()]
920
+ }
921
+ return DEFAULT_CODE_FONT_WEIGHT
922
+ }
923
+
924
+ // ─── the font-family list: parse, serialize, discover ───────────────────────
925
+ //
926
+ // The stored value stays a plain CSS font-family string (that is what the host
927
+ // schema declares and what the theme token consumes), so this section is purely
928
+ // a presentation layer over it: parse the string into an ordered list for the
929
+ // chips, and serialize the chips back. The weight is the one thing the string
930
+ // cannot express, so it is a second field rather than anything added here.
931
+
932
+ /** Generic CSS families. Valid anywhere in a list, but only useful at the end. */
933
+ const GENERIC_FAMILIES = [
934
+ 'system-ui',
935
+ 'sans-serif',
936
+ 'serif',
937
+ 'monospace',
938
+ 'cursive',
939
+ 'fantasy',
940
+ 'math',
941
+ 'emoji',
942
+ 'fangsong',
943
+ 'ui-sans-serif',
944
+ 'ui-serif',
945
+ 'ui-monospace',
946
+ 'ui-rounded',
97
947
  ]
98
948
 
99
- /** Curated code-family presets. */
100
- const CODE_FAMILY_PRESETS = [
101
- { id: 'system', label: 'System mono', value: DEFAULT_CODE_FONT_FAMILY },
102
- {
103
- id: 'jetbrains',
104
- label: 'JetBrains Mono',
105
- value: '"JetBrains Mono", "Fira Code", Consolas, monospace',
106
- },
107
- { id: 'fira', label: 'Fira Code', value: '"Fira Code", Consolas, monospace' },
108
- {
109
- id: 'cascadia',
110
- label: 'Cascadia Code',
111
- value: '"Cascadia Code", "Cascadia Mono", Consolas, monospace',
112
- },
113
- {
114
- id: 'maple',
115
- label: 'Maple Mono',
116
- value: '"Maple Mono", "JetBrains Mono", Consolas, monospace',
117
- },
118
- { id: 'mono', label: 'Generic mono', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
949
+ /**
950
+ * Popular families offered as suggestions even when not detected. Detection
951
+ * cannot see every font, and a family may be installed later, so the picker
952
+ * must not present itself as the complete truth.
953
+ */
954
+ const COMMON_FAMILIES = [
955
+ 'Inter',
956
+ 'IBM Plex Sans',
957
+ 'IBM Plex Mono',
958
+ 'Noto Sans',
959
+ 'Noto Sans SC',
960
+ 'Noto Serif',
961
+ 'Source Han Sans SC',
962
+ 'Source Han Serif SC',
963
+ 'JetBrains Mono',
964
+ 'Fira Code',
965
+ 'Fira Sans',
966
+ 'Cascadia Code',
967
+ 'Cascadia Mono',
968
+ 'Maple Mono',
969
+ 'Roboto',
970
+ 'Roboto Mono',
971
+ 'Open Sans',
972
+ 'Lato',
973
+ 'Montserrat',
974
+ 'Poppins',
975
+ 'Ubuntu',
976
+ 'Ubuntu Mono',
977
+ 'DejaVu Sans',
978
+ 'DejaVu Sans Mono',
979
+ 'Hack',
980
+ 'Inconsolata',
981
+ 'Iosevka',
982
+ 'Comic Sans MS',
983
+ 'PingFang SC',
984
+ 'Hiragino Sans GB',
985
+ 'Microsoft YaHei',
986
+ 'Microsoft YaHei UI',
987
+ 'Microsoft JhengHei',
988
+ 'SimSun',
989
+ 'SimHei',
990
+ 'KaiTi',
991
+ 'Segoe UI',
992
+ 'Segoe UI Variable',
993
+ 'Helvetica Neue',
994
+ 'Arial',
995
+ 'Consolas',
996
+ 'Menlo',
997
+ 'Monaco',
998
+ 'SF Mono',
999
+ 'Courier New',
1000
+ 'Times New Roman',
1001
+ 'Georgia',
119
1002
  ]
120
1003
 
1004
+ /**
1005
+ * Families worth probing for when the Local Font Access API is unavailable.
1006
+ * Each entry costs two text measurements, so this stays curated rather than
1007
+ * exhaustive.
1008
+ */
1009
+ const PROBE_FAMILIES = [
1010
+ ...new Set([...COMMON_FAMILIES, ...GENERIC_FAMILIES]),
1011
+ '-apple-system',
1012
+ 'BlinkMacSystemFont',
1013
+ 'Meiryo',
1014
+ 'Yu Gothic',
1015
+ 'Malgun Gothic',
1016
+ 'Segoe UI Emoji',
1017
+ 'Noto Color Emoji',
1018
+ 'Apple Color Emoji',
1019
+ 'Cambria',
1020
+ 'Calibri',
1021
+ 'Candara',
1022
+ 'Corbel',
1023
+ 'Franklin Gothic Medium',
1024
+ 'Trebuchet MS',
1025
+ 'Verdana',
1026
+ 'Tahoma',
1027
+ 'Lucida Console',
1028
+ 'Lucida Sans Unicode',
1029
+ 'Palatino Linotype',
1030
+ 'Book Antiqua',
1031
+ 'Garamond',
1032
+ 'FangSong',
1033
+ 'Microsoft Himalaya',
1034
+ 'Sarasa Mono SC',
1035
+ 'LXGW WenKai',
1036
+ 'HarmonyOS Sans SC',
1037
+ 'MiSans',
1038
+ 'Source Code Pro',
1039
+ 'Roboto Condensed',
1040
+ 'Roboto Slab',
1041
+ 'PT Sans',
1042
+ 'PT Mono',
1043
+ 'Nunito',
1044
+ 'Rubik',
1045
+ 'Work Sans',
1046
+ 'Space Mono',
1047
+ 'Victor Mono',
1048
+ 'Cousine',
1049
+ 'Anonymous Pro',
1050
+ 'Liberation Mono',
1051
+ 'Liberation Sans',
1052
+ 'Nimbus Mono PS',
1053
+ 'Droid Sans Mono',
1054
+ ]
1055
+
1056
+ /** The family used as the "not installed" baseline when probing. */
1057
+ const PROBE_BASELINE = 'monospace'
1058
+ /** Text that renders differently across families in both width and height. */
1059
+ const PROBE_TEXT = 'mmmmmmmmmmlliWWQ@#中永'
1060
+ /** Probe font size, in px. */
1061
+ const PROBE_SIZE = 72
1062
+ /** One finished discovery result. */
1063
+ const FONT_DISCOVERY_CACHE_KEY = 'dsh-font:discovery:v2'
1064
+
1065
+ /**
1066
+ * Split a CSS font-family string into its individual family names, unwrapping
1067
+ * quotes. Commas inside quotes are preserved, which a naive `split(',')` gets
1068
+ * wrong for the `"Foo, Bar"` form.
1069
+ * @param value - a CSS font-family value.
1070
+ * @returns family names in order; `[]` for a blank value.
1071
+ */
1072
+ function parseFamilyList(value) {
1073
+ if (typeof value !== 'string') return []
1074
+ const families = []
1075
+ let current = ''
1076
+ let quote = ''
1077
+ for (const char of value) {
1078
+ if (quote !== '') {
1079
+ if (char === quote) quote = ''
1080
+ else current += char
1081
+ continue
1082
+ }
1083
+ if (char === '"' || char === "'") {
1084
+ quote = char
1085
+ continue
1086
+ }
1087
+ if (char === ',') {
1088
+ families.push(current)
1089
+ current = ''
1090
+ continue
1091
+ }
1092
+ current += char
1093
+ }
1094
+ families.push(current)
1095
+ return families.map((family) => family.trim()).filter((family) => family !== '')
1096
+ }
1097
+
1098
+ /**
1099
+ * Render one family name for a CSS list, quoting it when CSS requires it.
1100
+ * A family is a sequence of identifiers, so anything with a space or a leading
1101
+ * digit must be quoted; names are emitted double-quoted because that is the
1102
+ * form the shipped defaults already use.
1103
+ * @param family - a bare family name.
1104
+ * @returns the CSS token for it.
1105
+ */
1106
+ function quoteFamily(family) {
1107
+ const name = family.trim()
1108
+ if (name === '') return ''
1109
+ // A CSS-wide keyword or a generic family must not be quoted: `"sans-serif"`
1110
+ // would name a literal font instead of the generic family. A single leading
1111
+ // hyphen is part of an identifier (`-apple-system`), so it survives unquoted
1112
+ // too — which is what lets the shipped interface stack round-trip unchanged.
1113
+ if (/^-?[A-Za-z][\w-]*$/.test(name)) return name
1114
+ return `"${name.replaceAll('"', '')}"`
1115
+ }
1116
+
1117
+ /**
1118
+ * Join family names back into a CSS font-family string.
1119
+ * @param families - family names in priority order.
1120
+ * @returns the CSS value.
1121
+ */
1122
+ function serializeFamilyList(families) {
1123
+ return families.map(quoteFamily).filter((token) => token !== '').join(', ')
1124
+ }
1125
+
1126
+ /**
1127
+ * Measure whether one family is actually installed.
1128
+ *
1129
+ * The technique is the classic width/height comparison: render the probe text
1130
+ * in `<family>, <baseline>` and again in the bare baseline. When the family is
1131
+ * absent both renders lay out identically, because the browser fell through to
1132
+ * the same baseline font. This needs no permission and works in every browser,
1133
+ * which is why it is the fallback rather than the primary.
1134
+ * @param family - family name to test.
1135
+ * @returns whether it renders differently from the baseline.
1136
+ */
1137
+ function isFamilyAvailable(family) {
1138
+ if (typeof document === 'undefined') return false
1139
+ if (family === PROBE_BASELINE) return true
1140
+ const probe = document.createElement('span')
1141
+ probe.textContent = PROBE_TEXT
1142
+ probe.setAttribute('aria-hidden', 'true')
1143
+ probe.style.cssText = [
1144
+ 'position:absolute',
1145
+ 'left:-9999px',
1146
+ 'top:-9999px',
1147
+ 'visibility:hidden',
1148
+ 'white-space:nowrap',
1149
+ `font-size:${String(PROBE_SIZE)}px`,
1150
+ 'line-height:normal',
1151
+ ].join(';')
1152
+ const parent = document.body ?? document.documentElement
1153
+ if (parent === null || parent === undefined) return false
1154
+ parent.appendChild(probe)
1155
+ try {
1156
+ probe.style.fontFamily = PROBE_BASELINE
1157
+ const baseWidth = probe.offsetWidth
1158
+ const baseHeight = probe.offsetHeight
1159
+ probe.style.fontFamily = `${quoteFamily(family)}, ${PROBE_BASELINE}`
1160
+ return probe.offsetWidth !== baseWidth || probe.offsetHeight !== baseHeight
1161
+ } finally {
1162
+ probe.remove()
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Ask the browser for the real installed families and their face styles.
1168
+ *
1169
+ * Chromium's Local Font Access API is the only way to enumerate actual fonts.
1170
+ * It is permission-gated, absent in Firefox and Safari, and — per the spec —
1171
+ * browsers are not obliged to return the complete list, so the result is a
1172
+ * supplement to the curated catalogue, never a replacement.
1173
+ *
1174
+ * The per-face `style` names (`Regular`, `Medium`, `Bold Italic`) are collected
1175
+ * alongside the families because they are what lets the editor offer *this*
1176
+ * family's weights and catch a weight the family does not have, instead of
1177
+ * offering the whole CSS vocabulary blindly.
1178
+ * @returns `{ families, styles }`, or undefined when unavailable or declined.
1179
+ */
1180
+ async function queryInstalledFamilies() {
1181
+ if (typeof window === 'undefined') return undefined
1182
+ const query = window.queryLocalFonts
1183
+ if (typeof query !== 'function') return undefined
1184
+ try {
1185
+ const fonts = await query.call(window)
1186
+ const families = new Set()
1187
+ const styles = {}
1188
+ for (const font of fonts) {
1189
+ if (typeof font?.family !== 'string' || font.family.trim() === '') continue
1190
+ const family = font.family.trim()
1191
+ families.add(family)
1192
+ const style = typeof font.style === 'string' ? font.style.trim() : ''
1193
+ if (style === '') continue
1194
+ const list = styles[family] ?? (styles[family] = [])
1195
+ if (!list.includes(style)) list.push(style)
1196
+ }
1197
+ return {
1198
+ families: [...families].sort((left, right) => left.localeCompare(right)),
1199
+ styles,
1200
+ }
1201
+ } catch {
1202
+ // A denied or dismissed permission prompt lands here. Fall back quietly:
1203
+ // the picker still works, just with a smaller catalogue.
1204
+ return undefined
1205
+ }
1206
+ }
1207
+
1208
+ /** Read the cached discovery result, if it is still valid. */
1209
+ function readDiscoveryCache() {
1210
+ try {
1211
+ const raw = sessionStorage.getItem(FONT_DISCOVERY_CACHE_KEY)
1212
+ if (raw === null) return undefined
1213
+ const parsed = JSON.parse(raw)
1214
+ if (parsed === null || typeof parsed !== 'object') return undefined
1215
+ if (!Array.isArray(parsed.families)) return undefined
1216
+ // A cache written before the face styles existed has the families but not
1217
+ // the weights, so it is treated as a miss rather than half-adopted.
1218
+ const styles = parsed.styles
1219
+ if (styles === null || typeof styles !== 'object' || Array.isArray(styles)) return undefined
1220
+ return { families: parsed.families, styles, enumerated: parsed.enumerated === true }
1221
+ } catch {
1222
+ return undefined
1223
+ }
1224
+ }
1225
+
1226
+ /** Remember a discovery result for the rest of the session. */
1227
+ function writeDiscoveryCache(result) {
1228
+ try {
1229
+ sessionStorage.setItem(FONT_DISCOVERY_CACHE_KEY, JSON.stringify(result))
1230
+ } catch {
1231
+ /* private mode or a full quota; the in-memory copy still serves this render */
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Discover selectable families, cheapest source first: the session cache, then
1237
+ * the Local Font Access API when the browser offers it, then the measurement
1238
+ * probe. Measured families are verified as present; enumerated and curated ones
1239
+ * are offered without that guarantee, and the UI says so.
1240
+ * @returns `{ families, styles, enumerated, measured }`.
1241
+ */
1242
+ async function discoverFamilies() {
1243
+ const cached = readDiscoveryCache()
1244
+ if (cached !== undefined) {
1245
+ const result = { families: cached.families, styles: cached.styles, enumerated: cached.enumerated }
1246
+ writeDiscoveryCache(result)
1247
+ return { ...result, measured: true }
1248
+ }
1249
+
1250
+ const enumerated = await queryInstalledFamilies()
1251
+ if (enumerated !== undefined && enumerated.families.length > 0) {
1252
+ const families = [...new Set([...enumerated.families, ...COMMON_FAMILIES])].sort((left, right) =>
1253
+ left.localeCompare(right),
1254
+ )
1255
+ const result = { families, styles: enumerated.styles, enumerated: true }
1256
+ writeDiscoveryCache(result)
1257
+ return { ...result, measured: true }
1258
+ }
1259
+
1260
+ const installed = PROBE_FAMILIES.filter((family) => {
1261
+ try {
1262
+ return isFamilyAvailable(family)
1263
+ } catch {
1264
+ return false
1265
+ }
1266
+ })
1267
+ const families = [...new Set([...installed, ...COMMON_FAMILIES])].sort((left, right) =>
1268
+ left.localeCompare(right),
1269
+ )
1270
+ const result = { families, styles: {}, enumerated: false }
1271
+ writeDiscoveryCache(result)
1272
+ return { ...result, measured: true }
1273
+ }
1274
+
1275
+ /**
1276
+ * Rank the catalogue against what the user has typed: exact match, then prefix,
1277
+ * then substring, then everything else. An empty query returns the catalogue
1278
+ * as-is so the dropdown doubles as a browse list.
1279
+ * @param families - the catalogue.
1280
+ * @param query - the current input text.
1281
+ * @returns the ordered candidates.
1282
+ */
1283
+ function rankFamilyMatches(families, query) {
1284
+ const needle = query.trim().toLowerCase()
1285
+ if (needle === '') return families
1286
+ const exact = []
1287
+ const prefix = []
1288
+ const contains = []
1289
+ for (const family of families) {
1290
+ const lower = family.toLowerCase()
1291
+ if (lower === needle) exact.push(family)
1292
+ else if (lower.startsWith(needle)) prefix.push(family)
1293
+ else if (lower.includes(needle)) contains.push(family)
1294
+ }
1295
+ return [...exact, ...prefix, ...contains]
1296
+ }
1297
+
121
1298
  /** Row copy, keyed by locale. `zh` is the key-set source of truth. */
122
1299
  const zh = {
123
1300
  'font.title': '字体',
124
1301
  'font.description': '自定义界面与代码字体、字号,设置会保存到本机',
125
1302
  'font.uiFamily': '界面字体',
126
- 'font.uiFamilyHint': 'CSS font-family 列表,例如 Inter, "PingFang SC", sans-serif',
1303
+ 'font.uiFamilyHint':
1304
+ '字体查询:字体族,后面可以跟字重,例如 Inter, "PingFang SC", sans-serif;逗号分隔,靠前的优先',
127
1305
  'font.codeFamily': '代码字体',
128
- 'font.codeFamilyHint': '用于代码块、行内代码与终端等宽文本',
1306
+ 'font.codeFamilyHint':
1307
+ '用于代码块、行内代码与终端等宽文本,例如 Geist Mono medium, monospace',
1308
+ 'font.uiWeight': '界面字重',
1309
+ 'font.codeWeight': '代码字重',
1310
+ 'font.weightShipped': '(出厂值,未覆盖)',
1311
+ 'font.suggestions': '字体建议',
1312
+ 'font.familyKind': '字体族',
1313
+ 'font.genericKind': '通用族',
1314
+ 'font.pending': '按 Enter 应用(文本不会被改写)',
1315
+ 'font.emptyQuery': '这个查询还是空的:没有写入任何字体,当前仍在用已保存的设置;要回到出厂值请按「恢复默认」',
1316
+ 'font.diag.unknownFamily': '本机字体列表里没有 {name},仍会写入,浏览器可能回退到后面的字体',
1317
+ 'font.diag.missingWeight': '{family} 在本机没有 {weight} 这个字面({word}),浏览器会合成',
1318
+ 'font.diag.duplicateWeight': '字重只能写一次,已采用第一个;多余的 {word} 被忽略',
1319
+ 'font.diag.unclosedQuote': '引号没有闭合,之后的内容都会被当作同一个字体名',
1320
+ 'font.diag.trailingText': '引号后面多了“{text}”,它既不是字重也不属于这个字体名,已被忽略',
1321
+ 'font.weight.thin': '极细',
1322
+ 'font.weight.extralight': '特细',
1323
+ 'font.weight.light': '细体',
1324
+ 'font.weight.regular': '常规',
1325
+ 'font.weight.medium': '中等',
1326
+ 'font.weight.semibold': '半粗',
1327
+ 'font.weight.bold': '粗体',
1328
+ 'font.weight.extrabold': '特粗',
1329
+ 'font.weight.black': '黑体',
129
1330
  'font.uiScale': '界面字号',
130
1331
  'font.uiScaleHint': '按比例缩放整个界面的文字大小',
131
1332
  'font.contentSize': '会话正文字号',
@@ -134,9 +1335,12 @@ const zh = {
134
1335
  'font.codeSizeHint': '代码块与行内代码的字号',
135
1336
  'font.unit': 'px',
136
1337
  'font.reset': '恢复默认',
137
- 'font.presets': '预设',
138
1338
  'font.increase': '增大',
139
1339
  'font.decrease': '减小',
1340
+ 'font.add': '插入',
1341
+ 'font.catalogProbed':
1342
+ '字体列表只包含探测到的常用字体(读取本机字体的权限不可用或被拒绝);任何字体名仍然可以直接写',
1343
+ 'font.genericWarning': '末尾缺少通用字体族(如 sans-serif),指定字体都缺失时可能回退到意外字体',
140
1344
  }
141
1345
 
142
1346
  /** English dictionary, checked complete against the `zh` key set. */
@@ -145,9 +1349,38 @@ const en = {
145
1349
  'font.description':
146
1350
  'Customize the interface and code fonts and sizes; values are saved on this machine',
147
1351
  'font.uiFamily': 'Interface font',
148
- 'font.uiFamilyHint': 'A CSS font-family list, e.g. Inter, "PingFang SC", sans-serif',
1352
+ 'font.uiFamilyHint':
1353
+ 'A font query: a family, optionally followed by a weight — e.g. Inter, "PingFang SC", sans-serif. Comma-separated, earlier wins',
149
1354
  'font.codeFamily': 'Code font',
150
- 'font.codeFamilyHint': 'Used for code blocks, inline code, and monospace text',
1355
+ 'font.codeFamilyHint':
1356
+ 'Used for code blocks, inline code, and monospace text, e.g. Geist Mono medium, monospace',
1357
+ 'font.uiWeight': 'Interface weight',
1358
+ 'font.codeWeight': 'Code weight',
1359
+ 'font.weightShipped': ' (shipped, not overridden)',
1360
+ 'font.suggestions': 'Font suggestions',
1361
+ 'font.familyKind': 'family',
1362
+ 'font.genericKind': 'generic',
1363
+ 'font.pending': 'Press Enter to apply (your text is not rewritten)',
1364
+ 'font.emptyQuery':
1365
+ 'This query is empty: nothing is written, the saved setting is still in use — press Reset to defaults to go back to the shipped stack',
1366
+ 'font.diag.unknownFamily':
1367
+ 'No {name} in this machine\u2019s font list; it is still stored, but the browser may fall through to the next family',
1368
+ 'font.diag.missingWeight':
1369
+ '{family} has no {weight} face on this machine ({word}), so the browser will synthesize it',
1370
+ 'font.diag.duplicateWeight': 'A query carries one weight; the first wins and {word} is ignored',
1371
+ 'font.diag.unclosedQuote':
1372
+ 'The quote is never closed, so everything after it is read as the same family name',
1373
+ 'font.diag.trailingText':
1374
+ '“{text}” follows the closing quote; it is neither a weight nor part of that name, so it is ignored',
1375
+ 'font.weight.thin': 'Thin',
1376
+ 'font.weight.extralight': 'ExtraLight',
1377
+ 'font.weight.light': 'Light',
1378
+ 'font.weight.regular': 'Regular',
1379
+ 'font.weight.medium': 'Medium',
1380
+ 'font.weight.semibold': 'SemiBold',
1381
+ 'font.weight.bold': 'Bold',
1382
+ 'font.weight.extrabold': 'ExtraBold',
1383
+ 'font.weight.black': 'Black',
151
1384
  'font.uiScale': 'Interface text size',
152
1385
  'font.uiScaleHint': 'Scales every interface text size proportionally',
153
1386
  'font.contentSize': 'Conversation text size',
@@ -156,9 +1389,13 @@ const en = {
156
1389
  'font.codeSizeHint': 'Size of code blocks and inline code',
157
1390
  'font.unit': 'px',
158
1391
  'font.reset': 'Reset to defaults',
159
- 'font.presets': 'Presets',
160
1392
  'font.increase': 'Increase',
161
1393
  'font.decrease': 'Decrease',
1394
+ 'font.add': 'Insert',
1395
+ 'font.catalogProbed':
1396
+ 'The font list holds only common fonts found by probing (reading this machine\u2019s fonts is unavailable or was declined); any family can still be typed',
1397
+ 'font.genericWarning':
1398
+ 'No generic family at the end (such as sans-serif), so a missing font may fall back unpredictably',
162
1399
  }
163
1400
 
164
1401
  /** The stylesheet this plugin owns, keyed by the resolved settings section. */
@@ -166,11 +1403,31 @@ function fontStyleSheet(section) {
166
1403
  const scale = section[UI_FONT_SCALE_FIELD]
167
1404
  const contentSize = section[CONTENT_FONT_SIZE_FIELD]
168
1405
  const codeSize = section[CODE_FONT_SIZE_FIELD]
1406
+ const codeWeight = normalizeWeight(section[CODE_FONT_WEIGHT_FIELD])
1407
+ const uiWeight = normalizeWeight(section[UI_FONT_WEIGHT_FIELD])
169
1408
 
170
1409
  const scaleRules = UI_TEXT_STEPS.map(
171
1410
  (step) => `.dsh-font-size-${String(step)}{font-size:calc(${String(step)}px * var(--dsh-font-ui-scale,1)) !important}`,
172
1411
  ).join('')
173
1412
 
1413
+ // The interface weight is opt-in. At the shipped 400 nothing below changes,
1414
+ // which keeps a default install's sheet byte-identical to the shipped one; at
1415
+ // anything else the base of every `font:` shorthand has to be written out,
1416
+ // because a shorthand with no weight component resets `font-weight` to
1417
+ // `normal` and would silently defeat an inherited value.
1418
+ const shippedUiWeight = uiWeight === DEFAULT_UI_FONT_WEIGHT
1419
+ const baseWeight = shippedUiWeight ? '' : `${String(uiWeight)} `
1420
+ const emphasis = (shipped) => (shippedUiWeight ? shipped : emphasisWeight(shipped, uiWeight))
1421
+ const uiWeightRules = shippedUiWeight
1422
+ ? []
1423
+ : [
1424
+ '/* The interface weight: the base of interface text. Only the text that',
1425
+ ' inherits its weight moves — a label or a button whose weight the design',
1426
+ ' system fixes keeps it — and the heading steps keep their shipped',
1427
+ ' distance from the base so the hierarchy survives the change. */',
1428
+ `html body{font-weight:${String(uiWeight)}}`,
1429
+ ]
1430
+
174
1431
  return [
175
1432
  '/* dsh-font: interface scale + conversation text sizes.',
176
1433
  ' The scale is emitted as one utility class per hard-coded UI text size and',
@@ -179,34 +1436,49 @@ function fontStyleSheet(section) {
179
1436
  'html body{',
180
1437
  `--dsh-font-ui-scale:${String(scale)};`,
181
1438
  `--dsh-font-code-size:${String(codeSize)}px;`,
1439
+ `--dsh-font-code-weight:${String(codeWeight)};`,
182
1440
  // A CSS-side mirror of the content size. The authoritative declaration is
183
1441
  // inline on `body` (written by applyFonts), because ui-layout's theme
184
1442
  // presenter owns that one and an inline value outranks any stylesheet.
185
1443
  `--dsh-font-conversation-size:${String(contentSize)}px;`,
186
1444
  '}',
1445
+ ...uiWeightRules,
187
1446
  scaleRules,
188
1447
  '/* Conversation text sizes: absolute px, replacing the shipped 12..17px',
189
1448
  ' ladder that ui-layout drives from the `ui-theme` namespace. This is the',
190
1449
  ' size axis for conversation content; the scale above is the axis for the',
191
1450
  ' surrounding interface, and the two never compose. */',
192
1451
  'html body{',
193
- `--dsh-font-markdown-base:var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
194
- `--dsh-font-markdown-h1:700 calc(${String(contentSize)}px + 7px) / calc(${String(contentSize)}px + 16px) var(--dsw-font-family) !important;`,
195
- `--dsh-font-markdown-h2:700 calc(${String(contentSize)}px + 5px) / calc(${String(contentSize)}px + 14px) var(--dsw-font-family) !important;`,
196
- `--dsh-font-markdown-h3:700 calc(${String(contentSize)}px + 4px) / calc(${String(contentSize)}px + 12px) var(--dsw-font-family) !important;`,
197
- `--dsh-font-markdown-h4:600 var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
198
- `--dsh-font-markdown-table:calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
199
- `--dsh-font-markdown-table-head:500 calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
1452
+ `--dsh-font-markdown-base:${baseWeight}var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
1453
+ `--dsh-font-markdown-h1:${String(emphasis(700))} calc(${String(contentSize)}px + 7px) / calc(${String(contentSize)}px + 16px) var(--dsw-font-family) !important;`,
1454
+ `--dsh-font-markdown-h2:${String(emphasis(700))} calc(${String(contentSize)}px + 5px) / calc(${String(contentSize)}px + 14px) var(--dsw-font-family) !important;`,
1455
+ `--dsh-font-markdown-h3:${String(emphasis(700))} calc(${String(contentSize)}px + 4px) / calc(${String(contentSize)}px + 12px) var(--dsw-font-family) !important;`,
1456
+ `--dsh-font-markdown-h4:${String(emphasis(600))} var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
1457
+ `--dsh-font-markdown-table:${baseWeight}calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
1458
+ `--dsh-font-markdown-table-head:${String(emphasis(500))} calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
200
1459
  '}',
201
1460
  '/* Code text sizes, on their own axis. */',
202
1461
  'html body{',
203
- `--dsw-font-markdown-code:var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 7px) var(--ds-font-family-code) !important;`,
1462
+ `--dsw-font-markdown-code:var(--dsh-font-code-weight,400) var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 7px) var(--ds-font-family-code) !important;`,
204
1463
  `--dsw-font-markdown-code-font-size:var(--dsh-font-code-size,12px) !important;`,
205
- `--dsw-font-markdown-code-block:var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 8px) var(--ds-font-family-code) !important;`,
1464
+ `--dsw-font-markdown-code-block:var(--dsh-font-code-weight,400) var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 8px) var(--ds-font-family-code) !important;`,
206
1465
  `--dsw-font-markdown-code-block-font-size:var(--dsh-font-code-size,12px) !important;`,
207
- `--dsw-font-markdown-code-block-small:calc(var(--dsh-font-code-size,12px) - 1px) / calc(var(--dsh-font-code-size,12px) + 5px) var(--ds-font-family-code) !important;`,
1466
+ `--dsw-font-markdown-code-block-small:var(--dsh-font-code-weight,400) calc(var(--dsh-font-code-size,12px) - 1px) / calc(var(--dsh-font-code-size,12px) + 5px) var(--ds-font-family-code) !important;`,
208
1467
  `--dsw-font-markdown-code-block-small-font-size:calc(var(--dsh-font-code-size,12px) - 1px) !important;`,
209
1468
  '}',
1469
+ '/* The code weight, which the design system has no token for. The three',
1470
+ ' `font:` shorthands above carry it for the surfaces that read a token,',
1471
+ ' but most code in the interface is styled directly with',
1472
+ ' `font-family: var(--ds-font-family-code)` in a component stylesheet with',
1473
+ ' its own literal weight — the tool I/O cards, the terminal output, the',
1474
+ ' diff and source previews. Those are matched structurally instead, and',
1475
+ ' the `!important` is what outranks `font: 500 12px/18px …`.',
1476
+ ' Note that this is deliberately NOT a universal rule: it reaches code the',
1477
+ ' interface owns, never the surrounding labels. */',
1478
+ 'html body pre,html body code,html body [class*="code" i]{',
1479
+ `font-family:var(--ds-font-family-code) !important;`,
1480
+ `font-weight:var(--dsh-font-code-weight,${String(DEFAULT_CODE_FONT_WEIGHT)}) !important;`,
1481
+ '}',
210
1482
  ].join('\n')
211
1483
  }
212
1484
 
@@ -379,6 +1651,8 @@ function createFontRowStore() {
379
1651
  init: () => ({
380
1652
  uiFontFamily: DEFAULT_UI_FONT_FAMILY,
381
1653
  codeFontFamily: DEFAULT_CODE_FONT_FAMILY,
1654
+ codeFontWeight: DEFAULT_CODE_FONT_WEIGHT,
1655
+ uiFontWeight: DEFAULT_UI_FONT_WEIGHT,
382
1656
  uiFontScale: 1,
383
1657
  contentFontSize: 14,
384
1658
  codeFontSize: 12,
@@ -389,6 +1663,8 @@ function createFontRowStore() {
389
1663
  if (revision !== undefined && revision <= draft.revision) return
390
1664
  draft.uiFontFamily = section[UI_FONT_FAMILY_FIELD]
391
1665
  draft.codeFontFamily = section[CODE_FONT_FAMILY_FIELD]
1666
+ draft.codeFontWeight = normalizeWeight(section[CODE_FONT_WEIGHT_FIELD])
1667
+ draft.uiFontWeight = normalizeWeight(section[UI_FONT_WEIGHT_FIELD])
392
1668
  draft.uiFontScale = section[UI_FONT_SCALE_FIELD]
393
1669
  draft.contentFontSize = section[CONTENT_FONT_SIZE_FIELD]
394
1670
  draft.codeFontSize = section[CODE_FONT_SIZE_FIELD]
@@ -409,9 +1685,7 @@ const ROW_CSS = [
409
1685
  '.dsh-font-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}',
410
1686
  '.dsh-font-value{color:var(--dsw-alias-label-secondary);font-size:12px;font-variant-numeric:tabular-nums;line-height:18px}',
411
1687
  '.dsh-font-hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
412
- '.dsh-font-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform);border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;font-family:inherit;font-size:13px;line-height:20px;outline:none}',
413
- '.dsh-font-input:focus{border-color:var(--dsw-alias-brand-primary)}',
414
- '.dsh-font-code{font-family:var(--ds-font-family-code)}',
1688
+ '.dsh-font-code{font-family:var(--ds-font-family-code);font-weight:var(--dsh-font-code-weight,400)}',
415
1689
  '.dsh-font-sliderRow{align-items:center;gap:10px;display:flex}',
416
1690
  '.dsh-font-slider{flex:1;min-width:0;accent-color:var(--dsw-alias-brand-primary)}',
417
1691
  '.dsh-font-stepper{background:var(--dsw-alias-bg-module-platform);border-radius:16px;justify-content:center;align-items:center;gap:2px;min-width:96px;height:32px;display:inline-flex;flex:none;padding:0 4px}',
@@ -419,12 +1693,55 @@ const ROW_CSS = [
419
1693
  '.dsh-font-step:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
420
1694
  '.dsh-font-step:disabled{color:var(--dsw-alias-label-caption);cursor:default}',
421
1695
  '.dsh-font-stepValue{text-align:center;min-width:32px;color:var(--dsw-alias-label-primary);font-size:13px;font-variant-numeric:tabular-nums;line-height:20px}',
422
- '.dsh-font-presets{flex-wrap:wrap;gap:6px;display:flex}',
423
- '.dsh-font-chip{border:.5px solid var(--dsw-alias-border-l4);background:0 0;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:12px;padding:3px 10px;font-family:inherit;font-size:11px;line-height:16px}',
424
- '.dsh-font-chip:hover{background:var(--dsw-alias-interactive-bg-hover)}',
425
- '.dsh-font-chip[data-active="true"]{border-color:var(--dsw-static-neutral-bluish-400);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform)}',
426
1696
  '.dsh-font-reset{align-self:flex-start;border:.5px solid var(--dsw-alias-border-l4);background:0 0;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:10px;padding:5px 12px;font-family:inherit;font-size:12px;line-height:18px}',
427
1697
  '.dsh-font-reset:hover{background:var(--dsw-alias-interactive-bg-hover)}',
1698
+ // ── the query editor ──────────────────────────────────────────────────────
1699
+ //
1700
+ // The editor is a textarea with a painted layer behind it. The textarea owns
1701
+ // the text and the caret but renders it transparent; the layer renders the
1702
+ // same characters, split into coloured tokens.
1703
+ //
1704
+ // ONE FONT, AND IT IS NOT THE USER'S. A textarea cannot style a substring, so
1705
+ // the layer and the field can only stay aligned if every character comes from
1706
+ // the same face at the same weight — the whole box therefore uses one system
1707
+ // monospace at 400, whatever font the query names. The user's font would break
1708
+ // this twice over: a programming face with ligatures (`->` in Fira Code) draws
1709
+ // one glyph in the layer while the field draws two, and a weight the face does
1710
+ // not have is synthesized differently in each. Ligatures, kerning, and
1711
+ // stretching are switched off outright for the same reason.
1712
+ //
1713
+ // The layer is therefore only ever allowed colour, background, and
1714
+ // text-decoration: anything that changes a glyph's advance would desynchronize
1715
+ // the two.
1716
+ '.dsh-font-query{flex-direction:column;gap:4px;display:flex}',
1717
+ '.dsh-font-queryBox{position:relative;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-module-platform)}',
1718
+ '.dsh-font-queryBox:focus-within{border-color:var(--dsw-alias-brand-primary)}',
1719
+ '.dsh-font-queryLayer,.dsh-font-queryInput{box-sizing:border-box;width:100%;margin:0;padding:5px 10px;border:none;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:13px;font-weight:400;font-style:normal;font-stretch:normal;font-variant-ligatures:none;font-kerning:none;font-feature-settings:"liga" 0,"calt" 0,"dlig" 0;line-height:20px;letter-spacing:normal;word-spacing:normal;text-transform:none;text-indent:0;tab-size:4;white-space:pre-wrap;overflow-wrap:break-word;word-break:break-word}',
1720
+ '.dsh-font-queryLayer{position:absolute;inset:0;overflow:hidden;pointer-events:none;color:var(--dsw-alias-label-primary)}',
1721
+ '.dsh-font-queryInput{position:relative;display:block;min-height:32px;max-height:120px;resize:none;overflow-y:auto;background:transparent;color:transparent;caret-color:var(--dsw-alias-label-primary);outline:none}',
1722
+ '.dsh-font-queryInput::placeholder{color:var(--dsw-alias-label-tertiary)}',
1723
+ '.dsh-font-queryInput::selection{background:var(--dsw-alias-interactive-bg-active)}',
1724
+ '.dsh-font-qFamily{color:var(--dsw-alias-label-primary)}',
1725
+ // The family that is in effect: the first one the browser can actually use.
1726
+ '.dsh-font-qEffective{background:var(--dsw-alias-markdown-inline-code);border-radius:3px}',
1727
+ // Keywords, not state: a generic family and a weight word are syntax. The
1728
+ // accent is the link colour because `--dsw-alias-brand-primary` resolves to
1729
+ // the ordinary text colour in both themes, which would colour nothing.
1730
+ '.dsh-font-qGeneric,.dsh-font-qWeight{color:var(--dsw-alias-link)}',
1731
+ '.dsh-font-qUnknown{color:var(--dsw-alias-state-warn-primary)}',
1732
+ '.dsh-font-qWeightMissing{color:var(--dsw-alias-state-warn-primary);text-decoration:underline wavy var(--dsw-alias-state-warn-primary)}',
1733
+ '.dsh-font-qComma{color:var(--dsw-alias-label-caption)}',
1734
+ '.dsh-font-menu{position:absolute;z-index:20;left:0;right:0;top:calc(100% + 4px);max-height:240px;overflow-y:auto;margin:0;padding:4px;list-style:none;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:var(--dsw-elevation-panel)}',
1735
+ '.dsh-font-option{cursor:pointer;border-radius:6px;padding:5px 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:8px}',
1736
+ '.dsh-font-optionActive{background:var(--dsw-alias-interactive-bg-hover)}',
1737
+ '.dsh-font-optionKey{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}',
1738
+ '.dsh-font-optionDetail{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px}',
1739
+ '.dsh-font-optionCustom{color:var(--dsw-alias-label-secondary);border-top:.5px solid var(--dsw-alias-border-l2);border-radius:0 0 6px 6px;display:block}',
1740
+ '.dsh-font-meta{flex-wrap:wrap;gap:8px;justify-content:space-between;display:flex}',
1741
+ '.dsh-font-warn{color:var(--dsw-alias-state-warn-primary);font-size:11px;line-height:16px}',
1742
+ // Text the reader could not parse: a red line, not a suggestion.
1743
+ '.dsh-font-warn.dsh-font-error{color:var(--dsw-alias-state-error-primary)}',
1744
+ '.dsh-font-pending{color:var(--dsw-alias-link);font-size:11px;line-height:16px}',
428
1745
  ].join('')
429
1746
 
430
1747
  /** Install the row chrome stylesheet for the plugin's lifetime. */
@@ -466,50 +1783,437 @@ function Field({ label, value, hint, children }) {
466
1783
  )
467
1784
  }
468
1785
 
1786
+ /** The class one highlight token is painted with. */
1787
+ const QUERY_TOKEN_CLASS = {
1788
+ family: 'dsh-font-qFamily',
1789
+ generic: 'dsh-font-qGeneric',
1790
+ unknown: 'dsh-font-qUnknown',
1791
+ weight: 'dsh-font-qWeight',
1792
+ weightMissing: 'dsh-font-qWeightMissing',
1793
+ comma: 'dsh-font-qComma',
1794
+ space: '',
1795
+ }
1796
+
1797
+ /**
1798
+ * The class for one token, plus the pill that marks the family in effect.
1799
+ *
1800
+ * Only colour, background, and text-decoration may ever appear here: the layer
1801
+ * this paints shares a box with the real textarea, so any property that changes
1802
+ * a glyph's advance would slide every colour off its character.
1803
+ * @param token - one token from {@link fontQueryTokens}.
1804
+ * @param effectiveFamily - the lowercase name of the family in effect.
1805
+ * @returns the class attribute value.
1806
+ */
1807
+ function queryTokenClass(token, effectiveFamily) {
1808
+ const base = QUERY_TOKEN_CLASS[token.kind] ?? ''
1809
+ if (token.family === undefined || effectiveFamily === undefined) return base
1810
+ return token.family.toLowerCase() === effectiveFamily
1811
+ ? `${base} dsh-font-qEffective`.trim()
1812
+ : base
1813
+ }
1814
+
469
1815
  /**
470
- * A commit-on-blur/Enter text input that always reflects the durable value
471
- * once the user stops editing.
1816
+ * The font-query editor: a textarea over a painted layer, with autocomplete.
1817
+ *
1818
+ * Why a textarea and not `contenteditable`: the query is a *string* the user is
1819
+ * editing, so the browser's own text editing — selection, undo, IME, soft wrap
1820
+ * — is exactly what is wanted, and a painted layer behind a transparent
1821
+ * textarea reproduces it with syntax colours. `contenteditable` would mean
1822
+ * re-implementing all of that on top of a DOM that fights back.
1823
+ *
1824
+ * THE RULE THIS COMPONENT FOLLOWS: the text belongs to the user.
1825
+ *
1826
+ * It is never rewritten — not quoted, not reordered, not tidied, not refilled
1827
+ * when it is empty. Applying a query (Enter, or leaving the field) parses it and
1828
+ * writes the settings it names; it does not touch a character. Anything wrong
1829
+ * with the query is marked under the field and left alone. The only exception is
1830
+ * a completion the user picks from the list, which is an explicit request for
1831
+ * that replacement, and the only way a value from outside takes the text over is
1832
+ * a change that means something different from what is on screen (Reset, another
1833
+ * tab) while the field is not focused.
1834
+ *
472
1835
  * @param props - React props.
473
- * @returns the input element.
1836
+ * @returns the editor element.
474
1837
  */
475
- function FamilyInput({ value, placeholder, onCommit, monospace }) {
476
- const [draft, setDraft] = useState(value)
477
- const editing = useRef(false)
1838
+ function FontQueryEditor({
1839
+ value,
1840
+ weight,
1841
+ catalogue,
1842
+ styles,
1843
+ enumerated,
1844
+ monospace,
1845
+ label,
1846
+ labels,
1847
+ onFamilies,
1848
+ onWeight,
1849
+ }) {
1850
+ /**
1851
+ * The axis's shipped weight. A query at this weight carries no word: the
1852
+ * shipped value is not something the user chose, and spelling it out put a
1853
+ * `regular` after every interface font that nobody asked for. What applies is
1854
+ * still always stated in the line under the field.
1855
+ */
1856
+ const shippedWeight = monospace === true ? DEFAULT_CODE_FONT_WEIGHT : DEFAULT_UI_FONT_WEIGHT
1857
+ const options = { catalogue, styles, enumerated }
1858
+ /** Families plus a weight, as the text the field edits. */
1859
+ const asQuery = (families, weight) =>
1860
+ serializeFontQuery(families, weight === shippedWeight ? undefined : weight)
1861
+ // The stored value rendered as the field's text. A weight word left inside the
1862
+ // family string by a hand edit belongs to the weight field, so the two stored
1863
+ // values are shown as one query rather than the raw string.
1864
+ const stored = parseFontQuery(value, options)
1865
+ const storedFamilies = stored.families.length > 0 ? stored.families : parseFamilyList(value)
1866
+ const storedText = asQuery(storedFamilies, weight)
1867
+
1868
+ /**
1869
+ * THE EDITING RULE: the text is the user's.
1870
+ *
1871
+ * Nothing here ever rewrites what was typed. `draft` holds it verbatim, and it
1872
+ * is replaced only by a value that arrives from outside (Reset, another tab) —
1873
+ * or by a completion the user explicitly picked. Applying a query parses it and
1874
+ * writes the two settings fields; it does not touch the text. A query that
1875
+ * resolves to nothing is an unfinished edit and writes nothing at all, rather
1876
+ * than being "helpfully" replaced by the shipped stack: silently refilling a
1877
+ * box the user just cleared is indistinguishable from a bug.
1878
+ *
1879
+ * `undefined` means "nothing typed yet — show the stored value".
1880
+ */
1881
+ const [draft, setDraft] = useState(undefined)
1882
+ const [open, setOpen] = useState(false)
1883
+ const [active, setActive] = useState(0)
1884
+ const [caret, setCaret] = useState(0)
1885
+ const inputRef = useRef(null)
1886
+ const layerRef = useRef(null)
1887
+ const focused = useRef(false)
1888
+ /** A caret to restore after the next render, for edits made without a mouse. */
1889
+ const pendingCaret = useRef(undefined)
1890
+
1891
+ const text = draft === undefined ? storedText : draft
1892
+ const position = Math.min(Math.max(caret, 0), text.length)
1893
+ const context = queryContextAt(text, position)
1894
+ const suggestion = querySuggestions(context, { ...options, shippedWeight })
1895
+ const parsed = parseFontQuery(text, options)
1896
+ const tokens = fontQueryTokens(text, options)
1897
+ /** The custom row is a completion too, so it takes part in keyboard travel. */
1898
+ const rows =
1899
+ suggestion.custom === undefined
1900
+ ? suggestion.items
1901
+ : [...suggestion.items, { id: 'custom', kind: 'custom', insert: suggestion.custom }]
1902
+ const highlighted = clampHighlight(rows, active)
1903
+ /** What the axis is set to — the setting, not the text being typed. */
1904
+ const appliedWeight = normalizeWeight(weight)
1905
+ /** A query that says nothing about a family is an unfinished edit. */
1906
+ const unfilled = parsed.families.length === 0 && parsed.weight === undefined
1907
+ // The text carries something the setting does not (yet). Compared by MEANING,
1908
+ // so lowercase or unquoted input is not reported as "not applied".
1909
+ const pendingText =
1910
+ draft === undefined ? undefined : asQuery(parsed.families, parsed.weight ?? weight)
1911
+ const pending = pendingText !== undefined && pendingText !== storedText
1912
+ const effectiveFamily =
1913
+ parsed.effective >= 0 ? parsed.families[parsed.effective].toLowerCase() : undefined
1914
+ const typed = context.inner.trim()
478
1915
 
1916
+ const messages = parsed.diagnostics
1917
+ .map((diagnostic) => ({
1918
+ text: describeDiagnostic(diagnostic, labels),
1919
+ kind: diagnosticKind(diagnostic.code),
1920
+ }))
1921
+ .filter((message) => message.text !== '')
1922
+ if (parsed.families.length > 0 && !parsed.families.some((f) => isGenericFamilyName(f.toLowerCase()))) {
1923
+ messages.push({ text: labels.genericWarning, kind: 'warn' })
1924
+ }
1925
+
1926
+ // The auto-height and the caret both belong to the render *after* the one that
1927
+ // changed the text: a controlled textarea cannot be given a selection range in
1928
+ // the same tick as the value it refers to.
479
1929
  useEffect(() => {
480
- if (!editing.current) setDraft(value)
481
- }, [value])
482
-
483
- const commit = useCallback(() => {
484
- editing.current = false
485
- const next = draft.trim()
486
- if (next === '') {
487
- setDraft(value)
1930
+ const node = inputRef.current
1931
+ if (node === null || node === undefined) return
1932
+ node.style.height = 'auto'
1933
+ node.style.height = `${String(Math.min(node.scrollHeight, 120))}px`
1934
+ const wanted = pendingCaret.current
1935
+ if (wanted === undefined) return
1936
+ pendingCaret.current = undefined
1937
+ const clamped = Math.min(wanted, node.value.length)
1938
+ node.focus()
1939
+ node.setSelectionRange(clamped, clamped)
1940
+ setCaret(clamped)
1941
+ }, [text])
1942
+
1943
+ // A value that arrived from elsewhere — the Reset button, another tab — takes
1944
+ // the field over. Our own write coming back does not: it means the same thing
1945
+ // as the text on screen, and replacing the text then would be the plugin
1946
+ // rewriting the user's typing. Nor does anything interrupt a focused field.
1947
+ useEffect(() => {
1948
+ if (focused.current) return
1949
+ setDraft((current) => {
1950
+ if (current === undefined) return undefined
1951
+ const read = parseFontQuery(current, options)
1952
+ return asQuery(read.families, read.weight ?? weight) === storedText ? current : undefined
1953
+ })
1954
+ }, [storedText])
1955
+
1956
+ /**
1957
+ * Apply a query: parse it and write the settings fields it names.
1958
+ *
1959
+ * The text is NOT touched — not quoted, not reordered, not tidied. A query
1960
+ * that names no family is an unfinished edit and writes nothing, leaving the
1961
+ * stored stack alone; a query that names a weight but no family still moves
1962
+ * that axis, because a weight alone is a complete statement about it.
1963
+ * @param raw - the text to apply.
1964
+ */
1965
+ const apply = (raw) => {
1966
+ const read = parseFontQuery(raw, options)
1967
+ if (read.families.length > 0) onFamilies(serializeFamilyList(read.families))
1968
+ if (read.weight !== undefined) onWeight(read.weight)
1969
+ }
1970
+
1971
+ /**
1972
+ * Take one completion the user picked. This is the one place text is replaced
1973
+ * without the user having typed the replacement, and it only ever runs from an
1974
+ * explicit pick: a click on a row, or Tab on the highlighted one.
1975
+ * @param row - the chosen suggestion row.
1976
+ */
1977
+ const accept = (row) => {
1978
+ const next = applySuggestion(text, suggestion, row.insert, row.kind)
1979
+ pendingCaret.current = next.caret
1980
+ setDraft(next.text)
1981
+ setCaret(next.caret)
1982
+ setOpen(false)
1983
+ setActive(0)
1984
+ // A weight row is an instruction about that axis value, not just text: the
1985
+ // shipped weight is implicit in the text, so the number has to travel with
1986
+ // the row rather than be re-parsed out of the insertion.
1987
+ const read = parseFontQuery(next.text, options)
1988
+ if (read.families.length > 0) onFamilies(serializeFamilyList(read.families))
1989
+ const nextWeight = row.weight ?? read.weight
1990
+ if (nextWeight !== undefined) onWeight(nextWeight)
1991
+ }
1992
+
1993
+ /** Move the caret's entry, which is the keyboard's reorder. */
1994
+ const move = (delta) => {
1995
+ const next = moveFontQueryEntry(text, position, delta)
1996
+ if (next.text === text) return
1997
+ pendingCaret.current = next.caret
1998
+ setDraft(next.text)
1999
+ setCaret(next.caret)
2000
+ }
2001
+
2002
+ const trackCaret = (event) => {
2003
+ const node = event.target
2004
+ if (node !== null && node !== undefined && typeof node.selectionStart === 'number') {
2005
+ setCaret(node.selectionStart)
2006
+ }
2007
+ }
2008
+
2009
+ const onKeyDown = (event) => {
2010
+ if (event.altKey === true && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
2011
+ event.preventDefault()
2012
+ move(event.key === 'ArrowUp' ? -1 : 1)
488
2013
  return
489
2014
  }
490
- if (next !== value) onCommit(next)
491
- }, [draft, onCommit, value])
492
-
493
- return React.createElement('input', {
494
- type: 'text',
495
- spellCheck: false,
496
- className: monospace === true ? 'dsh-font-input dsh-font-code' : 'dsh-font-input',
497
- value: draft,
498
- placeholder,
499
- onChange: (event) => {
500
- editing.current = true
501
- setDraft(event.target.value)
502
- },
503
- onBlur: commit,
504
- onKeyDown: (event) => {
505
- if (event.key === 'Enter') event.currentTarget.blur()
506
- if (event.key === 'Escape') {
507
- editing.current = false
508
- setDraft(value)
509
- event.currentTarget.blur()
2015
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
2016
+ event.preventDefault()
2017
+ if (!open) {
2018
+ setOpen(true)
2019
+ return
510
2020
  }
511
- },
512
- })
2021
+ setActive((current) => {
2022
+ const last = Math.max(rows.length - 1, 0)
2023
+ return event.key === 'ArrowDown'
2024
+ ? Math.min(current + 1, last)
2025
+ : Math.max(current - 1, 0)
2026
+ })
2027
+ return
2028
+ }
2029
+ if (event.key === 'Enter') {
2030
+ event.preventDefault()
2031
+ // Enter APPLIES, it never completes: transforming what was typed because
2032
+ // the user pressed Enter is the rudest thing this control could do. Taking
2033
+ // a suggestion is Tab (or a click), which are unambiguous requests for it.
2034
+ setOpen(false)
2035
+ apply(text)
2036
+ return
2037
+ }
2038
+ if (event.key === 'Tab' && open && highlighted >= 0 && typed !== '') {
2039
+ event.preventDefault()
2040
+ accept(rows[highlighted])
2041
+ return
2042
+ }
2043
+ if (event.key === 'Escape') {
2044
+ if (open) setOpen(false)
2045
+ // The one way back to the stored value: the user asks for their own text
2046
+ // to be discarded.
2047
+ else setDraft(undefined)
2048
+ }
2049
+ }
2050
+
2051
+ return React.createElement(
2052
+ 'div',
2053
+ { className: 'dsh-font-query' },
2054
+ React.createElement(
2055
+ 'div',
2056
+ { className: 'dsh-font-queryBox' },
2057
+ React.createElement(
2058
+ 'div',
2059
+ { className: 'dsh-font-queryLayer', ref: layerRef, 'aria-hidden': 'true' },
2060
+ tokens.map((token, index) =>
2061
+ React.createElement(
2062
+ 'span',
2063
+ { key: `${token.kind}-${String(index)}`, className: queryTokenClass(token, effectiveFamily) },
2064
+ token.text,
2065
+ ),
2066
+ ),
2067
+ // A zero-width space keeps the layer's last line box as tall as the
2068
+ // textarea's: a trailing newline would otherwise collapse in the layer
2069
+ // alone, and the box would jump as soon as one is typed.
2070
+ '\u200b',
2071
+ ),
2072
+ React.createElement('textarea', {
2073
+ ref: inputRef,
2074
+ className: 'dsh-font-queryInput',
2075
+ value: text,
2076
+ rows: 1,
2077
+ spellCheck: false,
2078
+ autoComplete: 'off',
2079
+ autoCorrect: 'off',
2080
+ autoCapitalize: 'off',
2081
+ // No placeholder: a ghost of the shipped stack in an empty box reads as
2082
+ // a value the plugin put there. The hint above the field is the example.
2083
+ role: 'combobox',
2084
+ 'aria-label': label,
2085
+ 'aria-expanded': open && rows.length > 0,
2086
+ 'aria-autocomplete': 'list',
2087
+ onFocus: (event) => {
2088
+ focused.current = true
2089
+ setOpen(true)
2090
+ trackCaret(event)
2091
+ },
2092
+ onBlur: () => {
2093
+ focused.current = false
2094
+ setOpen(false)
2095
+ // A caret restored after this would pull focus straight back.
2096
+ pendingCaret.current = undefined
2097
+ // An untouched field writes nothing: the stored value is already what
2098
+ // the settings hold, and a blur is not an edit.
2099
+ if (draft !== undefined) apply(text)
2100
+ },
2101
+ onChange: (event) => {
2102
+ const node = event.target
2103
+ const raw = node.value
2104
+ // Typed text goes in EXACTLY as typed. No comma is inserted, no quote
2105
+ // is added, nothing is reordered: a control that edits your keystrokes
2106
+ // is broken even when its guess would have been right.
2107
+ setDraft(raw)
2108
+ setCaret(typeof node.selectionStart === 'number' ? node.selectionStart : raw.length)
2109
+ setOpen(true)
2110
+ setActive(0)
2111
+ },
2112
+ onKeyDown,
2113
+ onKeyUp: trackCaret,
2114
+ onClick: trackCaret,
2115
+ onSelect: trackCaret,
2116
+ onScroll: (event) => {
2117
+ const layer = layerRef.current
2118
+ if (layer === null || layer === undefined) return
2119
+ layer.scrollTop = event.target.scrollTop
2120
+ layer.scrollLeft = event.target.scrollLeft
2121
+ },
2122
+ }),
2123
+ open && rows.length > 0
2124
+ ? React.createElement(
2125
+ 'ul',
2126
+ { className: 'dsh-font-menu', role: 'listbox', 'aria-label': labels.list },
2127
+ rows.map((row, index) =>
2128
+ React.createElement(
2129
+ 'li',
2130
+ {
2131
+ key: row.id,
2132
+ role: 'option',
2133
+ 'aria-selected': index === highlighted,
2134
+ className: `dsh-font-option${index === highlighted ? ' dsh-font-optionActive' : ''}${row.kind === 'custom' ? ' dsh-font-optionCustom' : ''}`,
2135
+ // `onMouseDown` beats the textarea's blur, so a pick is not
2136
+ // lost to the commit that blur would otherwise run first.
2137
+ onMouseDown: (event) => {
2138
+ event.preventDefault()
2139
+ accept(row)
2140
+ },
2141
+ onMouseEnter: () => {
2142
+ setActive(index)
2143
+ },
2144
+ },
2145
+ row.kind === 'weight'
2146
+ ? [
2147
+ React.createElement(
2148
+ 'span',
2149
+ { key: 'key', className: 'dsh-font-optionKey' },
2150
+ `${row.family} ${row.word}`,
2151
+ ),
2152
+ React.createElement(
2153
+ 'span',
2154
+ { key: 'detail', className: 'dsh-font-optionDetail' },
2155
+ labels.weightName(row.weight),
2156
+ ),
2157
+ ]
2158
+ : row.kind === 'custom'
2159
+ ? `${labels.add}: "${row.insert}"`
2160
+ : [
2161
+ React.createElement(
2162
+ 'span',
2163
+ { key: 'key', className: 'dsh-font-optionKey' },
2164
+ row.name,
2165
+ ),
2166
+ React.createElement(
2167
+ 'span',
2168
+ { key: 'detail', className: 'dsh-font-optionDetail' },
2169
+ row.kind === 'generic' ? labels.generic : labels.font,
2170
+ ),
2171
+ ],
2172
+ ),
2173
+ ),
2174
+ )
2175
+ : null,
2176
+ ),
2177
+ React.createElement(
2178
+ 'div',
2179
+ { className: 'dsh-font-meta' },
2180
+ React.createElement(
2181
+ 'span',
2182
+ { className: monospace === true ? 'dsh-font-hint dsh-font-code' : 'dsh-font-hint' },
2183
+ `font-family: ${serializeFamilyList(storedFamilies)}`,
2184
+ ),
2185
+ ),
2186
+ // The value the axis is SET to — not what the text parses to. Nothing here
2187
+ // pretends the typed query has been applied: that is what the line below is
2188
+ // for, and it is why clearing the field cannot change the readout.
2189
+ React.createElement(
2190
+ 'div',
2191
+ { className: 'dsh-font-hint' },
2192
+ `${labels.weightLine}: ${labels.weightName(appliedWeight)} ${String(appliedWeight)}${
2193
+ appliedWeight === shippedWeight ? labels.weightShipped : ''
2194
+ }`,
2195
+ ),
2196
+ // An unfinished edit is reported, never "corrected": the stored stack is
2197
+ // still in use, and the text is left exactly as it was typed. There is
2198
+ // nothing to apply either, so the pending line stays out of it.
2199
+ unfilled && draft !== undefined
2200
+ ? React.createElement('div', { className: 'dsh-font-hint' }, labels.emptyQuery)
2201
+ : null,
2202
+ pending && !unfilled
2203
+ ? React.createElement('div', { className: 'dsh-font-pending' }, labels.pending)
2204
+ : null,
2205
+ messages.map((message, index) =>
2206
+ React.createElement(
2207
+ 'div',
2208
+ {
2209
+ key: `${String(index)}`,
2210
+ className:
2211
+ message.kind === 'error' ? 'dsh-font-warn dsh-font-error' : 'dsh-font-warn',
2212
+ },
2213
+ message.text,
2214
+ ),
2215
+ ),
2216
+ )
513
2217
  }
514
2218
 
515
2219
  /**
@@ -568,44 +2272,73 @@ function SliderControl({ min, max, step, value, format, onChange, ariaLabel, inc
568
2272
  }
569
2273
 
570
2274
  /**
571
- * A row of one-click family presets.
572
- * @param props - React props.
573
- * @returns the preset strip.
574
- */
575
- function PresetStrip({ presets, current, onPick }) {
576
- return React.createElement(
577
- 'div',
578
- { className: 'dsh-font-presets' },
579
- presets.map((preset) =>
580
- React.createElement(
581
- 'button',
582
- {
583
- key: preset.id,
584
- type: 'button',
585
- className: 'dsh-font-chip',
586
- 'data-active': preset.value === current ? 'true' : 'false',
587
- onClick: () => {
588
- onPick(preset.value)
589
- },
590
- },
591
- preset.label,
592
- ),
593
- ),
594
- )
595
- }
596
-
597
- /**
598
- * The General-settings row: five controls over the `ui-font` namespace.
2275
+ * The General-settings row: the two font queries plus the four size axes.
599
2276
  * @param props - composed slot props (`t`, `useStore`, and the inject actions).
600
2277
  * @returns the row element tree.
601
2278
  */
602
2279
  function FontRow({ t, useStore, setField, reset }) {
603
2280
  const uiFontFamily = useStore((s) => s.uiFontFamily)
604
2281
  const codeFontFamily = useStore((s) => s.codeFontFamily)
2282
+ const codeFontWeight = useStore((s) => s.codeFontWeight)
2283
+ const uiFontWeight = useStore((s) => s.uiFontWeight)
605
2284
  const uiFontScale = useStore((s) => s.uiFontScale)
606
2285
  const contentFontSize = useStore((s) => s.contentFontSize)
607
2286
  const codeFontSize = useStore((s) => s.codeFontSize)
608
2287
 
2288
+ // Discovery runs once per session (the result is cached) and only when this
2289
+ // row is actually rendered, so the cost is never paid by a user who never
2290
+ // opens Settings.
2291
+ const [catalog, setCatalog] = useState({
2292
+ families: COMMON_FAMILIES,
2293
+ styles: {},
2294
+ enumerated: false,
2295
+ })
2296
+ useEffect(() => {
2297
+ let cancelled = false
2298
+ discoverFamilies()
2299
+ .then((result) => {
2300
+ if (!cancelled) setCatalog(result)
2301
+ })
2302
+ .catch(() => {
2303
+ /* the curated catalogue is already in state */
2304
+ })
2305
+ return () => {
2306
+ cancelled = true
2307
+ }
2308
+ }, [])
2309
+
2310
+ // The catalogue's provenance is worth one line, and only when it is BAD news:
2311
+ // an enumerated list is the machine's own and needs no announcing, while the
2312
+ // probe fallback is a short curated list the completion popup can never
2313
+ // complete — which the user would otherwise read as "my font is missing".
2314
+ // `measured` distinguishes a finished fallback from discovery still running.
2315
+ const catalogueNotice =
2316
+ catalog.measured === true && catalog.enumerated !== true ? t('font.catalogProbed') : undefined
2317
+
2318
+ /**
2319
+ * The editors' copy, resolved once per render so the components stay free of
2320
+ * the locale service and remain pure functions of their props.
2321
+ * @param weightLine - the axis's weight label (`代码字重` / `界面字重`).
2322
+ * @returns the label bag both {@link FontQueryEditor}s consume.
2323
+ */
2324
+ const editorLabels = (weightLine) => ({
2325
+ list: t('font.suggestions'),
2326
+ add: t('font.add'),
2327
+ font: t('font.familyKind'),
2328
+ generic: t('font.genericKind'),
2329
+ weightLine,
2330
+ weightShipped: t('font.weightShipped'),
2331
+ pending: t('font.pending'),
2332
+ emptyQuery: t('font.emptyQuery'),
2333
+ genericWarning: t('font.genericWarning'),
2334
+ unknownFamily: t('font.diag.unknownFamily'),
2335
+ missingWeight: t('font.diag.missingWeight'),
2336
+ duplicateWeight: t('font.diag.duplicateWeight'),
2337
+ unclosedQuote: t('font.diag.unclosedQuote'),
2338
+ trailingText: t('font.diag.trailingText'),
2339
+ weightName: (weight) => t(`font.weight.${WEIGHT_KEYS[normalizeWeight(weight)]}`),
2340
+ })
2341
+
609
2342
  return React.createElement(
610
2343
  'div',
611
2344
  { className: 'dsh-font-row' },
@@ -614,41 +2347,52 @@ function FontRow({ t, useStore, setField, reset }) {
614
2347
  { className: 'dsh-font-head' },
615
2348
  React.createElement('div', { className: 'dsh-font-title' }, t('font.title')),
616
2349
  React.createElement('div', { className: 'dsh-font-desc' }, t('font.description')),
2350
+ catalogueNotice === undefined
2351
+ ? null
2352
+ : React.createElement('div', { className: 'dsh-font-hint' }, catalogueNotice),
617
2353
  ),
618
2354
  React.createElement(
619
2355
  Field,
620
2356
  { label: t('font.uiFamily'), hint: t('font.uiFamilyHint') },
621
- React.createElement(FamilyInput, {
2357
+ React.createElement(FontQueryEditor, {
622
2358
  value: uiFontFamily,
623
- placeholder: DEFAULT_UI_FONT_FAMILY,
624
- onCommit: (value) => {
625
- setField(UI_FONT_FAMILY_FIELD, value)
2359
+ weight: normalizeWeight(uiFontWeight),
2360
+ catalogue: catalog.families,
2361
+ styles: catalog.styles,
2362
+ enumerated: catalog.enumerated,
2363
+ label: t('font.uiFamily'),
2364
+ labels: editorLabels(t('font.uiWeight')),
2365
+ // A commit that resolves to what is already stored writes nothing: the
2366
+ // settings document is durable, and a no-op round trip is still a write.
2367
+ onFamilies: (value) => {
2368
+ if (value !== uiFontFamily) setField(UI_FONT_FAMILY_FIELD, value)
626
2369
  },
627
- }),
628
- React.createElement(PresetStrip, {
629
- presets: UI_FAMILY_PRESETS,
630
- current: uiFontFamily,
631
- onPick: (value) => {
632
- setField(UI_FONT_FAMILY_FIELD, value)
2370
+ onWeight: (weight) => {
2371
+ if (normalizeWeight(weight) !== normalizeWeight(uiFontWeight)) {
2372
+ setField(UI_FONT_WEIGHT_FIELD, weight)
2373
+ }
633
2374
  },
634
2375
  }),
635
2376
  ),
636
2377
  React.createElement(
637
2378
  Field,
638
2379
  { label: t('font.codeFamily'), hint: t('font.codeFamilyHint') },
639
- React.createElement(FamilyInput, {
2380
+ React.createElement(FontQueryEditor, {
640
2381
  value: codeFontFamily,
641
- placeholder: DEFAULT_CODE_FONT_FAMILY,
2382
+ weight: normalizeWeight(codeFontWeight),
2383
+ catalogue: catalog.families,
2384
+ styles: catalog.styles,
2385
+ enumerated: catalog.enumerated,
642
2386
  monospace: true,
643
- onCommit: (value) => {
644
- setField(CODE_FONT_FAMILY_FIELD, value)
2387
+ label: t('font.codeFamily'),
2388
+ labels: editorLabels(t('font.codeWeight')),
2389
+ onFamilies: (value) => {
2390
+ if (value !== codeFontFamily) setField(CODE_FONT_FAMILY_FIELD, value)
645
2391
  },
646
- }),
647
- React.createElement(PresetStrip, {
648
- presets: CODE_FAMILY_PRESETS,
649
- current: codeFontFamily,
650
- onPick: (value) => {
651
- setField(CODE_FONT_FAMILY_FIELD, value)
2392
+ onWeight: (weight) => {
2393
+ if (normalizeWeight(weight) !== normalizeWeight(codeFontWeight)) {
2394
+ setField(CODE_FONT_WEIGHT_FIELD, weight)
2395
+ }
652
2396
  },
653
2397
  }),
654
2398
  ),
@@ -759,6 +2503,8 @@ export function apply(ctx) {
759
2503
  return {
760
2504
  [UI_FONT_FAMILY_FIELD]: text(UI_FONT_FAMILY_FIELD, DEFAULT_UI_FONT_FAMILY),
761
2505
  [CODE_FONT_FAMILY_FIELD]: text(CODE_FONT_FAMILY_FIELD, DEFAULT_CODE_FONT_FAMILY),
2506
+ [CODE_FONT_WEIGHT_FIELD]: normalizeWeight(raw[CODE_FONT_WEIGHT_FIELD]),
2507
+ [UI_FONT_WEIGHT_FIELD]: normalizeWeight(raw[UI_FONT_WEIGHT_FIELD]),
762
2508
  [UI_FONT_SCALE_FIELD]: number(UI_FONT_SCALE_FIELD, 1, UI_FONT_SCALE_MIN, UI_FONT_SCALE_MAX),
763
2509
  [CONTENT_FONT_SIZE_FIELD]: Math.round(
764
2510
  number(CONTENT_FONT_SIZE_FIELD, 14, CONTENT_FONT_SIZE_MIN, CONTENT_FONT_SIZE_MAX),
@@ -784,6 +2530,8 @@ export function apply(ctx) {
784
2530
  const defaults = {
785
2531
  [UI_FONT_FAMILY_FIELD]: DEFAULT_UI_FONT_FAMILY,
786
2532
  [CODE_FONT_FAMILY_FIELD]: DEFAULT_CODE_FONT_FAMILY,
2533
+ [CODE_FONT_WEIGHT_FIELD]: DEFAULT_CODE_FONT_WEIGHT,
2534
+ [UI_FONT_WEIGHT_FIELD]: DEFAULT_UI_FONT_WEIGHT,
787
2535
  [UI_FONT_SCALE_FIELD]: 1,
788
2536
  [CONTENT_FONT_SIZE_FIELD]: 14,
789
2537
  [CODE_FONT_SIZE_FIELD]: 12,