@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/README.md +274 -14
- package/README.zh.md +120 -9
- package/lib/client.js +1911 -145
- package/lib/index.js +66 -4
- package/package.json +9 -3
- package/scripts/build-client.mjs +24 -1
- package/scripts/release.mjs +91 -0
- package/scripts/verify-client.mjs +1177 -12
- package/scripts/verify-host.mjs +59 -5
- package/scripts/verify-profile.mjs +64 -16
- package/src/client.js +1893 -145
|
@@ -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
|
-
/**
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
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()
|