dsh-fonttune 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,2455 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-fonttune",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ // ---- inlined from src/shared.cjs ----
7
+ var __dfpShared = (function () {
8
+ var module = { exports: {} };
9
+ var exports = module.exports;
10
+ /**
11
+ * dsh-fonttune shared core.
12
+ *
13
+ * Pure functions and constants only — no document, no cordis, no React. The
14
+ * host half (`lib/index.js`, ESM) and the browser half (`lib/client.js`, the
15
+ * lazy-CJS loader format) both load this file, so it is written as a CJS module
16
+ * that also works when a browser bundle inlines it verbatim.
17
+ *
18
+ * The single source of truth for: the durable field names, the CSS a
19
+ * configuration produces, and the sanitizing that keeps user-typed font names
20
+ * from breaking out of the injected stylesheet.
21
+ *
22
+ * @module dsh-fonttune/shared
23
+ */
24
+ "use strict";
25
+
26
+ /** Settings namespace registered by the host half. */
27
+ var NAMESPACE = "dsh-fonttune";
28
+
29
+ /** Field carrying the body/UI CSS font-family stack (empty = leave DSH alone). */
30
+ var SANS_FIELD = "sans";
31
+
32
+ /** Field carrying the code CSS font-family stack (empty = leave DSH alone). */
33
+ var MONO_FIELD = "mono";
34
+
35
+ /** Field carrying the global font-size offset in px (0 = leave DSH alone). */
36
+ var SIZE_FIELD = "sizeOffset";
37
+
38
+ /** Field carrying the global font weight (0 = leave DSH alone). */
39
+ var WEIGHT_FIELD = "weight";
40
+
41
+ /** Allowed font-size offset range. The upper bound stays under a 2x scale. */
42
+ var SIZE_MIN = -3;
43
+ var SIZE_MAX = 6;
44
+
45
+ /** Allowed font weight range, and the value meaning "do not touch". */
46
+ var WEIGHT_MIN = 300;
47
+ var WEIGHT_MAX = 600;
48
+ var WEIGHT_UNSET = 0;
49
+
50
+ /** Marker on both injected style tags, used for scoping every rule we write. */
51
+ var MARKER = "dfp";
52
+
53
+ /** `data-plugin-css` value of the tag applying families/offset/weight. */
54
+ var STYLE_TAG = "dsh-fonttune";
55
+
56
+ /** `data-plugin-css` value of the card's own chrome stylesheet. */
57
+ var CARD_STYLE_TAG = "dsh-fonttune-card";
58
+
59
+ /** Composition defaults: every axis dormant, so installing changes nothing. */
60
+ var DEFAULTS = {
61
+ sans: "",
62
+ mono: "",
63
+ sizeOffset: 0,
64
+ weight: WEIGHT_UNSET,
65
+ };
66
+
67
+ /**
68
+ * Longest accepted font stack, in characters (mirrored by the host schema).
69
+ */
70
+ var MAX_STACK_LENGTH = 200;
71
+
72
+ /** Longest accepted single family name, in characters. */
73
+ var MAX_FAMILY_LENGTH = 64;
74
+
75
+ /**
76
+ * What may survive in a family name.
77
+ *
78
+ * This is an allow-list, not a deny-list: a family name is letters (any
79
+ * script, so CJK and accented Latin pass), digits, spaces and the handful of
80
+ * punctuation marks real families use. Everything else — quotes, braces,
81
+ * semicolons, colons, commas, parentheses, slashes, angle brackets, comment
82
+ * markers — is dropped, which is what makes it impossible for a typed name to
83
+ * end a declaration, open a rule, or reach `url(...)`.
84
+ */
85
+ var UNSAFE_CHARS = /[^\p{L}\p{N} .,_-]/gu;
86
+
87
+ /**
88
+ * Strip everything a family name cannot contain, and collapse whitespace.
89
+ * @param {unknown} value - candidate text.
90
+ * @returns {string} the safe text (possibly empty).
91
+ */
92
+ function sanitize(value) {
93
+ if (typeof value !== "string") return "";
94
+ return value.replace(UNSAFE_CHARS, "").replace(/\s+/g, " ").trim();
95
+ }
96
+
97
+ /**
98
+ * Sanitize one family name and drop what cannot be one.
99
+ * @param {unknown} value - candidate family name.
100
+ * @returns {string} the safe name, or "" when nothing usable is left.
101
+ */
102
+ function sanitizeFamily(value) {
103
+ var name = sanitize(value).slice(0, MAX_FAMILY_LENGTH);
104
+ // A lone comma would split into an empty entry; a lone quote cannot pair.
105
+ if (name === "" || name === ",") return "";
106
+ return name;
107
+ }
108
+
109
+ /**
110
+ * Render one family name the way it must appear inside a CSS list.
111
+ *
112
+ * Generic keywords are passed through; anything else is double-quoted, because
113
+ * unquoted multi-word names are invalid CSS unless every word is an identifier.
114
+ * @param {string} name - a sanitized family name.
115
+ * @returns {string} one CSS list entry.
116
+ */
117
+ function quoteFamily(name) {
118
+ var text = sanitizeFamily(name);
119
+ if (text === "") return "";
120
+ if (GENERIC_FAMILIES.indexOf(text.toLowerCase()) >= 0) return text;
121
+ return '"' + text + '"';
122
+ }
123
+
124
+ /**
125
+ * CSS-wide generic family keywords and the two system shorthands.
126
+ */
127
+ var GENERIC_FAMILIES = [
128
+ "serif",
129
+ "sans-serif",
130
+ "monospace",
131
+ "cursive",
132
+ "fantasy",
133
+ "system-ui",
134
+ "ui-serif",
135
+ "ui-sans-serif",
136
+ "ui-monospace",
137
+ "ui-rounded",
138
+ "math",
139
+ "emoji",
140
+ "fangsong",
141
+ "-apple-system",
142
+ "blinkmacsystemfont",
143
+ ];
144
+
145
+ /**
146
+ * Normalize a configuration-shaped object coming from the settings document,
147
+ * a config layer, or a test fixture.
148
+ * @param {unknown} value - candidate configuration.
149
+ * @returns {{sans: string, mono: string, sizeOffset: number, weight: number}} the normalized config.
150
+ */
151
+ function normalizeConfig(value) {
152
+ var source = value !== null && typeof value === "object" ? value : {};
153
+ var size = Number(source[SIZE_FIELD]);
154
+ if (!isFinite(size)) size = 0;
155
+ size = Math.round(Math.min(SIZE_MAX, Math.max(SIZE_MIN, size)));
156
+ var weight = Number(source[WEIGHT_FIELD]);
157
+ if (!isFinite(weight)) weight = WEIGHT_UNSET;
158
+ weight = Math.round(weight);
159
+ if (weight !== WEIGHT_UNSET) {
160
+ weight = Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weight));
161
+ }
162
+ var config = {};
163
+ config[SANS_FIELD] = sanitize(source[SANS_FIELD]).slice(0, MAX_STACK_LENGTH);
164
+ config[MONO_FIELD] = sanitize(source[MONO_FIELD]).slice(0, MAX_STACK_LENGTH);
165
+ config[SIZE_FIELD] = size;
166
+ config[WEIGHT_FIELD] = weight;
167
+ return config;
168
+ }
169
+
170
+ /**
171
+ * Format a family list as a CSS font-family value.
172
+ * @param {readonly string[]} families - family names in precedence order.
173
+ * @returns {string} the CSS list, or "" when nothing usable remains.
174
+ */
175
+ function formatStack(families) {
176
+ var parts = [];
177
+ for (var index = 0; index < families.length; index += 1) {
178
+ var entry = quoteFamily(families[index]);
179
+ if (entry !== "") parts.push(entry);
180
+ }
181
+ return parts.join(", ");
182
+ }
183
+
184
+ /**
185
+ * Split a CSS font-family value back into family names.
186
+ *
187
+ * Tolerates both quoting styles, missing spaces after commas, and stray
188
+ * whitespace — the value may have been typed by hand or written by an earlier
189
+ * version of the picker.
190
+ * @param {unknown} value - a CSS font-family value.
191
+ * @returns {string[]} the family names, in order, without quotes.
192
+ */
193
+ function parseStack(value) {
194
+ if (typeof value !== "string" || value.trim() === "") return [];
195
+ var out = [];
196
+ var current = "";
197
+ var quote = "";
198
+ for (var index = 0; index < value.length; index += 1) {
199
+ var char = value.charAt(index);
200
+ if (quote !== "") {
201
+ if (char === quote) quote = "";
202
+ else current += char;
203
+ continue;
204
+ }
205
+ if (char === '"' || char === "'") {
206
+ quote = char;
207
+ continue;
208
+ }
209
+ if (char === ",") {
210
+ out.push(current);
211
+ current = "";
212
+ continue;
213
+ }
214
+ current += char;
215
+ }
216
+ out.push(current);
217
+ var families = [];
218
+ for (var index2 = 0; index2 < out.length; index2 += 1) {
219
+ var name = sanitizeFamily(out[index2]);
220
+ if (name === "") continue;
221
+ // A generic keyword quoted by hand ("monospace") is normalized back.
222
+ families.push(name);
223
+ }
224
+ return families;
225
+ }
226
+
227
+ /**
228
+ * Keyword-only family names: they match every script, so they are neither a
229
+ * western nor a CJK slot value.
230
+ * @param {string} name - a family name.
231
+ * @returns {boolean} true for generic keywords.
232
+ */
233
+ function isGenericFamilyName(name) {
234
+ var lower = name.toLowerCase();
235
+ return (
236
+ lower === "system-ui" ||
237
+ lower === "sans-serif" ||
238
+ lower === "serif" ||
239
+ lower === "monospace" ||
240
+ lower === "cursive" ||
241
+ lower === "fantasy" ||
242
+ lower === "math" ||
243
+ lower === "emoji" ||
244
+ lower === "fangsong" ||
245
+ lower === "ui-monospace" ||
246
+ lower === "ui-sans-serif" ||
247
+ lower === "ui-serif" ||
248
+ lower === "ui-rounded"
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Name-shaped CJK recognition, used only when the browser cannot measure a
254
+ * family's actual glyph coverage. Deliberately generous: a false "east" on a
255
+ * western font merely means the simple mode shows it in the CJK slot, while a
256
+ * miss would put a CJK family into the western slot where it hurts.
257
+ * @param {string} name - a family name.
258
+ * @returns {boolean} true when the name looks like a CJK family.
259
+ */
260
+ function isCJKFamilyName(name) {
261
+ var lower = name.toLowerCase();
262
+ if (
263
+ /(?:yahei|jhenghei|pingfang|hiragino|simsun|simhei|nsimsun|kaiti|fangsong|meiryo|yu ?goth|yu ?minch|ms ?gothic|ms ?mincho|noto (?:sans|serif) (?:sc|tc|cjk|jp|kr|hk)|source han|sourcehansc|sourcehanserifc|sarasa|misans|harmonyos|wenquanyi|lxgw|unifont|dengxian)/.test(
264
+ lower
265
+ )
266
+ ) {
267
+ return true;
268
+ }
269
+ return /[\u4e00-\u9fff]/.test(name);
270
+ }
271
+
272
+ /**
273
+ * Set the western slot of a stack: the first entry a classifier counts as
274
+ * non-east. Simple mode binds this to the front of the stack, so a tuned
275
+ * order beyond the two slots is never rearranged.
276
+ * @param {readonly string[]} families - the current stack, in precedence order.
277
+ * @param {string} family - the family to place in the slot.
278
+ * @param {(name: string) => boolean} isEast - CJK classifier.
279
+ * @returns {string[]} the new stack.
280
+ */
281
+ function setWestEntry(families, family, isEast) {
282
+ var next = [];
283
+ for (var index = 0; index < families.length; index += 1) {
284
+ if (families[index].toLowerCase() !== family.toLowerCase()) next.push(families[index]);
285
+ }
286
+ for (var index2 = 0; index2 < next.length; index2 += 1) {
287
+ if (!isEast(next[index2])) {
288
+ next[index2] = family;
289
+ return next;
290
+ }
291
+ }
292
+ next.unshift(family);
293
+ return next;
294
+ }
295
+
296
+ /**
297
+ * Set the CJK slot of a stack: the first entry the classifier counts as east,
298
+ * or a new entry right after the western slot — the position CSS semantics
299
+ * need for `western, cjk, ...` to actually route glyphs.
300
+ * @param {readonly string[]} families - the current stack, in precedence order.
301
+ * @param {string} family - the family to place in the slot.
302
+ * @param {(name: string) => boolean} isEast - CJK classifier.
303
+ * @returns {string[]} the new stack.
304
+ */
305
+ function setEastEntry(families, family, isEast) {
306
+ var next = [];
307
+ for (var index = 0; index < families.length; index += 1) {
308
+ if (families[index].toLowerCase() !== family.toLowerCase()) next.push(families[index]);
309
+ }
310
+ for (var index2 = 0; index2 < next.length; index2 += 1) {
311
+ if (isEast(next[index2])) {
312
+ next[index2] = family;
313
+ return next;
314
+ }
315
+ }
316
+ var insertAt = 0;
317
+ for (var index3 = 0; index3 < next.length; index3 += 1) {
318
+ if (!isEast(next[index3])) {
319
+ // Right after the western slot: a generic catch-all later in the stack
320
+ // must not shadow the CJK entry.
321
+ insertAt = index3 + 1;
322
+ break;
323
+ }
324
+ }
325
+ next.splice(insertAt, 0, family);
326
+ return next;
327
+ }
328
+
329
+ /**
330
+ * Remove one family wherever it sits in the stack.
331
+ * @param {readonly string[]} families - the current stack.
332
+ * @param {string} family - the family to remove.
333
+ * @returns {string[]} the new stack.
334
+ */
335
+ function removeStackEntry(families, family) {
336
+ var next = [];
337
+ for (var index = 0; index < families.length; index += 1) {
338
+ if (families[index].toLowerCase() !== family.toLowerCase()) next.push(families[index]);
339
+ }
340
+ return next;
341
+ }
342
+
343
+ /**
344
+ * Compute the uniform scale a pixel offset produces over DSH's 16px base.
345
+ *
346
+ * Scaling by one ratio is what keeps a size change proportional: every design
347
+ * token that carries a size or a line height rides the same factor, so nothing
348
+ * compounds through nesting and no element ends up with a size its own line
349
+ * height does not expect.
350
+ * @param {number} sizeOffset - offset in px.
351
+ * @param {number} [base=16] - the px size the ratio is taken against.
352
+ * @returns {number} the scale factor (1 when the offset is 0).
353
+ */
354
+ function scaleFor(sizeOffset, base) {
355
+ var offset = Number(sizeOffset);
356
+ if (!isFinite(offset) || offset === 0) return 1;
357
+ var reference = typeof base === "number" && base > 0 ? base : 16;
358
+ var scale = (reference + offset) / reference;
359
+ return scale < 0.5 ? 0.5 : scale > 2 ? 2 : scale;
360
+ }
361
+
362
+ /**
363
+ * Whether a configuration asks for any change at all.
364
+ * @param {{sans: string, mono: string, sizeOffset: number, weight: number}} config - normalized config.
365
+ * @returns {boolean} true when nothing should be injected.
366
+ */
367
+ function isDormant(config) {
368
+ return (
369
+ config[SANS_FIELD] === "" &&
370
+ config[MONO_FIELD] === "" &&
371
+ config[SIZE_FIELD] === 0 &&
372
+ config[WEIGHT_FIELD] === WEIGHT_UNSET
373
+ );
374
+ }
375
+
376
+ /**
377
+ * Build the stylesheet one configuration applies.
378
+ *
379
+ * Families are declared at their SOURCE: DSH's design tokens chain to two
380
+ * variables (`--dsw-font-family` for every sans token, `--ds-font-family-code`
381
+ * for every code token — verified against the shipped theme), so overriding
382
+ * those variables is what reaches the conversation markdown and the sidebar,
383
+ * whose elements declare `font-family: var(--dsw-font-family)` themselves and
384
+ * would never inherit a plain `body` rule. The explicit `body` / `pre,code`
385
+ * rules stay as a second path for elements that hardcode a family.
386
+ *
387
+ * The size offset rewrites DSH's own design tokens rather than every element:
388
+ * a token whose name ends in `-font-size` or `-line-height` is re-declared as
389
+ * itself multiplied by one scale factor. `baseTokens` supplies the untouched
390
+ * values (read from the live document, falling back to the embedded map), so
391
+ * the ratio composes with DSH's own font-size slider instead of replacing it.
392
+ *
393
+ * @param {{sans: string, mono: string, sizeOffset: number, weight: number}} config - normalized config.
394
+ * @param {Record<string, string>} [baseTokens] - token name to untouched value.
395
+ * @returns {string} declarations for one `<style>` element ("" when dormant).
396
+ */
397
+ function buildFontCss(config, baseTokens) {
398
+ var normalized = normalizeConfig(config);
399
+ var sans = formatStack(parseStack(normalized[SANS_FIELD]));
400
+ var mono = formatStack(parseStack(normalized[MONO_FIELD]));
401
+ var offset = normalized[SIZE_FIELD];
402
+ var weight = normalized[WEIGHT_FIELD];
403
+ if (isDormant(normalized)) return "";
404
+ var declarations = [];
405
+
406
+ if (sans !== "") {
407
+ declarations.push(":root,body{--dsw-font-family:" + sans + " !important}");
408
+ declarations.push("body{font-family:" + sans + " !important}");
409
+ }
410
+ if (mono !== "") {
411
+ // The theme's code tokens chain to `--ds-font-family-code`; the other name
412
+ // is what dsh-ui-font historically wrote and costs nothing to cover.
413
+ declarations.push(
414
+ ":root,body{--dsw-font-mono:" + mono + " !important;--ds-font-family-code:" + mono + " !important}"
415
+ );
416
+ // Written after the body rule: equal specificity on a code element means
417
+ // the later declaration wins, so code keeps its own family inside the UI.
418
+ declarations.push(
419
+ "pre,code,kbd,samp,var,tt,textarea,.cm-editor .cm-content{font-family:" +
420
+ mono +
421
+ " !important}"
422
+ );
423
+ }
424
+ if (offset !== 0) {
425
+ var scale = round(scaleFor(offset), 6);
426
+ var names = tokenNames(baseTokens);
427
+ var scaled = [];
428
+ for (var index = 0; index < names.length; index += 1) {
429
+ var name = names[index];
430
+ var base = baseTokens[name];
431
+ if (typeof base !== "string" || base === "") continue;
432
+ // A base that itself references another token via var() derives from it:
433
+ // scaling the var target already scales this one, so re-scaling here
434
+ // would compound (markdown-base = var(--dsh-content-font-size) would
435
+ // take the ratio twice). DSH's chain does the work instead.
436
+ if (base.indexOf("var(") !== -1) continue;
437
+ // !important: the theme writes `--dsh-content-font-size` INLINE on body,
438
+ // and an inline declaration outranks a plain stylesheet one — without
439
+ // the flag body itself would keep DSH's own size while every descendant
440
+ // scales, splitting the page in two.
441
+ scaled.push(name + ":calc((" + base + ") * " + scale + ") !important");
442
+ }
443
+ if (scaled.length > 0) {
444
+ declarations.push(bodyAndDescendants(scaled.join(";")));
445
+ }
446
+ }
447
+ if (weight !== WEIGHT_UNSET) {
448
+ // Written as the user chose it: a variable font honors every integer, and
449
+ // a static one rounds to its own nearest step by itself.
450
+ declarations.push(bodyAndDescendants("font-weight:" + weight + " !important"));
451
+ }
452
+ return declarations.join("\n");
453
+ }
454
+
455
+ /**
456
+ * Match DSH's typography tokens: the design system's sizes plus the content
457
+ * size the theme plugin writes on `body` (whose secondary variant carries a
458
+ * suffix of its own). Line heights scale with sizes so a token's shorthand
459
+ * never disagrees with its parts.
460
+ *
461
+ * Deliberately a shape test rather than an enumeration: DSH generates these
462
+ * tokens at runtime, so whatever a future release names them, a token that
463
+ * ends in a size or a line height is one that must scale.
464
+ */
465
+ var TOKEN_PATTERN = /^--(?:dsw-font-[a-z0-9-]*|dsh-content-font)-(?:font-size|line-height)(?:-secondary)?$|^--dsh-content-font-(?:size|line-height)(?:-secondary)?$/;
466
+
467
+ /**
468
+ * A `body, body *` selector list, kept in one place because every global
469
+ * declaration needs the same reach (DSH writes some tokens on descendants).
470
+ * @param {string} declarations - CSS declarations without braces.
471
+ * @returns {string} the rule.
472
+ */
473
+ function bodyAndDescendants(declarations) {
474
+ return "body,body *{" + declarations + "}";
475
+ }
476
+
477
+ /**
478
+ * Whether one custom-property name is a token this plugin scales.
479
+ * @param {string} name - custom property name including the leading dashes.
480
+ * @returns {boolean} true when the token carries a size or a line height.
481
+ */
482
+ function isFontToken(name) {
483
+ return typeof name === "string" && TOKEN_PATTERN.test(name);
484
+ }
485
+
486
+ /**
487
+ * Ordered token names present in a base map, sizes before line heights so a
488
+ * reader of the stylesheet sees each size next to its height.
489
+ * @param {Record<string, string>} [baseTokens] - token name to value.
490
+ * @returns {string[]} the names to scale.
491
+ */
492
+ function tokenNames(baseTokens) {
493
+ if (baseTokens === null || typeof baseTokens !== "object") return [];
494
+ var names = Object.keys(baseTokens).filter(isFontToken);
495
+ names.sort(function (left, right) {
496
+ if (left.length !== right.length) return left.length - right.length;
497
+ return left < right ? -1 : left > right ? 1 : 0;
498
+ });
499
+ return names;
500
+ }
501
+
502
+ /**
503
+ * Embedded fallback for the tokens the theme plugin defines at runtime.
504
+ *
505
+ * The live document is always preferred; this map only covers the window
506
+ * before those declarations exist (and any token a future release renames
507
+ * away from the pattern). Values mirror the size/line-height pairs shipped in
508
+ * DSH 0.1.5-rc.2.
509
+ */
510
+ var FALLBACK_TOKENS = {
511
+ "--dsw-font-xxxs-11-font-size": "11px",
512
+ "--dsw-font-xxxs-11-line-height": "18px",
513
+ "--dsw-font-xxxs-strong-11-font-size": "11px",
514
+ "--dsw-font-xxxs-strong-11-line-height": "18px",
515
+ "--dsw-font-xxs-12-font-size": "12px",
516
+ "--dsw-font-xxs-12-line-height": "20px",
517
+ "--dsw-font-xxs-strong-12-font-size": "12px",
518
+ "--dsw-font-xxs-strong-12-line-height": "20px",
519
+ "--dsw-font-xs-13-font-size": "13px",
520
+ "--dsw-font-xs-13-line-height": "22px",
521
+ "--dsw-font-xs-strong-13-font-size": "13px",
522
+ "--dsw-font-xs-strong-13-line-height": "22px",
523
+ "--dsw-font-s-14-font-size": "14px",
524
+ "--dsw-font-s-14-line-height": "24px",
525
+ "--dsw-font-s-strong-14-font-size": "14px",
526
+ "--dsw-font-s-strong-14-line-height": "24px",
527
+ "--dsw-font-base-16-font-size": "16px",
528
+ "--dsw-font-base-16-line-height": "26px",
529
+ "--dsw-font-base-strong-16-font-size": "16px",
530
+ "--dsw-font-base-strong-16-line-height": "26px",
531
+ "--dsw-font-m-18-font-size": "18px",
532
+ "--dsw-font-m-18-line-height": "28px",
533
+ "--dsw-font-l-20-font-size": "20px",
534
+ "--dsw-font-l-20-line-height": "30px",
535
+ "--dsw-font-xl-24-font-size": "24px",
536
+ "--dsw-font-xl-24-line-height": "34px",
537
+ "--dsw-font-markdown-base-font-size": "14px",
538
+ "--dsw-font-markdown-base-line-height": "24px",
539
+ "--dsw-font-markdown-small-font-size": "13px",
540
+ "--dsw-font-markdown-small-line-height": "22px",
541
+ "--dsw-font-markdown-h1-font-size": "21px",
542
+ "--dsw-font-markdown-h1-line-height": "30px",
543
+ "--dsw-font-markdown-h2-font-size": "19px",
544
+ "--dsw-font-markdown-h2-line-height": "28px",
545
+ "--dsw-font-markdown-h3-font-size": "17px",
546
+ "--dsw-font-markdown-h3-line-height": "26px",
547
+ "--dsw-font-markdown-h4-font-size": "15px",
548
+ "--dsw-font-markdown-h4-line-height": "24px",
549
+ "--dsw-font-markdown-code-font-size": "13px",
550
+ "--dsw-font-markdown-code-line-height": "20px",
551
+ "--dsw-font-markdown-code-block-font-size": "13px",
552
+ "--dsw-font-markdown-code-block-line-height": "20px",
553
+ "--dsw-font-markdown-code-block-small-font-size": "12px",
554
+ "--dsw-font-markdown-code-block-small-line-height": "18px",
555
+ "--dsw-font-markdown-table-font-size": "13px",
556
+ "--dsw-font-markdown-table-line-height": "22px",
557
+ "--dsh-content-font-size": "14px",
558
+ "--dsh-content-font-size-secondary": "13px",
559
+ };
560
+
561
+ /**
562
+ * Round to a fixed number of decimals so generated CSS stays readable.
563
+ * @param {number} value - the number.
564
+ * @param {number} digits - decimals to keep.
565
+ * @returns {number} the rounded number.
566
+ */
567
+ function round(value, digits) {
568
+ var factor = Math.pow(10, digits);
569
+ return Math.round(value * factor) / factor;
570
+ }
571
+
572
+ /**
573
+ * Curated families offered when the browser cannot enumerate local fonts —
574
+ * and offered first even when it can, because a working CJK stack is the
575
+ * common case and scrolling a thousand families is not.
576
+ */
577
+ var PRESETS = {
578
+ mono: [
579
+ "JetBrains Mono",
580
+ "Cascadia Code",
581
+ "Cascadia Mono",
582
+ "Fira Code",
583
+ "Source Code Pro",
584
+ "IBM Plex Mono",
585
+ "Roboto Mono",
586
+ "SF Mono",
587
+ "Menlo",
588
+ "Consolas",
589
+ "DejaVu Sans Mono",
590
+ "Sarasa Mono SC",
591
+ "Sarasa Mono HC",
592
+ "Noto Sans Mono CJK SC",
593
+ "Microsoft YaHei Mono",
594
+ "monospace",
595
+ ],
596
+ cjk: [
597
+ "Microsoft YaHei",
598
+ "Microsoft YaHei UI",
599
+ "微软雅黑",
600
+ "PingFang SC",
601
+ "Hiragino Sans GB",
602
+ "Source Han Sans SC",
603
+ "Noto Sans SC",
604
+ "Noto Sans CJK SC",
605
+ "Sarasa Gothic SC",
606
+ "SimSun",
607
+ "宋体",
608
+ "NSimSun",
609
+ "SimHei",
610
+ "黑体",
611
+ "KaiTi",
612
+ "楷体",
613
+ "FangSong",
614
+ "仿宋",
615
+ "Microsoft JhengHei",
616
+ "DengXian",
617
+ "HarmonyOS Sans SC",
618
+ "Alibaba PuHuiTi 3",
619
+ ],
620
+ latin: [
621
+ "Inter",
622
+ "Segoe UI",
623
+ "Segoe UI Variable",
624
+ "Helvetica Neue",
625
+ "Arial",
626
+ "Calibri",
627
+ "Tahoma",
628
+ "Verdana",
629
+ "Georgia",
630
+ "Times New Roman",
631
+ "Cambria",
632
+ ],
633
+ generic: ["system-ui", "sans-serif", "serif"],
634
+ };
635
+
636
+ var shared = {
637
+ NAMESPACE: NAMESPACE,
638
+ SANS_FIELD: SANS_FIELD,
639
+ MONO_FIELD: MONO_FIELD,
640
+ SIZE_FIELD: SIZE_FIELD,
641
+ WEIGHT_FIELD: WEIGHT_FIELD,
642
+ SIZE_MIN: SIZE_MIN,
643
+ SIZE_MAX: SIZE_MAX,
644
+ WEIGHT_MIN: WEIGHT_MIN,
645
+ WEIGHT_MAX: WEIGHT_MAX,
646
+ WEIGHT_UNSET: WEIGHT_UNSET,
647
+ MARKER: MARKER,
648
+ STYLE_TAG: STYLE_TAG,
649
+ CARD_STYLE_TAG: CARD_STYLE_TAG,
650
+ DEFAULTS: DEFAULTS,
651
+ MAX_STACK_LENGTH: MAX_STACK_LENGTH,
652
+ MAX_FAMILY_LENGTH: MAX_FAMILY_LENGTH,
653
+ FALLBACK_TOKENS: FALLBACK_TOKENS,
654
+ PRESETS: PRESETS,
655
+ sanitize: sanitize,
656
+ sanitizeFamily: sanitizeFamily,
657
+ quoteFamily: quoteFamily,
658
+ normalizeConfig: normalizeConfig,
659
+ formatStack: formatStack,
660
+ parseStack: parseStack,
661
+ scaleFor: scaleFor,
662
+ isDormant: isDormant,
663
+ buildFontCss: buildFontCss,
664
+ isFontToken: isFontToken,
665
+ isGenericFamilyName: isGenericFamilyName,
666
+ isCJKFamilyName: isCJKFamilyName,
667
+ setWestEntry: setWestEntry,
668
+ setEastEntry: setEastEntry,
669
+ removeStackEntry: removeStackEntry,
670
+ };
671
+
672
+ if (typeof module !== "undefined" && module.exports) module.exports = shared;
673
+
674
+ return module.exports;
675
+ })();
676
+ // ---- src/client.js ----
677
+ /**
678
+ * dsh-fonttune — browser half.
679
+ *
680
+ * Runs inside the DSH web client as a lazy-CJS module bundle (see `build.mjs`).
681
+ * It owns one card in Settings -> Plugins -> Plugin configuration, keyed by the
682
+ * settings namespace the host half registers, and it applies the saved
683
+ * configuration by rewriting a single `<style>` element.
684
+ *
685
+ * Why a style element rather than per-element inline styles: the plugin has to
686
+ * reach elements DSH renders later, and DSH's own font-size slider writes a
687
+ * custom property on `body` at runtime. Reading DSH's typography tokens and
688
+ * re-declaring them multiplied by one ratio keeps the offset composed with
689
+ * that slider instead of replacing it, and keeps every size proportional
690
+ * without compounding through nesting.
691
+ *
692
+ * Module scope stays side-effect free: the loader materializes the factory
693
+ * only when the plugin is first used, and everything that touches the document
694
+ * lives inside `apply`.
695
+ *
696
+ * @module dsh-fonttune/client
697
+ */
698
+
699
+ var React = require("react");
700
+ var createPortal = require("react-dom").createPortal;
701
+ var primitives = require("@deepseek-ai/dsh-client-ui-primitives");
702
+
703
+ var h = React.createElement;
704
+ var useCallback = React.useCallback;
705
+ var useEffect = React.useEffect;
706
+ var useMemo = React.useMemo;
707
+ var useRef = React.useRef;
708
+ var useState = React.useState;
709
+ var useSyncExternalStore = React.useSyncExternalStore;
710
+
711
+ var shared = __dfpShared;
712
+ var NAMESPACE = shared.NAMESPACE;
713
+ var SANS_FIELD = shared.SANS_FIELD;
714
+ var MONO_FIELD = shared.MONO_FIELD;
715
+ var SIZE_FIELD = shared.SIZE_FIELD;
716
+ var WEIGHT_FIELD = shared.WEIGHT_FIELD;
717
+ var SIZE_MIN = shared.SIZE_MIN;
718
+ var SIZE_MAX = shared.SIZE_MAX;
719
+ var WEIGHT_MIN = shared.WEIGHT_MIN;
720
+ var WEIGHT_MAX = shared.WEIGHT_MAX;
721
+ var WEIGHT_UNSET = shared.WEIGHT_UNSET;
722
+ var CARD_STYLE_TAG = shared.CARD_STYLE_TAG;
723
+ var STYLE_TAG = shared.STYLE_TAG;
724
+ var FALLBACK_TOKENS = shared.FALLBACK_TOKENS;
725
+ var PRESETS = shared.PRESETS;
726
+ var buildFontCss = shared.buildFontCss;
727
+ var formatStack = shared.formatStack;
728
+ var isFontToken = shared.isFontToken;
729
+ var normalizeConfig = shared.normalizeConfig;
730
+ var parseStack = shared.parseStack;
731
+ var quoteFamily = shared.quoteFamily;
732
+ var sanitizeFamily = shared.sanitizeFamily;
733
+ var isGenericFamilyName = shared.isGenericFamilyName;
734
+ var isCJKFamilyName = shared.isCJKFamilyName;
735
+ var setWestEntry = shared.setWestEntry;
736
+ var setEastEntry = shared.setEastEntry;
737
+ var removeStackEntry = shared.removeStackEntry;
738
+
739
+ /** Services this bundle waits for before it applies. */
740
+ var inject = ["slots", "locale", "settingsScope"];
741
+
742
+ /** The weight DSH uses for body text; choosing it means "leave it alone". */
743
+ var NEUTRAL_WEIGHT = 400;
744
+
745
+ /** Panel width, kept in one place because positioning reads it too. */
746
+ var PANEL_WIDTH = 320;
747
+
748
+ /** Panel height cap, used to decide whether it opens up or down. */
749
+ var PANEL_HEIGHT = 380;
750
+
751
+ /**
752
+ * Cap on how many enumerated families the picker lists at once: a machine can
753
+ * report well over a thousand, which would make the panel useless.
754
+ */
755
+ var MAX_VISIBLE_FONTS = 240;
756
+
757
+ /* ------------------------------------------------------------------ *
758
+ * copy
759
+ * ------------------------------------------------------------------ */
760
+
761
+ var DICTS = {
762
+ en: {
763
+ "card.title": "Font plus",
764
+ "card.description":
765
+ "UI and code font families, a global size offset, and font weight",
766
+ "card.expand": "Expand",
767
+ "card.collapse": "Collapse",
768
+ "card.resetAll": "Reset all",
769
+ "card.readOnly": "This deployment keeps settings in memory only.",
770
+
771
+ "common.overridden": "Changed",
772
+ "common.reset": "Reset",
773
+
774
+ "sans.label": "Body font",
775
+ "sans.hint":
776
+ "Each family is tried in order: put a Latin face first and CJK faces after it. Empty leaves DSH's own stack.",
777
+ "mono.label": "Code font",
778
+ "mono.hint":
779
+ "Applies to code blocks, inline code and monospaced text. Empty leaves DSH's own stack.",
780
+
781
+ "stack.empty": "No family selected",
782
+ "stack.add": "Add family",
783
+ "stack.search": "Search families",
784
+ "stack.loading": "Reading the families installed on this machine…",
785
+ "stack.denied":
786
+ "Font access was refused, so the built-in list is shown; any name can still be typed.",
787
+ "stack.unsupported":
788
+ "This browser cannot list installed fonts, so the built-in list is shown; any name can still be typed.",
789
+ "stack.custom": "Use “{name}”",
790
+ "stack.remove": "Remove {name}",
791
+ "stack.drag": "Drag {name} to reorder",
792
+ "stack.earlier": "Move {name} earlier",
793
+ "stack.later": "Move {name} later",
794
+ "stack.done": "Done",
795
+ "stack.groupSelected": "Selected",
796
+ "stack.groupMono": "Monospace",
797
+ "stack.groupCjk": "Chinese (CJK)",
798
+ "stack.groupLatin": "Latin",
799
+ "stack.groupGeneric": "Generic",
800
+ "stack.groupLocal": "Installed on this machine",
801
+ "stack.hintOrder": "Earlier entries win; the first installed family is the one used.",
802
+
803
+ "mode.label": "Edit mode",
804
+ "mode.simple": "Basic",
805
+ "mode.advanced": "Advanced",
806
+ "mode.simpleHint":
807
+ "Manages only the front two slots of the stack — Western, then CJK. Everything you ordered in Advanced stays untouched.",
808
+ "sansWest.label": "Body · Western",
809
+ "sansEast.label": "Body · CJK",
810
+ "monoWest.label": "Code · Western",
811
+ "monoEast.label": "Code · CJK",
812
+ "split.pick": "Choose…",
813
+ "split.unset": "Not set",
814
+ "split.remove": "Remove {name}",
815
+ "split.rest": "Other fallbacks (reorder them in Advanced): {names}",
816
+
817
+ "size.label": "Font size offset",
818
+ "size.hint":
819
+ "Adds {offset} to every size DSH uses, on top of its own font-size setting. 0 keeps DSH's sizes.",
820
+ "size.unit": "px",
821
+
822
+ "weight.label": "Font weight",
823
+ "weight.hint": "Overrides text weight everywhere, headings included. Default weight is 400.",
824
+ "weight.unset": "Unset",
825
+
826
+ "preview.label": "Preview",
827
+ "preview.sansCaption": "Body",
828
+ "preview.monoCaption": "Code",
829
+ "preview.sample":
830
+ "The quick brown fox jumps over the lazy dog — 中文排版预览,标点符号,数字 0123456789。",
831
+ "preview.code": "const greet = (name) => `hello ${name}`; // 代码预览",
832
+
833
+ "footnote.local":
834
+ "Stored in the Host settings document. Every change applies immediately.",
835
+ },
836
+ zh: {
837
+ "card.title": "字体增强",
838
+ "card.description": "正文与代码字体、全局字号偏移、字重",
839
+ "card.expand": "展开",
840
+ "card.collapse": "收起",
841
+ "card.resetAll": "全部重置",
842
+ "card.readOnly": "当前部署只在内存里保存设置。",
843
+
844
+ "common.overridden": "已修改",
845
+ "common.reset": "重置",
846
+
847
+ "sans.label": "正文字体",
848
+ "sans.hint":
849
+ "按顺序回退:拉丁字体放前面、中文字体放后面;留空表示沿用 DSH 的字体栈。",
850
+ "mono.label": "代码字体",
851
+ "mono.hint": "作用于代码块、行内代码和等宽文本;留空表示沿用 DSH 的字体栈。",
852
+
853
+ "stack.empty": "尚未选择字体",
854
+ "stack.add": "添加字体",
855
+ "stack.search": "搜索字体",
856
+ "stack.loading": "正在读取本机已安装的字体…",
857
+ "stack.denied": "未获得字体访问权限,显示内置列表;仍可手动输入任意字体名。",
858
+ "stack.unsupported":
859
+ "此浏览器无法列出已安装字体,显示内置列表;仍可手动输入任意字体名。",
860
+ "stack.custom": "使用“{name}”",
861
+ "stack.remove": "移除 {name}",
862
+ "stack.drag": "拖动 {name} 调整顺序",
863
+ "stack.earlier": "将 {name} 前移",
864
+ "stack.later": "将 {name} 后移",
865
+ "stack.done": "完成",
866
+ "stack.groupSelected": "已选",
867
+ "stack.groupMono": "等宽",
868
+ "stack.groupCjk": "中文(CJK)",
869
+ "stack.groupLatin": "拉丁",
870
+ "stack.groupGeneric": "通用",
871
+ "stack.groupLocal": "本机已安装",
872
+ "stack.hintOrder": "顺序靠前的优先命中,取第一个已安装的字体。",
873
+
874
+ "mode.label": "编辑方式",
875
+ "mode.simple": "简单",
876
+ "mode.advanced": "高级",
877
+ "mode.simpleHint":
878
+ "只管理栈最前面的西文/中文两项;你在高级模式里排好的其余回退原样保留。",
879
+ "sansWest.label": "正文 · 西文字体",
880
+ "sansEast.label": "正文 · 中文字体",
881
+ "monoWest.label": "代码 · 西文字体",
882
+ "monoEast.label": "代码 · 中文字体",
883
+ "split.pick": "选择…",
884
+ "split.unset": "未选择",
885
+ "split.remove": "移除 {name}",
886
+ "split.rest": "其余回退项(在高级模式中排序):{names}",
887
+
888
+ "size.label": "字号偏移",
889
+ "size.hint":
890
+ "给 DSH 使用的每一档字号统一加 {offset},与设置里的「字号大小」叠加;0 表示保持原样。",
891
+ "size.unit": "px",
892
+
893
+ "weight.label": "字重",
894
+ "weight.hint": "覆盖全局文字粗细(含标题);默认字重为400。",
895
+ "weight.unset": "未设置",
896
+
897
+ "preview.label": "预览",
898
+ "preview.sansCaption": "正文",
899
+ "preview.monoCaption": "代码",
900
+ "preview.sample":
901
+ "The quick brown fox jumps over the lazy dog —— 中文排版预览,标点符号,数字 0123456789。",
902
+ "preview.code": "const greet = (name) => `hello ${name}`; // 代码预览",
903
+
904
+ "footnote.local": "保存在宿主设置文档里;每次改动立即生效。",
905
+ },
906
+ };
907
+
908
+ /**
909
+ * Translate one key for the active locale, falling back to English and then to
910
+ * the key itself, so a missing dictionary entry never blanks the card.
911
+ * @param {string} locale - active locale id.
912
+ * @param {string} key - dictionary key.
913
+ * @param {Record<string, string>} [params] - `{name}` substitutions.
914
+ * @returns {string} the text.
915
+ */
916
+ function translate(locale, key, params) {
917
+ var table = DICTS[locale] || DICTS.en;
918
+ var text = table[key];
919
+ if (text === undefined) text = DICTS.en[key];
920
+ if (text === undefined) return key;
921
+ if (params === undefined) return text;
922
+ return text.replace(/\{(\w+)\}/g, function (match, name) {
923
+ return params[name] === undefined ? match : String(params[name]);
924
+ });
925
+ }
926
+
927
+ /* ------------------------------------------------------------------ *
928
+ * card chrome stylesheet
929
+ * ------------------------------------------------------------------ */
930
+
931
+ /**
932
+ * Card chrome, matching the plugin configuration section's own card: the same
933
+ * radii, borders, spacing and design tokens. The section ships those rules in
934
+ * a CSS module a plugin bundle cannot import, so they are reproduced here.
935
+ */
936
+ var CARD_CSS = [
937
+ ".dfp-card{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}",
938
+ ".dfp-card:hover{border-color:var(--dsw-alias-label-dimmed)}",
939
+ ".dfp-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
940
+ ".dfp-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
941
+ ".dfp-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}",
942
+ ".dfp-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
943
+ ".dfp-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}",
944
+ ".dfp-description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}",
945
+ ".dfp-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}",
946
+ ".dfp-chevronOpen{transform:rotate(180deg)}",
947
+ ".dfp-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}",
948
+ ".dfp-readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}",
949
+ ".dfp-footer{border-top:.5px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}",
950
+ ".dfp-resetAll{appearance:none;font:inherit;cursor:pointer;border-radius:8px;padding:5px 10px;font-size:13px;line-height:1.5;margin-right:auto;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2)}",
951
+ ".dfp-resetAll:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}",
952
+ ".dfp-resetAll:disabled{opacity:.4;cursor:default}",
953
+
954
+ ".dfp-field{padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
955
+ ".dfp-fieldLast{border-bottom:0}",
956
+ ".dfp-fieldHead{align-items:center;gap:8px;display:flex}",
957
+ ".dfp-fieldLabel{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}",
958
+ ".dfp-overridden{flex:none;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;padding:1px 8px;font-size:11px;line-height:16px}",
959
+ ".dfp-fieldReset{flex:none;margin-left:auto;appearance:none;font:inherit;cursor:pointer;background:0 0;border:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:0}",
960
+ ".dfp-fieldReset:hover{color:var(--dsw-alias-label-primary)}",
961
+ ".dfp-hint{color:var(--dsw-alias-label-tertiary);margin:4px 0 0;font-size:12px;line-height:18px}",
962
+
963
+ ".dfp-chips{align-items:center;flex-wrap:wrap;gap:6px;margin-top:10px;display:flex}",
964
+ ".dfp-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
965
+ ".dfp-chip{align-items:center;gap:2px;height:26px;padding:0 2px 0 4px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;display:inline-flex}",
966
+ ".dfp-chipDragging{opacity:.45}",
967
+ ".dfp-chipDrop{box-shadow:0 0 0 2px var(--dsw-alias-brand-primary)}",
968
+ ".dfp-grip{cursor:grab;color:var(--dsw-alias-label-tertiary);padding:0 2px;font-size:13px;line-height:1;user-select:none}",
969
+ ".dfp-grip:active{cursor:grabbing}",
970
+ ".dfp-chipLabel{max-width:200px;font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
971
+ ".dfp-chipButton{align-items:center;justify-content:center;width:20px;height:20px;padding:0;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:4px;display:inline-flex;font-size:13px;line-height:1}",
972
+ ".dfp-chipButton:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3)}",
973
+ ".dfp-chipButton:disabled{opacity:.35;cursor:default}",
974
+ ".dfp-add{align-items:center;gap:6px;height:28px;padding:0 10px;color:var(--dsw-alias-label-primary);cursor:pointer;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;display:inline-flex;font-size:13px;line-height:18px}",
975
+ ".dfp-add:hover{background:var(--dsw-alias-bg-layer-3)}",
976
+
977
+ ".dfp-modeRow{align-items:center;gap:10px;margin:14px 0 0;display:flex}",
978
+ ".dfp-modeLabel{flex:1;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}",
979
+ ".dfp-modeSeg{flex:none;display:inline-flex;overflow:hidden;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px}",
980
+ ".dfp-modeButton{appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-left:.5px solid var(--dsw-alias-border-l3);padding:4px 14px;font-size:12px;line-height:18px}",
981
+ ".dfp-modeButton:first-child{border-left:none}",
982
+ ".dfp-modeButton:hover{color:var(--dsw-alias-label-primary)}",
983
+ ".dfp-modeButtonActive{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3)}",
984
+ ".dfp-slotRow{align-items:center;gap:10px;margin-top:10px;display:flex}",
985
+ ".dfp-slotLabel{flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;min-width:96px}",
986
+ ".dfp-splitRow{flex:1;align-items:center;gap:4px;display:inline-flex;min-width:0}",
987
+ ".dfp-pick{flex:1;appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;min-height:28px;padding:0 10px;display:inline-flex;align-items:center}",
988
+ ".dfp-pick:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3)}",
989
+ ".dfp-pick:disabled{opacity:.4;cursor:default}",
990
+ ".dfp-pickEmpty{color:var(--dsw-alias-label-tertiary)}",
991
+
992
+ ".dfp-panel{box-sizing:border-box;position:fixed;z-index:1200;flex-direction:column;width:320px;max-height:380px;padding:8px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:12px;box-shadow:var(--dsw-shadow-lv3,0 12px 32px #00000024);display:flex;gap:6px}",
993
+ ".dfp-search{width:100%;box-sizing:border-box;padding:5px 8px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;font:inherit;font-size:13px;line-height:18px}",
994
+ ".dfp-note{padding:4px 2px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}",
995
+ ".dfp-list{flex-direction:column;gap:1px;flex:1;min-height:0;overflow-y:auto;display:flex}",
996
+ ".dfp-group{padding:6px 6px 2px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;position:sticky;top:0;background:var(--dsw-alias-bg-layer-2)}",
997
+ ".dfp-option{align-items:center;justify-content:space-between;gap:8px;width:100%;box-sizing:border-box;padding:4px 8px;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer;background:0 0;border:none;border-radius:6px;display:flex;font-size:13px;line-height:20px}",
998
+ ".dfp-option:hover{background:var(--dsw-alias-bg-layer-3)}",
999
+ ".dfp-optionLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
1000
+ ".dfp-optionCheck{flex:none;color:var(--dsw-alias-brand-primary)}",
1001
+ ".dfp-footerRow{justify-content:flex-end;display:flex}",
1002
+
1003
+ ".dfp-sliderRow{align-items:center;gap:10px;margin-top:10px;display:flex}",
1004
+ ".dfp-slider{flex:1;min-width:0;height:20px;accent-color:var(--dsw-alias-brand-primary)}",
1005
+ ".dfp-value{flex:none;min-width:56px;text-align:right;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;font-variant-numeric:tabular-nums}",
1006
+ ".dfp-scale{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;display:flex;justify-content:space-between}",
1007
+
1008
+ ".dfp-previewBox{margin-top:10px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l2);border-radius:10px}",
1009
+ ".dfp-previewCaption{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;margin-bottom:4px}",
1010
+ ".dfp-previewText{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;word-break:break-word}",
1011
+ ].join("");
1012
+
1013
+ /**
1014
+ * Install the card's chrome stylesheet once, owned by the plugin's fiber.
1015
+ * @param {object} ctx - client cordis context.
1016
+ */
1017
+ function installCardStyles(ctx) {
1018
+ ctx.effect(
1019
+ function () {
1020
+ if (typeof document === "undefined") return undefined;
1021
+ if (document.querySelector('style[data-plugin-css="' + CARD_STYLE_TAG + '"]') !== null) {
1022
+ return undefined;
1023
+ }
1024
+ var tag = document.createElement("style");
1025
+ tag.dataset.plugin = "dsh-fonttune";
1026
+ tag.dataset.pluginCss = CARD_STYLE_TAG;
1027
+ tag.textContent = CARD_CSS;
1028
+ document.head.append(tag);
1029
+ return function () {
1030
+ tag.remove();
1031
+ };
1032
+ },
1033
+ "dsh-fonttune: card stylesheet"
1034
+ );
1035
+ }
1036
+
1037
+ /* ------------------------------------------------------------------ *
1038
+ * DSH token discovery
1039
+ * ------------------------------------------------------------------ */
1040
+
1041
+ /**
1042
+ * Whether a stylesheet belongs to this plugin. Reading bases must skip our own
1043
+ * `<style>` elements: once the size rule is live, its declarations would come
1044
+ * back as "untouched values" on the next read and compound the offset every
1045
+ * refresh cycle (fonts grew by the ratio every 4 seconds in the field).
1046
+ * @param {StyleSheet} sheet - any document stylesheet.
1047
+ * @returns {boolean} true when the sheet was installed by this plugin.
1048
+ */
1049
+ function isOwnSheet(sheet) {
1050
+ try {
1051
+ var owner = sheet.ownerNode;
1052
+ if (owner === null || owner === undefined) return false;
1053
+ var marker = owner.dataset && (owner.dataset.pluginCss || owner.dataset.plugin);
1054
+ return marker === STYLE_TAG || marker === CARD_STYLE_TAG || marker === "dsh-fonttune";
1055
+ } catch (error) {
1056
+ return false;
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Snapshot the typography tokens the size offset scales.
1062
+ *
1063
+ * Three sources, in rising authority: the embedded fallback map (covers the
1064
+ * frame before DSH's runtime tokens exist), declarations read from the
1065
+ * same-origin stylesheets — excluding this plugin's own — and the theme's
1066
+ * inline writes on `body` (the font-size slider's value lives there and
1067
+ * nowhere else). The computed style is deliberately NOT a source: after our
1068
+ * rule applies it reports the SCALED values, and feeding those back as bases
1069
+ * compounds the offset on every refresh.
1070
+ *
1071
+ * @returns {Record<string, string>} token name to its untouched value.
1072
+ */
1073
+ function readBaseTokens() {
1074
+ var out = {};
1075
+ var name;
1076
+ for (name in FALLBACK_TOKENS) {
1077
+ if (Object.prototype.hasOwnProperty.call(FALLBACK_TOKENS, name)) {
1078
+ out[name] = FALLBACK_TOKENS[name];
1079
+ }
1080
+ }
1081
+ if (typeof document === "undefined" || !document.body) return out;
1082
+
1083
+ try {
1084
+ var sheets = document.styleSheets;
1085
+ for (var sheetIndex = 0; sheetIndex < sheets.length; sheetIndex += 1) {
1086
+ if (isOwnSheet(sheets[sheetIndex])) continue;
1087
+ var rules = null;
1088
+ try {
1089
+ rules = sheets[sheetIndex].cssRules;
1090
+ } catch (error) {
1091
+ continue; // Cross-origin stylesheet: the inline source still answers.
1092
+ }
1093
+ if (!rules) continue;
1094
+ for (var ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
1095
+ var style = rules[ruleIndex].style;
1096
+ if (!style) continue;
1097
+ for (var propertyIndex = 0; propertyIndex < style.length; propertyIndex += 1) {
1098
+ var property = style[propertyIndex];
1099
+ if (property.slice(0, 2) !== "--" || !isFontToken(property)) continue;
1100
+ var declared = style.getPropertyValue(property).trim();
1101
+ if (declared !== "") out[property] = declared;
1102
+ }
1103
+ }
1104
+ }
1105
+ } catch (error) {
1106
+ // Keep what the earlier sources produced.
1107
+ }
1108
+
1109
+ try {
1110
+ // The theme authors these directly on `body`; the raw inline value is the
1111
+ // untouched one even while this plugin's `!important` rule is winning.
1112
+ var inline = document.body.style;
1113
+ for (name in out) {
1114
+ if (!Object.prototype.hasOwnProperty.call(out, name)) continue;
1115
+ var authored = inline.getPropertyValue(name).trim();
1116
+ if (authored !== "") out[name] = authored;
1117
+ }
1118
+ for (var inlineIndex = 0; inlineIndex < inline.length; inlineIndex += 1) {
1119
+ var inlineName = inline[inlineIndex];
1120
+ if (inlineName.slice(0, 2) !== "--" || !isFontToken(inlineName)) continue;
1121
+ var inlineValue = inline.getPropertyValue(inlineName).trim();
1122
+ if (inlineValue !== "") out[inlineName] = inlineValue;
1123
+ }
1124
+ } catch (error) {
1125
+ // Detached document or no layout engine: the fallback map stands.
1126
+ }
1127
+ return out;
1128
+ }
1129
+
1130
+ /**
1131
+ * Create the writer that keeps one `<style>` element in sync.
1132
+ * @param {() => Record<string, string>} tokens - reads the current base tokens.
1133
+ * @returns {(config: unknown) => void} the applier.
1134
+ */
1135
+ function createStylesheet(tokens) {
1136
+ var tag = null;
1137
+ var lastCss = null;
1138
+ return function apply(config) {
1139
+ if (typeof document === "undefined") return;
1140
+ var css = buildFontCss(config, tokens());
1141
+ if (css === lastCss) return;
1142
+ lastCss = css;
1143
+ if (tag === null) {
1144
+ tag = document.createElement("style");
1145
+ tag.dataset.plugin = "dsh-fonttune";
1146
+ tag.dataset.pluginCss = STYLE_TAG;
1147
+ document.head.append(tag);
1148
+ }
1149
+ tag.textContent = css;
1150
+ };
1151
+ }
1152
+
1153
+ /* ------------------------------------------------------------------ *
1154
+ * hooks and controls
1155
+ * ------------------------------------------------------------------ */
1156
+
1157
+ /**
1158
+ * Read the settings snapshot and re-render whenever it is replaced.
1159
+ *
1160
+ * `scope.subscribe` must reach React WRAPPED, never as a bare method
1161
+ * reference: it is a prototype method that reads `this.store`, and React
1162
+ * invokes the subscriber as a plain function, so a detached reference throws
1163
+ * `Cannot read properties of undefined (reading 'store')` the first time the
1164
+ * card renders.
1165
+ * @param {{getSnapshot: () => any, subscribe: (fn: () => void) => () => void}} scope - bound scope.
1166
+ * @returns {any} the current snapshot.
1167
+ */
1168
+ function useScopeSnapshot(scope) {
1169
+ var cache = useRef({ snapshot: null, revision: -1 });
1170
+ var getRevision = useCallback(
1171
+ function () {
1172
+ var snapshot = scope.getSnapshot();
1173
+ cache.current = {
1174
+ snapshot: snapshot,
1175
+ revision: typeof snapshot.revision === "number" ? snapshot.revision : -1,
1176
+ };
1177
+ return cache.current.revision;
1178
+ },
1179
+ [scope]
1180
+ );
1181
+ useSyncExternalStore(
1182
+ function (onChange) {
1183
+ return scope.subscribe(onChange);
1184
+ },
1185
+ getRevision,
1186
+ getRevision
1187
+ );
1188
+ return scope.getSnapshot();
1189
+ }
1190
+
1191
+ /**
1192
+ * One labelled field row with its overridden badge and reset control.
1193
+ * @param {object} props - copy, override state, reset action and children.
1194
+ * @returns {object} the row element.
1195
+ */
1196
+ function FieldShell(props) {
1197
+ return h(
1198
+ "div",
1199
+ { className: "dfp-field" + (props.last ? " dfp-fieldLast" : "") },
1200
+ h(
1201
+ "div",
1202
+ { className: "dfp-fieldHead" },
1203
+ h("span", { className: "dfp-fieldLabel" }, props.label),
1204
+ props.overridden
1205
+ ? h("span", { className: "dfp-overridden" }, props.t("common.overridden"))
1206
+ : null,
1207
+ props.overridden
1208
+ ? h(
1209
+ "button",
1210
+ {
1211
+ type: "button",
1212
+ className: "dfp-fieldReset",
1213
+ disabled: props.disabled,
1214
+ onClick: props.onReset,
1215
+ },
1216
+ props.t("common.reset")
1217
+ )
1218
+ : null
1219
+ ),
1220
+ h("p", { className: "dfp-hint" }, props.hint),
1221
+ props.children
1222
+ );
1223
+ }
1224
+
1225
+ /**
1226
+ * A slider over a whole-number range with a live value readout.
1227
+ * Dragging only moves a local value; the change handler runs once the
1228
+ * pointer is released (or on blur/keyup), so intermediate steps never
1229
+ * rewrite the settings document — the page does not recompute per pixel.
1230
+ * @param {object} props - range, value, labels, change handler and an
1231
+ * optional pendingText(v) formatting the readout while dragging.
1232
+ * @returns {object} the slider element.
1233
+ */
1234
+ function NumberSlider(props) {
1235
+ var [pending, setPending] = useState(null);
1236
+ // The value the host has not confirmed yet. Between the release and the
1237
+ // settings round-trip the committed prop is still the OLD number — clearing
1238
+ // the local value right away would show that old number for one frame
1239
+ // (the "bounce back, then settle" the user saw), so the local value stays
1240
+ // on screen until the confirmed value arrives.
1241
+ var awaitingRef = useRef(null);
1242
+ useEffect(
1243
+ function () {
1244
+ if (awaitingRef.current === null) {
1245
+ // idle: any value that arrives from outside (reset, another page) wins
1246
+ setPending(null);
1247
+ return undefined;
1248
+ }
1249
+ if (props.value === awaitingRef.current) {
1250
+ awaitingRef.current = null;
1251
+ setPending(null);
1252
+ }
1253
+ return undefined;
1254
+ },
1255
+ [props.value]
1256
+ );
1257
+ var commit = function (value) {
1258
+ awaitingRef.current = value;
1259
+ props.onChange(value);
1260
+ };
1261
+ useEffect(
1262
+ function () {
1263
+ if (pending === null) return undefined;
1264
+ var release = function () {
1265
+ // one release per drag; a late pointerup must not rewrite the same value
1266
+ if (awaitingRef.current !== null) return;
1267
+ commit(pending);
1268
+ };
1269
+ window.addEventListener("pointerup", release, true);
1270
+ window.addEventListener("touchend", release, true);
1271
+ return function () {
1272
+ window.removeEventListener("pointerup", release, true);
1273
+ window.removeEventListener("touchend", release, true);
1274
+ };
1275
+ },
1276
+ [pending]
1277
+ );
1278
+ var shown = pending === null ? props.value : pending;
1279
+ var readout =
1280
+ pending === null
1281
+ ? props.readout
1282
+ : props.pendingText
1283
+ ? props.pendingText(pending)
1284
+ : String(pending);
1285
+ return h(
1286
+ "div",
1287
+ null,
1288
+ h(
1289
+ "div",
1290
+ { className: "dfp-sliderRow" },
1291
+ h("input", {
1292
+ type: "range",
1293
+ className: "dfp-slider",
1294
+ min: props.min,
1295
+ max: props.max,
1296
+ step: 1,
1297
+ value: shown,
1298
+ disabled: props.disabled,
1299
+ "aria-label": props.label,
1300
+ onChange: function (event) {
1301
+ setPending(Number(event.target.value));
1302
+ },
1303
+ onKeyUp: function () {
1304
+ if (pending !== null) commit(pending);
1305
+ },
1306
+ onBlur: function () {
1307
+ if (pending !== null) commit(pending);
1308
+ },
1309
+ }),
1310
+ h("span", { className: "dfp-value" }, readout)
1311
+ ),
1312
+ h(
1313
+ "div",
1314
+ { className: "dfp-scale" },
1315
+ h("span", null, props.minLabel),
1316
+ h("span", null, props.maxLabel)
1317
+ )
1318
+ );
1319
+ }
1320
+
1321
+ /* ------------------------------------------------------------------ *
1322
+ * font enumeration
1323
+ * ------------------------------------------------------------------ */
1324
+
1325
+ /**
1326
+ * @typedef {{status: "unsupported"|"loading"|"denied"|"ready", families: string[]}} Catalog
1327
+ */
1328
+
1329
+ /** Enumeration is session-stable, so it is resolved once and kept. */
1330
+ var catalogCache = null;
1331
+
1332
+ /**
1333
+ * Enumerate installed families; the built-in presets stand in when the browser
1334
+ * cannot or will not enumerate them.
1335
+ * @returns {Promise<Catalog>} the catalog.
1336
+ */
1337
+ async function loadCatalog() {
1338
+ if (catalogCache !== null) return catalogCache;
1339
+ var host = /** @type {{queryLocalFonts?: () => Promise<Array<{family: string}>>}} */ (
1340
+ globalThis
1341
+ );
1342
+ if (typeof host.queryLocalFonts !== "function") {
1343
+ catalogCache = { status: "unsupported", families: [] };
1344
+ return catalogCache;
1345
+ }
1346
+ try {
1347
+ var fonts = await host.queryLocalFonts();
1348
+ var seen = {};
1349
+ var families = [];
1350
+ for (var index = 0; index < fonts.length; index += 1) {
1351
+ var family = sanitizeFamily(fonts[index].family);
1352
+ if (family === "") continue;
1353
+ var key = family.toLowerCase();
1354
+ if (seen[key] === true) continue;
1355
+ seen[key] = true;
1356
+ families.push(family);
1357
+ }
1358
+ families.sort(function (left, right) {
1359
+ return left.localeCompare(right);
1360
+ });
1361
+ catalogCache =
1362
+ families.length === 0
1363
+ ? { status: "unsupported", families: [] }
1364
+ : { status: "ready", families: families };
1365
+ } catch (error) {
1366
+ // A refused permission is the common case here: `queryLocalFonts` prompts.
1367
+ catalogCache = { status: "denied", families: [] };
1368
+ }
1369
+ return catalogCache;
1370
+ }
1371
+
1372
+ /** Per-session classification cache; measurement is deterministic. */
1373
+ var cjkCache = {};
1374
+
1375
+ /**
1376
+ * Measure whether a family actually renders CJK text.
1377
+ *
1378
+ * Width comparison is the trick that works without loading anything: a family
1379
+ * that lacks the glyphs leaves the string to the fallback, so both runs measure
1380
+ * identically. Returns null when measuring is impossible, letting the caller
1381
+ * fall back to the name heuristic.
1382
+ * @param {string} name - a family name.
1383
+ * @returns {boolean|null} the measured verdict, or null when unavailable.
1384
+ */
1385
+ function measureCJK(name) {
1386
+ if (typeof document === "undefined" || !document.body) return null;
1387
+ try {
1388
+ var canvas = document.createElement("canvas");
1389
+ var context = canvas.getContext("2d");
1390
+ if (context === null) return null;
1391
+ var probe = "中文字體测试";
1392
+ context.font = '72px ' + quoteFamily(name) + ", monospace";
1393
+ var withFamily = context.measureText(probe).width;
1394
+ context.font = "72px monospace";
1395
+ var without = context.measureText(probe).width;
1396
+ if (withFamily <= 0) return null;
1397
+ return withFamily !== without;
1398
+ } catch (error) {
1399
+ return null;
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * Classify a family for the simple mode's West/CJK slots.
1405
+ *
1406
+ * The name heuristic answers FIRST: it is deliberately generous, because a
1407
+ * false "east" only parks the family in the CJK slot while a miss would hide a
1408
+ * CJK family in the western slot — and crucially, a preset family this machine
1409
+ * has not installed must still land in the CJK slot (measurement would report
1410
+ * "no coverage" for anything uninstalled, since the fallback renders the
1411
+ * probe). The canvas measurement only adds a check for families whose names do
1412
+ * not hint at CJK. Verdicts are cached per session.
1413
+ * @param {string} name - a family name.
1414
+ * @returns {boolean} true when the family belongs in the CJK slot.
1415
+ */
1416
+ function classifyFamily(name) {
1417
+ var key = name.toLowerCase();
1418
+ if (Object.prototype.hasOwnProperty.call(cjkCache, key)) return cjkCache[key];
1419
+ var verdict;
1420
+ if (isGenericFamilyName(name)) verdict = false;
1421
+ else if (isCJKFamilyName(name)) verdict = true;
1422
+ else {
1423
+ verdict = measureCJK(name);
1424
+ if (verdict === null) verdict = false;
1425
+ }
1426
+ cjkCache[key] = verdict;
1427
+ return verdict;
1428
+ }
1429
+
1430
+ /** Preset groups, in the order the picker lists them. */
1431
+ var PRESET_GROUPS = [
1432
+ { label: "stack.groupMono", families: PRESETS.mono },
1433
+ { label: "stack.groupCjk", families: PRESETS.cjk },
1434
+ { label: "stack.groupLatin", families: PRESETS.latin },
1435
+ { label: "stack.groupGeneric", families: PRESETS.generic },
1436
+ ];
1437
+
1438
+ /* ------------------------------------------------------------------ *
1439
+ * the family picker
1440
+ * ------------------------------------------------------------------ */
1441
+
1442
+ /**
1443
+ * The family picker: the current stack as draggable, removable chips plus an
1444
+ * anchored panel of presets, enumerated families and a "use what was typed"
1445
+ * row.
1446
+ *
1447
+ * `single` mode reuses the same panel for the simple mode's West/CJK slots:
1448
+ * one trigger button shows the current family, a click picks it and closes,
1449
+ * and nothing about the rest of the stack is touched.
1450
+ * @param {object} props - copy, the CSS stack value, the change handler, and
1451
+ * for `single` mode the current value plus the pick handler.
1452
+ * @returns {object} the picker element.
1453
+ */
1454
+ function StackPicker(props) {
1455
+ var single = props.single === true;
1456
+ var t = props.t;
1457
+ var anchorRef = useRef(null);
1458
+ var panelRef = useRef(null);
1459
+ var [open, setOpen] = useState(false);
1460
+ var [query, setQuery] = useState("");
1461
+ var [catalog, setCatalog] = useState({ status: "loading", families: [] });
1462
+ var [dragIndex, setDragIndex] = useState(-1);
1463
+ var [dropIndex, setDropIndex] = useState(-1);
1464
+ var [position, setPosition] = useState(null);
1465
+
1466
+ var families = useMemo(
1467
+ function () {
1468
+ return parseStack(props.value);
1469
+ },
1470
+ [props.value]
1471
+ );
1472
+
1473
+ useEffect(
1474
+ function () {
1475
+ if (!open) return undefined;
1476
+ var cancelled = false;
1477
+ void loadCatalog().then(function (next) {
1478
+ if (!cancelled) setCatalog(next);
1479
+ });
1480
+ return function () {
1481
+ cancelled = true;
1482
+ };
1483
+ },
1484
+ [open]
1485
+ );
1486
+
1487
+ // The panel is portaled to `body` and positioned from the trigger, so the
1488
+ // settings sheet's own scroll container cannot clip it.
1489
+ useEffect(
1490
+ function () {
1491
+ if (!open) return undefined;
1492
+ var place = function () {
1493
+ var anchor = anchorRef.current;
1494
+ var view = globalThis;
1495
+ if (!anchor || typeof view.innerWidth !== "number") return;
1496
+ var rect = anchor.getBoundingClientRect();
1497
+ var margin = 10;
1498
+ var left = Math.max(
1499
+ margin,
1500
+ Math.min(rect.right - PANEL_WIDTH, view.innerWidth - PANEL_WIDTH - margin)
1501
+ );
1502
+ var below = rect.bottom + 6;
1503
+ var top =
1504
+ below + PANEL_HEIGHT > view.innerHeight - margin
1505
+ ? Math.max(margin, rect.top - PANEL_HEIGHT - 6)
1506
+ : below;
1507
+ setPosition({ left: left, top: top });
1508
+ };
1509
+ place();
1510
+ const view = globalThis;
1511
+ view.addEventListener("resize", place);
1512
+ view.addEventListener("scroll", place, true);
1513
+ return function () {
1514
+ view.removeEventListener("resize", place);
1515
+ view.removeEventListener("scroll", place, true);
1516
+ };
1517
+ },
1518
+ [open]
1519
+ );
1520
+
1521
+ useEffect(
1522
+ function () {
1523
+ if (!open) return undefined;
1524
+ var onPointerDown = function (event) {
1525
+ var anchor = anchorRef.current;
1526
+ var panel = panelRef.current;
1527
+ if (anchor && anchor.contains(event.target)) return;
1528
+ if (panel && panel.contains(event.target)) return;
1529
+ setOpen(false);
1530
+ };
1531
+ var onKeyDown = function (event) {
1532
+ if (event.key === "Escape") setOpen(false);
1533
+ };
1534
+ document.addEventListener("pointerdown", onPointerDown, true);
1535
+ document.addEventListener("keydown", onKeyDown);
1536
+ return function () {
1537
+ document.removeEventListener("pointerdown", onPointerDown, true);
1538
+ document.removeEventListener("keydown", onKeyDown);
1539
+ };
1540
+ },
1541
+ [open]
1542
+ );
1543
+
1544
+ var commit = function (next) {
1545
+ props.onChange(formatStack(next));
1546
+ };
1547
+
1548
+ var moveChip = function (from, to) {
1549
+ if (from === to || from < 0 || to < 0 || from >= families.length || to >= families.length) {
1550
+ return;
1551
+ }
1552
+ var next = families.slice();
1553
+ var moved = next.splice(from, 1)[0];
1554
+ if (moved === undefined) return;
1555
+ next.splice(to, 0, moved);
1556
+ commit(next);
1557
+ };
1558
+
1559
+ var toggle = function (name) {
1560
+ if (single) {
1561
+ props.onPick(name);
1562
+ setOpen(false);
1563
+ return;
1564
+ }
1565
+ var lower = name.toLowerCase();
1566
+ var existing = -1;
1567
+ for (var index = 0; index < families.length; index += 1) {
1568
+ if (families[index].toLowerCase() === lower) existing = index;
1569
+ }
1570
+ if (existing >= 0) {
1571
+ commit(
1572
+ families.filter(function (_, index) {
1573
+ return index !== existing;
1574
+ })
1575
+ );
1576
+ return;
1577
+ }
1578
+ commit(families.concat([name]));
1579
+ };
1580
+
1581
+ var needle = query.trim().toLowerCase();
1582
+ var selectedLower = {};
1583
+ if (single) {
1584
+ if (typeof props.value === "string" && props.value !== "") {
1585
+ selectedLower[props.value.toLowerCase()] = true;
1586
+ }
1587
+ } else {
1588
+ for (var selectedIndex = 0; selectedIndex < families.length; selectedIndex += 1) {
1589
+ selectedLower[families[selectedIndex].toLowerCase()] = true;
1590
+ }
1591
+ }
1592
+ var matches = function (name) {
1593
+ return needle === "" || name.toLowerCase().indexOf(needle) >= 0;
1594
+ };
1595
+ var options = [];
1596
+ var pushGroup = function (labelKey, names, limit, skipSelected) {
1597
+ var rows = [];
1598
+ for (var index = 0; index < names.length; index += 1) {
1599
+ var name = sanitizeFamily(names[index]);
1600
+ if (name === "" || !matches(name)) continue;
1601
+ if (skipSelected === true && selectedLower[name.toLowerCase()] === true) continue;
1602
+ rows.push(name);
1603
+ if (limit !== undefined && rows.length >= limit) break;
1604
+ }
1605
+ if (rows.length > 0) options.push({ label: labelKey, families: rows });
1606
+ };
1607
+
1608
+ var selectedRows = [];
1609
+ for (var familyIndex = 0; familyIndex < families.length; familyIndex += 1) {
1610
+ if (matches(families[familyIndex])) selectedRows.push(families[familyIndex]);
1611
+ }
1612
+ if (selectedRows.length > 0) {
1613
+ options.push({ label: "stack.groupSelected", families: selectedRows });
1614
+ }
1615
+
1616
+ if (catalog.status === "ready") {
1617
+ if (needle === "") {
1618
+ for (var groupIndex = 0; groupIndex < PRESET_GROUPS.length; groupIndex += 1) {
1619
+ pushGroup(PRESET_GROUPS[groupIndex].label, PRESET_GROUPS[groupIndex].families, undefined, !single);
1620
+ }
1621
+ }
1622
+ pushGroup("stack.groupLocal", catalog.families, MAX_VISIBLE_FONTS, !single);
1623
+ } else {
1624
+ for (var fallbackIndex = 0; fallbackIndex < PRESET_GROUPS.length; fallbackIndex += 1) {
1625
+ pushGroup(
1626
+ PRESET_GROUPS[fallbackIndex].label,
1627
+ PRESET_GROUPS[fallbackIndex].families,
1628
+ undefined,
1629
+ !single
1630
+ );
1631
+ }
1632
+ }
1633
+
1634
+ var typed = query.trim();
1635
+ var typedKnown = false;
1636
+ if (typed !== "") {
1637
+ for (var optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
1638
+ for (var nameIndex = 0; nameIndex < options[optionIndex].families.length; nameIndex += 1) {
1639
+ if (options[optionIndex].families[nameIndex].toLowerCase() === typed.toLowerCase()) {
1640
+ typedKnown = true;
1641
+ }
1642
+ }
1643
+ }
1644
+ }
1645
+
1646
+ var panel = null;
1647
+ if (open) {
1648
+ var rows = [];
1649
+ for (var groupRender = 0; groupRender < options.length; groupRender += 1) {
1650
+ var group = options[groupRender];
1651
+ rows.push(h("div", { className: "dfp-group", key: "g:" + group.label }, t(group.label)));
1652
+ for (var rowIndex = 0; rowIndex < group.families.length; rowIndex += 1) {
1653
+ var name = group.families[rowIndex];
1654
+ var isSelected = selectedLower[name.toLowerCase()] === true;
1655
+ rows.push(
1656
+ h(
1657
+ "button",
1658
+ {
1659
+ type: "button",
1660
+ className: "dfp-option",
1661
+ key: "o:" + group.label + ":" + name,
1662
+ style: { fontFamily: quoteFamily(name) },
1663
+ "data-family": name,
1664
+ onClick: function (event) {
1665
+ toggle(event.currentTarget.dataset.family);
1666
+ },
1667
+ },
1668
+ h("span", { className: "dfp-optionLabel" }, name),
1669
+ isSelected ? h("span", { className: "dfp-optionCheck" }, "✓") : null
1670
+ )
1671
+ );
1672
+ }
1673
+ }
1674
+ if (typed !== "" && !typedKnown) {
1675
+ rows.push(
1676
+ h(
1677
+ "button",
1678
+ {
1679
+ type: "button",
1680
+ className: "dfp-option",
1681
+ key: "custom",
1682
+ onClick: function () {
1683
+ if (single) {
1684
+ props.onPick(typed);
1685
+ setOpen(false);
1686
+ } else {
1687
+ commit(families.concat([typed]));
1688
+ }
1689
+ setQuery("");
1690
+ },
1691
+ },
1692
+ h("span", { className: "dfp-optionLabel" }, t("stack.custom", { name: typed }))
1693
+ )
1694
+ );
1695
+ }
1696
+ if (rows.length === 0) {
1697
+ rows.push(h("div", { className: "dfp-note", key: "none" }, t("stack.empty")));
1698
+ }
1699
+ var notes = [];
1700
+ if (catalog.status === "loading") notes.push(t("stack.loading"));
1701
+ if (catalog.status === "denied") notes.push(t("stack.denied"));
1702
+ if (catalog.status === "unsupported") notes.push(t("stack.unsupported"));
1703
+
1704
+ panel = createPortal(
1705
+ h(
1706
+ "div",
1707
+ {
1708
+ className: "dfp-panel",
1709
+ ref: panelRef,
1710
+ role: "listbox",
1711
+ "aria-multiselectable": single ? "false" : "true",
1712
+ "aria-label": props.label,
1713
+ style: position === null ? undefined : { left: position.left, top: position.top },
1714
+ },
1715
+ h("input", {
1716
+ type: "search",
1717
+ className: "dfp-search",
1718
+ "aria-label": t("stack.search"),
1719
+ placeholder: t("stack.search"),
1720
+ spellCheck: false,
1721
+ value: query,
1722
+ onChange: function (event) {
1723
+ setQuery(event.target.value);
1724
+ },
1725
+ }),
1726
+ notes.length > 0
1727
+ ? h(
1728
+ "div",
1729
+ { className: "dfp-note" },
1730
+ notes.map(function (note, index) {
1731
+ return h("div", { key: index }, note);
1732
+ })
1733
+ )
1734
+ : null,
1735
+ h("div", { className: "dfp-list" }, rows),
1736
+ h(
1737
+ "div",
1738
+ { className: "dfp-footerRow" },
1739
+ h(
1740
+ "button",
1741
+ {
1742
+ type: "button",
1743
+ className: "dfp-add",
1744
+ onClick: function () {
1745
+ setOpen(false);
1746
+ },
1747
+ },
1748
+ t("stack.done")
1749
+ )
1750
+ )
1751
+ ),
1752
+ document.body
1753
+ );
1754
+ }
1755
+
1756
+ var chips = families.map(function (name, index) {
1757
+ return h(
1758
+ "span",
1759
+ {
1760
+ className:
1761
+ "dfp-chip" +
1762
+ (dragIndex === index ? " dfp-chipDragging" : "") +
1763
+ (dropIndex === index && dragIndex !== index ? " dfp-chipDrop" : ""),
1764
+ key: name + ":" + index,
1765
+ draggable: true,
1766
+ title: t("stack.drag", { name: name }),
1767
+ onDragStart: function (event) {
1768
+ setDragIndex(index);
1769
+ try {
1770
+ event.dataTransfer.effectAllowed = "move";
1771
+ event.dataTransfer.setData("text/plain", name);
1772
+ } catch (error) {
1773
+ // Some environments refuse dataTransfer writes; the drag still works.
1774
+ }
1775
+ },
1776
+ onDragOver: function (event) {
1777
+ event.preventDefault();
1778
+ setDropIndex(index);
1779
+ },
1780
+ onDrop: function (event) {
1781
+ event.preventDefault();
1782
+ moveChip(dragIndex, index);
1783
+ setDragIndex(-1);
1784
+ setDropIndex(-1);
1785
+ },
1786
+ onDragEnd: function () {
1787
+ setDragIndex(-1);
1788
+ setDropIndex(-1);
1789
+ },
1790
+ },
1791
+ h("span", { className: "dfp-grip", "aria-hidden": "true" }, "⠿"),
1792
+ h("span", { className: "dfp-chipLabel", style: { fontFamily: quoteFamily(name) } }, name),
1793
+ h(
1794
+ "button",
1795
+ {
1796
+ type: "button",
1797
+ className: "dfp-chipButton",
1798
+ "aria-label": t("stack.earlier", { name: name }),
1799
+ disabled: index === 0,
1800
+ onClick: function () {
1801
+ moveChip(index, index - 1);
1802
+ },
1803
+ },
1804
+ "‹"
1805
+ ),
1806
+ h(
1807
+ "button",
1808
+ {
1809
+ type: "button",
1810
+ className: "dfp-chipButton",
1811
+ "aria-label": t("stack.later", { name: name }),
1812
+ disabled: index === families.length - 1,
1813
+ onClick: function () {
1814
+ moveChip(index, index + 1);
1815
+ },
1816
+ },
1817
+ "›"
1818
+ ),
1819
+ h(
1820
+ "button",
1821
+ {
1822
+ type: "button",
1823
+ className: "dfp-chipButton",
1824
+ "aria-label": t("stack.remove", { name: name }),
1825
+ onClick: function () {
1826
+ commit(
1827
+ families.filter(function (_, current) {
1828
+ return current !== index;
1829
+ })
1830
+ );
1831
+ },
1832
+ },
1833
+ "×"
1834
+ )
1835
+ );
1836
+ });
1837
+
1838
+ if (single) {
1839
+ var hasValue = typeof props.value === "string" && props.value !== "";
1840
+ return h(
1841
+ "span",
1842
+ { className: "dfp-splitRow" },
1843
+ h(
1844
+ "span",
1845
+ { ref: anchorRef },
1846
+ h(
1847
+ "button",
1848
+ {
1849
+ type: "button",
1850
+ className: "dfp-pick" + (hasValue ? "" : " dfp-pickEmpty"),
1851
+ "aria-haspopup": "listbox",
1852
+ "aria-expanded": open,
1853
+ disabled: props.disabled === true,
1854
+ onClick: function () {
1855
+ setQuery("");
1856
+ setOpen(!open);
1857
+ },
1858
+ },
1859
+ h(
1860
+ "span",
1861
+ {
1862
+ className: "dfp-chipLabel",
1863
+ style: hasValue ? { fontFamily: quoteFamily(props.value) } : undefined,
1864
+ },
1865
+ hasValue ? props.value : t("split.pick")
1866
+ )
1867
+ )
1868
+ ),
1869
+ hasValue
1870
+ ? h(
1871
+ "button",
1872
+ {
1873
+ type: "button",
1874
+ className: "dfp-chipButton",
1875
+ "aria-label": t("split.remove", { name: props.value }),
1876
+ disabled: props.disabled === true,
1877
+ onClick: props.onRemove,
1878
+ },
1879
+ "×"
1880
+ )
1881
+ : null,
1882
+ panel
1883
+ );
1884
+ }
1885
+
1886
+ return h(
1887
+ "div",
1888
+ null,
1889
+ h(
1890
+ "div",
1891
+ { className: "dfp-chips" },
1892
+ families.length === 0 ? h("span", { className: "dfp-empty" }, t("stack.empty")) : null,
1893
+ chips,
1894
+ h(
1895
+ "span",
1896
+ { ref: anchorRef },
1897
+ h(
1898
+ "button",
1899
+ {
1900
+ type: "button",
1901
+ className: "dfp-add",
1902
+ "aria-expanded": open,
1903
+ "aria-haspopup": "listbox",
1904
+ onClick: function () {
1905
+ setQuery("");
1906
+ setOpen(!open);
1907
+ },
1908
+ },
1909
+ h(primitives.IconPlusOutline16, null),
1910
+ h("span", null, t("stack.add"))
1911
+ )
1912
+ )
1913
+ ),
1914
+ families.length > 1 ? h("p", { className: "dfp-hint" }, t("stack.hintOrder")) : null,
1915
+ panel
1916
+ );
1917
+ }
1918
+
1919
+ /* ------------------------------------------------------------------ *
1920
+ * the card
1921
+ * ------------------------------------------------------------------ */
1922
+
1923
+ /** localStorage key of the card's view mode. */
1924
+ var MODE_KEY = "dsh-fonttune.mode.v1";
1925
+
1926
+ /**
1927
+ * Read the view mode the card opens in. Simple is the default; an unreadable
1928
+ * or missing storage (tests, private modes) must not break the card.
1929
+ * @returns {"simple"|"advanced"} the persisted mode.
1930
+ */
1931
+ function readViewMode() {
1932
+ try {
1933
+ if (globalThis.localStorage.getItem(MODE_KEY) === "advanced") return "advanced";
1934
+ } catch (error) {
1935
+ // Storage unavailable: the default view stands.
1936
+ }
1937
+ return "simple";
1938
+ }
1939
+
1940
+ /**
1941
+ * Derive the simple mode's two slots from one stack: the front non-CJK entry
1942
+ * is the western slot, the first CJK entry is the eastern slot, and everything
1943
+ * else is reported untouched so a tuned order stays visible.
1944
+ * @param {string} value - the CSS stack value.
1945
+ * @returns {{west: string|null, east: string|null, rest: string[]}} the slots.
1946
+ */
1947
+ function deriveSlots(value) {
1948
+ var families = parseStack(value);
1949
+ var west = null;
1950
+ var east = null;
1951
+ var rest = [];
1952
+ for (var index = 0; index < families.length; index += 1) {
1953
+ var name = families[index];
1954
+ if (classifyFamily(name)) {
1955
+ if (east === null) east = name;
1956
+ else rest.push(name);
1957
+ } else if (west === null) west = name;
1958
+ else rest.push(name);
1959
+ }
1960
+ return { west: west, east: east, rest: rest };
1961
+ }
1962
+
1963
+ /**
1964
+ * One family axis in the simple mode: the western slot and the CJK slot as
1965
+ * two single-pick triggers, plus a hint naming the untouched remainder.
1966
+ * @param {object} props - copy, the derived slots, handlers, disabled flag.
1967
+ * @returns {object} the field element.
1968
+ */
1969
+ function SimpleFamilyField(props) {
1970
+ var t = props.t;
1971
+ var slots = props.slots;
1972
+ return h(
1973
+ "div",
1974
+ { className: "dfp-field" },
1975
+ h(
1976
+ "div",
1977
+ { className: "dfp-fieldHead" },
1978
+ h("span", { className: "dfp-fieldLabel" }, props.label)
1979
+ ),
1980
+ h("p", { className: "dfp-hint" }, t("mode.simpleHint")),
1981
+ h(
1982
+ "div",
1983
+ { className: "dfp-slotRow" },
1984
+ h("span", { className: "dfp-slotLabel" }, t(props.westLabel)),
1985
+ h(StackPicker, {
1986
+ t: t,
1987
+ label: t(props.westLabel),
1988
+ single: true,
1989
+ value: slots.west,
1990
+ disabled: props.disabled,
1991
+ onPick: props.onPickWest,
1992
+ onRemove: props.onRemoveWest,
1993
+ })
1994
+ ),
1995
+ h(
1996
+ "div",
1997
+ { className: "dfp-slotRow" },
1998
+ h("span", { className: "dfp-slotLabel" }, t(props.eastLabel)),
1999
+ h(StackPicker, {
2000
+ t: t,
2001
+ label: t(props.eastLabel),
2002
+ single: true,
2003
+ value: slots.east,
2004
+ disabled: props.disabled,
2005
+ onPick: props.onPickEast,
2006
+ onRemove: props.onRemoveEast,
2007
+ })
2008
+ ),
2009
+ slots.rest.length > 0
2010
+ ? h("p", { className: "dfp-hint" }, t("split.rest", { names: slots.rest.join(", ") }))
2011
+ : null
2012
+ );
2013
+ }
2014
+
2015
+ /**
2016
+ * Render the plugin's card: the settings section dispatches this component
2017
+ * under the namespace key, and the host has to serve that namespace for it to
2018
+ * appear at all.
2019
+ * @param {object} props - slot props (the injected face plus the locale seat).
2020
+ * @returns {object} the card element.
2021
+ */
2022
+ function FontCard(props) {
2023
+ var t = props.t;
2024
+ var scope = props.scope;
2025
+ var [open, setOpen] = useState(false);
2026
+ var [view, setView] = useState(readViewMode);
2027
+ var snapshot = useScopeSnapshot(scope);
2028
+ var config = normalizeConfig(snapshot.value);
2029
+ var user = snapshot.user !== null && typeof snapshot.user === "object" ? snapshot.user : {};
2030
+ var writable = snapshot.writable === true;
2031
+
2032
+ var changeView = function (next) {
2033
+ setView(next);
2034
+ try {
2035
+ globalThis.localStorage.setItem(MODE_KEY, next);
2036
+ } catch (error) {
2037
+ // Unavailable storage only costs the persistence of the preference.
2038
+ }
2039
+ };
2040
+ // The simple mode edits only the two front slots; the stack value itself is
2041
+ // the single source of truth, and switching views writes nothing at all.
2042
+ var pickSansWest = function (family) {
2043
+ setField(SANS_FIELD, formatStack(setWestEntry(parseStack(config[SANS_FIELD]), family, classifyFamily)));
2044
+ };
2045
+ var pickSansEast = function (family) {
2046
+ setField(SANS_FIELD, formatStack(setEastEntry(parseStack(config[SANS_FIELD]), family, classifyFamily)));
2047
+ };
2048
+ var dropSansEntry = function (family) {
2049
+ setField(SANS_FIELD, formatStack(removeStackEntry(parseStack(config[SANS_FIELD]), family)));
2050
+ };
2051
+ var pickMonoWest = function (family) {
2052
+ setField(MONO_FIELD, formatStack(setWestEntry(parseStack(config[MONO_FIELD]), family, classifyFamily)));
2053
+ };
2054
+ var pickMonoEast = function (family) {
2055
+ setField(MONO_FIELD, formatStack(setEastEntry(parseStack(config[MONO_FIELD]), family, classifyFamily)));
2056
+ };
2057
+ var dropMonoEntry = function (family) {
2058
+ setField(MONO_FIELD, formatStack(removeStackEntry(parseStack(config[MONO_FIELD]), family)));
2059
+ };
2060
+
2061
+ var setField = function (field, value) {
2062
+ var result = scope.set(field, value);
2063
+ if (result && typeof result.catch === "function") {
2064
+ result.catch(function () {
2065
+ // A failed write reloads the Host state through the scope itself.
2066
+ });
2067
+ }
2068
+ };
2069
+ var resetField = function (field) {
2070
+ var result = scope.unset(field);
2071
+ if (result && typeof result.catch === "function") {
2072
+ result.catch(function () {});
2073
+ }
2074
+ };
2075
+ var overridden = function (field) {
2076
+ return Object.prototype.hasOwnProperty.call(user, field);
2077
+ };
2078
+
2079
+ var offset = config[SIZE_FIELD];
2080
+ var weight = config[WEIGHT_FIELD];
2081
+ var offsetText = offset > 0 ? "+" + offset : String(offset);
2082
+
2083
+ return h(
2084
+ "li",
2085
+ { className: "dfp-card" + (open ? " dfp-cardOpen" : "") },
2086
+ h(
2087
+ "button",
2088
+ {
2089
+ type: "button",
2090
+ className: "dfp-header",
2091
+ "aria-expanded": open,
2092
+ "aria-label": t(open ? "card.collapse" : "card.expand") + ": " + t("card.title"),
2093
+ onClick: function () {
2094
+ setOpen(!open);
2095
+ },
2096
+ },
2097
+ h(
2098
+ "span",
2099
+ { className: "dfp-headText" },
2100
+ h("span", { className: "dfp-name" }, t("card.title")),
2101
+ h("span", { className: "dfp-description" }, t("card.description"))
2102
+ ),
2103
+ h(
2104
+ "span",
2105
+ { className: "dfp-chevron" + (open ? " dfp-chevronOpen" : ""), "aria-hidden": "true" },
2106
+ "⌄"
2107
+ )
2108
+ ),
2109
+ open
2110
+ ? h(
2111
+ "div",
2112
+ { className: "dfp-body" },
2113
+ writable ? null : h("p", { className: "dfp-readOnly", role: "status" }, t("card.readOnly")),
2114
+
2115
+ h(
2116
+ "div",
2117
+ { className: "dfp-modeRow", role: "group", "aria-label": t("mode.label") },
2118
+ h("span", { className: "dfp-modeLabel" }, t("mode.label")),
2119
+ h(
2120
+ "div",
2121
+ { className: "dfp-modeSeg" },
2122
+ h(
2123
+ "button",
2124
+ {
2125
+ type: "button",
2126
+ className: "dfp-modeButton" + (view === "simple" ? " dfp-modeButtonActive" : ""),
2127
+ "aria-pressed": view === "simple",
2128
+ onClick: function () {
2129
+ changeView("simple");
2130
+ },
2131
+ },
2132
+ t("mode.simple")
2133
+ ),
2134
+ h(
2135
+ "button",
2136
+ {
2137
+ type: "button",
2138
+ className: "dfp-modeButton" + (view === "advanced" ? " dfp-modeButtonActive" : ""),
2139
+ "aria-pressed": view === "advanced",
2140
+ onClick: function () {
2141
+ changeView("advanced");
2142
+ },
2143
+ },
2144
+ t("mode.advanced")
2145
+ )
2146
+ )
2147
+ ),
2148
+
2149
+ view === "simple"
2150
+ ? h(SimpleFamilyField, {
2151
+ t: t,
2152
+ label: t("sans.label"),
2153
+ westLabel: "sansWest.label",
2154
+ eastLabel: "sansEast.label",
2155
+ slots: deriveSlots(config[SANS_FIELD]),
2156
+ disabled: !writable,
2157
+ onPickWest: pickSansWest,
2158
+ onPickEast: pickSansEast,
2159
+ onRemoveWest: function () {
2160
+ var slots = deriveSlots(config[SANS_FIELD]);
2161
+ if (slots.west !== null) dropSansEntry(slots.west);
2162
+ },
2163
+ onRemoveEast: function () {
2164
+ var slots = deriveSlots(config[SANS_FIELD]);
2165
+ if (slots.east !== null) dropSansEntry(slots.east);
2166
+ },
2167
+ })
2168
+ : h(
2169
+ FieldShell,
2170
+ {
2171
+ t: t,
2172
+ label: t("sans.label"),
2173
+ hint: t("sans.hint"),
2174
+ overridden: overridden(SANS_FIELD),
2175
+ disabled: !writable,
2176
+ onReset: function () {
2177
+ resetField(SANS_FIELD);
2178
+ },
2179
+ },
2180
+ h(StackPicker, {
2181
+ t: t,
2182
+ label: t("sans.label"),
2183
+ value: config[SANS_FIELD],
2184
+ onChange: function (value) {
2185
+ setField(SANS_FIELD, value);
2186
+ },
2187
+ })
2188
+ ),
2189
+
2190
+ view === "simple"
2191
+ ? h(SimpleFamilyField, {
2192
+ t: t,
2193
+ label: t("mono.label"),
2194
+ westLabel: "monoWest.label",
2195
+ eastLabel: "monoEast.label",
2196
+ slots: deriveSlots(config[MONO_FIELD]),
2197
+ disabled: !writable,
2198
+ onPickWest: pickMonoWest,
2199
+ onPickEast: pickMonoEast,
2200
+ onRemoveWest: function () {
2201
+ var slots = deriveSlots(config[MONO_FIELD]);
2202
+ if (slots.west !== null) dropMonoEntry(slots.west);
2203
+ },
2204
+ onRemoveEast: function () {
2205
+ var slots = deriveSlots(config[MONO_FIELD]);
2206
+ if (slots.east !== null) dropMonoEntry(slots.east);
2207
+ },
2208
+ })
2209
+ : h(
2210
+ FieldShell,
2211
+ {
2212
+ t: t,
2213
+ label: t("mono.label"),
2214
+ hint: t("mono.hint"),
2215
+ overridden: overridden(MONO_FIELD),
2216
+ disabled: !writable,
2217
+ onReset: function () {
2218
+ resetField(MONO_FIELD);
2219
+ },
2220
+ },
2221
+ h(StackPicker, {
2222
+ t: t,
2223
+ label: t("mono.label"),
2224
+ value: config[MONO_FIELD],
2225
+ onChange: function (value) {
2226
+ setField(MONO_FIELD, value);
2227
+ },
2228
+ })
2229
+ ),
2230
+
2231
+ h(
2232
+ FieldShell,
2233
+ {
2234
+ t: t,
2235
+ label: t("size.label"),
2236
+ hint: t("size.hint", { offset: offsetText }),
2237
+ overridden: overridden(SIZE_FIELD) && offset !== 0,
2238
+ disabled: !writable,
2239
+ onReset: function () {
2240
+ resetField(SIZE_FIELD);
2241
+ },
2242
+ },
2243
+ h(NumberSlider, {
2244
+ min: SIZE_MIN,
2245
+ max: SIZE_MAX,
2246
+ value: offset,
2247
+ disabled: !writable,
2248
+ label: t("size.label"),
2249
+ readout: offsetText + " " + t("size.unit"),
2250
+ minLabel: SIZE_MIN + " " + t("size.unit"),
2251
+ maxLabel: "+" + SIZE_MAX + " " + t("size.unit"),
2252
+ pendingText: function (value) {
2253
+ return (value > 0 ? "+" + value : String(value)) + " " + t("size.unit");
2254
+ },
2255
+ onChange: function (value) {
2256
+ setField(SIZE_FIELD, value);
2257
+ },
2258
+ })
2259
+ ),
2260
+
2261
+ h(
2262
+ FieldShell,
2263
+ {
2264
+ t: t,
2265
+ label: t("weight.label"),
2266
+ hint: t("weight.hint"),
2267
+ overridden: overridden(WEIGHT_FIELD) && weight !== WEIGHT_UNSET,
2268
+ disabled: !writable,
2269
+ onReset: function () {
2270
+ resetField(WEIGHT_FIELD);
2271
+ },
2272
+ },
2273
+ h(NumberSlider, {
2274
+ min: WEIGHT_MIN,
2275
+ max: WEIGHT_MAX,
2276
+ value: weight === WEIGHT_UNSET ? NEUTRAL_WEIGHT : weight,
2277
+ disabled: !writable,
2278
+ label: t("weight.label"),
2279
+ readout: weight === WEIGHT_UNSET ? t("weight.unset") : String(weight),
2280
+ minLabel: String(WEIGHT_MIN),
2281
+ maxLabel: String(WEIGHT_MAX),
2282
+ onChange: function (value) {
2283
+ // 400 is DSH's own body weight, so choosing it means "leave the
2284
+ // axis alone" rather than "write 400 on every element".
2285
+ if (value === NEUTRAL_WEIGHT) resetField(WEIGHT_FIELD);
2286
+ else setField(WEIGHT_FIELD, value);
2287
+ },
2288
+ })
2289
+ ),
2290
+
2291
+ h(
2292
+ "div",
2293
+ { className: "dfp-field dfp-fieldLast" },
2294
+ h(
2295
+ "div",
2296
+ { className: "dfp-fieldHead" },
2297
+ h("span", { className: "dfp-fieldLabel" }, t("preview.label"))
2298
+ ),
2299
+ h(
2300
+ "div",
2301
+ { className: "dfp-previewBox" },
2302
+ h("div", { className: "dfp-previewCaption" }, t("preview.sansCaption")),
2303
+ h(
2304
+ "div",
2305
+ {
2306
+ className: "dfp-previewText",
2307
+ style:
2308
+ config[SANS_FIELD] === ""
2309
+ ? undefined
2310
+ : { fontFamily: formatStack(parseStack(config[SANS_FIELD])) },
2311
+ },
2312
+ t("preview.sample")
2313
+ ),
2314
+ h(
2315
+ "div",
2316
+ { className: "dfp-previewCaption", style: { marginTop: 10 } },
2317
+ t("preview.monoCaption")
2318
+ ),
2319
+ h(
2320
+ "div",
2321
+ {
2322
+ className: "dfp-previewText",
2323
+ style:
2324
+ config[MONO_FIELD] === ""
2325
+ ? undefined
2326
+ : { fontFamily: formatStack(parseStack(config[MONO_FIELD])) },
2327
+ },
2328
+ t("preview.code")
2329
+ )
2330
+ )
2331
+ ),
2332
+
2333
+ h(
2334
+ "div",
2335
+ { className: "dfp-footer" },
2336
+ h(
2337
+ "button",
2338
+ {
2339
+ type: "button",
2340
+ className: "dfp-resetAll",
2341
+ disabled: !writable,
2342
+ onClick: function () {
2343
+ resetField(SANS_FIELD);
2344
+ resetField(MONO_FIELD);
2345
+ resetField(SIZE_FIELD);
2346
+ resetField(WEIGHT_FIELD);
2347
+ },
2348
+ },
2349
+ t("card.resetAll")
2350
+ )
2351
+ )
2352
+ )
2353
+ : null
2354
+ );
2355
+ }
2356
+
2357
+ /* ------------------------------------------------------------------ *
2358
+ * plugin body
2359
+ * ------------------------------------------------------------------ */
2360
+
2361
+ /**
2362
+ * Mount the card and keep the page's typography in sync with the settings.
2363
+ * @param {object} ctx - client cordis context.
2364
+ */
2365
+ function apply(ctx) {
2366
+ installCardStyles(ctx);
2367
+
2368
+ var tokens = readBaseTokens();
2369
+ var applyCss = createStylesheet(function () {
2370
+ return tokens;
2371
+ });
2372
+ var scope = ctx.settingsScope.bind({ namespace: NAMESPACE });
2373
+
2374
+ var sync = function () {
2375
+ var snapshot = scope.getSnapshot();
2376
+ if (snapshot.value === undefined) return;
2377
+ applyCss(snapshot.value);
2378
+ };
2379
+ ctx.effect(
2380
+ function () {
2381
+ return scope.subscribe(sync);
2382
+ },
2383
+ "dsh-fonttune: settings adoption"
2384
+ );
2385
+ sync();
2386
+
2387
+ // DSH writes its tokens (and its content font size) after this bundle
2388
+ // activates, and the host's own boot row only covers the first frame; the
2389
+ // size axis therefore re-reads the live values and reapplies when they moved.
2390
+ ctx.effect(
2391
+ function () {
2392
+ if (typeof document === "undefined") return undefined;
2393
+ var lastSignature = "";
2394
+ var recheck = function () {
2395
+ var next = readBaseTokens();
2396
+ var signature = "";
2397
+ var name;
2398
+ for (name in next) {
2399
+ if (!Object.prototype.hasOwnProperty.call(next, name)) continue;
2400
+ signature += name + "=" + next[name] + ";";
2401
+ }
2402
+ if (signature === lastSignature) return;
2403
+ lastSignature = signature;
2404
+ for (name in next) {
2405
+ if (!Object.prototype.hasOwnProperty.call(next, name)) continue;
2406
+ tokens[name] = next[name];
2407
+ }
2408
+ sync();
2409
+ };
2410
+ var timer = globalThis.setTimeout(recheck, 500);
2411
+ var interval = globalThis.setInterval(recheck, 4000);
2412
+ return function () {
2413
+ globalThis.clearTimeout(timer);
2414
+ globalThis.clearInterval(interval);
2415
+ };
2416
+ },
2417
+ "dsh-fonttune: token refresh"
2418
+ );
2419
+
2420
+ var t = function (key, params) {
2421
+ var locale = "en";
2422
+ try {
2423
+ locale = ctx.locale.getLocale().active;
2424
+ } catch (error) {
2425
+ // A composition without the locale service still gets English copy.
2426
+ }
2427
+ return translate(locale, key, params);
2428
+ };
2429
+ ctx.effect(
2430
+ function () {
2431
+ return ctx.locale.register(NAMESPACE, DICTS);
2432
+ },
2433
+ "dsh-fonttune: dictionaries"
2434
+ );
2435
+
2436
+ ctx.slots.inject("settings.plugin.item", function () {
2437
+ return ctx.slots.register(
2438
+ {
2439
+ name: "settings.plugin.item",
2440
+ key: NAMESPACE,
2441
+ inject: function () {
2442
+ return { scope: scope, t: t };
2443
+ },
2444
+ },
2445
+ FontCard
2446
+ );
2447
+ });
2448
+ }
2449
+
2450
+ exports.apply = apply;
2451
+ exports.inject = inject;
2452
+
2453
+ return module.exports;
2454
+ }
2455
+ });