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