@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.
@@ -24,13 +24,52 @@ const source = readFileSync(bundlePath, 'utf8')
24
24
  /** The package name, which the bundle id must equal. */
25
25
  const PACKAGE_NAME = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name
26
26
 
27
- /** Minimal React stub: enough for the row component to build a tree. */
27
+ /**
28
+ * Minimal React stub: enough to build a tree, plus enough hook state to drive
29
+ * interactions.
30
+ *
31
+ * Only ONE component instance may hold live hook state at a time, because the
32
+ * slots are keyed by `useState` call order. `mount()` therefore creates that
33
+ * instance: it returns a `render(props)` that seeds fresh slots on the first
34
+ * call and reuses them afterwards, so a state write followed by a re-render
35
+ * behaves like the real thing. A previous instance's slots are discarded, which
36
+ * is what keeps one harness from clobbering another's.
37
+ *
38
+ * `useEffect` is deliberately inert: nothing under test depends on an effect
39
+ * having run, and the components only use effects for work the tests drive
40
+ * explicitly.
41
+ *
42
+ * @returns `{ render, state }`.
43
+ */
44
+ function mount() {
45
+ let slots
46
+ const state = () => slots
47
+ const render = (component, props) => {
48
+ if (slots === undefined) slots = [] // first render of this instance
49
+ react.__hookIndex = 0
50
+ react.__slots = slots
51
+ return component(props)
52
+ }
53
+ return { render, state }
54
+ }
55
+
28
56
  const react = {
29
57
  createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
30
58
  useCallback: (fn) => fn,
31
59
  useEffect: () => undefined,
32
60
  useRef: (value) => ({ current: value }),
33
- useState: (value) => [value, () => undefined],
61
+ useState: (value) => {
62
+ const slots = react.__slots ?? (react.__slots = [])
63
+ const index = react.__hookIndex ?? 0
64
+ if (slots.length <= index) slots.push(value)
65
+ react.__hookIndex = index + 1
66
+ return [
67
+ slots[index],
68
+ (next) => {
69
+ slots[index] = typeof next === 'function' ? next(slots[index]) : next
70
+ },
71
+ ]
72
+ },
34
73
  }
35
74
 
36
75
  /** A tiny observable store, matching the `@deepseek-ai/dsh-client-store` face. */
@@ -108,6 +147,80 @@ assert.ok(Array.isArray(plugin.inject), 'bundle must export inject as an array')
108
147
  assert.deepEqual(plugin.inject, ['slots', 'locale', 'settingsScope'])
109
148
  assert.equal(typeof plugin.fontStyleSheet, 'function')
110
149
  assert.equal(typeof plugin.applyFonts, 'function')
150
+ assert.equal(typeof plugin.parseFamilyList, 'function')
151
+ assert.equal(typeof plugin.serializeFamilyList, 'function')
152
+ assert.equal(typeof plugin.normalizeWeight, 'function')
153
+ assert.equal(typeof plugin.rankFamilyMatches, 'function')
154
+ for (const name of [
155
+ 'weightWord',
156
+ 'faceWeights',
157
+ 'emphasisWeight',
158
+ 'parseFontQuery',
159
+ 'fontQueryTokens',
160
+ 'serializeFontQuery',
161
+ 'queryContextAt',
162
+ 'querySuggestions',
163
+ 'applySuggestion',
164
+ 'moveFontQueryEntry',
165
+ 'queryTokenClass',
166
+ 'clampHighlight',
167
+ 'describeDiagnostic',
168
+ 'FontQueryEditor',
169
+ ]) {
170
+ assert.equal(typeof plugin[name], 'function', `the bundle must export ${name}()`)
171
+ }
172
+
173
+ // ── the family list: parse and serialize ────────────────────────────────────
174
+ // A naive `split(',')` is the obvious implementation and it is wrong: a quoted
175
+ // family may itself contain a comma, and CSS allows that.
176
+ assert.deepEqual(plugin.parseFamilyList('Inter, "PingFang SC", sans-serif'), [
177
+ 'Inter',
178
+ 'PingFang SC',
179
+ 'sans-serif',
180
+ ])
181
+ assert.deepEqual(plugin.parseFamilyList('"Foo, Bar", monospace'), ['Foo, Bar', 'monospace'])
182
+ assert.deepEqual(plugin.parseFamilyList("'Single Quoted', serif"), ['Single Quoted', 'serif'])
183
+ assert.deepEqual(plugin.parseFamilyList(' Arial , , Helvetica '), ['Arial', 'Helvetica'])
184
+ assert.deepEqual(plugin.parseFamilyList(''), [])
185
+ assert.deepEqual(plugin.parseFamilyList(undefined), [])
186
+
187
+ // Serialization quotes only what CSS requires. Quoting a generic family would
188
+ // name a literal font instead of the generic one, which breaks silently.
189
+ assert.equal(
190
+ plugin.serializeFamilyList(['Inter', 'PingFang SC', 'sans-serif']),
191
+ 'Inter, "PingFang SC", sans-serif',
192
+ )
193
+ assert.equal(plugin.serializeFamilyList(['Foo, Bar', 'monospace']), '"Foo, Bar", monospace')
194
+ assert.equal(
195
+ plugin.serializeFamilyList(['Segoe UI Variable', 'system-ui']),
196
+ '"Segoe UI Variable", system-ui',
197
+ )
198
+
199
+ // Round-tripping must be stable: this property is what lets the plain CSS
200
+ // string stay the stored form with no schema change and no migration.
201
+ for (const value of [
202
+ 'Inter, "PingFang SC", sans-serif',
203
+ '"SF Mono", "JetBrains Mono", Consolas, monospace',
204
+ '"Foo, Bar", "Baz, Qux", serif',
205
+ ]) {
206
+ assert.equal(
207
+ plugin.serializeFamilyList(plugin.parseFamilyList(value)),
208
+ value,
209
+ `round-trip changed ${value}`,
210
+ )
211
+ }
212
+
213
+ // ── suggestion ranking ──────────────────────────────────────────────────────
214
+ const catalogue = ['Fira Code', 'Fira Sans', 'Inter', 'Inter Tight', 'Roboto Mono', 'monospace']
215
+ assert.deepEqual(plugin.rankFamilyMatches(catalogue, ''), catalogue)
216
+ assert.equal(plugin.rankFamilyMatches(catalogue, 'inter')[0], 'Inter', 'an exact match must lead')
217
+ assert.deepEqual(
218
+ plugin.rankFamilyMatches(catalogue, 'fira').slice(0, 2),
219
+ ['Fira Code', 'Fira Sans'],
220
+ 'prefix matches must precede substring matches',
221
+ )
222
+ assert.deepEqual(plugin.rankFamilyMatches(catalogue, 'code'), ['Fira Code'])
223
+ assert.deepEqual(plugin.rankFamilyMatches(catalogue, 'zzz'), [])
111
224
 
112
225
  // The build must have substituted the template's identity placeholder, or the
113
226
  // bundle would register the placeholder instead of the real package name.
@@ -120,6 +233,7 @@ assert.ok(
120
233
  const section = {
121
234
  uiFontFamily: 'Inter, sans-serif',
122
235
  codeFontFamily: '"JetBrains Mono", monospace',
236
+ codeFontWeight: 500,
123
237
  uiFontScale: 1.25,
124
238
  contentFontSize: 16,
125
239
  codeFontSize: 13,
@@ -127,6 +241,7 @@ const section = {
127
241
  const sheet = plugin.fontStyleSheet(section)
128
242
  assert.match(sheet, /--dsh-font-ui-scale:1\.25;/)
129
243
  assert.match(sheet, /--dsh-font-code-size:13px;/)
244
+ assert.match(sheet, /--dsh-font-code-weight:500;/)
130
245
  // The content size is an INLINE custom property on `body` (ui-layout's theme
131
246
  // presenter owns that declaration), so the sheet must not declare it — an
132
247
  // inline value would win and nothing here could override it.
@@ -149,6 +264,25 @@ assert.match(sheet, /--dsh-font-markdown-h1:700 calc\(16px \+ 7px\) \/ calc\(16p
149
264
  assert.match(sheet, /--dsh-font-markdown-base:var\(--dsh-font-conversation-size,14px\) \/ calc\(16px \+ 10px\)/)
150
265
  assert.match(sheet, /--dsw-font-markdown-code-block-font-size:var\(--dsh-font-code-size,12px\) !important;/)
151
266
 
267
+ // The code weight has no design-system token of its own, so it rides in the
268
+ // `font:` value of every code token — a bare `font-family` cannot carry it, and
269
+ // the shipped ladder is a literal 400 that nothing else would move. The size
270
+ // and line-height must survive the substitution untouched.
271
+ assert.match(sheet, /--dsw-font-markdown-code:var\(--dsh-font-code-weight,400\) var\(--dsh-font-code-size,12px\) \/ calc\(var\(--dsh-font-code-size,12px\) \+ 7px\)/)
272
+ assert.match(sheet, /--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\)/)
273
+ assert.match(sheet, /--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\)/)
274
+
275
+ // Most code in the interface never reads a token: it is styled with
276
+ // `font-family: var(--ds-font-family-code)` and a literal weight inside a
277
+ // component stylesheet. Those are reached structurally, and only there — the
278
+ // rule must stay scoped to code rather than becoming a universal weight.
279
+ assert.match(sheet, /html body pre,html body code,html body \[class\*="code" i\]\{/)
280
+ assert.match(sheet, /font-weight:var\(--dsh-font-code-weight,400\) !important;/)
281
+ assert.ok(
282
+ !/body\s*\*\{[^}]*font-weight/.test(sheet),
283
+ 'the code weight must not be applied to every element',
284
+ )
285
+
152
286
  // A different content size must move the ladder, not just the base variable.
153
287
  const bigger = plugin.fontStyleSheet({ ...section, contentFontSize: 20 })
154
288
  assert.match(bigger, /--dsh-font-markdown-h1:700 calc\(20px \+ 7px\) \/ calc\(20px \+ 16px\)/)
@@ -207,12 +341,19 @@ assert.equal(rootProperties.get('--ds-font-family-code'), '"JetBrains Mono", mon
207
341
  // presenter value would win over the stylesheet.
208
342
  assert.equal(bodyProperties.get('--dsh-content-font-size'), '16px')
209
343
 
210
- // Re-applying must rewrite the same tag, not accumulate stylesheets.
211
- plugin.applyFonts({ ...section, contentFontSize: 18, uiFontScale: 1 })
344
+ // Re-applying must rewrite the same tag, not accumulate stylesheets, and a
345
+ // changed weight must reach the sheet.
346
+ plugin.applyFonts({ ...section, contentFontSize: 18, uiFontScale: 1, codeFontWeight: 700 })
212
347
  assert.equal(styleTags.length, 1, 'applyFonts must reuse its own stylesheet tag')
213
348
  assert.match(styleTags[0].textContent, /--dsh-font-ui-scale:1;/)
349
+ assert.match(styleTags[0].textContent, /--dsh-font-code-weight:700;/)
214
350
  assert.equal(bodyProperties.get('--dsh-content-font-size'), '18px')
215
351
 
352
+ // An unreadable stored weight must fall back to the shipped one rather than
353
+ // writing an invalid declaration into every code `font:` shorthand.
354
+ plugin.applyFonts({ ...section, codeFontWeight: 'wobble' })
355
+ assert.match(styleTags[0].textContent, /--dsh-font-code-weight:400;/)
356
+
216
357
  // ── the interface-scale stamping pass ───────────────────────────────────────
217
358
  // A fake tree whose computed sizes are the shipped ones.
218
359
  const sizes = new Map()
@@ -290,6 +431,892 @@ globalThis.document = {
290
431
  },
291
432
  }
292
433
 
434
+ // ── the font query: what the settings row edits ─────────────────────────────
435
+ //
436
+ // The language is the CSS font-family list plus a weight word, which is the one
437
+ // thing the list cannot carry. Everything here is a pure function of (text,
438
+ // catalogue), so the table below is the specification.
439
+
440
+ const QUERY_CATALOGUE = [
441
+ 'Book Antiqua',
442
+ 'Fira Code',
443
+ 'Fira Sans',
444
+ 'Franklin Gothic Medium',
445
+ 'Geist Mono',
446
+ 'Inter',
447
+ 'Inter Tight',
448
+ 'Roboto Mono',
449
+ 'monospace',
450
+ ]
451
+ /** The faces one machine reports, keyed by family. */
452
+ const QUERY_STYLES = {
453
+ 'Geist Mono': ['Regular', 'Medium', 'Bold', 'Bold Italic'],
454
+ Inter: ['Thin', 'Regular', 'SemiBold'],
455
+ }
456
+ const QUERY = { catalogue: QUERY_CATALOGUE, styles: QUERY_STYLES, enumerated: true }
457
+
458
+ // One family plus its weight: the case the whole language exists for. Geist Mono
459
+ // is a variable font, so `medium` is its 500 face and not part of a family name.
460
+ {
461
+ const read = plugin.parseFontQuery('Geist Mono medium', QUERY)
462
+ assert.deepEqual(read.families, ['Geist Mono'])
463
+ assert.equal(read.weight, 500)
464
+ assert.equal(read.weightWord, 'medium')
465
+ assert.equal(read.effective, 0)
466
+ assert.deepEqual(read.diagnostics, [])
467
+ }
468
+
469
+ // The canonical form quotes the family and leaves the weight where it reads as
470
+ // a property of that family — and parses back into the same two values, which is
471
+ // what lets one text field be the source of both settings.
472
+ {
473
+ const value = plugin.serializeFontQuery(['Geist Mono', 'monospace'], 500)
474
+ assert.equal(value, '"Geist Mono" medium, monospace')
475
+ const read = plugin.parseFontQuery(value, QUERY)
476
+ assert.deepEqual(read.families, ['Geist Mono', 'monospace'])
477
+ assert.equal(read.weight, 500)
478
+ assert.equal(read.weightWord, 'medium')
479
+ }
480
+
481
+ // A bare weight word stands on its own: it sets the axis weight without naming
482
+ // a family, which is how a query that only carries a weight is written.
483
+ {
484
+ const read = plugin.parseFontQuery('Inter, medium', QUERY)
485
+ assert.deepEqual(read.families, ['Inter'])
486
+ assert.equal(read.weight, 500)
487
+ }
488
+ {
489
+ const read = plugin.parseFontQuery('medium', QUERY)
490
+ assert.deepEqual(read.families, [])
491
+ assert.equal(read.weight, 500)
492
+ assert.equal(read.effective, -1)
493
+ }
494
+
495
+ // Only the LAST word of an unquoted entry can be a weight, and a name that is
496
+ // itself a catalogued family keeps its word: stripping `Book Antiqua` or
497
+ // `Franklin Gothic Medium` would silently retarget the stack at a font nobody
498
+ // picked. `Fira Sans Book` is the case the split is for.
499
+ for (const name of ['Book Antiqua', 'Franklin Gothic Medium']) {
500
+ const read = plugin.parseFontQuery(name, QUERY)
501
+ assert.deepEqual(read.families, [name])
502
+ assert.equal(read.weight, undefined)
503
+ }
504
+ {
505
+ const read = plugin.parseFontQuery('Fira Sans Book', QUERY)
506
+ assert.deepEqual(read.families, ['Fira Sans'])
507
+ assert.equal(read.weight, 400)
508
+ }
509
+ // Anything that is not a weight word is part of the name, even after a word
510
+ // that is one — `Geist Mono SemiBold Italic` is one name, not a guess.
511
+ {
512
+ const read = plugin.parseFontQuery('Geist Mono SemiBold Italic', QUERY)
513
+ assert.deepEqual(read.families, ['Geist Mono SemiBold Italic'])
514
+ assert.equal(read.weight, undefined)
515
+ }
516
+ // A quoted name is verbatim, so only text AFTER the closing quote can be a
517
+ // weight — this is the form the canonical serializer writes.
518
+ {
519
+ const read = plugin.parseFontQuery('"Geist Mono" bold', QUERY)
520
+ assert.deepEqual(read.families, ['Geist Mono'])
521
+ assert.equal(read.weight, 700)
522
+ }
523
+ {
524
+ const read = plugin.parseFontQuery('"Book Antiqua"', QUERY)
525
+ assert.deepEqual(read.families, ['Book Antiqua'])
526
+ assert.equal(read.weight, undefined)
527
+ }
528
+
529
+ // The weight belongs to the axis, so it is honoured wherever it is written but
530
+ // only counted once.
531
+ {
532
+ const read = plugin.parseFontQuery('Inter bold, monospace light', QUERY)
533
+ assert.equal(read.weight, 700)
534
+ assert.deepEqual(read.diagnostics.map((d) => d.code), ['duplicate-weight', 'missing-weight'])
535
+ assert.equal(read.diagnostics[1].weight, 700)
536
+ }
537
+
538
+ // Bad text is reported, never silently dropped: an unclosed quote swallows the
539
+ // rest of the entry, and a word after a quoted name is neither the name nor a
540
+ // weight.
541
+ {
542
+ const unclosed = plugin.parseFontQuery('"Geist Mono', QUERY)
543
+ assert.deepEqual(unclosed.families, ['Geist Mono'])
544
+ assert.deepEqual(unclosed.diagnostics.map((d) => d.code), ['unclosed-quote'])
545
+ }
546
+ {
547
+ const stray = plugin.parseFontQuery('"Geist Mono" wobble', QUERY)
548
+ assert.deepEqual(stray.families, ['Geist Mono'])
549
+ assert.equal(stray.weight, undefined)
550
+ assert.deepEqual(stray.diagnostics.map((d) => d.code), ['trailing-text'])
551
+ }
552
+
553
+ // A family the machine does not list is worth saying out loud — but only when
554
+ // the catalogue is authoritative; the curated fallback list is not evidence.
555
+ {
556
+ const read = plugin.parseFontQuery('Nope Sans, monospace', QUERY)
557
+ assert.deepEqual(read.diagnostics.map((d) => d.code), ['unknown-family'])
558
+ assert.equal(read.diagnostics[0].name, 'Nope Sans')
559
+ assert.deepEqual(
560
+ plugin.parseFontQuery('Nope Sans, monospace', { catalogue: QUERY_CATALOGUE, enumerated: false })
561
+ .diagnostics,
562
+ [],
563
+ )
564
+ // The generic fallback is what the browser will really use when nothing
565
+ // before it is installed.
566
+ assert.equal(read.effective, 1)
567
+ }
568
+
569
+ // A weight the family has no face for is synthesized by the browser, so it is
570
+ // reported — and only when the faces were actually read.
571
+ {
572
+ const read = plugin.parseFontQuery('Inter bold', QUERY)
573
+ assert.deepEqual(read.diagnostics.map((d) => d.code), ['missing-weight'])
574
+ assert.equal(read.diagnostics[0].name, 'Inter')
575
+ assert.equal(read.diagnostics[0].weight, 700)
576
+ assert.deepEqual(
577
+ plugin.parseFontQuery('Inter bold', { catalogue: QUERY_CATALOGUE, styles: {}, enumerated: true })
578
+ .diagnostics,
579
+ [],
580
+ )
581
+ }
582
+
583
+ // Serialization: quoting rules, the weight only on the first family, and an
584
+ // empty stack staying empty rather than becoming a stray space.
585
+ assert.equal(plugin.serializeFontQuery(['Inter', 'sans-serif'], 400), 'Inter regular, sans-serif')
586
+ assert.equal(plugin.serializeFontQuery(['Foo, Bar', 'monospace'], undefined), '"Foo, Bar", monospace')
587
+ assert.equal(plugin.serializeFontQuery(['-apple-system', 'sans-serif'], 400), '-apple-system regular, sans-serif')
588
+ assert.equal(plugin.serializeFontQuery([], 500), '')
589
+ assert.equal(plugin.serializeFontQuery(undefined, undefined), '')
590
+ for (const value of [
591
+ 'Inter regular, "PingFang SC", sans-serif',
592
+ '"Geist Mono" medium, monospace',
593
+ '"Foo, Bar", "Baz, Qux", serif',
594
+ ]) {
595
+ const read = plugin.parseFontQuery(value, QUERY)
596
+ assert.equal(
597
+ plugin.serializeFontQuery(read.families, read.weight),
598
+ value,
599
+ `the canonical form of ${value} must be itself`,
600
+ )
601
+ }
602
+
603
+ // The number a stored value stands for, and the word the query spells it with.
604
+ assert.equal(plugin.normalizeWeight('medium'), 500)
605
+ assert.equal(plugin.normalizeWeight('Medium'), 500)
606
+ assert.equal(plugin.normalizeWeight(700), 700)
607
+ assert.equal(plugin.normalizeWeight('book'), 400)
608
+ assert.equal(plugin.normalizeWeight(undefined), 400)
609
+ assert.equal(plugin.normalizeWeight('wobble'), 400)
610
+ assert.equal(plugin.normalizeWeight(550), 400)
611
+ assert.equal(plugin.weightWord(500), 'medium')
612
+ assert.equal(plugin.weightWord(400), 'regular')
613
+ assert.equal(plugin.weightWord(900), 'black')
614
+
615
+ // Face styles are the only weight evidence the browser gives, and its spelling
616
+ // is not guaranteed, so an intensifier written apart has to be folded back.
617
+ assert.deepEqual(plugin.faceWeights(['Semi Bold', 'ExtraLight', 'Bold Italic', 'Regular']), [
618
+ 200, 400, 600, 700,
619
+ ])
620
+ assert.deepEqual(plugin.faceWeights(['Light', 'Medium', 'Black']), [300, 500, 900])
621
+ assert.deepEqual(plugin.faceWeights(['Italic', 'Oblique']), [])
622
+ assert.deepEqual(plugin.faceWeights(undefined), [])
623
+
624
+ // ── the highlight: tokens that concatenate back into the input ───────────────
625
+ // This invariant is what keeps the painted layer aligned with the textarea, so
626
+ // it is asserted over shapes that stress quotes, weight words, and spacing.
627
+ for (const value of [
628
+ '',
629
+ 'Geist Mono medium',
630
+ 'Geist Mono medium, "Zhuque Fangsong (technical preview)", monospace',
631
+ '"Foo, Bar" bold , Inter',
632
+ 'Inter,',
633
+ ' ',
634
+ '"unclosed, monospace',
635
+ ]) {
636
+ const tokens = plugin.fontQueryTokens(value, QUERY)
637
+ assert.equal(
638
+ tokens.map((token) => token.text).join(''),
639
+ value,
640
+ `tokens must reproduce ${JSON.stringify(value)} exactly`,
641
+ )
642
+ }
643
+
644
+ {
645
+ const tokens = plugin.fontQueryTokens('Geist Mono medium, "Foo, Bar", monospace', {
646
+ ...QUERY,
647
+ catalogue: [...QUERY_CATALOGUE, 'Foo, Bar'],
648
+ })
649
+ assert.deepEqual(
650
+ tokens.map((token) => [token.text, token.kind]),
651
+ [
652
+ ['Geist Mono ', 'family'],
653
+ ['medium', 'weight'],
654
+ [',', 'comma'],
655
+ [' ', 'space'],
656
+ ['"Foo, Bar"', 'family'],
657
+ [',', 'comma'],
658
+ [' ', 'space'],
659
+ ['monospace', 'generic'],
660
+ ],
661
+ )
662
+ // The family is carried on the token so the layer can mark the one in effect.
663
+ assert.equal(tokens[0].family, 'Geist Mono')
664
+ assert.equal(tokens[1].family, 'Geist Mono')
665
+ assert.equal(tokens[7].family, 'monospace')
666
+ }
667
+
668
+ // A family the machine does not have is painted apart, but only when the
669
+ // catalogue is authoritative — otherwise every curated name would look wrong.
670
+ {
671
+ const unknown = plugin.fontQueryTokens('Nope Sans, monospace', QUERY)
672
+ assert.equal(unknown[0].kind, 'unknown')
673
+ assert.equal(plugin.fontQueryTokens('Nope Sans', { catalogue: [], enumerated: false })[0].kind, 'family')
674
+ }
675
+
676
+ // The pill marks the family that is in effect — the first one the browser can
677
+ // actually use — and nothing else.
678
+ {
679
+ const tokens = plugin.fontQueryTokens('"Nope Sans", monospace', QUERY)
680
+ assert.equal(plugin.queryTokenClass(tokens[0], 'monospace'), 'dsh-font-qUnknown')
681
+ assert.equal(plugin.queryTokenClass(tokens[3], 'monospace'), 'dsh-font-qGeneric dsh-font-qEffective')
682
+ assert.equal(plugin.queryTokenClass({ text: ',', kind: 'comma' }, 'monospace'), 'dsh-font-qComma')
683
+ assert.equal(plugin.queryTokenClass({ text: ' ', kind: 'space' }, 'monospace'), '')
684
+ }
685
+
686
+ // The highlighted row is clamped against a list that may have shrunk under the
687
+ // cursor, so the popup can never commit a row that is not on screen.
688
+ assert.equal(plugin.clampHighlight([], 3), -1)
689
+ assert.equal(plugin.clampHighlight(['a', 'b'], 9), 1)
690
+ assert.equal(plugin.clampHighlight(['a', 'b'], -4), 0)
691
+
692
+ // ── the caret's entry, and what the popup offers there ───────────────────────
693
+ // The completion replaces the WHOLE entry, because a family name is several
694
+ // words: completing `geist mo` has to replace both of them.
695
+ {
696
+ const context = plugin.queryContextAt('Inter, geist mo', 15)
697
+ assert.deepEqual(
698
+ [context.start, context.end, context.core, context.head, context.word],
699
+ [6, 15, 'geist mo', 'geist', 'mo'],
700
+ )
701
+ }
702
+ // A trailing complete weight word is not part of the family, so the family text
703
+ // ends before it — `"Geist Mono" medium` must still suggest families.
704
+ {
705
+ const context = plugin.queryContextAt('"Geist Mono" medium', 19)
706
+ assert.equal(context.weightWord, 'medium')
707
+ assert.equal(context.familyEnd, 12)
708
+ assert.equal(context.caretInCore >= context.familyEnd, true)
709
+ }
710
+ // The last entry is the caret's entry even when the caret sits past its end,
711
+ // and an empty query is one empty entry rather than none.
712
+ assert.equal(plugin.queryContextAt('Inter, monospace', 999).core, 'monospace')
713
+ assert.equal(plugin.queryContextAt('', 0).core, '')
714
+ assert.equal(plugin.queryContextAt('Inter', 2).word, 'Inter')
715
+
716
+ // A partial family name completes to the family, and the typed text stays
717
+ // available as a custom row because the catalogue is never complete.
718
+ {
719
+ const suggestion = plugin.querySuggestions(plugin.queryContextAt('geist', 5), QUERY)
720
+ assert.deepEqual(suggestion.items.map((item) => item.insert), ['"Geist Mono"'])
721
+ assert.equal(suggestion.custom, 'geist')
722
+ assert.deepEqual([suggestion.start, suggestion.end], [0, 5])
723
+ }
724
+ // An exact family name leads with its WEIGHTS, written as `<family> <weight>` so
725
+ // one pick sets both fields — and only the weights the machine actually has.
726
+ {
727
+ const suggestion = plugin.querySuggestions(plugin.queryContextAt('Geist Mono', 10), QUERY)
728
+ assert.deepEqual(
729
+ suggestion.items.slice(0, 3).map((item) => [item.kind, item.insert, item.weight]),
730
+ [
731
+ ['weight', '"Geist Mono" regular', 400],
732
+ ['weight', '"Geist Mono" medium', 500],
733
+ ['weight', '"Geist Mono" bold', 700],
734
+ ],
735
+ )
736
+ // No custom row: the typed text already names a family.
737
+ assert.equal(suggestion.custom, undefined)
738
+ }
739
+ // With no face data the whole closed vocabulary is offered instead, so a weight
740
+ // is still discoverable without the Local Font Access permission.
741
+ {
742
+ const suggestion = plugin.querySuggestions(plugin.queryContextAt('monospace', 9), {
743
+ catalogue: QUERY_CATALOGUE,
744
+ enumerated: false,
745
+ })
746
+ assert.deepEqual(
747
+ suggestion.items.slice(0, 9).map((item) => item.weight),
748
+ [100, 200, 300, 400, 500, 600, 700, 800, 900],
749
+ )
750
+ }
751
+ // A query that already states its weight is never silently moved by an Enter
752
+ // that accepts the highlighted row: the stated weight leads its own list, and a
753
+ // family pick carries the word along.
754
+ {
755
+ const context = plugin.queryContextAt('"Geist Mono" medium', 19)
756
+ const suggestion = plugin.querySuggestions(context, QUERY)
757
+ assert.equal(suggestion.items[0].word, 'medium')
758
+ assert.equal(suggestion.items[0].insert, '"Geist Mono" medium')
759
+ assert.equal(suggestion.items.at(-1).insert, '"Geist Mono" medium')
760
+ // Swapping the family keeps the weight the user wrote rather than resetting it.
761
+ const swap = plugin.queryContextAt('"Geist Mono" medium', 19)
762
+ const inter = plugin.querySuggestions(
763
+ { ...swap, inner: 'Inter', head: 'Inter', word: '' },
764
+ QUERY,
765
+ ).items.find((item) => item.name === 'Inter')
766
+ assert.equal(inter.insert, 'Inter medium')
767
+ }
768
+ // A word after a complete family is read as a weight prefix: `Geist Mono b`
769
+ // offers Bold, and nothing else — Geist Mono has no Black face here.
770
+ {
771
+ const suggestion = plugin.querySuggestions(plugin.queryContextAt('Geist Mono b', 12), QUERY)
772
+ assert.deepEqual(suggestion.items.map((item) => item.word), ['bold'])
773
+ assert.equal(suggestion.custom, 'Geist Mono b')
774
+ }
775
+ // A partial family name keeps the FAMILY list first, even when the text before
776
+ // the caret happens to be a family of its own: `inter t` means Inter Tight.
777
+ {
778
+ const suggestion = plugin.querySuggestions(plugin.queryContextAt('inter t', 7), QUERY)
779
+ assert.equal(suggestion.items[0].name, 'Inter Tight')
780
+ assert.equal(suggestion.items[0].kind, 'family')
781
+ }
782
+ // An empty query browses, and offers no custom row — an Enter there must not
783
+ // replace the stack with whatever happens to sort first.
784
+ {
785
+ const suggestion = plugin.querySuggestions(plugin.queryContextAt('', 0), QUERY)
786
+ assert.ok(suggestion.items.length > 0, 'an empty query browses the catalogue')
787
+ assert.equal(suggestion.custom, undefined)
788
+ }
789
+ // A name the catalogue does not have is still insertable, quoted the way CSS
790
+ // requires it — and the weight word the user typed goes in with it.
791
+ {
792
+ const suggestion = plugin.querySuggestions(plugin.queryContextAt('My Font bold', 11), QUERY)
793
+ assert.deepEqual(suggestion.items, [])
794
+ assert.equal(suggestion.custom, 'My Font bold')
795
+ }
796
+ // Taking a completion replaces the entry and invites the next one with a comma —
797
+ // except after a weight, which completes the entry instead.
798
+ {
799
+ const context = plugin.queryContextAt('geist', 5)
800
+ const suggestion = plugin.querySuggestions(context, QUERY)
801
+ assert.equal(suggestion.at, 'entry')
802
+ assert.deepEqual(plugin.applySuggestion('geist', suggestion, '"Geist Mono"', 'family'), {
803
+ text: '"Geist Mono", ',
804
+ caret: 14,
805
+ })
806
+ assert.deepEqual(plugin.applySuggestion('geist', suggestion, '"Geist Mono" medium', 'weight'), {
807
+ text: '"Geist Mono" medium',
808
+ caret: 19,
809
+ })
810
+ // An entry in the middle keeps the comma that already separates it.
811
+ const middle = plugin.queryContextAt('geist, monospace', 5)
812
+ assert.deepEqual(
813
+ plugin.applySuggestion('geist, monospace', plugin.querySuggestions(middle, QUERY), '"Geist Mono"', 'family'),
814
+ { text: '"Geist Mono", monospace', caret: 12 },
815
+ )
816
+ // A custom row inserts the typed text as it stands.
817
+ const custom = plugin.querySuggestions(plugin.queryContextAt('My Font', 7), QUERY)
818
+ assert.equal(plugin.applySuggestion('My Font', custom, 'My Font', 'custom').text, 'My Font, ')
819
+ }
820
+
821
+ // A caret at the START of a complete entry is a boundary: the pick goes in ahead
822
+ // of it, so the family already there stays as a fallback. That is how a font is
823
+ // put in charge without dragging anything.
824
+ {
825
+ const context = plugin.queryContextAt('"Geist Mono", monospace', 0)
826
+ const suggestion = plugin.querySuggestions(context, QUERY)
827
+ assert.equal(suggestion.at, 'before')
828
+ assert.deepEqual(plugin.applySuggestion('"Geist Mono", monospace', suggestion, 'Inter', 'family'), {
829
+ text: 'Inter, "Geist Mono", monospace',
830
+ caret: 7,
831
+ })
832
+ // Ahead of a middle entry: the entry's own spacing does not double up.
833
+ const middle = plugin.queryContextAt('"Geist Mono", monospace', 14)
834
+ assert.equal(middle.core, 'monospace')
835
+ assert.deepEqual(
836
+ plugin.applySuggestion('"Geist Mono", monospace', plugin.querySuggestions(middle, QUERY), 'Inter', 'family'),
837
+ { text: '"Geist Mono", Inter, monospace', caret: 21 },
838
+ )
839
+ // A weight completes the entry it sits next to, at a boundary or not.
840
+ assert.equal(
841
+ plugin.applySuggestion('"Geist Mono", monospace', suggestion, '"Inter" medium', 'weight').text,
842
+ '"Inter" medium, monospace',
843
+ )
844
+ }
845
+
846
+ // ── moving an entry: the keyboard's answer to drag-and-drop ─────────────────
847
+ // Order is significant (the first installed family wins), so it has to be
848
+ // editable — but as text, which leaves every space and comma exactly where the
849
+ // user put it.
850
+ assert.deepEqual(plugin.moveFontQueryEntry('Inter, monospace, "Fira Code"', 3, 1), {
851
+ text: 'monospace, Inter, "Fira Code"',
852
+ caret: 14,
853
+ })
854
+ assert.deepEqual(plugin.moveFontQueryEntry('Inter, monospace, "Fira Code"', 26, -1), {
855
+ text: 'Inter, "Fira Code", monospace',
856
+ caret: 15,
857
+ })
858
+ // The spacing is the user's, not the serializer's: a move only reorders.
859
+ {
860
+ const source = 'Inter , monospace'
861
+ const moved = plugin.moveFontQueryEntry(source, 16, -1)
862
+ assert.deepEqual([...moved.text].sort(), [...source].sort(), 'a move must not lose a character')
863
+ assert.equal(moved.text.replace(/\s+/g, ' '), 'monospace , Inter')
864
+ }
865
+ // Moving off either end of the list changes nothing at all.
866
+ {
867
+ const source = 'Inter, monospace'
868
+ assert.deepEqual(plugin.moveFontQueryEntry(source, 2, -1), { text: source, caret: 2 })
869
+ assert.deepEqual(plugin.moveFontQueryEntry(source, 16, 1), { text: source, caret: 16 })
870
+ assert.deepEqual(plugin.moveFontQueryEntry('Inter', 2, 1), { text: 'Inter', caret: 2 })
871
+ }
872
+
873
+ // ── the interface weight, which is opted into ───────────────────────────────
874
+ // The interface has a weight hierarchy, so setting the base has to move the
875
+ // heading steps with it rather than flatten them — and the shipped 400 must
876
+ // emit nothing at all, keeping a default install's sheet byte-identical.
877
+ assert.equal(plugin.emphasisWeight(700, 400), 700)
878
+ assert.equal(plugin.emphasisWeight(700, 500), 800)
879
+ assert.equal(plugin.emphasisWeight(600, 500), 700)
880
+ assert.equal(plugin.emphasisWeight(500, 500), 600)
881
+ assert.equal(plugin.emphasisWeight(700, 100), 700)
882
+ assert.equal(plugin.emphasisWeight(700, 900), 900)
883
+ {
884
+ const plain = plugin.fontStyleSheet({ ...section, uiFontWeight: 400 })
885
+ assert.equal(plain, sheet, 'the shipped interface weight must change nothing')
886
+ assert.ok(!plain.includes('font-weight:400}'), 'no rule is emitted for the shipped weight')
887
+
888
+ const heavy = plugin.fontStyleSheet({ ...section, uiFontWeight: 500 })
889
+ assert.match(heavy, /html body\{font-weight:500\}/)
890
+ assert.match(heavy, /--dsh-font-markdown-base:500 var\(--dsh-font-conversation-size,14px\)/)
891
+ assert.match(heavy, /--dsh-font-markdown-table:500 calc\(16px - 1px\)/)
892
+ assert.match(heavy, /--dsh-font-markdown-h1:800 calc\(16px \+ 7px\)/)
893
+ assert.match(heavy, /--dsh-font-markdown-h4:700 var\(--dsh-font-conversation-size,14px\)/)
894
+ assert.match(heavy, /--dsh-font-markdown-table-head:600 calc\(16px - 1px\)/)
895
+ // The code ladder is a different axis and must not move with it.
896
+ assert.match(heavy, /--dsw-font-markdown-code:var\(--dsh-font-code-weight,400\)/)
897
+ }
898
+
899
+ // ── the diagnostic messages the row shows ───────────────────────────────────
900
+ assert.equal(
901
+ plugin.describeDiagnostic(
902
+ { code: 'missing-weight', name: 'Inter', weight: 700, word: 'bold' },
903
+ { missingWeight: '{family}→{weight}→{word}' },
904
+ ),
905
+ 'Inter→700→bold',
906
+ )
907
+ assert.equal(
908
+ plugin.describeDiagnostic({ code: 'unknown-family', name: 'X' }, { unknownFamily: 'no {name}' }),
909
+ 'no X',
910
+ )
911
+ assert.equal(plugin.describeDiagnostic({ code: 'unclosed-quote' }, { unclosedQuote: 'open' }), 'open')
912
+ assert.equal(
913
+ plugin.describeDiagnostic({ code: 'trailing-text', text: 'wobble' }, { trailingText: 'stray {text}' }),
914
+ 'stray wobble',
915
+ )
916
+ assert.equal(plugin.describeDiagnostic({ code: 'nonsense' }, {}), '')
917
+
918
+ // ── the editor component ────────────────────────────────────────────────────
919
+ // The React stub does not recursively render function children, so the editor
920
+ // is driven at its own level: its handlers are called the way a browser calls
921
+ // them, and the tree it returns is inspected.
922
+
923
+ /** Collect every element in a tree, depth-first, flattening array children. */
924
+ function collectElements(node, out = []) {
925
+ if (node === null || node === undefined) return out
926
+ if (Array.isArray(node)) {
927
+ for (const entry of node) collectElements(entry, out)
928
+ return out
929
+ }
930
+ if (typeof node !== 'object') return out
931
+ out.push(node)
932
+ const children = Array.isArray(node.children) ? node.children : [node.children]
933
+ for (const child of children) collectElements(child, out)
934
+ collectElements(node.props?.children, out)
935
+ return out
936
+ }
937
+
938
+ /** The copy the editor takes as props, so the component stays locale-free. */
939
+ const EDITOR_LABELS = {
940
+ list: 'font.suggestions',
941
+ add: 'font.add',
942
+ font: 'font.familyKind',
943
+ generic: 'font.genericKind',
944
+ weightLine: 'font.codeWeight',
945
+ weightShipped: 'font.weightShipped',
946
+ pending: 'font.pending',
947
+ emptyQuery: 'font.emptyQuery',
948
+ genericWarning: 'font.genericWarning',
949
+ unknownFamily: 'no {name} here',
950
+ missingWeight: '{family} has no {weight} ({word})',
951
+ duplicateWeight: 'one weight only ({word})',
952
+ unclosedQuote: 'unclosed quote',
953
+ trailingText: 'stray {text}',
954
+ weightName: (weight) => `w${String(weight)}`,
955
+ }
956
+
957
+ /**
958
+ * Render the editor and drive its handlers, re-rendering after every write the
959
+ * way a state update would.
960
+ * @param overrides - prop overrides.
961
+ * @returns the tree plus interaction helpers and the recorded writes.
962
+ */
963
+ function renderEditor(overrides = {}) {
964
+ const familyWrites = []
965
+ const weightWrites = []
966
+ const instance = mount()
967
+ const props = {
968
+ value: '"Geist Mono", monospace',
969
+ weight: 500,
970
+ catalogue: QUERY_CATALOGUE,
971
+ styles: QUERY_STYLES,
972
+ enumerated: true,
973
+ monospace: true,
974
+ label: 'font.codeFamily',
975
+ labels: EDITOR_LABELS,
976
+ onFamilies: (value) => familyWrites.push(value),
977
+ onWeight: (weight) => weightWrites.push(weight),
978
+ ...overrides,
979
+ }
980
+ let tree = instance.render(plugin.FontQueryEditor, props)
981
+ const render = () => {
982
+ tree = instance.render(plugin.FontQueryEditor, props)
983
+ return tree
984
+ }
985
+ const helpers = {
986
+ props,
987
+ familyWrites,
988
+ weightWrites,
989
+ get tree() {
990
+ return tree
991
+ },
992
+ render,
993
+ /** The editor's real text surface. */
994
+ textarea() {
995
+ const node = collectElements(tree).find((element) => element.type === 'textarea')
996
+ assert.ok(node !== undefined, 'the editor must render a textarea')
997
+ return node
998
+ },
999
+ /** The painted tokens behind the textarea (a space token has no class). */
1000
+ painted() {
1001
+ return collectElements(tree).filter(
1002
+ (element) =>
1003
+ element.type === 'span' &&
1004
+ typeof element.props?.className === 'string' &&
1005
+ (element.props.className === '' || element.props.className.startsWith('dsh-font-q')),
1006
+ )
1007
+ },
1008
+ /** The completion rows, in the order the popup shows them. */
1009
+ rows() {
1010
+ return collectElements(tree).filter((element) => element.type === 'li')
1011
+ },
1012
+ type(text, caret) {
1013
+ helpers.textarea().props.onChange({ target: { value: text, selectionStart: caret ?? text.length } })
1014
+ return render()
1015
+ },
1016
+ key(key, extra = {}) {
1017
+ helpers.textarea().props.onKeyDown({ key, preventDefault: () => undefined, ...extra })
1018
+ return render()
1019
+ },
1020
+ focus(caret = 0) {
1021
+ helpers.textarea().props.onFocus({ target: { selectionStart: caret } })
1022
+ return render()
1023
+ },
1024
+ blur() {
1025
+ helpers.textarea().props.onBlur()
1026
+ return render()
1027
+ },
1028
+ }
1029
+ return helpers
1030
+ }
1031
+
1032
+ // The field shows the stored family and weight as one query, and offers no
1033
+ // placeholder: a ghost of the shipped stack in an empty box reads as a value the
1034
+ // plugin put there.
1035
+ {
1036
+ const editor = renderEditor()
1037
+ assert.equal(editor.textarea().props.value, '"Geist Mono" medium, monospace')
1038
+ assert.equal(editor.textarea().props.role, 'combobox')
1039
+ assert.equal(editor.textarea().props.placeholder, undefined)
1040
+ assert.equal(editor.rows().length, 0, 'the popup starts closed')
1041
+ }
1042
+
1043
+ // The painted layer renders exactly the same characters as the field, and marks
1044
+ // the family that is in effect.
1045
+ {
1046
+ const editor = renderEditor()
1047
+ const painted = editor.painted()
1048
+ assert.equal(painted.map((token) => token.children.join('')).join(''), '"Geist Mono" medium, monospace')
1049
+ assert.match(painted[0].props.className, /dsh-font-qEffective/)
1050
+ assert.equal(painted[0].children.join(''), '"Geist Mono" ')
1051
+ assert.match(painted[1].props.className, /dsh-font-qWeight/)
1052
+ assert.equal(painted[1].children.join(''), 'medium')
1053
+ assert.match(painted.at(-1).props.className, /dsh-font-qGeneric/)
1054
+ }
1055
+
1056
+ // Focusing opens the browse list; typing narrows it, and the typed text stays
1057
+ // available as a custom row because the catalogue is never complete.
1058
+ {
1059
+ const editor = renderEditor({ value: 'monospace', weight: 400 })
1060
+ editor.focus(0)
1061
+ assert.ok(editor.rows().length > 0, 'focus opens the completion list')
1062
+ editor.type('geist', 5)
1063
+ assert.equal(editor.rows().length, 2, 'the one matching family, then the custom row')
1064
+ assert.equal(editor.rows()[0].props.role, 'option')
1065
+ assert.match(editor.rows()[0].props.className, /dsh-font-optionActive/)
1066
+ assert.match(editor.rows()[1].props.className, /dsh-font-optionCustom/)
1067
+ }
1068
+
1069
+ // Enter APPLIES; it never completes. Transforming what was typed because Enter
1070
+ // was pressed is the one thing this control must not do.
1071
+ {
1072
+ const editor = renderEditor({ value: 'sans-serif', weight: 400 })
1073
+ editor.focus(0)
1074
+ editor.type('geist', 5)
1075
+ editor.key('Enter')
1076
+ assert.equal(editor.textarea().props.value, 'geist', 'Enter must not rewrite the text')
1077
+ assert.deepEqual(editor.familyWrites, ['geist'], 'the typed name is what gets stored')
1078
+ }
1079
+
1080
+ // Tab takes the highlighted completion — an unambiguous request for it — and the
1081
+ // entry is replaced with the quoted family.
1082
+ {
1083
+ const editor = renderEditor({ value: 'sans-serif', weight: 400 })
1084
+ editor.focus(0)
1085
+ editor.type('geist', 5)
1086
+ editor.key('Tab')
1087
+ assert.equal(editor.textarea().props.value, '"Geist Mono", ')
1088
+ assert.deepEqual(editor.familyWrites, ['"Geist Mono"'])
1089
+ }
1090
+
1091
+ // Picking a weight writes the weight AND keeps the family: one edit, one place.
1092
+ {
1093
+ const editor = renderEditor({ value: 'monospace', weight: 400 })
1094
+ editor.type('Geist Mono', 10)
1095
+ const rows = editor.rows()
1096
+ assert.equal(rows.length, 4, 'the three faces Geist Mono has, then the family itself')
1097
+ editor.key('ArrowDown')
1098
+ editor.key('Tab')
1099
+ assert.equal(editor.textarea().props.value, '"Geist Mono" medium')
1100
+ assert.deepEqual(editor.familyWrites, ['"Geist Mono"'])
1101
+ assert.deepEqual(editor.weightWrites, [500])
1102
+ }
1103
+
1104
+ // Picking the SHIPPED weight takes the word away instead of writing it: the row
1105
+ // is offered (it is a real choice) but its text is just the family, and the
1106
+ // number travels with the row rather than being read back out of the text. The
1107
+ // weight the query already states leads its own list, so the pick below is one
1108
+ // step from what is written.
1109
+ {
1110
+ const editor = renderEditor({ value: 'monospace', weight: 500 })
1111
+ editor.type('Geist Mono medium', 17)
1112
+ editor.key('ArrowDown')
1113
+ editor.key('Tab')
1114
+ assert.equal(editor.textarea().props.value, '"Geist Mono"')
1115
+ assert.deepEqual(editor.weightWrites, [400])
1116
+ }
1117
+
1118
+ // A typed name the catalogue does not list is taken as it stands: Enter applies
1119
+ // it, Tab accepts the custom row (which is the version with the comma).
1120
+ {
1121
+ const editor = renderEditor({ value: 'sans-serif', weight: 400, catalogue: [] })
1122
+ editor.type('My Font bold', 11)
1123
+ editor.key('Enter')
1124
+ assert.equal(editor.textarea().props.value, 'My Font bold', 'Enter leaves the text alone')
1125
+ assert.deepEqual(editor.familyWrites, ['"My Font"'])
1126
+ assert.deepEqual(editor.weightWrites, [700])
1127
+ }
1128
+ {
1129
+ const editor = renderEditor({ value: 'sans-serif', weight: 400, catalogue: [] })
1130
+ editor.type('My Font bold', 11)
1131
+ editor.key('Tab')
1132
+ assert.equal(editor.textarea().props.value, 'My Font bold, ')
1133
+ assert.deepEqual(editor.familyWrites, ['"My Font"'])
1134
+ }
1135
+
1136
+ // An empty query is an UNFINISHED EDIT, not a value: it writes nothing at all.
1137
+ // Refilling the box with the shipped stack — which is what "an empty stack is
1138
+ // not a valid CSS value" used to justify — is indistinguishable from a bug to
1139
+ // the person who just cleared it.
1140
+ {
1141
+ const editor = renderEditor({ value: '', weight: 400 })
1142
+ editor.focus(0)
1143
+ assert.ok(editor.rows().length > 0, 'an empty query browses')
1144
+ editor.key('Enter')
1145
+ assert.deepEqual(editor.familyWrites, [], 'an empty query writes no family')
1146
+ assert.deepEqual(editor.weightWrites, [], 'and no weight')
1147
+ assert.equal(editor.textarea().props.value, '')
1148
+ }
1149
+
1150
+ // Blur applies what was typed and leaves the text EXACTLY as it is: no quotes
1151
+ // added, no word moved, no comma dropped. The stored value is the plugin's
1152
+ // serialization of the parse; the box is the user's text, and the two are
1153
+ // allowed to differ.
1154
+ {
1155
+ const editor = renderEditor({ value: 'sans-serif', weight: 500 })
1156
+ editor.type('geist mono', 10)
1157
+ editor.blur()
1158
+ assert.equal(editor.textarea().props.value, 'geist mono', 'blur must not rewrite the text')
1159
+ assert.equal(editor.familyWrites.at(-1), '"geist mono"')
1160
+ // The query names no weight, so the weight already set is left alone rather
1161
+ // than silently reset.
1162
+ assert.deepEqual(editor.weightWrites, [])
1163
+ }
1164
+ // A weight the text names IS applied.
1165
+ {
1166
+ const editor = renderEditor({ value: 'sans-serif', weight: 500 })
1167
+ editor.type('geist mono medium', 17)
1168
+ editor.blur()
1169
+ assert.equal(editor.textarea().props.value, 'geist mono medium')
1170
+ assert.equal(editor.weightWrites.at(-1), 500)
1171
+ }
1172
+ // Clearing the box writes NOTHING and stays empty: the saved stack is still the
1173
+ // one in use, and the field says so instead of refilling itself with a string
1174
+ // from nowhere.
1175
+ {
1176
+ const editor = renderEditor()
1177
+ editor.type('', 0)
1178
+ editor.blur()
1179
+ assert.equal(editor.textarea().props.value, '')
1180
+ assert.deepEqual(editor.familyWrites, [])
1181
+ assert.deepEqual(editor.weightWrites, [])
1182
+ const text = []
1183
+ const walk = (node) => {
1184
+ if (typeof node === 'string') text.push(node)
1185
+ else if (Array.isArray(node)) for (const child of node) walk(child)
1186
+ else if (typeof node === 'object' && node !== null) {
1187
+ for (const child of node.children ?? []) walk(child)
1188
+ walk(node.props?.children)
1189
+ }
1190
+ }
1191
+ walk(editor.tree)
1192
+ assert.ok(
1193
+ text.some((line) => line.includes('font.emptyQuery')),
1194
+ 'an unfinished edit is reported, not corrected',
1195
+ )
1196
+ assert.ok(
1197
+ !text.some((line) => line.includes('font.pending')),
1198
+ 'and there is nothing to apply, so no pending line either',
1199
+ )
1200
+ }
1201
+
1202
+ // The shipped weight is NOT spelled out in the field: nobody chose it, and a
1203
+ // `regular` appearing after every interface font reads as junk the plugin
1204
+ // injected. It is stated in the line under the field instead, and it comes back
1205
+ // the moment a weight is actually chosen.
1206
+ {
1207
+ const plain = renderEditor({ value: 'sans-serif', weight: 400, monospace: false })
1208
+ assert.equal(plain.textarea().props.value, 'sans-serif')
1209
+ const text = []
1210
+ const walk = (node) => {
1211
+ if (typeof node === 'string') text.push(node)
1212
+ else if (Array.isArray(node)) for (const child of node) walk(child)
1213
+ else if (typeof node === 'object' && node !== null) {
1214
+ for (const child of node.children ?? []) walk(child)
1215
+ walk(node.props?.children)
1216
+ }
1217
+ }
1218
+ walk(plain.tree)
1219
+ assert.ok(
1220
+ text.some((line) => line.includes('font.codeWeight: w400 400font.weightShipped')),
1221
+ 'the shipped weight is reported below the field',
1222
+ )
1223
+
1224
+ const chosen = renderEditor({ value: 'sans-serif', weight: 300, monospace: false })
1225
+ assert.equal(chosen.textarea().props.value, 'sans-serif light')
1226
+ const code = renderEditor({ value: '"Geist Mono", monospace', weight: 400, monospace: true })
1227
+ assert.equal(code.textarea().props.value, '"Geist Mono", monospace')
1228
+ // A weight word left in the stored family string by a hand edit is the weight
1229
+ // field's business, so what is SHOWN is derived from the two values.
1230
+ const legacy = renderEditor({ value: 'Geist Mono medium, monospace', weight: 400, monospace: true })
1231
+ assert.equal(legacy.textarea().props.value, '"Geist Mono", monospace')
1232
+ }
1233
+
1234
+ // Alt+Arrow moves the entry under the caret, which is the editor's replacement
1235
+ // for dragging a chip — and it is a text edit, not a settings write.
1236
+ {
1237
+ const editor = renderEditor({ value: 'Inter, monospace', weight: undefined })
1238
+ editor.focus(0)
1239
+ editor.key('ArrowDown', { altKey: true })
1240
+ assert.equal(editor.textarea().props.value, 'monospace, Inter')
1241
+ assert.equal(editor.familyWrites.length, 0)
1242
+ }
1243
+
1244
+ // Typing at the front of an existing stack inserts EXACTLY the typed characters:
1245
+ // no comma is conjured up, nothing is moved. Putting a family in charge is either
1246
+ // typed out by the user (comma included) or taken from the completion list, where
1247
+ // the pick at a boundary is an explicit request for it.
1248
+ {
1249
+ const editor = renderEditor({ value: 'sans-serif', weight: 400 })
1250
+ editor.focus(0)
1251
+ editor.type('Isans-serif', 1)
1252
+ assert.equal(editor.textarea().props.value, 'Isans-serif', 'only the typed characters appear')
1253
+ // Putting the comma in is the user's job, and then the name completes as usual.
1254
+ editor.type('I, sans-serif', 12)
1255
+ assert.equal(editor.textarea().props.value, 'I, sans-serif')
1256
+ editor.blur()
1257
+ assert.deepEqual(editor.familyWrites.at(-1), 'I, sans-serif')
1258
+ }
1259
+ // A pick at the START of a complete family inserts a new entry ahead of it — the
1260
+ // one text transformation the user asks for by picking from the list.
1261
+ {
1262
+ const editor = renderEditor({ value: 'sans-serif', weight: 400 })
1263
+ editor.type('Inter, sans-serif', 0)
1264
+ assert.equal(editor.rows()[0].props['aria-selected'], true)
1265
+ editor.key('ArrowDown')
1266
+ editor.key('Tab')
1267
+ assert.equal(editor.textarea().props.value, '"Inter Tight", Inter, sans-serif')
1268
+ }
1269
+
1270
+ // Escape closes the list first, and only a second press discards the draft —
1271
+ // the standard two-step, so a stray Escape cannot throw away typing.
1272
+ {
1273
+ const editor = renderEditor()
1274
+ editor.focus(0)
1275
+ assert.ok(editor.rows().length > 0)
1276
+ editor.key('Escape')
1277
+ assert.equal(editor.rows().length, 0, 'Escape closes the popup')
1278
+ editor.type('Inter', 5)
1279
+ editor.key('Escape')
1280
+ assert.equal(editor.textarea().props.value, 'Inter', 'the first Escape only closes the list')
1281
+ editor.key('Escape')
1282
+ assert.equal(
1283
+ editor.textarea().props.value,
1284
+ '"Geist Mono" medium, monospace',
1285
+ 'the second Escape reverts the draft',
1286
+ )
1287
+ }
1288
+
1289
+ // The row's copy: the weight line, the un-written hint, and one line per
1290
+ // diagnostic the query raised.
1291
+ {
1292
+ const editor = renderEditor()
1293
+ const text = []
1294
+ const walk = (node) => {
1295
+ if (typeof node === 'string') text.push(node)
1296
+ else if (Array.isArray(node)) for (const child of node) walk(child)
1297
+ else if (typeof node === 'object' && node !== null) {
1298
+ for (const child of node.children ?? []) walk(child)
1299
+ walk(node.props?.children)
1300
+ }
1301
+ }
1302
+ walk(editor.tree)
1303
+ assert.ok(text.some((line) => line.includes('font.codeWeight: w500 500')), 'the applied weight is stated')
1304
+ assert.ok(text.some((line) => line.includes('font-family: "Geist Mono", monospace')), 'and the stack')
1305
+
1306
+ const warned = renderEditor({ value: '"Nope Sans", monospace', weight: 700 })
1307
+ const warnings = collectElements(warned.tree)
1308
+ .filter((element) => element.props?.className === 'dsh-font-warn')
1309
+ .map((element) => String(element.children.join('')))
1310
+ assert.deepEqual(warnings, ['no Nope Sans here'])
1311
+ }
1312
+
1313
+ // The interface axis is not monospace, and its weight word is its own field.
1314
+ {
1315
+ const editor = renderEditor({ monospace: false, weight: 300, value: 'Inter, sans-serif' })
1316
+ assert.equal(editor.tree.props.className, 'dsh-font-query')
1317
+ assert.equal(editor.textarea().props.value, 'Inter light, sans-serif')
1318
+ }
1319
+
293
1320
  // ── apply(ctx) end to end ───────────────────────────────────────────────────
294
1321
  const themeOverrides = []
295
1322
  const registeredSlots = []
@@ -324,7 +1351,10 @@ const ctx = {
324
1351
  return { dispose: () => undefined }
325
1352
  },
326
1353
  on: () => undefined,
327
- get: (name) => (name === 'theme' ? { overrideTokens: (source, tokens) => themeOverrides.push({ source, tokens }) } : undefined),
1354
+ get: (name) =>
1355
+ name === 'theme'
1356
+ ? { overrideTokens: (source, tokens) => themeOverrides.push({ source, tokens }) }
1357
+ : undefined,
328
1358
  locale,
329
1359
  settingsScope: { bind: (spec) => (assert.equal(spec.namespace, 'ui-font'), scope) },
330
1360
  slots: {
@@ -347,8 +1377,43 @@ assert.deepEqual(Object.keys(themeOverrides[0].tokens).sort(), ['--ds-font-famil
347
1377
  assert.equal(themeOverrides[0].tokens['--dsw-font-family'].light, section.uiFontFamily)
348
1378
  assert.equal(themeOverrides[0].tokens['--dsw-font-family'].dark, section.uiFontFamily)
349
1379
 
1380
+ // The editor's painted layer and its real textarea must share ONE font, and it
1381
+ // must not be the user's: a textarea cannot style a substring, so a face with
1382
+ // ligatures would draw one glyph in the layer and two in the field, and a
1383
+ // synthesized weight would differ between them. Anything that changes a glyph's
1384
+ // advance slides the colours off the characters.
1385
+ {
1386
+ const rowStyle = appended
1387
+ .map((node) => String(node.textContent ?? ''))
1388
+ .find((css) => css.includes('dsh-font-queryInput'))
1389
+ assert.ok(rowStyle !== undefined, 'the row stylesheet must be installed')
1390
+ const rule = /\.dsh-font-queryLayer,\.dsh-font-queryInput\{([^}]*)\}/.exec(rowStyle)?.[1]
1391
+ assert.ok(rule !== undefined, 'both layers must carry exactly the same font rule')
1392
+ assert.match(rule, /font-family:ui-monospace/)
1393
+ assert.match(rule, /font-weight:400/)
1394
+ assert.match(rule, /font-variant-ligatures:none/)
1395
+ assert.match(rule, /font-feature-settings:"liga" 0/)
1396
+ assert.ok(!rule.includes('--ds-font-family-code'), 'the field must not use the code font')
1397
+ assert.ok(!rule.includes('--dsw-font-family'), 'nor the interface font')
1398
+ assert.ok(
1399
+ !/\.dsh-font-queryCode/.test(rowStyle),
1400
+ 'no per-axis font rule may exist for the field',
1401
+ )
1402
+ }
1403
+
350
1404
  assert.equal(dictionaries.length, 1)
351
1405
  assert.deepEqual(Object.keys(dictionaries[0].dict.zh).sort(), Object.keys(dictionaries[0].dict.en).sort())
1406
+ for (const key of [
1407
+ 'font.suggestions',
1408
+ 'font.pending',
1409
+ 'font.diag.unknownFamily',
1410
+ 'font.diag.missingWeight',
1411
+ 'font.diag.duplicateWeight',
1412
+ 'font.diag.unclosedQuote',
1413
+ 'font.diag.trailingText',
1414
+ ]) {
1415
+ assert.ok(key in dictionaries[0].dict.zh, `the dictionary is missing ${key}`)
1416
+ }
352
1417
 
353
1418
  assert.equal(registeredSlots.length, 1)
354
1419
  const [{ options, component }] = registeredSlots
@@ -363,12 +1428,22 @@ const actions = options.inject(options.store.create())
363
1428
  assert.equal(typeof actions.setField, 'function')
364
1429
  assert.equal(typeof actions.reset, 'function')
365
1430
 
1431
+ /** Render the row with `setField` recorded. */
1432
+ function renderRow() {
1433
+ const writes = []
1434
+ const row = component({
1435
+ t: (key) => key,
1436
+ useStore: (selector) => selector(options.store.getSnapshot()),
1437
+ setField: (field, value) => {
1438
+ writes.push([field, value])
1439
+ },
1440
+ reset: () => undefined,
1441
+ })
1442
+ return { row, writes }
1443
+ }
1444
+
366
1445
  // The component must render a tree containing the localized labels.
367
- const rendered = component({
368
- t: (key) => key,
369
- useStore: (selector) => selector(options.store.getSnapshot()),
370
- ...actions,
371
- })
1446
+ const { row: rendered, writes } = renderRow()
372
1447
  const labels = []
373
1448
  const collect = (node) => {
374
1449
  if (node === null || node === undefined) return
@@ -379,8 +1454,6 @@ const collect = (node) => {
379
1454
  if (typeof node !== 'object') return
380
1455
  const children = Array.isArray(node.children) ? node.children : [node.children]
381
1456
  for (const child of children) collect(child)
382
- // `Field` and `SliderControl` carry their copy in props, because the stubs
383
- // above do not render function components.
384
1457
  collect(node.props?.children)
385
1458
  for (const key of ['label', 'value', 'hint', 'ariaLabel', 'placeholder']) {
386
1459
  collect(node.props?.[key])
@@ -399,6 +1472,98 @@ for (const key of [
399
1472
  assert.ok(labels.includes(key), `rendered row is missing ${key}`)
400
1473
  }
401
1474
 
1475
+ // The catalogue's provenance is announced once, and only when it is bad news:
1476
+ // an enumerated list is the machine's own and needs no badge, while the probe
1477
+ // fallback is a short curated list the popup can never complete. Discovery that
1478
+ // has not answered yet says nothing either, so no caveat flashes and vanishes.
1479
+ {
1480
+ const text = (node, out = []) => {
1481
+ if (typeof node === 'string' || typeof node === 'number') out.push(String(node))
1482
+ else if (Array.isArray(node)) for (const child of node) text(child, out)
1483
+ else if (typeof node === 'object' && node !== null) {
1484
+ for (const child of node.children ?? []) text(child, out)
1485
+ text(node.props?.children, out)
1486
+ }
1487
+ return out
1488
+ }
1489
+ // The row's first state slot is its catalogue; seeding it is how the verifier
1490
+ // reaches the two discovery outcomes without a React runtime.
1491
+ const notice = (catalog) => {
1492
+ react.__slots = [catalog]
1493
+ react.__hookIndex = 0
1494
+ return text(
1495
+ component({
1496
+ t: (key) => key,
1497
+ useStore: (selector) => selector(options.store.getSnapshot()),
1498
+ setField: () => undefined,
1499
+ reset: () => undefined,
1500
+ }),
1501
+ ).join('|')
1502
+ }
1503
+ assert.match(
1504
+ notice({ families: [], styles: {}, enumerated: false, measured: true }),
1505
+ /font\.catalogProbed/,
1506
+ 'the probe fallback must be called out',
1507
+ )
1508
+ assert.doesNotMatch(
1509
+ notice({ families: [], styles: {}, enumerated: true, measured: true }),
1510
+ /font\.catalogProbed/,
1511
+ 'a machine read needs no badge',
1512
+ )
1513
+ assert.doesNotMatch(
1514
+ notice({ families: [], styles: {}, enumerated: false }),
1515
+ /font\.catalogProbed/,
1516
+ 'discovery still running must say nothing',
1517
+ )
1518
+ react.__slots = undefined
1519
+ react.__hookIndex = 0
1520
+ }
1521
+
1522
+ // The row owns the wiring: one query editor per axis, each writing its own
1523
+ // family and weight fields, and each holding the copy for its axis.
1524
+ {
1525
+ const editors = collectElements(rendered).filter(
1526
+ (element) => typeof element.type === 'function' && element.type.name === 'FontQueryEditor',
1527
+ )
1528
+ assert.equal(editors.length, 2, 'one editor per axis')
1529
+
1530
+ const [ui, code] = editors
1531
+ assert.equal(ui.props.value, section.uiFontFamily)
1532
+ assert.equal(ui.props.weight, 400, 'the shipped interface weight')
1533
+ assert.equal(ui.props.monospace, undefined)
1534
+ assert.equal(ui.props.labels.weightLine, 'font.uiWeight')
1535
+ assert.equal(ui.props.label, 'font.uiFamily')
1536
+
1537
+ assert.equal(code.props.value, section.codeFontFamily)
1538
+ assert.equal(code.props.weight, section.codeFontWeight)
1539
+ assert.equal(code.props.monospace, true)
1540
+ assert.equal(code.props.labels.weightLine, 'font.codeWeight')
1541
+ assert.equal(code.props.labels.weightName(500), 'font.weight.medium')
1542
+
1543
+ ui.props.onFamilies('Inter Tight, sans-serif')
1544
+ ui.props.onWeight(300)
1545
+ code.props.onFamilies('"Geist Mono", monospace')
1546
+ code.props.onWeight(700)
1547
+ assert.deepEqual(writes, [
1548
+ ['uiFontFamily', 'Inter Tight, sans-serif'],
1549
+ ['uiFontWeight', 300],
1550
+ ['codeFontFamily', '"Geist Mono", monospace'],
1551
+ ['codeFontWeight', 700],
1552
+ ])
1553
+
1554
+ // A commit that resolves to the stored value must not write at all: the
1555
+ // settings document is durable, and a no-op round trip is still a write.
1556
+ ui.props.onFamilies(section.uiFontFamily)
1557
+ ui.props.onWeight(400)
1558
+ code.props.onFamilies(section.codeFontFamily)
1559
+ code.props.onWeight(section.codeFontWeight)
1560
+ assert.equal(writes.length, 4, 'an unchanged commit must not write')
1561
+
1562
+ // Both axes share the machine's catalogue, faces included.
1563
+ assert.equal(ui.props.styles, code.props.styles)
1564
+ assert.ok(Array.isArray(code.props.catalogue))
1565
+ }
1566
+
402
1567
  // A pushed settings change must repaint.
403
1568
  rootProperties.clear()
404
1569
  scopeListener()