dsh-fonttune 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -19,6 +19,26 @@ var __dfpShared = (function () {
19
19
  * configuration produces, and the sanitizing that keeps user-typed font names
20
20
  * from breaking out of the injected stylesheet.
21
21
  *
22
+ * 0.2.x shape: the axes are grouped into three SURFACES — interface (the UI
23
+ * chrome), dialog (the conversation markdown) and code. The CONVERSATION owns
24
+ * every axis: a family, a size offset, a weight and a line-height ratio. The
25
+ * interface owns a family and a weight, and by default FOLLOWS the conversation
26
+ * on both (the switch turns that off and reveals its own controls); DSH exposes
27
+ * no interface size or line-height hook at all, so those two axes are retired
28
+ * and the durable fields inject nothing. Code carries a family, a size offset,
29
+ * a weight, an additive line-height offset and the ligature/feature controls.
30
+ * `perTheme` enables a second value set for the dark theme, stored as a sparse
31
+ * override map.
32
+ *
33
+ * The interface weight is the one axis that has to reach the WHOLE page (DSH
34
+ * pins its labels with literal weights on class rules, so nothing narrower can
35
+ * move them), which is why it is a `body, body *` rule with an exclusion for
36
+ * the conversation markdown subtree: the conversation's own weight rule then
37
+ * owns that subtree completely and neither axis can disturb the other. That
38
+ * exclusion is what makes the axis safe — measured on a live page (computed
39
+ * styles, `test/weight-verify.mjs`): every text element outside the markdown
40
+ * subtree takes the weight, and not one element inside it moves.
41
+ *
22
42
  * @module dsh-fonttune/shared
23
43
  */
24
44
  "use strict";
@@ -26,24 +46,99 @@ var __dfpShared = (function () {
26
46
  /** Settings namespace registered by the host half. */
27
47
  var NAMESPACE = "dsh-fonttune";
28
48
 
29
- /** Field carrying the body/UI CSS font-family stack (empty = leave DSH alone). */
49
+ /* ------------------------------------------------------------------ *
50
+ * durable field names
51
+ * ------------------------------------------------------------------ */
52
+
53
+ /** Field carrying the interface CSS font-family stack (empty = leave DSH). */
30
54
  var SANS_FIELD = "sans";
31
55
 
32
- /** Field carrying the code CSS font-family stack (empty = leave DSH alone). */
56
+ /**
57
+ * Field carrying the dialog stack. The conversation owns the family: empty
58
+ * means DSH's own default family. A document written before 0.2.0 only has the
59
+ * interface family, so an empty dialog takes it (see `expandFollow`) — that is
60
+ * a migration bridge, not a live rule the card describes.
61
+ */
62
+ var STACK_DIALOG_FIELD = "stackDialog";
63
+
64
+ /** Field carrying the code CSS font-family stack (empty = leave DSH). */
33
65
  var MONO_FIELD = "mono";
34
66
 
35
- /** Field carrying the body/UI font-size offset in px (0 = leave DSH alone). */
67
+ /** Field carrying the interface font-size offset in px (0 = leave DSH alone). */
36
68
  var SIZE_FIELD = "sizeOffset";
37
69
 
70
+ /** Field carrying the dialog font-size offset in px (0 = DSH's own sizes). */
71
+ var SIZE_DIALOG_FIELD = "sizeOffsetDialog";
72
+
38
73
  /** Field carrying the code font-size offset in px (0 = leave DSH alone). */
39
74
  var CODE_SIZE_FIELD = "sizeOffsetCode";
40
75
 
41
- /** Field carrying the body/UI font weight (0 = leave DSH alone). */
76
+ /**
77
+ * Field carrying the interface font weight (0 = leave DSH alone). While the
78
+ * interface follows the conversation this is the fallback for a conversation
79
+ * that sets no weight of its own.
80
+ */
42
81
  var WEIGHT_FIELD = "weight";
43
82
 
83
+ /** Field carrying the dialog font weight (0 = DSH's own weights). */
84
+ var WEIGHT_DIALOG_FIELD = "weightDialog";
85
+
44
86
  /** Field carrying the code font weight (0 = leave DSH alone). */
45
87
  var CODE_WEIGHT_FIELD = "weightCode";
46
88
 
89
+ /**
90
+ * Field carrying the interface line-height ratio in percent (100 = untouched).
91
+ * A ratio rather than a px offset: interface line heights form a design ladder
92
+ * (14/18/20/22/24/26/28/32), and a ratio keeps every step proportional.
93
+ */
94
+ var LINE_HEIGHT_FIELD = "lineHeight";
95
+
96
+ /** Field carrying the dialog line-height ratio in percent (100 = DSH's own). */
97
+ var LINE_HEIGHT_DIALOG_FIELD = "lineHeightDialog";
98
+
99
+ /** Field carrying the code line-height offset in px (0 = leave DSH alone). */
100
+ var CODE_LINE_HEIGHT_FIELD = "lineHeightCode";
101
+
102
+ /** Ligature mode: 0 = DSH default, 1 = forced on, 2 = forced off. */
103
+ var LIGATURES_FIELD = "codeLigatures";
104
+
105
+ /** Advanced `font-feature-settings` value for code (sanitized; empty = none). */
106
+ var FEATURES_FIELD = "codeFeatures";
107
+
108
+ /** Disable synthetic (faux) italic — the "tilted CJK" rendering. */
109
+ var NO_SYNTHETIC_ITALIC_FIELD = "noSyntheticItalic";
110
+
111
+ /** Disable synthetic (faux) bold, for faces without a real bold. */
112
+ var NO_SYNTHETIC_BOLD_FIELD = "noSyntheticBold";
113
+
114
+ /** Whether the dark theme keeps its own value set (sparse overrides). */
115
+ var PER_THEME_FIELD = "perTheme";
116
+
117
+ /**
118
+ * JSON string of the dark-theme sparse overrides: a map of axis field to
119
+ * value, applied over the single set while the dark theme is active.
120
+ */
121
+ var DARK_VALUES_FIELD = "darkValues";
122
+
123
+ /** JSON string of saved presets: [{name, values, savedAt}]. */
124
+ var PRESETS_FIELD = "presets";
125
+
126
+ /** Name of the preset the card's edits auto-save into. */
127
+ var ACTIVE_PRESET_FIELD = "activePreset";
128
+
129
+ /**
130
+ * Whether the INTERFACE follows the conversation. The conversation owns every
131
+ * axis; the interface has a family and a weight of its own (DSH exposes no
132
+ * interface size or line-height hook), so on — the default — the conversation's
133
+ * family and weight win and the interface's own values stand as the fallback
134
+ * for an axis the conversation does not set.
135
+ */
136
+ var UI_FOLLOWS_FIELD = "uiFollowsDialog";
137
+
138
+ /* ------------------------------------------------------------------ *
139
+ * ranges and constants
140
+ * ------------------------------------------------------------------ */
141
+
47
142
  /** Allowed font-size offset range. The upper bound stays under a 2x scale. */
48
143
  var SIZE_MIN = -3;
49
144
  var SIZE_MAX = 6;
@@ -53,10 +148,23 @@ var WEIGHT_MIN = 300;
53
148
  var WEIGHT_MAX = 600;
54
149
  var WEIGHT_UNSET = 0;
55
150
 
151
+ /** Allowed line-height ratio range, in percent (100 = untouched). */
152
+ var LINE_HEIGHT_MIN = 100;
153
+ var LINE_HEIGHT_MAX = 160;
154
+
155
+ /** Allowed code line-height offset range, in px. */
156
+ var CODE_LINE_HEIGHT_MIN = -4;
157
+ var CODE_LINE_HEIGHT_MAX = 8;
158
+
159
+ /** Ligature modes. */
160
+ var LIGATURES_DEFAULT = 0;
161
+ var LIGATURES_ON = 1;
162
+ var LIGATURES_OFF = 2;
163
+
56
164
  /** Marker on both injected style tags, used for scoping every rule we write. */
57
165
  var MARKER = "dfp";
58
166
 
59
- /** `data-plugin-css` value of the tag applying families/offset/weight. */
167
+ /** `data-plugin-css` value of the tag applying the configuration. */
60
168
  var STYLE_TAG = "dsh-fonttune";
61
169
 
62
170
  /** `data-plugin-css` value of the card's own chrome stylesheet. */
@@ -65,13 +173,52 @@ var CARD_STYLE_TAG = "dsh-fonttune-card";
65
173
  /** Composition defaults: every axis dormant, so installing changes nothing. */
66
174
  var DEFAULTS = {
67
175
  sans: "",
176
+ stackDialog: "",
68
177
  mono: "",
69
178
  sizeOffset: 0,
179
+ sizeOffsetDialog: 0,
70
180
  sizeOffsetCode: 0,
71
181
  weight: WEIGHT_UNSET,
182
+ weightDialog: WEIGHT_UNSET,
72
183
  weightCode: WEIGHT_UNSET,
184
+ lineHeight: LINE_HEIGHT_MIN,
185
+ lineHeightDialog: LINE_HEIGHT_MIN,
186
+ lineHeightCode: 0,
187
+ codeLigatures: LIGATURES_DEFAULT,
188
+ codeFeatures: "",
189
+ noSyntheticItalic: false,
190
+ noSyntheticBold: false,
191
+ perTheme: false,
192
+ darkValues: "{}",
193
+ presets: "[]",
194
+ activePreset: "",
195
+ uiFollowsDialog: true,
73
196
  };
74
197
 
198
+ /**
199
+ * The axis fields a preset snapshots: everything the card edits, excluding
200
+ * the storage fields themselves.
201
+ */
202
+ var VALUE_FIELDS = [
203
+ SANS_FIELD,
204
+ STACK_DIALOG_FIELD,
205
+ MONO_FIELD,
206
+ SIZE_FIELD,
207
+ SIZE_DIALOG_FIELD,
208
+ CODE_SIZE_FIELD,
209
+ WEIGHT_FIELD,
210
+ WEIGHT_DIALOG_FIELD,
211
+ CODE_WEIGHT_FIELD,
212
+ LINE_HEIGHT_FIELD,
213
+ LINE_HEIGHT_DIALOG_FIELD,
214
+ CODE_LINE_HEIGHT_FIELD,
215
+ LIGATURES_FIELD,
216
+ FEATURES_FIELD,
217
+ NO_SYNTHETIC_ITALIC_FIELD,
218
+ NO_SYNTHETIC_BOLD_FIELD,
219
+ UI_FOLLOWS_FIELD,
220
+ ];
221
+
75
222
  /**
76
223
  * Longest accepted font stack, in characters (mirrored by the host schema).
77
224
  */
@@ -80,6 +227,19 @@ var MAX_STACK_LENGTH = 200;
80
227
  /** Longest accepted single family name, in characters. */
81
228
  var MAX_FAMILY_LENGTH = 64;
82
229
 
230
+ /** Longest accepted `font-feature-settings` value, in characters. */
231
+ var MAX_FEATURES_LENGTH = 120;
232
+
233
+ /** Most presets one configuration may hold. */
234
+ var MAX_PRESETS = 20;
235
+
236
+ /** Longest preset name, in characters. */
237
+ var MAX_PRESET_NAME = 32;
238
+
239
+ /* ------------------------------------------------------------------ *
240
+ * sanitizing
241
+ * ------------------------------------------------------------------ */
242
+
83
243
  /**
84
244
  * What may survive in a family name.
85
245
  *
@@ -150,28 +310,93 @@ var GENERIC_FAMILIES = [
150
310
  "blinkmacsystemfont",
151
311
  ];
152
312
 
313
+ /**
314
+ * Sanitize an advanced `font-feature-settings` value.
315
+ *
316
+ * Feature tags are letters and digits; entries may carry on/off/number and are
317
+ * comma separated. The allow-list (letters, digits, quotes, spaces, commas)
318
+ * keeps the value from closing the declaration or opening another one, and a
319
+ * coarse shape test rejects anything that is not a tag-entry list.
320
+ * @param {unknown} value - candidate features value.
321
+ * @returns {string} the sanitized value, or "".
322
+ */
323
+ function sanitizeFeatures(value) {
324
+ if (typeof value !== "string") return "";
325
+ var text = value.replace(FEATURES_UNSAFE, "").slice(0, MAX_FEATURES_LENGTH).trim();
326
+ if (text === "") return "";
327
+ if (!/^(?:"?[a-zA-Z0-9]{1,8}"?(?: (?:on|off|-?\d+))?(?:, ?)?)+$/.test(text)) return "";
328
+ return text;
329
+ }
330
+
331
+ /** Characters a `font-feature-settings` value cannot contain. */
332
+ var FEATURES_UNSAFE = /[^a-zA-Z0-9"' ,]/g;
333
+
334
+ /* ------------------------------------------------------------------ *
335
+ * configuration normalization
336
+ * ------------------------------------------------------------------ */
337
+
338
+ /** Clamp/validate one field by name (shared by config and value sets). */
339
+ function normalizeField(field, value) {
340
+ switch (field) {
341
+ case SANS_FIELD:
342
+ case STACK_DIALOG_FIELD:
343
+ case MONO_FIELD:
344
+ return sanitize(value).slice(0, MAX_STACK_LENGTH);
345
+ case SIZE_FIELD:
346
+ case SIZE_DIALOG_FIELD:
347
+ case CODE_SIZE_FIELD:
348
+ return clampOffset(value);
349
+ case WEIGHT_FIELD:
350
+ case WEIGHT_DIALOG_FIELD:
351
+ case CODE_WEIGHT_FIELD:
352
+ return clampWeight(value);
353
+ case LINE_HEIGHT_FIELD:
354
+ case LINE_HEIGHT_DIALOG_FIELD:
355
+ return clampRatio(value);
356
+ case CODE_LINE_HEIGHT_FIELD:
357
+ return clampLineOffset(value);
358
+ case LIGATURES_FIELD:
359
+ return clampLigatures(value);
360
+ case FEATURES_FIELD:
361
+ return sanitizeFeatures(value);
362
+ case NO_SYNTHETIC_ITALIC_FIELD:
363
+ case NO_SYNTHETIC_BOLD_FIELD:
364
+ return toBool(value);
365
+ case UI_FOLLOWS_FIELD:
366
+ // Absent means the default: the interface follows the conversation.
367
+ return value === undefined || value === null || value === "" ? true : toBool(value);
368
+ default:
369
+ return undefined;
370
+ }
371
+ }
372
+
153
373
  /**
154
374
  * Normalize a configuration-shaped object coming from the settings document,
155
- * a config layer, or a test fixture.
375
+ * a config layer, or a test fixture. Storage fields (`darkValues`,
376
+ * `presets`) are decoded and validated here too, so every consumer sees
377
+ * plain data.
156
378
  * @param {unknown} value - candidate configuration.
157
- * @returns {{sans: string, mono: string, sizeOffset: number, sizeOffsetCode: number, weight: number}} the normalized config.
379
+ * @returns {Record<string, string|number|boolean|object|array>} the normalized config.
158
380
  */
159
381
  function normalizeConfig(value) {
160
382
  var source = value !== null && typeof value === "object" ? value : {};
161
- var size = clampOffset(source[SIZE_FIELD]);
162
- var codeSize = clampOffset(source[CODE_SIZE_FIELD]);
163
- var weight = clampWeight(source[WEIGHT_FIELD]);
164
- var codeWeight = clampWeight(source[CODE_WEIGHT_FIELD]);
165
383
  var config = {};
166
- config[SANS_FIELD] = sanitize(source[SANS_FIELD]).slice(0, MAX_STACK_LENGTH);
167
- config[MONO_FIELD] = sanitize(source[MONO_FIELD]).slice(0, MAX_STACK_LENGTH);
168
- config[SIZE_FIELD] = size;
169
- config[CODE_SIZE_FIELD] = codeSize;
170
- config[WEIGHT_FIELD] = weight;
171
- config[CODE_WEIGHT_FIELD] = codeWeight;
384
+ for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
385
+ var field = VALUE_FIELDS[index];
386
+ config[field] = normalizeField(field, source[field]);
387
+ }
388
+ config[PER_THEME_FIELD] = toBool(source[PER_THEME_FIELD]);
389
+ config[DARK_VALUES_FIELD] = normalizeDarkValues(source[DARK_VALUES_FIELD]);
390
+ config[PRESETS_FIELD] = normalizePresets(source[PRESETS_FIELD]);
391
+ config[ACTIVE_PRESET_FIELD] = sanitize(source[ACTIVE_PRESET_FIELD]).slice(0, MAX_PRESET_NAME);
172
392
  return config;
173
393
  }
174
394
 
395
+ /** Coerce a settings value to a strict boolean; anything unusable is false. */
396
+ function toBool(value) {
397
+ return value === true || value === 1 || value === "1" || value === "true";
398
+ }
399
+
175
400
  /**
176
401
  * Clamp one size offset to the allowed range, with 0 for anything unusable.
177
402
  * @param {unknown} value - candidate offset in px.
@@ -196,6 +421,109 @@ function clampWeight(value) {
196
421
  return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weight));
197
422
  }
198
423
 
424
+ /**
425
+ * Clamp one line-height ratio to the allowed percent range.
426
+ * @param {unknown} value - candidate ratio in percent.
427
+ * @returns {number} the integer ratio (100 when unusable).
428
+ */
429
+ function clampRatio(value) {
430
+ var ratio = Math.round(Number(value));
431
+ if (!isFinite(ratio)) return LINE_HEIGHT_MIN;
432
+ return Math.min(LINE_HEIGHT_MAX, Math.max(LINE_HEIGHT_MIN, ratio));
433
+ }
434
+
435
+ /**
436
+ * Clamp one code line-height offset to the allowed px range.
437
+ * @param {unknown} value - candidate offset in px.
438
+ * @returns {number} the integer offset (0 when unusable).
439
+ */
440
+ function clampLineOffset(value) {
441
+ var offset = Math.round(Number(value));
442
+ if (!isFinite(offset) || offset === 0) return 0;
443
+ return Math.min(CODE_LINE_HEIGHT_MAX, Math.max(CODE_LINE_HEIGHT_MIN, offset));
444
+ }
445
+
446
+ /**
447
+ * Clamp one ligature mode to the three known values.
448
+ * @param {unknown} value - candidate mode.
449
+ * @returns {number} 0, 1 or 2.
450
+ */
451
+ function clampLigatures(value) {
452
+ var mode = Math.round(Number(value));
453
+ if (!isFinite(mode) || mode <= 0) return LIGATURES_DEFAULT;
454
+ return mode >= LIGATURES_OFF ? LIGATURES_OFF : LIGATURES_ON;
455
+ }
456
+
457
+ /**
458
+ * Validate and normalize one sparse axis-value map (dark overrides, presets).
459
+ * Only known axis fields survive, each clamped.
460
+ * @param {unknown} values - candidate map.
461
+ * @returns {Record<string, string|number|boolean>} the safe subset.
462
+ */
463
+ function normalizeValueSet(values) {
464
+ var out = {};
465
+ if (values === null || typeof values !== "object" || Array.isArray(values)) return out;
466
+ for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
467
+ var field = VALUE_FIELDS[index];
468
+ if (!Object.prototype.hasOwnProperty.call(values, field)) continue;
469
+ out[field] = normalizeField(field, values[field]);
470
+ }
471
+ return out;
472
+ }
473
+
474
+ /**
475
+ * Parse and validate the durable `darkValues` JSON string.
476
+ * @param {unknown} value - candidate JSON text or object.
477
+ * @returns {Record<string, string|number|boolean>} the sparse override map.
478
+ */
479
+ function normalizeDarkValues(value) {
480
+ if (typeof value === "string") {
481
+ if (value.trim() === "") return {};
482
+ try {
483
+ value = JSON.parse(value);
484
+ } catch (error) {
485
+ return {};
486
+ }
487
+ }
488
+ return normalizeValueSet(value);
489
+ }
490
+
491
+ /**
492
+ * Parse and validate the durable `presets` JSON string.
493
+ * @param {unknown} value - candidate JSON text or array.
494
+ * @returns {{name: string, values: object, savedAt: number}[]}
495
+ */
496
+ function normalizePresets(value) {
497
+ var list = value;
498
+ if (typeof value === "string") {
499
+ if (value.trim() === "") return [];
500
+ try {
501
+ list = JSON.parse(value);
502
+ } catch (error) {
503
+ return [];
504
+ }
505
+ }
506
+ if (!Array.isArray(list)) return [];
507
+ var out = [];
508
+ for (var index = 0; index < list.length && out.length < MAX_PRESETS; index += 1) {
509
+ var entry = list[index];
510
+ if (entry === null || typeof entry !== "object") continue;
511
+ var name = sanitize(entry.name).slice(0, MAX_PRESET_NAME);
512
+ if (name === "") continue;
513
+ var stamp = Math.round(Number(entry.savedAt));
514
+ out.push({
515
+ name: name,
516
+ values: normalizeValueSet(entry.values),
517
+ savedAt: isFinite(stamp) && stamp > 0 ? stamp : 0,
518
+ });
519
+ }
520
+ return out;
521
+ }
522
+
523
+ /* ------------------------------------------------------------------ *
524
+ * stacks
525
+ * ------------------------------------------------------------------ */
526
+
199
527
  /**
200
528
  * Format a family list as a CSS font-family value.
201
529
  * @param {readonly string[]} families - family names in precedence order.
@@ -369,6 +697,10 @@ function removeStackEntry(families, family) {
369
697
  return next;
370
698
  }
371
699
 
700
+ /* ------------------------------------------------------------------ *
701
+ * scaling helpers
702
+ * ------------------------------------------------------------------ */
703
+
372
704
  /**
373
705
  * Compute the uniform scale a pixel offset produces over DSH's 16px base.
374
706
  *
@@ -389,152 +721,16 @@ function scaleFor(sizeOffset, base) {
389
721
  }
390
722
 
391
723
  /**
392
- * Whether a configuration asks for any change at all.
393
- * @param {{sans: string, mono: string, sizeOffset: number, sizeOffsetCode: number, weight: number}} config - normalized config.
394
- * @returns {boolean} true when nothing should be injected.
395
- */
396
- function isDormant(config) {
397
- return (
398
- config[SANS_FIELD] === "" &&
399
- config[MONO_FIELD] === "" &&
400
- config[SIZE_FIELD] === 0 &&
401
- config[CODE_SIZE_FIELD] === 0 &&
402
- config[WEIGHT_FIELD] === WEIGHT_UNSET &&
403
- config[CODE_WEIGHT_FIELD] === WEIGHT_UNSET
404
- );
405
- }
406
-
407
- /**
408
- * Build the stylesheet one configuration applies.
409
- *
410
- * Families are declared at their SOURCE: DSH's design tokens chain to two
411
- * variables (`--dsw-font-family` for every sans token, `--ds-font-family-code`
412
- * for every code token — verified against the shipped theme), so overriding
413
- * those variables is what reaches the conversation markdown and the sidebar,
414
- * whose elements declare `font-family: var(--dsw-font-family)` themselves and
415
- * would never inherit a plain `body` rule. The explicit `body` / `pre,code`
416
- * rules stay as a second path for elements that hardcode a family.
417
- *
418
- * The size offsets rewrite DSH's own design tokens rather than every element:
419
- * a token whose name ends in `-font-size` or `-line-height` is re-declared as
420
- * itself multiplied by one scale factor. `baseTokens` supplies the untouched
421
- * values (read from the live document, falling back to the embedded map), so
422
- * the ratio composes with DSH's own font-size slider instead of replacing it.
423
- *
424
- * Body and code are two independent axes. Body sizes hang off the content-size
425
- * chain (`--dsh-content-font-size` and the `--dsw-font-*` steps), while every
426
- * markdown code token is a bare literal (`--dsw-font-markdown-code-block:
427
- * 11px/19px …`) that the content chain never touches — so the code axis is the
428
- * only thing that moves it, and the body axis deliberately leaves those tokens
429
- * alone. Code sizing is consumed through the `font` SHORTHAND tokens (that is
430
- * what the shipped stylesheets use), which is why those are re-declared too,
431
- * not just their `-font-size` / `-line-height` parts.
432
- *
433
- * Weight has no token chain to ride: no shipped rule reads a `-font-weight`
434
- * token (measured — zero `font-weight: var(--dsw-font-…)` in rc.2), the theme
435
- * writes literal weights on its own class rules, and that is exactly why the
436
- * body axis has to be a blanket `!important` rule. The code axis is therefore a
437
- * selector-scoped `!important` rule placed after it, which wins on specificity
438
- * (`[class*=…]`) or on source order (`pre, …` ties with `body *`).
439
- *
440
- * @param {{sans: string, mono: string, sizeOffset: number, sizeOffsetCode: number, weight: number, weightCode: number}} config - normalized config.
441
- * @param {Record<string, string>} [baseTokens] - token name to untouched value.
442
- * @returns {string} declarations for one `<style>` element ("" when dormant).
724
+ * Round to a fixed number of decimals so generated CSS stays readable.
725
+ * @param {number} value - the number.
726
+ * @param {number} digits - decimals to keep.
727
+ * @returns {number} the rounded number.
443
728
  */
444
- function buildFontCss(config, baseTokens) {
445
- var normalized = normalizeConfig(config);
446
- var sans = formatStack(parseStack(normalized[SANS_FIELD]));
447
- var mono = formatStack(parseStack(normalized[MONO_FIELD]));
448
- var offset = normalized[SIZE_FIELD];
449
- var codeOffset = normalized[CODE_SIZE_FIELD];
450
- var weight = normalized[WEIGHT_FIELD];
451
- var codeWeight = normalized[CODE_WEIGHT_FIELD];
452
- if (isDormant(normalized)) return "";
453
- var declarations = [];
454
-
455
- if (sans !== "") {
456
- declarations.push(":root,body{--dsw-font-family:" + sans + " !important}");
457
- declarations.push("body{font-family:" + sans + " !important}");
458
- }
459
- if (mono !== "") {
460
- // The theme's code tokens chain to `--ds-font-family-code`; the other name
461
- // is what dsh-ui-font historically wrote and costs nothing to cover.
462
- declarations.push(
463
- ":root,body{--dsw-font-mono:" + mono + " !important;--ds-font-family-code:" + mono + " !important}"
464
- );
465
- // Written after the body rule: equal specificity on a code element means
466
- // the later declaration wins, so code keeps its own family inside the UI.
467
- declarations.push(
468
- "pre,code,kbd,samp,var,tt,textarea,.cm-editor .cm-content{font-family:" +
469
- mono +
470
- " !important}"
471
- );
472
- }
473
- if (offset !== 0) {
474
- var scaled = scaleTokens(bodyTokenNames(baseTokens), baseTokens, round(scaleFor(offset), 6));
475
- if (scaled.length > 0) {
476
- declarations.push(bodyAndDescendants(scaled.join(";")));
477
- }
478
- }
479
- if (codeOffset !== 0) {
480
- var scaledCode = scaleCodeTokens(baseTokens, round(scaleFor(codeOffset), 6));
481
- if (scaledCode.length > 0) {
482
- declarations.push(bodyAndDescendants(scaledCode.join(";")));
483
- }
484
- }
485
- if (weight !== WEIGHT_UNSET) {
486
- // Written as the user chose it: a variable font honors every integer, and
487
- // a static one rounds to its own nearest step by itself.
488
- declarations.push(bodyAndDescendants("font-weight:" + weight + " !important"));
489
- }
490
- if (codeWeight !== WEIGHT_UNSET) {
491
- declarations.push(CODE_SELECTOR + "{font-weight:" + codeWeight + " !important}");
492
- } else if (weight !== WEIGHT_UNSET) {
493
- // The body axis is a blanket `body, body *` rule, so code would inherit the
494
- // body weight unless it is explicitly taken back out. `normal` is what DSH
495
- // itself computes there (the code `font` shorthands carry no weight), so an
496
- // unset code weight means "code stays exactly as DSH shipped it".
497
- declarations.push(CODE_SELECTOR + "{font-weight:normal !important}");
498
- }
499
- return declarations.join("\n");
729
+ function round(value, digits) {
730
+ var factor = Math.pow(10, digits);
731
+ return Math.round(value * factor) / factor;
500
732
  }
501
733
 
502
- /**
503
- * What counts as code text for the weight axis.
504
- *
505
- * There is no token to hook here (see `buildFontCss`), so the code axis has to
506
- * name the elements. Two layers, both measured against rc.2:
507
- *
508
- * 1. The tags DSH's own stylesheets size with a code token: markdown `pre` /
509
- * inline `code`, the tool card's `:where(pre)` block, form controls, and the
510
- * CodeMirror editor. Their descendants are listed too, because the blanket
511
- * body rule matches EVERY element — a `<span>` inside a highlighted block
512
- * would otherwise be pulled back to the body weight.
513
- * 2. Containers that only a class name identifies: the terminal and tool-code
514
- * bodies are plain `div`s (`…_terminal`, `…_codeBody`, `md-code-block`).
515
- *
516
- * The `i` flag keeps a casing change in a future DSH from silently dropping the
517
- * rule, and an unmatched token/class simply means that surface follows the body
518
- * axis — the same degradation as an unknown token name on the size axes.
519
- */
520
- var CODE_SELECTOR = [
521
- "pre",
522
- "code",
523
- "kbd",
524
- "samp",
525
- "var",
526
- "tt",
527
- "textarea",
528
- ".cm-editor",
529
- ".dfp-previewCode",
530
- '[class*="code" i]',
531
- '[class*="terminal" i]',
532
- ]
533
- .map(function (selector) {
534
- return selector + "," + selector + " *";
535
- })
536
- .join(",");
537
-
538
734
  /**
539
735
  * Values a ratio may multiply: a number with a unit, a bare number, or an
540
736
  * expression. A keyword (`unset`, `inherit`) or an unrecognized shape is left
@@ -552,17 +748,17 @@ function isScalableValue(value) {
552
748
  }
553
749
 
554
750
  /**
555
- * Scale one set of tokens by one ratio.
751
+ * Scale one set of SIZE tokens by one ratio.
556
752
  *
557
753
  * A base that itself references another token via var() derives from it:
558
754
  * scaling the var target already scales this one, so re-scaling here would
559
755
  * compound (markdown-base = var(--dsh-content-font-size) would take the ratio
560
756
  * twice). DSH's chain does the work instead.
561
757
  *
562
- * `!important` is required: the theme writes `--dsh-content-font-size` INLINE on
563
- * body, and an inline declaration outranks a plain stylesheet one — without the
564
- * flag body itself would keep DSH's own size while every descendant scales,
565
- * splitting the page in two.
758
+ * `!important` is required: the theme writes `--dsh-content-font-size` INLINE
759
+ * on body, and an inline declaration outranks a plain stylesheet one —
760
+ * without the flag body itself would keep DSH's own size while every
761
+ * descendant scales, splitting the page in two.
566
762
  *
567
763
  * @param {string[]} names - token names to re-declare.
568
764
  * @param {Record<string, string>} baseTokens - token name to untouched value.
@@ -583,66 +779,143 @@ function scaleTokens(names, baseTokens, scale) {
583
779
  }
584
780
 
585
781
  /**
586
- * Re-declare the markdown code tokens at their own ratio.
587
- *
588
- * Two shapes exist and both must be covered, because the shipped stylesheets
589
- * consume the shorthand (`font: var(--dsw-font-markdown-code-block-small)`)
590
- * while the split tokens exist for anything that asks for a single part:
591
- * `11px/19px <family>` and a bare `11px`.
782
+ * Scale one set of LINE-HEIGHT tokens by one factor.
592
783
  *
784
+ * Unlike sizes, a line height never chains through another line height in
785
+ * DSH's theme (heights are literals or `calc(Npx + size-delta)`), so var()
786
+ * values are wrapped rather than skipped — the wrap keeps the var chain
787
+ * resolving at use time and cannot compound.
788
+ * @param {string[]} names - token names to re-declare.
593
789
  * @param {Record<string, string>} baseTokens - token name to untouched value.
594
- * @param {number} scale - the ratio to apply.
790
+ * @param {number} factor - the ratio to apply.
595
791
  * @returns {string[]} declarations.
596
792
  */
597
- function scaleCodeTokens(baseTokens, scale) {
598
- var names = codeTokenNames(baseTokens);
793
+ function scaleLineTokens(names, baseTokens, factor) {
599
794
  var out = [];
600
795
  for (var index = 0; index < names.length; index += 1) {
601
796
  var name = names[index];
602
797
  var base = baseTokens[name];
603
798
  if (typeof base !== "string" || base === "") continue;
604
- var shorthand = scaleFontShorthand(base, scale);
605
- if (shorthand !== null) {
606
- out.push(name + ":" + shorthand + " !important");
607
- continue;
608
- }
609
- // A part token that derives from another token follows that token's own
610
- // scaling; re-declaring it here would compound the ratio.
611
- if (base.indexOf("var(") !== -1) continue;
612
799
  if (!isScalableValue(base)) continue;
613
- out.push(name + ":calc((" + base + ") * " + scale + ") !important");
800
+ out.push(name + ":calc((" + base + ") * " + factor + ") !important");
614
801
  }
615
802
  return out;
616
803
  }
617
804
 
618
805
  /**
619
- * Multiply the size and the line height inside one `font` shorthand value,
620
- * leaving the family list (and anything else after it) untouched.
621
- * @param {string} value - a value shaped like `11px/19px var(--ds-font-family-code)`.
622
- * @param {number} scale - the ratio to apply.
623
- * @returns {string|null} the scaled shorthand, or null when the shape differs.
806
+ * Shift one set of tokens by an additive px amount.
807
+ * @param {string[]} names - token names to re-declare.
808
+ * @param {Record<string, string>} baseTokens - token name to untouched value.
809
+ * @param {number} offset - the px amount to add.
810
+ * @returns {string[]} declarations.
624
811
  */
625
- function scaleFontShorthand(value, scale) {
626
- var slash = value.indexOf("/");
812
+ function shiftTokens(names, baseTokens, offset) {
813
+ var out = [];
814
+ for (var index = 0; index < names.length; index += 1) {
815
+ var name = names[index];
816
+ var base = baseTokens[name];
817
+ if (typeof base !== "string" || base === "") continue;
818
+ if (!isScalableValue(base)) continue;
819
+ out.push(name + ":calc((" + base + ") + " + offset + "px) !important");
820
+ }
821
+ return out;
822
+ }
823
+
824
+ /* ------------------------------------------------------------------ *
825
+ * `font` shorthand parsing
826
+ * ------------------------------------------------------------------ */
827
+
828
+ /**
829
+ * The family reference every DSH `font` shorthand ends with. Markdown tokens
830
+ * chain to the sans variable, code tokens to the code variable.
831
+ */
832
+ var FAMILY_REFS = ["var(--dsw-font-family)", "var(--ds-font-family-code)"];
833
+
834
+ /**
835
+ * Parse one `font` shorthand into its parts.
836
+ *
837
+ * DSH's shorthands come in four shapes, all ending in a family var():
838
+ * `11px/19px var(--ds-font-family-code)` (code),
839
+ * `var(--dsh-content-font-size,14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family)` (base),
840
+ * `700 calc(21px + var(...)) / calc(30px + var(...)) var(--dsw-font-family)` (h1),
841
+ * `italic 600 var(...) / calc(...) var(--dsw-font-family)` (strong-italic).
842
+ *
843
+ * Because the size expression may itself contain `var(...)`, the split keys
844
+ * off the family reference (found from the right) and the `/`, never off
845
+ * whitespace inside `calc()`.
846
+ * @param {string} value - a `font` shorthand value.
847
+ * @returns {{lead: string, size: string, height: string, family: string}|null} the parts, or null when the shape differs.
848
+ */
849
+ function parseShorthand(value) {
850
+ if (typeof value !== "string") return null;
851
+ var familyAt = -1;
852
+ for (var index = 0; index < FAMILY_REFS.length; index += 1) {
853
+ var at = value.lastIndexOf(FAMILY_REFS[index]);
854
+ if (at > familyAt) familyAt = at;
855
+ }
856
+ if (familyAt <= 0) return null;
857
+ var family = value.slice(familyAt).trim();
858
+ if (family === "") return null;
859
+ var head = value.slice(0, familyAt).trim();
860
+ var slash = head.lastIndexOf("/");
627
861
  if (slash <= 0) return null;
628
- var size = value.slice(0, slash).trim();
629
- var rest = value.slice(slash + 1).trim();
630
- var gap = rest.search(/\s/);
631
- if (gap <= 0) return null;
632
- var height = rest.slice(0, gap).trim();
633
- var family = rest.slice(gap + 1).trim();
634
- if (size === "" || height === "" || family === "") return null;
862
+ var sizeChunk = head.slice(0, slash).trim();
863
+ var height = head.slice(slash + 1).trim();
864
+ if (sizeChunk === "" || height === "") return null;
865
+ var chunks = splitTopLevel(sizeChunk);
866
+ var size = chunks.pop();
867
+ var lead = chunks.join(" ");
635
868
  if (!isScalableValue(size) || !isScalableValue(height)) return null;
636
- return (
637
- "calc((" + size + ") * " + scale + ")/calc((" + height + ") * " + scale + ") " + family
638
- );
869
+ return { lead: lead, size: size, height: height, family: family };
870
+ }
871
+
872
+ /**
873
+ * Split a value on whitespace that sits OUTSIDE any parentheses, so a leading
874
+ * `700 calc(21px + var(…))` yields `["700", "calc(21px + var(...))"]`.
875
+ * @param {string} value - the chunk to split.
876
+ * @returns {string[]} the top-level chunks.
877
+ */
878
+ function splitTopLevel(value) {
879
+ var out = [];
880
+ var current = "";
881
+ var depth = 0;
882
+ for (var index = 0; index < value.length; index += 1) {
883
+ var char = value.charAt(index);
884
+ if (char === "(") depth += 1;
885
+ else if (char === ")") depth -= 1;
886
+ if (depth === 0 && /\s/.test(char)) {
887
+ if (current !== "") {
888
+ out.push(current);
889
+ current = "";
890
+ }
891
+ continue;
892
+ }
893
+ current += char;
894
+ }
895
+ if (current !== "") out.push(current);
896
+ return out;
897
+ }
898
+
899
+ /**
900
+ * Rebuild one `font` shorthand from parsed parts.
901
+ * @param {{lead: string, size: string, height: string, family: string}} parts - parsed parts.
902
+ * @returns {string} the shorthand value (without `!important`).
903
+ */
904
+ function rebuildShorthand(parts) {
905
+ var out = "";
906
+ if (parts.lead !== "") out += parts.lead + " ";
907
+ return out + parts.size + " / " + parts.height + " " + parts.family;
639
908
  }
640
909
 
910
+ /* ------------------------------------------------------------------ *
911
+ * token classification
912
+ * ------------------------------------------------------------------ */
913
+
641
914
  /**
642
- * Match DSH's typography tokens: the design system's sizes plus the content
643
- * size the theme plugin writes on `body` (whose secondary variant carries a
644
- * suffix of its own). Line heights scale with sizes so a token's shorthand
645
- * never disagrees with its parts.
915
+ * Match DSH's typography PART tokens: the design system's sizes plus the
916
+ * content size the theme plugin writes on `body` (whose secondary variant
917
+ * carries a suffix of its own). Line heights scale with sizes so a token's
918
+ * shorthand never disagrees with its parts.
646
919
  *
647
920
  * Deliberately a shape test rather than an enumeration: DSH generates these
648
921
  * tokens at runtime, so whatever a future release names them, a token that
@@ -650,6 +923,26 @@ function scaleFontShorthand(value, scale) {
650
923
  */
651
924
  var TOKEN_PATTERN = /^--(?:dsw-font-[a-z0-9-]*|dsh-content-font)-(?:font-size|line-height)(?:-secondary)?$|^--dsh-content-font-(?:size|line-height)(?:-secondary)?$/;
652
925
 
926
+ /**
927
+ * The content-size DELTA tokens the theme derives on `body`; the dialog size
928
+ * offset shifts these additively, which is what moves every delta-derived
929
+ * markdown size by exactly the offset.
930
+ */
931
+ var DELTA_PATTERN = /^--dsh-content-font-delta(?:-secondary)?$/;
932
+
933
+ /** The whole official content-size chain (size / line height / delta). */
934
+ var CONTENT_PATTERN = /^--dsh-content-font-(?:size|line-height|delta)(?:-secondary)?$/;
935
+
936
+ /**
937
+ * The PART-token suffixes DSH appends for single-axis consumers.
938
+ */
939
+ var PART_SUFFIXES = ["-font-size", "-line-height", "-font-family", "-font-weight", "-font-style"];
940
+
941
+ /**
942
+ * The bare family variables, which are NOT `font` shorthands.
943
+ */
944
+ var FAMILY_VARS = { "--dsw-font-family": true, "--dsw-font-mono": true, "--ds-font-family-code": true };
945
+
653
946
  /**
654
947
  * A `body, body *` selector list, kept in one place because every global
655
948
  * declaration needs the same reach (DSH writes some tokens on descendants).
@@ -661,22 +954,46 @@ function bodyAndDescendants(declarations) {
661
954
  }
662
955
 
663
956
  /**
664
- * Whether one custom-property name is a token this plugin scales.
957
+ * Whether one name belongs to the official CONTENT-size chain the conversation
958
+ * owns: `--dsh-content-font-size` / `-line-height` (the official "对话字号"
959
+ * setting writes the former) and the delta tokens derived from it.
960
+ *
961
+ * The interface axis must never scale these. Doing so was the real reason an
962
+ * interface size change moved the conversation: the markdown ladder consumes
963
+ * this chain directly, so scaling it is a conversation edit no matter which
964
+ * slider caused it.
665
965
  * @param {string} name - custom property name including the leading dashes.
666
- * @returns {boolean} true when the token carries a size or a line height.
966
+ * @returns {boolean}
967
+ */
968
+ function isContentToken(name) {
969
+ return typeof name === "string" && CONTENT_PATTERN.test(name);
970
+ }
971
+
972
+ /**
973
+ * Whether one custom-property name is a PART token this plugin scales.
974
+ * @param {string} name - custom property name including the leading dashes.
975
+ * @returns {boolean} true when the token carries a size or a line height.
667
976
  */
668
977
  function isFontToken(name) {
669
978
  return typeof name === "string" && TOKEN_PATTERN.test(name);
670
979
  }
671
980
 
672
981
  /**
673
- * Match DSH's markdown code tokens, which are their own sizing axis.
982
+ * Whether one name is a content-size delta token.
983
+ * @param {string} name - custom property name including the leading dashes.
984
+ * @returns {boolean}
985
+ */
986
+ function isDeltaToken(name) {
987
+ return typeof name === "string" && DELTA_PATTERN.test(name);
988
+ }
989
+
990
+ /**
991
+ * Whether one name is a markdown CODE token: its own sizing axis.
674
992
  *
675
993
  * The theme declares these as literals the content-size chain never reaches
676
994
  * (`--dsw-font-markdown-code:12px/19px …`, `-code-block:11px/19px …`,
677
995
  * `-code-block-small:11px/16px …`), and the shipped stylesheets consume them
678
- * through the `font` shorthand. Everything with this prefix therefore belongs
679
- * to the code offset and to nothing else.
996
+ * through the `font` shorthand.
680
997
  * @param {string} name - custom property name including the leading dashes.
681
998
  * @returns {boolean} true when the token sizes code.
682
999
  */
@@ -685,23 +1002,63 @@ function isCodeToken(name) {
685
1002
  }
686
1003
 
687
1004
  /**
688
- * Whether a token is one this plugin re-declares at all.
1005
+ * Whether one name is a markdown (dialog) token that is NOT code: the token
1006
+ * family the dialog axes own.
1007
+ * @param {string} name - custom property name including the leading dashes.
1008
+ * @returns {boolean}
1009
+ */
1010
+ function isMarkdownToken(name) {
1011
+ return typeof name === "string" && name.indexOf("--dsw-font-markdown-") === 0 && !isCodeToken(name);
1012
+ }
1013
+
1014
+ /**
1015
+ * Whether one NAME looks like a `font` shorthand token (a bare
1016
+ * `--dsw-font-…` name that is not a PART token or a family variable).
1017
+ * @param {string} name - custom property name including the leading dashes.
1018
+ * @returns {boolean}
1019
+ */
1020
+ function isShorthandName(name) {
1021
+ if (typeof name !== "string" || name.indexOf("--dsw-font-") !== 0) return false;
1022
+ if (FAMILY_VARS[name] === true) return false;
1023
+ for (var index = 0; index < PART_SUFFIXES.length; index += 1) {
1024
+ var suffix = PART_SUFFIXES[index];
1025
+ if (name.length > suffix.length && name.slice(-suffix.length) === suffix) return false;
1026
+ }
1027
+ return true;
1028
+ }
1029
+
1030
+ /**
1031
+ * Whether one token (name + value) is a `font` shorthand this plugin may
1032
+ * rewrite: a bare `--dsw-font-…` name whose value parses as a shorthand.
1033
+ * @param {string} name - custom property name.
1034
+ * @param {string} value - the token's untouched value.
1035
+ * @returns {boolean}
1036
+ */
1037
+ function isShorthandToken(name, value) {
1038
+ return isShorthandName(name) && parseShorthand(value) !== null;
1039
+ }
1040
+
1041
+ /**
1042
+ * Whether a token is one this plugin reads and re-declares at all.
689
1043
  * @param {string} name - custom property name including the leading dashes.
690
- * @returns {boolean} true for body sizes, body line heights and code tokens.
1044
+ * @returns {boolean} true for part tokens, code tokens, shorthand names and
1045
+ * the content-size deltas.
691
1046
  */
692
1047
  function isScaledToken(name) {
693
- return isFontToken(name) || isCodeToken(name);
1048
+ return isFontToken(name) || isCodeToken(name) || isShorthandName(name) || isDeltaToken(name);
694
1049
  }
695
1050
 
696
1051
  /**
697
- * Ordered token names present in a base map, sizes before line heights so a
698
- * reader of the stylesheet sees each size next to its height.
1052
+ * Ordered PART-token names present in a base map, sizes before line heights
1053
+ * so a reader of the stylesheet sees each size next to its height.
699
1054
  * @param {Record<string, string>} [baseTokens] - token name to value.
700
- * @returns {string[]} the names to scale.
1055
+ * @returns {string[]} the names.
701
1056
  */
702
1057
  function tokenNames(baseTokens) {
703
1058
  if (baseTokens === null || typeof baseTokens !== "object") return [];
704
- var names = Object.keys(baseTokens).filter(isScaledToken);
1059
+ var names = Object.keys(baseTokens).filter(function (name) {
1060
+ return isFontToken(name) || isCodeToken(name) || isDeltaToken(name);
1061
+ });
705
1062
  names.sort(function (left, right) {
706
1063
  if (left.length !== right.length) return left.length - right.length;
707
1064
  return left < right ? -1 : left > right ? 1 : 0;
@@ -710,102 +1067,848 @@ function tokenNames(baseTokens) {
710
1067
  }
711
1068
 
712
1069
  /**
713
- * The body/UI tokens: everything this plugin scales except the code family,
714
- * which answers to its own offset.
1070
+ * The interface PART size tokens: everything scalable except the code and
1071
+ * markdown families and the delta tokens (which the dialog offset shifts
1072
+ * additively, never multiplicatively).
715
1073
  * @param {Record<string, string>} [baseTokens] - token name to value.
716
- * @returns {string[]} the names the body offset re-declares.
1074
+ * @returns {string[]} the names the interface offset re-declares.
717
1075
  */
718
1076
  function bodyTokenNames(baseTokens) {
719
1077
  return tokenNames(baseTokens).filter(function (name) {
720
- return !isCodeToken(name);
1078
+ return (
1079
+ !isCodeToken(name) &&
1080
+ !isMarkdownToken(name) &&
1081
+ !isDeltaToken(name) &&
1082
+ !isContentToken(name)
1083
+ );
721
1084
  });
722
1085
  }
723
1086
 
724
1087
  /**
725
- * The code tokens: the `font` shorthands plus their `-font-size` /
726
- * `-line-height` parts, when the live stylesheets declare them.
1088
+ * The interface line-height PART tokens (non-code, non-markdown).
1089
+ * @param {Record<string, string>} [baseTokens] - token name to value.
1090
+ * @returns {string[]}
1091
+ */
1092
+ function uiLineTokenNames(baseTokens) {
1093
+ return bodyTokenNames(baseTokens).filter(function (name) {
1094
+ return name.indexOf("-line-height") >= 0;
1095
+ });
1096
+ }
1097
+
1098
+ /**
1099
+ * The markdown (dialog) PART tokens: sizes and line heights of the markdown
1100
+ * family, excluding code.
1101
+ * @param {Record<string, string>} [baseTokens] - token name to value.
1102
+ * @returns {string[]}
1103
+ */
1104
+ function markdownTokenNames(baseTokens) {
1105
+ return tokenNames(baseTokens).filter(isMarkdownToken);
1106
+ }
1107
+
1108
+ /**
1109
+ * The markdown content-size DELTA tokens present in a base map.
1110
+ * @param {Record<string, string>} [baseTokens] - token name to value.
1111
+ * @returns {string[]}
1112
+ */
1113
+ function deltaTokenNames(baseTokens) {
1114
+ if (baseTokens === null || typeof baseTokens !== "object") return [];
1115
+ return Object.keys(baseTokens).filter(isDeltaToken);
1116
+ }
1117
+
1118
+ /**
1119
+ * The code PART tokens: the `font` shorthands' size/height parts, when the
1120
+ * live stylesheets declare them. The shorthands themselves are handled by
1121
+ * `codeShorthandTokens`, so they are excluded here to avoid double
1122
+ * declarations.
727
1123
  * @param {Record<string, string>} [baseTokens] - token name to value.
728
- * @returns {string[]} the names the code offset re-declares.
1124
+ * @returns {string[]}
729
1125
  */
730
1126
  function codeTokenNames(baseTokens) {
731
- return tokenNames(baseTokens).filter(isCodeToken);
1127
+ return tokenNames(baseTokens).filter(function (name) {
1128
+ return isCodeToken(name) && !isShorthandName(name);
1129
+ });
1130
+ }
1131
+
1132
+ /**
1133
+ * Every `font` shorthand token in a base map that parses, sorted by name
1134
+ * length (families before variants, mirroring the part-token order).
1135
+ * @param {Record<string, string>} [baseTokens] - token name to value.
1136
+ * @returns {{name: string, parts: object}[]}
1137
+ */
1138
+ function shorthandTokens(baseTokens) {
1139
+ if (baseTokens === null || typeof baseTokens !== "object") return [];
1140
+ var out = [];
1141
+ var names = Object.keys(baseTokens).filter(isShorthandName);
1142
+ names.sort(function (left, right) {
1143
+ if (left.length !== right.length) return left.length - right.length;
1144
+ return left < right ? -1 : left > right ? 1 : 0;
1145
+ });
1146
+ for (var index = 0; index < names.length; index += 1) {
1147
+ var parts = parseShorthand(baseTokens[names[index]]);
1148
+ if (parts !== null) out.push({ name: names[index], parts: parts });
1149
+ }
1150
+ return out;
1151
+ }
1152
+
1153
+ /**
1154
+ * The markdown (dialog) `font` shorthand tokens, excluding code.
1155
+ * @param {Record<string, string>} [baseTokens] - token name to value.
1156
+ * @returns {{name: string, parts: object}[]}
1157
+ */
1158
+ function markdownShorthandTokens(baseTokens) {
1159
+ return shorthandTokens(baseTokens).filter(function (entry) {
1160
+ return isMarkdownToken(entry.name);
1161
+ });
1162
+ }
1163
+
1164
+ /**
1165
+ * The interface (non-markdown, non-code) `font` shorthand tokens.
1166
+ * @param {Record<string, string>} [baseTokens] - token name to value.
1167
+ * @returns {{name: string, parts: object}[]}
1168
+ */
1169
+ function uiShorthandTokens(baseTokens) {
1170
+ return shorthandTokens(baseTokens).filter(function (entry) {
1171
+ return !isMarkdownToken(entry.name) && !isCodeToken(entry.name);
1172
+ });
1173
+ }
1174
+
1175
+ /**
1176
+ * The code `font` shorthand tokens.
1177
+ * @param {Record<string, string>} [baseTokens] - token name to value.
1178
+ * @returns {{name: string, parts: object}[]}
1179
+ */
1180
+ function codeShorthandTokens(baseTokens) {
1181
+ return shorthandTokens(baseTokens).filter(function (entry) {
1182
+ return isCodeToken(entry.name);
1183
+ });
1184
+ }
1185
+
1186
+ /* ------------------------------------------------------------------ *
1187
+ * selectors
1188
+ * ------------------------------------------------------------------ */
1189
+
1190
+ /**
1191
+ * What counts as code text for the code axes.
1192
+ *
1193
+ * There is no token to hook here (no shipped rule reads a `-font-weight`
1194
+ * token — measured, zero `font-weight: var(--dsw-font-…)` in rc.2), so the
1195
+ * code axes have to name the elements. Two layers, both measured against
1196
+ * rc.2:
1197
+ *
1198
+ * 1. The tags DSH's own stylesheets size with a code token: markdown `pre` /
1199
+ * inline `code`, the tool card's `:where(pre)` block, form controls, and
1200
+ * CodeMirror-like editors. Their descendants are listed too, because the
1201
+ * blanket body rule matches EVERY element — a `<span>` inside a
1202
+ * highlighted block would otherwise be pulled back to the body weight.
1203
+ * 2. Containers that only a class name identifies: the terminal and tool-code
1204
+ * bodies are plain `div`s (`…_terminal`, `…_codeBody`, `md-code-block`).
1205
+ *
1206
+ * The `i` flag keeps a casing change in a future DSH from silently dropping
1207
+ * the rule, and an unmatched token/class simply means that surface follows
1208
+ * the interface axis — the same degradation as an unknown token name.
1209
+ */
1210
+ var CODE_SELECTOR = [
1211
+ "pre",
1212
+ "code",
1213
+ "kbd",
1214
+ "samp",
1215
+ "var",
1216
+ "tt",
1217
+ "textarea",
1218
+ ".cm-editor",
1219
+ ".dfp-previewCode",
1220
+ '[class*="code" i]',
1221
+ '[class*="terminal" i]',
1222
+ ]
1223
+ .map(function (selector) {
1224
+ return selector + "," + selector + " *";
1225
+ })
1226
+ .join(",");
1227
+
1228
+ /**
1229
+ * Everything a code surface must be excluded from a DIALOG-scoped rule:
1230
+ * the code elements themselves AND their descendants (a highlight `<span>`
1231
+ * inside a `<pre>` is not a `pre`, so the exclusion has to reach through
1232
+ * ancestry with complex `:not()` arguments — supported by the Chromium line
1233
+ * DSH targets).
1234
+ */
1235
+ var CODE_EXCLUDES = [
1236
+ "pre",
1237
+ "pre *",
1238
+ "code",
1239
+ "code *",
1240
+ "kbd",
1241
+ "kbd *",
1242
+ "samp",
1243
+ "samp *",
1244
+ "var",
1245
+ "var *",
1246
+ "tt",
1247
+ "tt *",
1248
+ "textarea",
1249
+ "textarea *",
1250
+ '[class*="code" i]',
1251
+ '[class*="code" i] *',
1252
+ '[class*="terminal" i]',
1253
+ '[class*="terminal" i] *',
1254
+ ]
1255
+ .map(function (selector) {
1256
+ return ":not(" + selector + ")";
1257
+ })
1258
+ .join("");
1259
+
1260
+ /**
1261
+ * The dialog surface: DSH renders the conversation markdown in a CSS-module
1262
+ * container whose class hashes per release (`_markdown_<hash>_…`). A
1263
+ * class-substring match degrades safely — no match means the dialog follows
1264
+ * the interface axis, the same behavior as an unknown token name.
1265
+ */
1266
+ var MARKDOWN_SELECTOR =
1267
+ '[class*="_markdown_" i]' +
1268
+ CODE_EXCLUDES +
1269
+ ',[class*="_markdown_" i] *' +
1270
+ CODE_EXCLUDES;
1271
+
1272
+ /** The dark-theme attribute DSH's ThemePresenter writes on body. */
1273
+ var DARK_ATTR = "body[data-ds-dark-theme]";
1274
+
1275
+ /**
1276
+ * Everything the INTERFACE weight rule must stay out of.
1277
+ *
1278
+ * A weight can only be expressed as a `body, body *` rule: DSH pins its labels
1279
+ * with literal weights on class rules (`…_label{font-weight:500}`,
1280
+ * `…_heading{font-weight:600}`) that nothing narrower reaches. That rule would
1281
+ * otherwise cover the conversation too, so the conversation markdown subtree is
1282
+ * excluded here — and with it every code surface, because the code axis owns
1283
+ * those and the two rules would otherwise fight over the same elements (the
1284
+ * `:not()` arguments raise this rule's specificity above a single class).
1285
+ *
1286
+ * `dfp-previewDialog` is the card's conversation preview: it carries the
1287
+ * conversation's own weight, so the interface rule must not flatten it. The
1288
+ * interface's own preview stays inside the rule, and `dfp-previewCode` is a
1289
+ * code surface like any other.
1290
+ */
1291
+ var INTERFACE_EXCLUDES =
1292
+ CODE_EXCLUDES +
1293
+ ":not(.cm-editor):not(.cm-editor *):not(.dfp-previewCode):not(.dfp-previewCode *)" +
1294
+ ':not([class*="_markdown_" i]):not([class*="_markdown_" i] *)' +
1295
+ ":not(.dfp-previewDialog):not(.dfp-previewDialog *)";
1296
+
1297
+ /**
1298
+ * Prefix every selector in a comma list, for the per-theme rules.
1299
+ * @param {string} selectorList - a comma-separated selector list.
1300
+ * @param {string} prefix - the prefix to prepend to each selector.
1301
+ * @returns {string} the prefixed list.
1302
+ */
1303
+ function prefixSelector(selectorList, prefix) {
1304
+ var parts = selectorList.split(",");
1305
+ var out = [];
1306
+ for (var index = 0; index < parts.length; index += 1) {
1307
+ out.push(prefix + " " + parts[index]);
1308
+ }
1309
+ return out.join(",");
1310
+ }
1311
+
1312
+ /* ------------------------------------------------------------------ *
1313
+ * per-theme resolution
1314
+ * ------------------------------------------------------------------ */
1315
+
1316
+ /**
1317
+ * Expand the FOLLOW semantics over one normalized configuration.
1318
+ *
1319
+ * The conversation owns every axis. Follow — the default — makes the interface
1320
+ * take the conversation's family and weight, keeping its own value only for an
1321
+ * axis the conversation leaves unset. Follow off leaves both of the interface's
1322
+ * axes standing on their own; the conversation is never touched either way,
1323
+ * because the interface's rules exclude the markdown subtree.
1324
+ *
1325
+ * The interface's size and line-height axes are retired and render nothing, so
1326
+ * nothing follows for them (see `buildAxisCss` for the measurement).
1327
+ *
1328
+ * @param {Record<string, unknown>} raw - a normalized configuration.
1329
+ * @returns {Record<string, unknown>} the set with the follow rule applied.
1330
+ */
1331
+ function expandFollow(raw) {
1332
+ var out = {};
1333
+ for (var field in raw) {
1334
+ if (Object.prototype.hasOwnProperty.call(raw, field)) out[field] = raw[field];
1335
+ }
1336
+ // Migration bridge, not a live rule: a document written before 0.2.0 carries
1337
+ // the family on the interface only, so an empty conversation family takes it.
1338
+ // The card never describes an empty field as "follows the interface".
1339
+ out[STACK_DIALOG_FIELD] =
1340
+ raw[STACK_DIALOG_FIELD] !== "" ? raw[STACK_DIALOG_FIELD] : raw[SANS_FIELD];
1341
+ out[WEIGHT_DIALOG_FIELD] = raw[WEIGHT_DIALOG_FIELD];
1342
+ if (raw[UI_FOLLOWS_FIELD] !== false) {
1343
+ out[SANS_FIELD] = out[STACK_DIALOG_FIELD];
1344
+ out[WEIGHT_FIELD] =
1345
+ raw[WEIGHT_DIALOG_FIELD] !== WEIGHT_UNSET ? raw[WEIGHT_DIALOG_FIELD] : raw[WEIGHT_FIELD];
1346
+ }
1347
+ return out;
1348
+ }
1349
+
1350
+ /**
1351
+ * Resolve the effective light and dark axis sets of one configuration.
1352
+ *
1353
+ * With `perTheme` off the dark set IS the light set; with it on, the sparse
1354
+ * `darkValues` map is applied over the single set first and follow-expansion
1355
+ * runs on the result, so a dark dialog value still tracks the dark interface
1356
+ * value when the interface itself is overridden per theme.
1357
+ * @param {unknown} config - candidate configuration.
1358
+ * @returns {{light: object, dark: object}} the two axis sets.
1359
+ */
1360
+ function resolveAxes(config) {
1361
+ var raw = normalizeConfig(config);
1362
+ var light = expandFollow(raw);
1363
+ if (raw[PER_THEME_FIELD] !== true) return { light: light, dark: light };
1364
+ // The dark overrides are applied to the RAW values and only then expanded: an
1365
+ // already-expanded set has the follow-borrowed values baked in, so expanding
1366
+ // it again would let a borrowed value shadow the dark override.
1367
+ var merged = {};
1368
+ for (var field in raw) {
1369
+ if (Object.prototype.hasOwnProperty.call(raw, field)) merged[field] = raw[field];
1370
+ }
1371
+ var overrides = raw[DARK_VALUES_FIELD];
1372
+ for (var key in overrides) {
1373
+ if (Object.prototype.hasOwnProperty.call(overrides, key)) merged[key] = overrides[key];
1374
+ }
1375
+ return { light: light, dark: expandFollow(merged) };
1376
+ }
1377
+
1378
+ /* ------------------------------------------------------------------ *
1379
+ * the stylesheet builder
1380
+ * ------------------------------------------------------------------ */
1381
+
1382
+ /**
1383
+ * Whether a normalized configuration asks for any change at all.
1384
+ * @param {unknown} config - candidate configuration.
1385
+ * @returns {boolean} true when nothing should be injected.
1386
+ */
1387
+ function isDormant(config) {
1388
+ var axis = config !== null && typeof config === "object" ? config : {};
1389
+ if (axis[PER_THEME_FIELD] === true) {
1390
+ var dark = axis[DARK_VALUES_FIELD];
1391
+ if (dark !== null && typeof dark === "object") {
1392
+ for (var field in dark) {
1393
+ if (Object.prototype.hasOwnProperty.call(dark, field)) return false;
1394
+ }
1395
+ }
1396
+ }
1397
+ return (
1398
+ // The retired interface size and line-height axes are deliberately absent:
1399
+ // a configuration that only sets them renders nothing, so it is dormant like
1400
+ // an empty one. The interface WEIGHT is not retired — it renders a blanket
1401
+ // rule — so it counts.
1402
+ sanitize(axis[SANS_FIELD]) === "" &&
1403
+ sanitize(axis[STACK_DIALOG_FIELD]) === "" &&
1404
+ sanitize(axis[MONO_FIELD]) === "" &&
1405
+ clampOffset(axis[SIZE_DIALOG_FIELD]) === 0 &&
1406
+ clampOffset(axis[CODE_SIZE_FIELD]) === 0 &&
1407
+ clampWeight(axis[WEIGHT_FIELD]) === WEIGHT_UNSET &&
1408
+ clampWeight(axis[WEIGHT_DIALOG_FIELD]) === WEIGHT_UNSET &&
1409
+ clampWeight(axis[CODE_WEIGHT_FIELD]) === WEIGHT_UNSET &&
1410
+ clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
1411
+ clampLineOffset(axis[CODE_LINE_HEIGHT_FIELD]) === 0 &&
1412
+ clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
1413
+ sanitizeFeatures(axis[FEATURES_FIELD]) === "" &&
1414
+ toBool(axis[NO_SYNTHETIC_ITALIC_FIELD]) === false &&
1415
+ toBool(axis[NO_SYNTHETIC_BOLD_FIELD]) === false
1416
+ );
1417
+ }
1418
+
1419
+ /**
1420
+ * Build the stylesheet one THEME VALUE SET applies.
1421
+ *
1422
+ * The dialog axes reach the conversation markdown through a scope rule keyed
1423
+ * on the markdown container (class-substring match) rather than through the
1424
+ * design tokens: the tokens' `font` shorthands inline their parts, so a
1425
+ * token-level dialog override would need a full shorthand rebuild, while a
1426
+ * scoped `!important` rule wins over every non-important declaration the
1427
+ * theme ships and degrades to "follows the interface" when nothing matches.
1428
+ * Code surfaces are excluded from the dialog scope by construction (see
1429
+ * `CODE_EXCLUDES`), so inline code inside a serif conversation stays mono.
1430
+ *
1431
+ * @param {Record<string, unknown>} axis - an expanded (follow-applied) value set.
1432
+ * @param {Record<string, string>} baseTokens - token name to untouched value.
1433
+ * @param {boolean} isDark - prefix every rule with the dark-theme attribute.
1434
+ * @returns {string} declarations for one `<style>` element.
1435
+ */
1436
+ function buildAxisCss(axis, baseTokens, isDark) {
1437
+ var sans = formatStack(parseStack(axis[SANS_FIELD]));
1438
+ var mono = formatStack(parseStack(axis[MONO_FIELD]));
1439
+ var declarations = [];
1440
+
1441
+ function blanket(declarations, excludes) {
1442
+ // The exclusion reaches the descendants only: `body` itself inherits
1443
+ // nothing, and neither the body element nor its dark attribute can be a
1444
+ // markdown container or a code surface.
1445
+ var suffix = excludes === undefined ? "" : excludes;
1446
+ var selector = isDark
1447
+ ? DARK_ATTR + "," + DARK_ATTR + " *" + suffix
1448
+ : "body,body *" + suffix;
1449
+ return selector + "{" + declarations + "}";
1450
+ }
1451
+ function codeRule(declarations) {
1452
+ var selector = isDark ? prefixSelector(CODE_SELECTOR, DARK_ATTR) : CODE_SELECTOR;
1453
+ return selector + "{" + declarations + "}";
1454
+ }
1455
+ function dialogRule(declarations) {
1456
+ var selector = isDark ? prefixSelector(MARKDOWN_SELECTOR, DARK_ATTR) : MARKDOWN_SELECTOR;
1457
+ return selector + "{" + declarations + "}";
1458
+ }
1459
+ /**
1460
+ * Re-assert the code family inside the dialog: scoped under the markdown
1461
+ * container, after the dialog rule and above it in specificity, so a
1462
+ * serif conversation keeps monospaced code.
1463
+ */
1464
+ function pushScopedCodeFamily() {
1465
+ var scopedCode = [];
1466
+ var surfaces = CODE_SELECTOR.split(",");
1467
+ for (var cIndex = 0; cIndex < surfaces.length; cIndex += 1) {
1468
+ scopedCode.push('[class*="_markdown_" i] ' + surfaces[cIndex]);
1469
+ }
1470
+ declarations.push(scopedCode.join(",") + "{font-family:" + mono + " !important}");
1471
+ }
1472
+
1473
+ // ---- families ----
1474
+ if (sans !== "") {
1475
+ declarations.push(
1476
+ (isDark ? DARK_ATTR : ":root,body") + "{--dsw-font-family:" + sans + " !important}"
1477
+ );
1478
+ declarations.push((isDark ? DARK_ATTR : "body") + "{font-family:" + sans + " !important}");
1479
+ }
1480
+ if (mono !== "") {
1481
+ // The theme's code tokens chain to `--ds-font-family-code`; the other name
1482
+ // is what dsh-ui-font historically wrote and costs nothing to cover.
1483
+ declarations.push(
1484
+ (isDark ? DARK_ATTR : ":root,body") +
1485
+ "{--dsw-font-mono:" +
1486
+ mono +
1487
+ " !important;--ds-font-family-code:" +
1488
+ mono +
1489
+ " !important}"
1490
+ );
1491
+ // Written after the interface family rule: equal specificity on a code
1492
+ // element means the later declaration wins, so code keeps its own family.
1493
+ declarations.push(codeRule("font-family:" + mono + " !important"));
1494
+ }
1495
+
1496
+ // ---- interface size / line height: RETIRED, and deliberately so ----
1497
+ // Verified against DSH 0.1.5-rc.2: the official settings expose exactly one
1498
+ // size hook, the CONVERSATION font size (`--dsh-content-font-size`). The
1499
+ // interface ladder tokens (`--dsw-font-xs-13`, `--dsw-font-s-14`, …) are
1500
+ // consumed only by conversation-area widgets (tool cards, trajectory,
1501
+ // attachment chips — 6 client-ui packages), while the settings sheet, the
1502
+ // left workspace/sidebar and the right sidebar declare their text sizes as
1503
+ // literals inside their own CSS modules. There is therefore no interface
1504
+ // size or line-height hook: scaling the ladder from these fields moved
1505
+ // nothing a user could recognise as "interface", and scaling the content
1506
+ // chain from them moved the conversation. The durable fields stay in the
1507
+ // schema (old documents keep parsing, presets keep round-tripping) but inject
1508
+ // nothing at all. The interface axis that DOES work is the family (a
1509
+ // page-wide `font-family` on body plus the theme variable) and the weight
1510
+ // (below).
1511
+
1512
+ // ---- interface weight ----
1513
+ // Measured on rc.2 (live page, computed styles): a `body{font-weight}` rule
1514
+ // only reaches the elements that INHERIT their weight, while `body, body *`
1515
+ // also reaches the labels DSH pins with a literal weight on a class rule —
1516
+ // which is why the axis has to be blanket, and why the rule without the
1517
+ // markdown exclusion drags the conversation along with it. With the
1518
+ // exclusion the interface still moves and the markdown subtree does not.
1519
+ var interfaceWeight = axis[WEIGHT_FIELD];
1520
+ var codeWeight = axis[CODE_WEIGHT_FIELD];
1521
+ if (interfaceWeight !== WEIGHT_UNSET) {
1522
+ declarations.push(
1523
+ blanket("font-weight:" + interfaceWeight + " !important", INTERFACE_EXCLUDES)
1524
+ );
1525
+ if (codeWeight === WEIGHT_UNSET) {
1526
+ // Code surfaces are excluded from the blanket rule, so without this they
1527
+ // would inherit the interface weight from body. `normal` is what DSH
1528
+ // computes for them (the code `font` shorthands carry no weight), so an
1529
+ // unset code weight keeps code exactly as DSH shipped it.
1530
+ declarations.push(codeRule("font-weight:normal !important"));
1531
+ }
1532
+ }
1533
+
1534
+ // ---- dialog family (scoped; skipped when it equals the interface) ----
1535
+ var dialog = formatStack(parseStack(axis[STACK_DIALOG_FIELD]));
1536
+ if (dialog !== "" && dialog !== sans) {
1537
+ declarations.push(dialogRule("font-family:" + dialog + " !important"));
1538
+ if (mono !== "") {
1539
+ // Keep the code family inside the dialog.
1540
+ pushScopedCodeFamily();
1541
+ }
1542
+ }
1543
+
1544
+ // ---- dialog weight (uniform inside the conversation) ----
1545
+ var dialogWeight = axis[WEIGHT_DIALOG_FIELD];
1546
+ if (dialogWeight !== WEIGHT_UNSET) {
1547
+ declarations.push(dialogRule("font-weight:" + dialogWeight + " !important"));
1548
+ // The card's conversation preview simulates this surface, so it takes the
1549
+ // same weight (and is excluded from the interface rule above).
1550
+ declarations.push(
1551
+ (isDark ? DARK_ATTR + " " : "") +
1552
+ ".dfp-previewDialog{font-weight:" +
1553
+ dialogWeight +
1554
+ " !important}"
1555
+ );
1556
+ }
1557
+
1558
+ // ---- dialog size offset (additive, on the official content-size source) ----
1559
+ // Every markdown size derives from the official content-size chain, and that
1560
+ // chain is COMPUTED ON BODY: custom properties resolve where they are
1561
+ // declared, so overriding the size deeper down would leave the derived tokens
1562
+ // (secondary size, both deltas, delta-based heading sizes, heights) at their
1563
+ // body-computed values. The offset is therefore added once, at the source —
1564
+ // `--dsh-content-font-size` becomes "official base + offset" — and the whole
1565
+ // ladder follows by derivation, with nothing counted twice. The interface
1566
+ // axis never touches this chain (see `bodyTokenNames`), so the conversation
1567
+ // moves only when this axis says so, and a dialog that FOLLOWS the interface
1568
+ // follows it through this value.
1569
+ var dialogOffsetValue = axis[SIZE_DIALOG_FIELD];
1570
+ if (dialogOffsetValue !== 0) {
1571
+ var contentSources = tokenNames(baseTokens).filter(function (name) {
1572
+ if (!isContentToken(name)) return false;
1573
+ var base = baseTokens[name];
1574
+ // The source token only: the derived ones carry var()/calc() and would
1575
+ // add the offset a second time if they were shifted as well.
1576
+ return (
1577
+ typeof base === "string" &&
1578
+ base !== "" &&
1579
+ base.indexOf("var(") === -1 &&
1580
+ base.indexOf("calc(") === -1
1581
+ );
1582
+ });
1583
+ var contentShift = shiftTokens(contentSources, baseTokens, dialogOffsetValue);
1584
+ if (contentShift.length > 0) declarations.push(blanket(contentShift.join(";")));
1585
+ // The `small` series is the only markdown family with literal sizes: it
1586
+ // does not derive from the chain, so its parts and shorthands shift here.
1587
+ var literals = markdownTokenNames(baseTokens).filter(function (name) {
1588
+ var base = baseTokens[name];
1589
+ return name.indexOf("-font-size") >= 0 && typeof base === "string" && base.indexOf("var(") === -1;
1590
+ });
1591
+ var literalShift = shiftTokens(literals, baseTokens, dialogOffsetValue);
1592
+ if (literalShift.length > 0) declarations.push(blanket(literalShift.join(";")));
1593
+ // The shorthands whose size is a literal (the small series) must ride the
1594
+ // shift too; the delta-derived shorthands flow through the chain above.
1595
+ var sizeShifted = [];
1596
+ var markdownEntries = shorthandTokens(baseTokens).filter(function (entry) {
1597
+ return isMarkdownToken(entry.name) && entry.parts.size.indexOf("var(") === -1;
1598
+ });
1599
+ for (var mIndex = 0; mIndex < markdownEntries.length; mIndex += 1) {
1600
+ var mEntry = markdownEntries[mIndex];
1601
+ var mSize =
1602
+ "calc((" + mEntry.parts.size + ") + " + dialogOffsetValue + "px)";
1603
+ sizeShifted.push(
1604
+ mEntry.name + ":" + rebuildShorthand({ lead: mEntry.parts.lead, size: mSize, height: mEntry.parts.height, family: mEntry.parts.family }) + " !important"
1605
+ );
1606
+ }
1607
+ if (sizeShifted.length > 0) declarations.push(blanket(sizeShifted.join(";")));
1608
+ }
1609
+
1610
+ // ---- dialog line height (ratio, through the shorthand heights) ----
1611
+ var dialogLine = axis[LINE_HEIGHT_DIALOG_FIELD];
1612
+ if (dialogLine !== LINE_HEIGHT_MIN) {
1613
+ var dialogFactor = round(dialogLine / LINE_HEIGHT_MIN, 6);
1614
+ var markdownLines = markdownTokenNames(baseTokens).filter(function (name) {
1615
+ return name.indexOf("-line-height") >= 0;
1616
+ });
1617
+ var dialogLineDecls = scaleLineTokens(markdownLines, baseTokens, dialogFactor);
1618
+ if (dialogLineDecls.length > 0) declarations.push(blanket(dialogLineDecls.join(";")));
1619
+ var dialogShorthandList = markdownShorthandTokens(baseTokens);
1620
+ var dialogShorthandShifted = [];
1621
+ for (var dIndex = 0; dIndex < dialogShorthandList.length; dIndex += 1) {
1622
+ var dEntry = dialogShorthandList[dIndex];
1623
+ dialogShorthandShifted.push(
1624
+ dEntry.name +
1625
+ ":" +
1626
+ rebuildShorthand({
1627
+ lead: dEntry.parts.lead,
1628
+ size: dEntry.parts.size,
1629
+ height: "calc((" + dEntry.parts.height + ") * " + dialogFactor + ")",
1630
+ family: dEntry.parts.family,
1631
+ }) +
1632
+ " !important"
1633
+ );
1634
+ }
1635
+ if (dialogShorthandShifted.length > 0) {
1636
+ declarations.push(blanket(dialogShorthandShifted.join(";")));
1637
+ }
1638
+ }
1639
+
1640
+ // ---- code axes ----
1641
+ var codeSize = axis[CODE_SIZE_FIELD];
1642
+ var codeLine = axis[CODE_LINE_HEIGHT_FIELD];
1643
+ if (codeSize !== 0 || codeLine !== 0) {
1644
+ var codeScaled = scaleCodeTokens(
1645
+ baseTokens,
1646
+ round(scaleFor(codeSize), 6),
1647
+ codeLine
1648
+ );
1649
+ if (codeScaled.length > 0) declarations.push(blanket(codeScaled.join(";")));
1650
+ }
1651
+ var codeWeightRule = axis[CODE_WEIGHT_FIELD];
1652
+ if (codeWeightRule !== WEIGHT_UNSET) {
1653
+ declarations.push(codeRule("font-weight:" + codeWeightRule + " !important"));
1654
+ }
1655
+ var ligatures = axis[LIGATURES_FIELD];
1656
+ if (ligatures === LIGATURES_ON) {
1657
+ declarations.push(codeRule("font-variant-ligatures:contextual !important"));
1658
+ } else if (ligatures === LIGATURES_OFF) {
1659
+ declarations.push(codeRule("font-variant-ligatures:none !important"));
1660
+ }
1661
+ var features = axis[FEATURES_FIELD];
1662
+ if (features !== "") {
1663
+ declarations.push(codeRule("font-feature-settings:" + features + " !important"));
1664
+ }
1665
+
1666
+ // ---- synthesis (faux italic / faux bold) ----
1667
+ var noItalic = axis[NO_SYNTHETIC_ITALIC_FIELD] === true;
1668
+ var noBold = axis[NO_SYNTHETIC_BOLD_FIELD] === true;
1669
+ if (noItalic && noBold) {
1670
+ declarations.push(blanket("font-synthesis:none !important"));
1671
+ } else {
1672
+ if (noItalic) declarations.push(blanket("font-synthesis-style:none !important"));
1673
+ if (noBold) declarations.push(blanket("font-synthesis-weight:none !important"));
1674
+ }
1675
+
1676
+ return declarations.join("\n");
1677
+ }
1678
+
1679
+ /**
1680
+ * Re-declare the markdown code tokens at their own ratio and line offset.
1681
+ *
1682
+ * Two shapes exist and both must be covered, because the shipped stylesheets
1683
+ * consume the shorthand (`font: var(--dsw-font-markdown-code-block-small)`)
1684
+ * while the split tokens exist for anything that asks for a single part:
1685
+ * `11px/19px <family>` and a bare `11px`. The shorthand's height rides BOTH
1686
+ * the ratio and the additive offset; the size rides the ratio only.
1687
+ *
1688
+ * @param {Record<string, string>} baseTokens - token name to untouched value.
1689
+ * @param {number} scale - the size ratio to apply.
1690
+ * @param {number} [lineOffset=0] - additive px on the height.
1691
+ * @returns {string[]} declarations.
1692
+ */
1693
+ function scaleCodeTokens(baseTokens, scale, lineOffset) {
1694
+ var shift = typeof lineOffset === "number" && lineOffset !== 0 ? lineOffset : 0;
1695
+ // The code axis covers every `--dsw-font-markdown-code*` token exactly once:
1696
+ // a value that parses as a shorthand rides the shorthand path, everything
1697
+ // else the part path.
1698
+ var entries = [];
1699
+ var seen = {};
1700
+ var all = tokenNames(baseTokens).filter(isCodeToken);
1701
+ for (var aIndex = 0; aIndex < all.length; aIndex += 1) {
1702
+ seen[all[aIndex]] = true;
1703
+ entries.push({ name: all[aIndex], value: baseTokens[all[aIndex]] });
1704
+ }
1705
+ var shorthands = shorthandTokens(baseTokens);
1706
+ for (var sIndex = 0; sIndex < shorthands.length; sIndex += 1) {
1707
+ var entry = shorthands[sIndex];
1708
+ if (!isCodeToken(entry.name) || seen[entry.name] === true) continue;
1709
+ seen[entry.name] = true;
1710
+ entries.push({ name: entry.name, value: baseTokens[entry.name] });
1711
+ }
1712
+ var out = [];
1713
+ for (var index = 0; index < entries.length; index += 1) {
1714
+ var name = entries[index].name;
1715
+ var base = entries[index].value;
1716
+ if (typeof base !== "string" || base === "") continue;
1717
+ var parsed = parseShorthand(base);
1718
+ if (parsed !== null) {
1719
+ var size = scale === 1 ? parsed.size : "calc((" + parsed.size + ") * " + scale + ")";
1720
+ var height = "calc((" + parsed.height + ") * " + scale + ")";
1721
+ if (shift !== 0) height = "calc(" + height + " + " + shift + "px)";
1722
+ out.push(
1723
+ name +
1724
+ ":" +
1725
+ rebuildShorthand({ lead: parsed.lead, size: size, height: height, family: parsed.family }) +
1726
+ " !important"
1727
+ );
1728
+ continue;
1729
+ }
1730
+ // A part token that derives from another token follows that token's own
1731
+ // scaling; re-declaring it here would compound the ratio — except the
1732
+ // line-height parts, which must additionally carry the offset.
1733
+ if (base.indexOf("var(") !== -1 && !(name.indexOf("-line-height") >= 0 && shift !== 0)) continue;
1734
+ if (!isScalableValue(base)) continue;
1735
+ if (name.indexOf("-line-height") >= 0 && shift !== 0) {
1736
+ out.push(
1737
+ name +
1738
+ ":calc(((" + base + ") * " + scale + ") + " + shift + "px) !important"
1739
+ );
1740
+ } else {
1741
+ out.push(name + ":calc((" + base + ") * " + scale + ") !important");
1742
+ }
1743
+ }
1744
+ return out;
732
1745
  }
733
1746
 
1747
+ /* ------------------------------------------------------------------ *
1748
+ * entry point
1749
+ * ------------------------------------------------------------------ */
1750
+
1751
+ /**
1752
+ * Build the stylesheet one configuration applies.
1753
+ *
1754
+ * The configuration resolves into a light set and — when `perTheme` is on — a
1755
+ * dark set; the light rules are emitted unprefixed and the dark rules carry
1756
+ * the dark-theme attribute, so the dark set wins by specificity exactly while
1757
+ * that theme is active. When `perTheme` is off, or the dark set equals the
1758
+ * light set, no prefixed rules are emitted at all.
1759
+ *
1760
+ * @param {unknown} config - normalized or raw configuration.
1761
+ * @param {Record<string, string>} [baseTokens] - token name to untouched value.
1762
+ * @returns {string} declarations for one `<style>` element ("" when dormant).
1763
+ */
1764
+ function buildFontCss(config, baseTokens) {
1765
+ var tokens = baseTokens === undefined || baseTokens === null ? FALLBACK_TOKENS : baseTokens;
1766
+ var sets = resolveAxes(config);
1767
+ if (isDormant(sets.light) && isDormant(sets.dark)) return "";
1768
+ var light = buildAxisCss(sets.light, tokens, false);
1769
+ if (sameValueSet(sets.light, sets.dark)) return light;
1770
+ var dark = buildAxisCss(sets.dark, tokens, true);
1771
+ if (dark === "") return light;
1772
+ return light + "\n" + dark;
1773
+ }
1774
+
1775
+ /**
1776
+ * Whether two axis sets produce the same declarations, compared over the
1777
+ * visual fields only (the storage fields — presets, the dark map itself —
1778
+ * never render).
1779
+ * @param {Record<string, unknown>} left - an expanded axis set.
1780
+ * @param {Record<string, unknown>} right - another expanded axis set.
1781
+ * @returns {boolean}
1782
+ */
1783
+ function sameValueSet(left, right) {
1784
+ for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
1785
+ var field = VALUE_FIELDS[index];
1786
+ if (left[field] !== right[field]) return false;
1787
+ }
1788
+ return true;
1789
+ }
1790
+
1791
+ /* ------------------------------------------------------------------ *
1792
+ * embedded fallback map
1793
+ * ------------------------------------------------------------------ */
1794
+
734
1795
  /**
735
1796
  * Embedded fallback for the tokens the theme plugin defines at runtime.
736
1797
  *
737
1798
  * The live document is always preferred; this map only covers the window
738
1799
  * before those declarations exist (and any token a future release renames
739
- * away from the pattern). Values mirror the size/line-height pairs shipped in
740
- * DSH 0.1.5-rc.2.
1800
+ * away from the pattern). Values are read byte for byte from the theme's own
1801
+ * stylesheet in DSH 0.1.5-rc.2.
741
1802
  */
742
1803
  var FALLBACK_TOKENS = {
1804
+ // interface ladder shorthands
1805
+ "--dsw-font-xxxs-11": "11px/14px var(--dsw-font-family)",
1806
+ "--dsw-font-xxxs-strong-11": "500 11px/14px var(--dsw-font-family)",
1807
+ "--dsw-font-xxs-12": "12px/18px var(--dsw-font-family)",
1808
+ "--dsw-font-xxs-strong-12": "500 12px/18px var(--dsw-font-family)",
1809
+ "--dsw-font-xs-13": "13px/20px var(--dsw-font-family)",
1810
+ "--dsw-font-xs-strong-13": "500 13px/20px var(--dsw-font-family)",
1811
+ "--dsw-font-s-14": "14px/22px var(--dsw-font-family)",
1812
+ "--dsw-font-s-strong-14": "500 14px/22px var(--dsw-font-family)",
1813
+ "--dsw-font-base-16": "16px/24px var(--dsw-font-family)",
1814
+ "--dsw-font-base-strong-16": "500 16px/24px var(--dsw-font-family)",
1815
+ "--dsw-font-m-18": "500 16px/28px var(--dsw-font-family)",
1816
+ "--dsw-font-l-20": "500 20px/28px var(--dsw-font-family)",
1817
+ "--dsw-font-xl-24": "600 24px/32px var(--dsw-font-family)",
1818
+ // interface ladder parts
743
1819
  "--dsw-font-xxxs-11-font-size": "11px",
744
- "--dsw-font-xxxs-11-line-height": "18px",
1820
+ "--dsw-font-xxxs-11-line-height": "14px",
745
1821
  "--dsw-font-xxxs-strong-11-font-size": "11px",
746
- "--dsw-font-xxxs-strong-11-line-height": "18px",
1822
+ "--dsw-font-xxxs-strong-11-line-height": "14px",
747
1823
  "--dsw-font-xxs-12-font-size": "12px",
748
- "--dsw-font-xxs-12-line-height": "20px",
1824
+ "--dsw-font-xxs-12-line-height": "18px",
749
1825
  "--dsw-font-xxs-strong-12-font-size": "12px",
750
- "--dsw-font-xxs-strong-12-line-height": "20px",
1826
+ "--dsw-font-xxs-strong-12-line-height": "18px",
751
1827
  "--dsw-font-xs-13-font-size": "13px",
752
- "--dsw-font-xs-13-line-height": "22px",
1828
+ "--dsw-font-xs-13-line-height": "20px",
753
1829
  "--dsw-font-xs-strong-13-font-size": "13px",
754
- "--dsw-font-xs-strong-13-line-height": "22px",
1830
+ "--dsw-font-xs-strong-13-line-height": "20px",
755
1831
  "--dsw-font-s-14-font-size": "14px",
756
- "--dsw-font-s-14-line-height": "24px",
1832
+ "--dsw-font-s-14-line-height": "22px",
757
1833
  "--dsw-font-s-strong-14-font-size": "14px",
758
- "--dsw-font-s-strong-14-line-height": "24px",
1834
+ "--dsw-font-s-strong-14-line-height": "22px",
759
1835
  "--dsw-font-base-16-font-size": "16px",
760
- "--dsw-font-base-16-line-height": "26px",
1836
+ "--dsw-font-base-16-line-height": "24px",
761
1837
  "--dsw-font-base-strong-16-font-size": "16px",
762
- "--dsw-font-base-strong-16-line-height": "26px",
763
- "--dsw-font-m-18-font-size": "18px",
1838
+ "--dsw-font-base-strong-16-line-height": "24px",
1839
+ "--dsw-font-m-18-font-size": "16px",
764
1840
  "--dsw-font-m-18-line-height": "28px",
765
1841
  "--dsw-font-l-20-font-size": "20px",
766
- "--dsw-font-l-20-line-height": "30px",
1842
+ "--dsw-font-l-20-line-height": "28px",
767
1843
  "--dsw-font-xl-24-font-size": "24px",
768
- "--dsw-font-xl-24-line-height": "34px",
769
- "--dsw-font-markdown-base-font-size": "14px",
770
- "--dsw-font-markdown-base-line-height": "24px",
771
- "--dsw-font-markdown-small-font-size": "13px",
772
- "--dsw-font-markdown-small-line-height": "22px",
773
- "--dsw-font-markdown-h1-font-size": "21px",
774
- "--dsw-font-markdown-h1-line-height": "30px",
775
- "--dsw-font-markdown-h2-font-size": "19px",
776
- "--dsw-font-markdown-h2-line-height": "28px",
777
- "--dsw-font-markdown-h3-font-size": "17px",
778
- "--dsw-font-markdown-h3-line-height": "26px",
779
- "--dsw-font-markdown-h4-font-size": "15px",
780
- "--dsw-font-markdown-h4-line-height": "24px",
781
- // The code family is consumed through the `font` shorthand, so the shorthand
782
- // names matter more than their parts — both are listed, at rc.2's values.
1844
+ "--dsw-font-xl-24-line-height": "32px",
1845
+ // markdown (dialog) shorthands
1846
+ "--dsw-font-markdown-h1": "700 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1847
+ "--dsw-font-markdown-h2": "700 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1848
+ "--dsw-font-markdown-h3": "700 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1849
+ "--dsw-font-markdown-h4": "600 var(--dsh-content-font-size,14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1850
+ "--dsw-font-markdown-base": "var(--dsh-content-font-size,14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1851
+ "--dsw-font-markdown-base-strong": "600 var(--dsh-content-font-size,14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1852
+ "--dsw-font-markdown-base-italic": "italic var(--dsh-content-font-size,14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1853
+ "--dsw-font-markdown-base-strong-italic": "italic 600 var(--dsh-content-font-size,14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family)",
1854
+ "--dsw-font-markdown-table": "var(--dsh-content-font-size-secondary,13px)/calc(22px + var(--dsh-content-font-delta-secondary,0px)) var(--dsw-font-family)",
1855
+ "--dsw-font-markdown-table-head": "500 var(--dsh-content-font-size-secondary,13px)/calc(22px + var(--dsh-content-font-delta-secondary,0px)) var(--dsw-font-family)",
1856
+ "--dsw-font-markdown-small": "12px/20px var(--dsw-font-family)",
1857
+ "--dsw-font-markdown-small-strong": "600 12px/20px var(--dsw-font-family)",
1858
+ "--dsw-font-markdown-small-italic": "italic 12px/20px var(--dsw-font-family)",
1859
+ "--dsw-font-markdown-small-strong-italic": "italic 600 12px/20px var(--dsw-font-family)",
1860
+ // markdown small-series literals (the additive dialog offset rewrites these)
1861
+ "--dsw-font-markdown-small-font-size": "12px",
1862
+ "--dsw-font-markdown-small-line-height": "20px",
1863
+ "--dsw-font-markdown-small-strong-font-size": "12px",
1864
+ "--dsw-font-markdown-small-strong-line-height": "20px",
1865
+ "--dsw-font-markdown-small-italic-font-size": "12px",
1866
+ "--dsw-font-markdown-small-italic-line-height": "20px",
1867
+ "--dsw-font-markdown-small-strong-italic-font-size": "12px",
1868
+ "--dsw-font-markdown-small-strong-italic-line-height": "20px",
1869
+ // markdown part tokens (the dialog line-height ratio scales their heights;
1870
+ // their var-derived sizes ride the delta tokens, never the ratio)
1871
+ "--dsw-font-markdown-base-font-size": "var(--dsh-content-font-size,14px)",
1872
+ "--dsw-font-markdown-base-line-height": "calc(24px + var(--dsh-content-font-delta))",
1873
+ "--dsw-font-markdown-base-strong-font-size": "var(--dsh-content-font-size,14px)",
1874
+ "--dsw-font-markdown-base-strong-line-height": "calc(24px + var(--dsh-content-font-delta))",
1875
+ "--dsw-font-markdown-base-italic-font-size": "var(--dsh-content-font-size,14px)",
1876
+ "--dsw-font-markdown-base-italic-line-height": "calc(24px + var(--dsh-content-font-delta))",
1877
+ "--dsw-font-markdown-base-strong-italic-font-size": "var(--dsh-content-font-size,14px)",
1878
+ "--dsw-font-markdown-base-strong-italic-line-height": "calc(24px + var(--dsh-content-font-delta))",
1879
+ "--dsw-font-markdown-h1-font-size": "calc(21px + var(--dsh-content-font-delta))",
1880
+ "--dsw-font-markdown-h1-line-height": "calc(30px + var(--dsh-content-font-delta))",
1881
+ "--dsw-font-markdown-h2-font-size": "calc(19px + var(--dsh-content-font-delta))",
1882
+ "--dsw-font-markdown-h2-line-height": "calc(28px + var(--dsh-content-font-delta))",
1883
+ "--dsw-font-markdown-h3-font-size": "calc(18px + var(--dsh-content-font-delta))",
1884
+ "--dsw-font-markdown-h3-line-height": "calc(26px + var(--dsh-content-font-delta))",
1885
+ "--dsw-font-markdown-h4-font-size": "var(--dsh-content-font-size,14px)",
1886
+ "--dsw-font-markdown-h4-line-height": "calc(24px + var(--dsh-content-font-delta))",
1887
+ "--dsw-font-markdown-table-font-size": "var(--dsh-content-font-size-secondary,13px)",
1888
+ "--dsw-font-markdown-table-line-height": "calc(22px + var(--dsh-content-font-delta-secondary,0px))",
1889
+ "--dsw-font-markdown-table-head-font-size": "var(--dsh-content-font-size-secondary,13px)",
1890
+ "--dsw-font-markdown-table-head-line-height": "calc(22px + var(--dsh-content-font-delta-secondary,0px))",
1891
+ // markdown delta tokens (the dialog offset shifts these additively)
1892
+ "--dsh-content-font-delta": "calc(var(--dsh-content-font-size,14px) - 14px)",
1893
+ "--dsh-content-font-delta-secondary": "calc(var(--dsh-content-font-size-secondary) - 13px)",
1894
+ // the code family: shorthand + parts, at rc.2's values
783
1895
  "--dsw-font-markdown-code": "12px/19px var(--ds-font-family-code)",
784
1896
  "--dsw-font-markdown-code-font-size": "12px",
785
1897
  "--dsw-font-markdown-code-line-height": "19px",
786
1898
  "--dsw-font-markdown-code-block": "11px/19px var(--ds-font-family-code)",
787
- "--dsw-font-markdown-code-block": "11px/19px var(--ds-font-family-code)",
788
1899
  "--dsw-font-markdown-code-block-font-size": "11px",
789
1900
  "--dsw-font-markdown-code-block-line-height": "19px",
790
1901
  "--dsw-font-markdown-code-block-small": "11px/16px var(--ds-font-family-code)",
791
1902
  "--dsw-font-markdown-code-block-small-font-size": "11px",
792
1903
  "--dsw-font-markdown-code-block-small-line-height": "16px",
793
- "--dsw-font-markdown-table-font-size": "13px",
794
- "--dsw-font-markdown-table-line-height": "22px",
1904
+ // content size (the interface chain's anchor, written inline by the theme)
795
1905
  "--dsh-content-font-size": "14px",
796
1906
  "--dsh-content-font-size-secondary": "13px",
797
1907
  };
798
1908
 
799
- /**
800
- * Round to a fixed number of decimals so generated CSS stays readable.
801
- * @param {number} value - the number.
802
- * @param {number} digits - decimals to keep.
803
- * @returns {number} the rounded number.
804
- */
805
- function round(value, digits) {
806
- var factor = Math.pow(10, digits);
807
- return Math.round(value * factor) / factor;
808
- }
1909
+ /* ------------------------------------------------------------------ *
1910
+ * curated family lists
1911
+ * ------------------------------------------------------------------ */
809
1912
 
810
1913
  /**
811
1914
  * Curated families offered when the browser cannot enumerate local fonts —
@@ -871,44 +1974,111 @@ var PRESETS = {
871
1974
  generic: ["system-ui", "sans-serif", "serif"],
872
1975
  };
873
1976
 
1977
+ /**
1978
+ * Legacy single-purpose helper kept for compatibility: scale the size and the
1979
+ * height inside one simple `size/height <family>` shorthand.
1980
+ * @param {string} value - a value shaped like `11px/19px var(--ds-font-family-code)`.
1981
+ * @param {number} scale - the ratio to apply.
1982
+ * @returns {string|null} the scaled shorthand, or null when the shape differs.
1983
+ */
1984
+ function scaleFontShorthand(value, scale) {
1985
+ var parts = parseShorthand(value);
1986
+ if (parts === null || parts.lead !== "") return null;
1987
+ return (
1988
+ "calc((" + parts.size + ") * " + scale + ")/calc((" + parts.height + ") * " + scale + ") " + parts.family
1989
+ );
1990
+ }
1991
+
874
1992
  var shared = {
875
1993
  NAMESPACE: NAMESPACE,
876
1994
  SANS_FIELD: SANS_FIELD,
1995
+ STACK_DIALOG_FIELD: STACK_DIALOG_FIELD,
877
1996
  MONO_FIELD: MONO_FIELD,
878
1997
  SIZE_FIELD: SIZE_FIELD,
1998
+ SIZE_DIALOG_FIELD: SIZE_DIALOG_FIELD,
879
1999
  CODE_SIZE_FIELD: CODE_SIZE_FIELD,
880
2000
  WEIGHT_FIELD: WEIGHT_FIELD,
2001
+ WEIGHT_DIALOG_FIELD: WEIGHT_DIALOG_FIELD,
881
2002
  CODE_WEIGHT_FIELD: CODE_WEIGHT_FIELD,
2003
+ LINE_HEIGHT_FIELD: LINE_HEIGHT_FIELD,
2004
+ LINE_HEIGHT_DIALOG_FIELD: LINE_HEIGHT_DIALOG_FIELD,
2005
+ CODE_LINE_HEIGHT_FIELD: CODE_LINE_HEIGHT_FIELD,
2006
+ LIGATURES_FIELD: LIGATURES_FIELD,
2007
+ FEATURES_FIELD: FEATURES_FIELD,
2008
+ NO_SYNTHETIC_ITALIC_FIELD: NO_SYNTHETIC_ITALIC_FIELD,
2009
+ NO_SYNTHETIC_BOLD_FIELD: NO_SYNTHETIC_BOLD_FIELD,
2010
+ PER_THEME_FIELD: PER_THEME_FIELD,
2011
+ DARK_VALUES_FIELD: DARK_VALUES_FIELD,
2012
+ PRESETS_FIELD: PRESETS_FIELD,
2013
+ ACTIVE_PRESET_FIELD: ACTIVE_PRESET_FIELD,
2014
+ UI_FOLLOWS_FIELD: UI_FOLLOWS_FIELD,
2015
+ VALUE_FIELDS: VALUE_FIELDS,
882
2016
  SIZE_MIN: SIZE_MIN,
883
2017
  SIZE_MAX: SIZE_MAX,
884
2018
  WEIGHT_MIN: WEIGHT_MIN,
885
2019
  WEIGHT_MAX: WEIGHT_MAX,
886
2020
  WEIGHT_UNSET: WEIGHT_UNSET,
2021
+ LINE_HEIGHT_MIN: LINE_HEIGHT_MIN,
2022
+ LINE_HEIGHT_MAX: LINE_HEIGHT_MAX,
2023
+ CODE_LINE_HEIGHT_MIN: CODE_LINE_HEIGHT_MIN,
2024
+ CODE_LINE_HEIGHT_MAX: CODE_LINE_HEIGHT_MAX,
2025
+ LIGATURES_DEFAULT: LIGATURES_DEFAULT,
2026
+ LIGATURES_ON: LIGATURES_ON,
2027
+ LIGATURES_OFF: LIGATURES_OFF,
887
2028
  MARKER: MARKER,
888
2029
  STYLE_TAG: STYLE_TAG,
889
2030
  CARD_STYLE_TAG: CARD_STYLE_TAG,
890
2031
  DEFAULTS: DEFAULTS,
891
2032
  MAX_STACK_LENGTH: MAX_STACK_LENGTH,
892
2033
  MAX_FAMILY_LENGTH: MAX_FAMILY_LENGTH,
2034
+ MAX_FEATURES_LENGTH: MAX_FEATURES_LENGTH,
2035
+ MAX_PRESETS: MAX_PRESETS,
2036
+ MAX_PRESET_NAME: MAX_PRESET_NAME,
893
2037
  FALLBACK_TOKENS: FALLBACK_TOKENS,
894
2038
  PRESETS: PRESETS,
2039
+ MARKDOWN_SELECTOR: MARKDOWN_SELECTOR,
2040
+ DARK_ATTR: DARK_ATTR,
895
2041
  sanitize: sanitize,
896
2042
  sanitizeFamily: sanitizeFamily,
2043
+ sanitizeFeatures: sanitizeFeatures,
897
2044
  quoteFamily: quoteFamily,
898
2045
  normalizeConfig: normalizeConfig,
2046
+ normalizeDarkValues: normalizeDarkValues,
2047
+ normalizePresets: normalizePresets,
2048
+ normalizeValueSet: normalizeValueSet,
899
2049
  clampOffset: clampOffset,
900
2050
  clampWeight: clampWeight,
2051
+ clampRatio: clampRatio,
2052
+ clampLineOffset: clampLineOffset,
2053
+ clampLigatures: clampLigatures,
901
2054
  CODE_SELECTOR: CODE_SELECTOR,
902
2055
  formatStack: formatStack,
903
2056
  parseStack: parseStack,
2057
+ parseShorthand: parseShorthand,
2058
+ rebuildShorthand: rebuildShorthand,
904
2059
  scaleFor: scaleFor,
905
2060
  isDormant: isDormant,
2061
+ buildAxisCss: buildAxisCss,
906
2062
  buildFontCss: buildFontCss,
2063
+ resolveAxes: resolveAxes,
2064
+ expandFollow: expandFollow,
907
2065
  isFontToken: isFontToken,
908
2066
  isCodeToken: isCodeToken,
2067
+ isMarkdownToken: isMarkdownToken,
2068
+ isDeltaToken: isDeltaToken,
2069
+ isShorthandName: isShorthandName,
2070
+ isShorthandToken: isShorthandToken,
909
2071
  isScaledToken: isScaledToken,
2072
+ tokenNames: tokenNames,
910
2073
  codeTokenNames: codeTokenNames,
2074
+ markdownTokenNames: markdownTokenNames,
911
2075
  bodyTokenNames: bodyTokenNames,
2076
+ uiLineTokenNames: uiLineTokenNames,
2077
+ deltaTokenNames: deltaTokenNames,
2078
+ shorthandTokens: shorthandTokens,
2079
+ markdownShorthandTokens: markdownShorthandTokens,
2080
+ uiShorthandTokens: uiShorthandTokens,
2081
+ codeShorthandTokens: codeShorthandTokens,
912
2082
  scaleFontShorthand: scaleFontShorthand,
913
2083
  isGenericFamilyName: isGenericFamilyName,
914
2084
  isCJKFamilyName: isCJKFamilyName,
@@ -928,14 +2098,19 @@ if (typeof module !== "undefined" && module.exports) module.exports = shared;
928
2098
  * Runs inside the DSH web client as a lazy-CJS module bundle (see `build.mjs`).
929
2099
  * It owns one card in Settings -> Plugins -> Plugin configuration, keyed by the
930
2100
  * settings namespace the host half registers, and it applies the saved
931
- * configuration by rewriting a single `<style>` element.
2101
+ * configuration by rewriting a single `<style>` element plus the official
2102
+ * `theme.overrideTokens` layer for the family variables.
932
2103
  *
933
- * Why a style element rather than per-element inline styles: the plugin has to
934
- * reach elements DSH renders later, and DSH's own font-size slider writes a
935
- * custom property on `body` at runtime. Reading DSH's typography tokens and
936
- * re-declaring them multiplied by one ratio keeps the offset composed with
937
- * that slider instead of replacing it, and keeps every size proportional
938
- * without compounding through nesting.
2104
+ * 0.2.x shape: the card is an accordion — four sections (conversation /
2105
+ * interface / code / global fine-tuning), each collapsed to a title plus a
2106
+ * summary of the current values, at most one open at a time; an expanded
2107
+ * section ends with an inline preview of just that part. The preset bar sits
2108
+ * under the edit mode: a dropdown of the presets plus rename/import/export, and
2109
+ * with a preset selected every edit auto-saves into it. The conversation owns
2110
+ * every axis; the interface's section leads with the follow switch — on (the
2111
+ * default) it takes the conversation's family and weight and shows no controls
2112
+ * of its own, off reveals its own family and weight. Light and dark themes share
2113
+ * one value set; the per-theme editor ships in a later release.
939
2114
  *
940
2115
  * Module scope stays side-effect free: the loader materializes the factory
941
2116
  * only when the plugin is first used, and everything that touches the document
@@ -959,27 +2134,53 @@ var useSyncExternalStore = React.useSyncExternalStore;
959
2134
  var shared = __dfpShared;
960
2135
  var NAMESPACE = shared.NAMESPACE;
961
2136
  var SANS_FIELD = shared.SANS_FIELD;
2137
+ var STACK_DIALOG_FIELD = shared.STACK_DIALOG_FIELD;
962
2138
  var MONO_FIELD = shared.MONO_FIELD;
963
- var SIZE_FIELD = shared.SIZE_FIELD;
2139
+ var SIZE_DIALOG_FIELD = shared.SIZE_DIALOG_FIELD;
964
2140
  var CODE_SIZE_FIELD = shared.CODE_SIZE_FIELD;
965
2141
  var WEIGHT_FIELD = shared.WEIGHT_FIELD;
2142
+ var WEIGHT_DIALOG_FIELD = shared.WEIGHT_DIALOG_FIELD;
966
2143
  var CODE_WEIGHT_FIELD = shared.CODE_WEIGHT_FIELD;
2144
+ var LINE_HEIGHT_DIALOG_FIELD = shared.LINE_HEIGHT_DIALOG_FIELD;
2145
+ var CODE_LINE_HEIGHT_FIELD = shared.CODE_LINE_HEIGHT_FIELD;
2146
+ var LIGATURES_FIELD = shared.LIGATURES_FIELD;
2147
+ var FEATURES_FIELD = shared.FEATURES_FIELD;
2148
+ var NO_SYNTHETIC_ITALIC_FIELD = shared.NO_SYNTHETIC_ITALIC_FIELD;
2149
+ var NO_SYNTHETIC_BOLD_FIELD = shared.NO_SYNTHETIC_BOLD_FIELD;
2150
+ var PER_THEME_FIELD = shared.PER_THEME_FIELD;
2151
+ var DARK_VALUES_FIELD = shared.DARK_VALUES_FIELD;
2152
+ var PRESETS_FIELD = shared.PRESETS_FIELD;
2153
+ var ACTIVE_PRESET_FIELD = shared.ACTIVE_PRESET_FIELD;
2154
+ var UI_FOLLOWS_FIELD = shared.UI_FOLLOWS_FIELD;
2155
+ var VALUE_FIELDS = shared.VALUE_FIELDS;
967
2156
  var SIZE_MIN = shared.SIZE_MIN;
968
2157
  var SIZE_MAX = shared.SIZE_MAX;
969
2158
  var WEIGHT_MIN = shared.WEIGHT_MIN;
970
2159
  var WEIGHT_MAX = shared.WEIGHT_MAX;
971
2160
  var WEIGHT_UNSET = shared.WEIGHT_UNSET;
2161
+ var LINE_HEIGHT_MIN = shared.LINE_HEIGHT_MIN;
2162
+ var LINE_HEIGHT_MAX = shared.LINE_HEIGHT_MAX;
2163
+ var CODE_LINE_HEIGHT_MIN = shared.CODE_LINE_HEIGHT_MIN;
2164
+ var CODE_LINE_HEIGHT_MAX = shared.CODE_LINE_HEIGHT_MAX;
2165
+ var LIGATURES_DEFAULT = shared.LIGATURES_DEFAULT;
2166
+ var LIGATURES_ON = shared.LIGATURES_ON;
2167
+ var LIGATURES_OFF = shared.LIGATURES_OFF;
972
2168
  var CARD_STYLE_TAG = shared.CARD_STYLE_TAG;
973
2169
  var STYLE_TAG = shared.STYLE_TAG;
974
2170
  var FALLBACK_TOKENS = shared.FALLBACK_TOKENS;
975
2171
  var PRESETS = shared.PRESETS;
2172
+ var DEFAULTS = shared.DEFAULTS;
976
2173
  var buildFontCss = shared.buildFontCss;
977
2174
  var formatStack = shared.formatStack;
978
2175
  var isFontToken = shared.isFontToken;
979
2176
  var isScaledToken = shared.isScaledToken;
980
2177
  var normalizeConfig = shared.normalizeConfig;
2178
+ var normalizeDarkValues = shared.normalizeDarkValues;
2179
+ var normalizePresets = shared.normalizePresets;
2180
+ var normalizeValueSet = shared.normalizeValueSet;
981
2181
  var parseStack = shared.parseStack;
982
2182
  var quoteFamily = shared.quoteFamily;
2183
+ var resolveAxes = shared.resolveAxes;
983
2184
  var sanitizeFamily = shared.sanitizeFamily;
984
2185
  var isGenericFamilyName = shared.isGenericFamilyName;
985
2186
  var isCJKFamilyName = shared.isCJKFamilyName;
@@ -1031,7 +2232,7 @@ var DICTS = {
1031
2232
  en: {
1032
2233
  "card.title": "Font tune",
1033
2234
  "card.description":
1034
- "Body and code fonts, a size and a weight for each, and a West/CJK split",
2235
+ "Interface and conversation fonts, a size, a weight and a line height for each, code extras and presets",
1035
2236
  "card.expand": "Expand",
1036
2237
  "card.collapse": "Collapse",
1037
2238
  "card.resetAll": "Reset all",
@@ -1039,8 +2240,10 @@ var DICTS = {
1039
2240
 
1040
2241
  "common.overridden": "Changed",
1041
2242
  "common.reset": "Reset",
2243
+ "common.on": "On",
2244
+ "common.off": "Off",
1042
2245
 
1043
- "sans.label": "Body font",
2246
+ "sans.label": "Interface font",
1044
2247
  "sans.hint":
1045
2248
  "Each family is tried in order: put a Latin face first and CJK faces after it. Empty leaves DSH's own stack.",
1046
2249
  "mono.label": "Code font",
@@ -1074,8 +2277,8 @@ var DICTS = {
1074
2277
  "mode.advanced": "Advanced",
1075
2278
  "mode.simpleHint":
1076
2279
  "Manages only the front two slots of the stack — Western, then CJK. Everything you ordered in Advanced stays untouched.",
1077
- "sansWest.label": "Body · Western",
1078
- "sansEast.label": "Body · CJK",
2280
+ "sansWest.label": "Interface · Western",
2281
+ "sansEast.label": "Interface · CJK",
1079
2282
  "monoWest.label": "Code · Western",
1080
2283
  "monoEast.label": "Code · CJK",
1081
2284
  "split.pick": "Choose…",
@@ -1083,35 +2286,135 @@ var DICTS = {
1083
2286
  "split.remove": "Remove {name}",
1084
2287
  "split.rest": "Other fallbacks (reorder them in Advanced): {names}",
1085
2288
 
1086
- "size.bodyLabel": "Body font size offset",
2289
+ "size.bodyLabel": "Interface font size offset",
1087
2290
  "size.bodyHint":
1088
- "Adds {offset} to text and interface sizes, on top of DSH's own font-size setting. 0 keeps DSH's sizes.",
2291
+ "Adds {offset} to interface text sizes, on top of DSH's own font-size setting. 0 keeps DSH's sizes.",
2292
+ "size.dialogLabel": "Conversation font size offset",
2293
+ "size.dialogHint":
2294
+ "Adds {offset} to the conversation text sizes, on top of DSH's own font-size setting. 0 keeps DSH's sizes.",
1089
2295
  "size.codeLabel": "Code font size offset",
1090
2296
  "size.codeHint":
1091
- "Adds {offset} to code blocks and inline code only; the body offset does not reach them. 0 keeps DSH's sizes.",
2297
+ "Adds {offset} to code blocks and inline code only; the interface offset does not reach them. 0 keeps DSH's sizes.",
1092
2298
  "size.unit": "px",
1093
2299
 
1094
- "weight.bodyLabel": "Body font weight",
1095
- "weight.bodyHint":
1096
- "Overrides the weight of text and interface copy, headings included. Code keeps its own weight. Default weight is 400.",
2300
+ "weight.uiLabel": "Interface font weight",
2301
+ "weight.uiHint":
2302
+ "Sets the weight of the whole interface (sidebars, settings, buttons, headings). The conversation keeps its own.",
2303
+ "weight.dialogLabel": "Conversation font weight",
2304
+ "weight.dialogHint":
2305
+ "Sets the weight of the conversation markdown; unset keeps DSH's own weights.",
1097
2306
  "weight.codeLabel": "Code font weight",
1098
2307
  "weight.codeHint":
1099
- "Overrides code blocks, inline code and terminal output only. 400 or unset keeps DSH's own weight and takes code out of the body weight.",
1100
- "weight.unset": "Unset",
2308
+ "Overrides code blocks, inline code and terminal output only. 400 or unset keeps DSH's own weight.",
2309
+
2310
+ "line.bodyLabel": "Interface line height",
2311
+ "line.bodyHint":
2312
+ "Scales every interface line height by {ratio}. 100% keeps DSH's own line heights.",
2313
+ "line.dialogLabel": "Conversation line height",
2314
+ "line.dialogHint":
2315
+ "Scales every conversation line height by {ratio}. 100% keeps DSH's own line heights.",
2316
+ "line.codeLabel": "Code line height",
2317
+ "line.codeHint":
2318
+ "Adds {offset} to code line heights only; 0 keeps DSH's own line heights.",
2319
+ "line.unit": "%",
2320
+
2321
+ "lig.label": "Code ligatures",
2322
+ "lig.hint":
2323
+ "Programming ligatures render multiple characters as one glyph (=> as an arrow, != as ≠). Browsers enable them by default.",
2324
+ "lig.default": "Default",
2325
+ "lig.on": "On",
2326
+ "lig.off": "Off",
2327
+ "feat.label": "Feature settings",
2328
+ "feat.hint":
2329
+ "Advanced font-feature-settings value, e.g. \"ss01\" on, \"cv01\" 1. Invalid values are ignored.",
2330
+ "feat.placeholder": "\"ss01\" on",
2331
+
2332
+ "synth.label": "Refuse synthetic styles",
2333
+ "synth.italic": "No faux italic",
2334
+ "synth.italicHint":
2335
+ "CJK faces have no italic, so the browser tilts them. On keeps marked text upright.",
2336
+ "synth.bold": "No faux bold",
2337
+ "synth.boldHint":
2338
+ "For faces without a real bold: bold text stops being thickened artificially.",
2339
+ "synth.simple": "No faux italic / faux bold",
2340
+ "synth.simpleHint":
2341
+ "Stops synthetic italic and synthetic bold everywhere; switch to Advanced to set them apart.",
2342
+
2343
+ "section.ui": "Interface",
2344
+ "section.dialog": "Conversation",
2345
+ "section.code": "Code",
2346
+ "section.fine": "Global fine-tuning",
2347
+ "section.presets": "Presets",
2348
+ "section.uiHint":
2349
+ "Sidebars, headings, buttons and every other chrome text.",
2350
+ "section.dialogHint":
2351
+ "The conversation markdown: paragraphs, tables and headings.",
2352
+ "section.codeHint": "Code blocks, inline code and terminal output.",
2353
+ "section.fineHint":
2354
+ "Settings that apply to the whole page.",
2355
+ "section.open": "Expand {name}",
2356
+ "section.close": "Collapse {name}",
2357
+
2358
+ "ui.follow": "Follows the conversation",
2359
+ "ui.followHint": "Font and weight",
2360
+ "ui.own": "Own values",
2361
+ "dialog.default": "DSH defaults",
2362
+
2363
+ "theme.label": "Per-theme values",
2364
+ "theme.on": "Light and dark keep separate values",
2365
+ "theme.edit": "Editing theme",
2366
+ "theme.light": "Light",
2367
+ "theme.dark": "Dark",
2368
+ "theme.mismatch":
2369
+ "The page is in {active} mode; the preview below shows the values being edited.",
2370
+
2371
+ "dialog.label": "Conversation font",
2372
+ "dialog.hint":
2373
+ "Applies to the conversation markdown (paragraphs, tables, headings); code surfaces keep the code font.",
2374
+ "dialogWest.label": "Conversation · Western",
2375
+ "dialogEast.label": "Conversation · CJK",
2376
+
2377
+ "preset.save": "Save current",
2378
+ "preset.namePlaceholder": "Preset name",
2379
+ "preset.apply": "Apply",
2380
+ "preset.overwrite": "Overwrite",
2381
+ "preset.delete": "Delete",
2382
+ "preset.export": "Export",
2383
+ "preset.import": "Import",
2384
+ "preset.importPlaceholder": "Paste an exported preset JSON here",
2385
+ "preset.label": "Presets",
2386
+ "preset.select": "Choose a preset",
2387
+ "preset.rename": "Rename",
2388
+ "preset.renamePlaceholder": "New name",
2389
+ "preset.renamed": "Renamed to “{name}”.",
2390
+ "preset.autoSave": "With a preset selected, every change saves into it automatically.",
2391
+ "preset.nameUsed": "That name is already taken.",
2392
+ "preset.saved": "Saved “{name}”.",
2393
+ "preset.applied": "Applied “{name}”.",
2394
+ "preset.deleted": "Deleted “{name}”.",
2395
+ "preset.updated": "Overwrote “{name}”.",
2396
+ "preset.exported": "Presets copied to the clipboard.",
2397
+ "preset.imported": "Imported {count} preset(s).",
2398
+ "preset.importBad": "That text is not a valid preset export.",
2399
+ "preset.full": "The preset list is full ({max}).",
2400
+ "preset.empty": "No presets saved yet.",
2401
+ "preset.nameTaken": "“{name}” already exists — saving overwrites it.",
1101
2402
 
1102
2403
  "preview.label": "Preview",
1103
- "preview.sansCaption": "Body",
2404
+ "preview.sansCaption": "Interface",
2405
+ "preview.dialogCaption": "Conversation",
1104
2406
  "preview.monoCaption": "Code",
1105
2407
  "preview.sample":
1106
2408
  "The quick brown fox jumps over the lazy dog — 中文排版预览,标点符号,数字 0123456789。",
1107
- "preview.code": "const greet = (name) => `hello ${name}`; // 代码预览",
2409
+ "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
1108
2410
 
1109
2411
  "footnote.local":
1110
2412
  "Stored in the Host settings document. Every change applies immediately.",
1111
2413
  },
1112
2414
  zh: {
1113
2415
  "card.title": "字体增强",
1114
- "card.description": "正文与代码字体、各自的字号与字重、中西文分栏",
2416
+ "card.description":
2417
+ "界面与对话字体、各自的字号/字重/行高,代码增强与预设方案",
1115
2418
  "card.expand": "展开",
1116
2419
  "card.collapse": "收起",
1117
2420
  "card.resetAll": "全部重置",
@@ -1119,8 +2422,10 @@ var DICTS = {
1119
2422
 
1120
2423
  "common.overridden": "已修改",
1121
2424
  "common.reset": "重置",
2425
+ "common.on": "开启",
2426
+ "common.off": "关闭",
1122
2427
 
1123
- "sans.label": "正文字体",
2428
+ "sans.label": "界面字体",
1124
2429
  "sans.hint":
1125
2430
  "按顺序回退:拉丁字体放前面、中文字体放后面;留空表示沿用 DSH 的字体栈。",
1126
2431
  "mono.label": "代码字体",
@@ -1152,8 +2457,8 @@ var DICTS = {
1152
2457
  "mode.advanced": "高级",
1153
2458
  "mode.simpleHint":
1154
2459
  "只管理栈最前面的西文/中文两项;你在高级模式里排好的其余回退原样保留。",
1155
- "sansWest.label": "正文 · 西文字体",
1156
- "sansEast.label": "正文 · 中文字体",
2460
+ "sansWest.label": "界面 · 西文字体",
2461
+ "sansEast.label": "界面 · 中文字体",
1157
2462
  "monoWest.label": "代码 · 西文字体",
1158
2463
  "monoEast.label": "代码 · 中文字体",
1159
2464
  "split.pick": "选择…",
@@ -1161,28 +2466,118 @@ var DICTS = {
1161
2466
  "split.remove": "移除 {name}",
1162
2467
  "split.rest": "其余回退项(在高级模式中排序):{names}",
1163
2468
 
1164
- "size.bodyLabel": "正文字号偏移",
2469
+ "size.bodyLabel": "界面字号偏移",
1165
2470
  "size.bodyHint":
1166
- "给正文与界面文字统一加 {offset},与设置里的「字号大小」叠加;0 表示保持原样。",
2471
+ "给界面文字统一加 {offset},与设置里的「字号大小」叠加;0 表示保持原样。",
2472
+ "size.dialogLabel": "对话字号偏移",
2473
+ "size.dialogHint":
2474
+ "给对话文字统一加 {offset},与设置里的「字号大小」叠加;0 表示保持 DSH 原样。",
1167
2475
  "size.codeLabel": "代码字号偏移",
1168
2476
  "size.codeHint":
1169
- "只作用于代码块与行内代码,与正文字号互不影响;0 表示保持原样。",
2477
+ "只作用于代码块与行内代码,与界面字号互不影响;0 表示保持原样。",
1170
2478
  "size.unit": "px",
1171
2479
 
1172
- "weight.bodyLabel": "正文字重",
1173
- "weight.bodyHint":
1174
- "覆盖正文与界面文字的粗细(含标题);代码不受它影响。默认字重为400。",
2480
+ "weight.uiLabel": "界面字重",
2481
+ "weight.uiHint":
2482
+ "设置整个界面的字重(侧栏、设置、按钮、标题);对话 Markdown 不受影响。",
2483
+ "weight.dialogLabel": "对话字重",
2484
+ "weight.dialogHint": "设置对话 Markdown 的粗细;未设置时保持 DSH 原本的粗细。",
1175
2485
  "weight.codeLabel": "代码字重",
1176
2486
  "weight.codeHint":
1177
- "只覆盖代码块、行内代码与终端输出;400 或未设置表示保持 DSH 原样,也就是不被正文字重带着走。",
1178
- "weight.unset": "未设置",
2487
+ "只覆盖代码块、行内代码与终端输出;400 或未设置表示保持 DSH 原样。",
2488
+
2489
+ "line.bodyLabel": "界面行高",
2490
+ "line.bodyHint": "把界面行高整体缩放为 {ratio};100% 表示保持原样。",
2491
+ "line.dialogLabel": "对话行高",
2492
+ "line.dialogHint": "把对话行高整体缩放为 {ratio};100% 表示保持 DSH 原样。",
2493
+ "line.codeLabel": "代码行高",
2494
+ "line.codeHint": "只给代码行高加 {offset}px;0 表示保持原样。",
2495
+ "line.unit": "%",
2496
+
2497
+ "lig.label": "代码连字",
2498
+ "lig.hint":
2499
+ "把多个字符连成一个字形(如 => 变箭头、!= 变 ≠)。浏览器默认就是开启的。",
2500
+ "lig.default": "默认",
2501
+ "lig.on": "开启",
2502
+ "lig.off": "关闭",
2503
+ "feat.label": "特性设置",
2504
+ "feat.hint":
2505
+ "高级的 font-feature-settings 值,例如 ss01 on、cv01 1;非法值会被忽略。",
2506
+ "feat.placeholder": "\"ss01\" on",
2507
+
2508
+ "synth.label": "拒绝合成样式",
2509
+ "synth.italic": "禁用伪斜体",
2510
+ "synth.italicHint":
2511
+ "中文字体没有斜体,浏览器会把标记文本硬掰歪;开启后保持直立。",
2512
+ "synth.bold": "禁用伪粗体",
2513
+ "synth.boldHint": "没有真实粗体的字体不再被人为加粗。",
2514
+ "synth.simple": "禁用伪斜体/伪粗体",
2515
+ "synth.simpleHint":
2516
+ "整页停用合成斜体与合成粗体;切到高级模式可分开设置。",
2517
+
2518
+ "section.ui": "界面",
2519
+ "section.dialog": "对话",
2520
+ "section.code": "代码",
2521
+ "section.fine": "全局微调",
2522
+ "section.presets": "预设方案",
2523
+ "section.uiHint": "侧栏、标题、按钮等界面文字。",
2524
+ "section.dialogHint": "会话里的 Markdown:段落、表格与标题。",
2525
+ "section.codeHint": "代码块、行内代码与终端输出。",
2526
+ "section.fineHint": "作用于整页的选项。",
2527
+ "section.open": "展开{name}",
2528
+ "section.close": "收起{name}",
2529
+
2530
+ "theme.label": "分主题数值",
2531
+ "theme.on": "深浅色各存一套数值",
2532
+ "theme.edit": "正在编辑",
2533
+ "theme.light": "浅色",
2534
+ "theme.dark": "深色",
2535
+ "theme.mismatch": "当前页面是{active}主题,下方预览显示的是正在编辑的那套数值。",
2536
+
2537
+ "ui.follow": "跟随对话设置",
2538
+ "ui.followHint": "字体与字重",
2539
+ "ui.own": "独立数值",
2540
+ "dialog.default": "DSH 默认",
2541
+
2542
+ "dialog.label": "对话字体",
2543
+ "dialog.hint":
2544
+ "作用于会话里的 Markdown(段落、表格、标题);代码表面仍用代码字体。",
2545
+ "dialogWest.label": "对话 · 西文字体",
2546
+ "dialogEast.label": "对话 · 中文字体",
2547
+
2548
+ "preset.save": "保存当前",
2549
+ "preset.namePlaceholder": "方案名称",
2550
+ "preset.apply": "应用",
2551
+ "preset.overwrite": "覆盖",
2552
+ "preset.delete": "删除",
2553
+ "preset.export": "导出",
2554
+ "preset.import": "导入",
2555
+ "preset.importPlaceholder": "粘贴导出的方案内容",
2556
+ "preset.label": "预设方案",
2557
+ "preset.select": "选择方案",
2558
+ "preset.rename": "改名",
2559
+ "preset.renamePlaceholder": "新名字",
2560
+ "preset.renamed": "已改名为“{name}”。",
2561
+ "preset.autoSave": "选中方案后,所有改动自动存入该方案。",
2562
+ "preset.nameUsed": "这个名字已被占用。",
2563
+ "preset.saved": "已保存“{name}”。",
2564
+ "preset.applied": "已应用“{name}”。",
2565
+ "preset.deleted": "已删除“{name}”。",
2566
+ "preset.updated": "已覆盖“{name}”。",
2567
+ "preset.exported": "方案已复制到剪贴板。",
2568
+ "preset.imported": "已导入 {count} 个方案。",
2569
+ "preset.importBad": "这段内容不是有效的方案导出。",
2570
+ "preset.full": "方案列表已满({max})。",
2571
+ "preset.empty": "还没有保存方案。",
2572
+ "preset.nameTaken": "“{name}”已存在,保存将覆盖它。",
1179
2573
 
1180
2574
  "preview.label": "预览",
1181
- "preview.sansCaption": "正文",
2575
+ "preview.sansCaption": "界面",
2576
+ "preview.dialogCaption": "对话",
1182
2577
  "preview.monoCaption": "代码",
1183
2578
  "preview.sample":
1184
2579
  "The quick brown fox jumps over the lazy dog —— 中文排版预览,标点符号,数字 0123456789。",
1185
- "preview.code": "const greet = (name) => `hello ${name}`; // 代码预览",
2580
+ "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
1186
2581
 
1187
2582
  "footnote.local": "保存在宿主设置文档里;每次改动立即生效。",
1188
2583
  },
@@ -1241,15 +2636,64 @@ var CARD_CSS = [
1241
2636
  ".dfp-resetAll:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}",
1242
2637
  ".dfp-resetAll:disabled{opacity:.4;cursor:default}",
1243
2638
 
1244
- ".dfp-field{padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
2639
+ ".dfp-field{padding:14px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
1245
2640
  ".dfp-fieldLast{border-bottom:0}",
1246
2641
  ".dfp-fieldHead{align-items:center;gap:8px;display:flex}",
1247
2642
  ".dfp-fieldLabel{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}",
2643
+ // The control that sits in the same row as its label, right-aligned — the
2644
+ // same shape as dsh-quick-toc's settings card (`.dqt-pinline`), so the
2645
+ // cards' rows line up row for row.
2646
+ ".dfp-inline{flex:1;justify-content:flex-end;align-items:center;gap:10px;display:flex}",
1248
2647
  ".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}",
1249
2648
  ".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}",
1250
2649
  ".dfp-fieldReset:hover{color:var(--dsw-alias-label-primary)}",
1251
2650
  ".dfp-hint{color:var(--dsw-alias-label-tertiary);margin:4px 0 0;font-size:12px;line-height:18px}",
1252
2651
 
2652
+ // accordion sections
2653
+ ".dfp-section{border-bottom:.5px solid var(--dsw-alias-border-l2)}",
2654
+ ".dfp-sectionLast{border-bottom:0}",
2655
+ ".dfp-sectionHead{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;align-items:center;gap:8px;padding:12px 0;display:flex}",
2656
+ ".dfp-sectionHead:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}",
2657
+ ".dfp-sectionTitle{flex:none;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:22px}",
2658
+ ".dfp-sectionSummary{flex:1;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right}",
2659
+ // While a section is open its summary disappears, so without this spacer the
2660
+ // chevron would slide left and sit next to the title; the spacer keeps the
2661
+ // arrow pinned to the far right in both states.
2662
+ ".dfp-sectionSpacer{flex:1;min-width:0}",
2663
+ ".dfp-sectionBody{padding:0 0 12px}",
2664
+ // The first control may not float away from the section title: the field
2665
+ // rows carry 16px top padding of their own, so the first one is tightened.
2666
+ ".dfp-sectionBody>.dfp-field:first-child{padding-top:10px}",
2667
+ ".dfp-sectionBody>.dfp-hint:first-child{margin-top:10px}",
2668
+
2669
+ // presets: one bar — a dropdown select plus rename/import/export. The
2670
+ // buttons must never wrap their two-character labels: they keep nowrap and
2671
+ // stay flex:none, so compressing the row eats the SELECT's width instead.
2672
+ ".dfp-presetBar{align-items:center;gap:8px;margin-top:8px;display:flex}",
2673
+ ".dfp-selectWrap{display:inline-flex;flex:1;min-width:0}",
2674
+ ".dfp-select{flex:1;min-width:0;height:30px;box-sizing:border-box;appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;padding:0 6px 0 10px;display:inline-flex;align-items:center;justify-content:space-between;gap:6px}",
2675
+ ".dfp-select:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}",
2676
+ ".dfp-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}",
2677
+ ".dfp-select:disabled{opacity:.4;cursor:default}",
2678
+ ".dfp-selectName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;line-height:18px;text-align:left}",
2679
+ ".dfp-selectChevron{flex:none;display:block;color:var(--dsw-alias-label-tertiary);transition:transform .16s}",
2680
+ ".dfp-selectChevronOpen{transform:rotate(180deg)}",
2681
+ ".dfp-drop{position:fixed;z-index:1200;box-sizing:border-box;min-width:160px;max-height:260px;overflow-y:auto;padding:4px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:10px;box-shadow:var(--dsw-shadow-lv3,0 12px 32px #00000024)}",
2682
+ ".dfp-miniButton{appearance:none;font:inherit;cursor:pointer;white-space:nowrap;flex:none;border-radius:6px;padding:3px 8px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);background:0 0;border:.5px solid var(--dsw-alias-border-l3)}",
2683
+ ".dfp-miniButton:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-specific-sidebar-nav-item-hover)}",
2684
+ ".dfp-miniButton:disabled{opacity:.4;cursor:default}",
2685
+ ".dfp-presetForm{align-items:center;gap:8px;margin-top:10px;display:flex}",
2686
+ ".dfp-textInput{flex:1;min-width:0;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}",
2687
+ // The auto-save hint and the transient message share one row: the hint holds
2688
+ // the left, the message fades in on the right. The row is always mounted and
2689
+ // one line tall, so a message appearing can never push anything down.
2690
+ ".dfp-presetMeta{align-items:center;gap:10px;margin-top:6px;display:flex;overflow:hidden}",
2691
+ // The hint never wraps: if a long message squeezes it, it truncates instead
2692
+ // of growing the row (a two-line row would push everything below it down).
2693
+ ".dfp-presetHint{flex:1;min-width:0;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
2694
+ ".dfp-status{flex:none;min-width:0;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;opacity:0;transition:opacity .24s}",
2695
+ ".dfp-statusOn{opacity:1}",
2696
+
1253
2697
  ".dfp-chips{align-items:center;flex-wrap:wrap;gap:6px;margin-top:10px;display:flex}",
1254
2698
  ".dfp-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
1255
2699
  ".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}",
@@ -1266,10 +2710,7 @@ var CARD_CSS = [
1266
2710
 
1267
2711
  // Segmented / toggle controls follow dsh-quick-toc's settings card colours:
1268
2712
  // the selected option takes the same --dsw-specific-sidebar-nav-item-active
1269
- // surface (not just darker text). Shape: one joined pill with a divider, as
1270
- // the card had before — quick-toc is being aligned to this shape instead.
1271
- ".dfp-modeRow{align-items:center;gap:10px;margin:14px 0 0;display:flex}",
1272
- ".dfp-modeLabel{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}",
2713
+ // surface (not just darker text). Shape: one joined pill with a divider.
1273
2714
  ".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}",
1274
2715
  ".dfp-modeButton{appearance:none;font:inherit;cursor:pointer;height:28px;padding:0 14px;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-left:.5px solid var(--dsw-alias-border-l3);font-size:13px;line-height:18px}",
1275
2716
  ".dfp-modeButton:first-child{border-left:none}",
@@ -1284,6 +2725,10 @@ var CARD_CSS = [
1284
2725
  ".dfp-pick:disabled{opacity:.4;cursor:default}",
1285
2726
  ".dfp-pickEmpty{color:var(--dsw-alias-label-tertiary)}",
1286
2727
 
2728
+ // the per-theme switch is gone (light and dark share one value set); the
2729
+ // on/off controls are all joined segmented controls now, so no pill switch
2730
+ // exists in this card and every row keeps the same 28px control height.
2731
+
1287
2732
  ".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}",
1288
2733
  ".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}",
1289
2734
  ".dfp-note{padding:4px 2px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}",
@@ -1300,9 +2745,12 @@ var CARD_CSS = [
1300
2745
  ".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}",
1301
2746
  ".dfp-scale{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;display:flex;justify-content:space-between}",
1302
2747
 
2748
+ // the per-section preview: an inline box at the end of the expanded
2749
+ // section body, showing only that section's own sample
1303
2750
  ".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}",
1304
2751
  ".dfp-previewCaption{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;margin-bottom:4px}",
1305
2752
  ".dfp-previewText{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;word-break:break-word}",
2753
+ ".dfp-previewCode{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}",
1306
2754
  ].join("");
1307
2755
 
1308
2756
  /**
@@ -1363,9 +2811,9 @@ function isOwnSheet(sheet) {
1363
2811
  * rule applies it reports the SCALED values, and feeding those back as bases
1364
2812
  * compounds the offset on every refresh.
1365
2813
  *
1366
- * Code shorthands (`--dsw-font-markdown-code-block` and friends) are collected
1367
- * too: they are `font` values rather than `-font-size` tokens, and they are
1368
- * what the shipped stylesheets actually consume.
2814
+ * `font` shorthand tokens (`--dsw-font-markdown-base` and friends) are
2815
+ * collected too: they are what the shipped stylesheets actually consume, and
2816
+ * the line-height axes rebuild their inline heights.
1369
2817
  *
1370
2818
  * @returns {Record<string, string>} token name to its untouched value.
1371
2819
  */
@@ -1428,6 +2876,15 @@ function readBaseTokens() {
1428
2876
 
1429
2877
  /**
1430
2878
  * Create the writer that keeps one `<style>` element in sync.
2879
+ *
2880
+ * The host half renders the same declarations into the served index so the
2881
+ * first frame is already correct, and that element carries this plugin's
2882
+ * `data-plugin-css` stamp. This half ADOPTS it instead of appending a second
2883
+ * copy: the served copy is only rebuilt on the next index render, so two
2884
+ * elements could never stay in sync — a rule a configuration no longer
2885
+ * produces (unset a weight, drop a size offset) would keep applying from the
2886
+ * stale copy until the user reloaded the page.
2887
+ *
1431
2888
  * @param {() => Record<string, string>} tokens - reads the current base tokens.
1432
2889
  * @returns {(config: unknown) => void} the applier.
1433
2890
  */
@@ -1437,9 +2894,13 @@ function createStylesheet(tokens) {
1437
2894
  return function apply(config) {
1438
2895
  if (typeof document === "undefined") return;
1439
2896
  var css = buildFontCss(config, tokens());
1440
- if (css === lastCss) return;
2897
+ if (css === lastCss && tag !== null && tag.isConnected) return;
1441
2898
  lastCss = css;
1442
- if (tag === null) {
2899
+ if (tag === null || !tag.isConnected) {
2900
+ // The served row first (it is already in the document), then our own.
2901
+ tag = document.querySelector('style[data-plugin-css="' + STYLE_TAG + '"]');
2902
+ }
2903
+ if (tag === null || !tag.isConnected) {
1443
2904
  tag = document.createElement("style");
1444
2905
  tag.dataset.plugin = "dsh-fonttune";
1445
2906
  tag.dataset.pluginCss = STYLE_TAG;
@@ -1449,6 +2910,24 @@ function createStylesheet(tokens) {
1449
2910
  };
1450
2911
  }
1451
2912
 
2913
+ /**
2914
+ * Read the DSH default of one family variable, for the token override pairs.
2915
+ * @param {string} name - custom property name.
2916
+ * @returns {string} the live value, or "" when unreadable.
2917
+ */
2918
+ function readDefaultFamily(name) {
2919
+ if (typeof document === "undefined") return "";
2920
+ try {
2921
+ var value = document.defaultView
2922
+ .getComputedStyle(document.body)
2923
+ .getPropertyValue(name)
2924
+ .trim();
2925
+ return value === "" ? "" : value;
2926
+ } catch (error) {
2927
+ return "";
2928
+ }
2929
+ }
2930
+
1452
2931
  /* ------------------------------------------------------------------ *
1453
2932
  * hooks and controls
1454
2933
  * ------------------------------------------------------------------ */
@@ -1514,24 +2993,67 @@ function FieldShell(props) {
1514
2993
  },
1515
2994
  props.t("common.reset")
1516
2995
  )
1517
- : null
2996
+ : null
1518
2997
  ),
1519
2998
  h("p", { className: "dfp-hint" }, props.hint),
1520
2999
  props.children
1521
3000
  );
1522
3001
  }
1523
3002
 
3003
+ /**
3004
+ * One accordion section: a title row that reads like a summary line, and the
3005
+ * controls only when expanded. The card keeps at most one section open.
3006
+ * @param {object} props - copy, open state, toggle handler and children.
3007
+ * @returns {object} the section element.
3008
+ */
3009
+ function Section(props) {
3010
+ return h(
3011
+ "div",
3012
+ { className: "dfp-section" + (props.last ? " dfp-sectionLast" : ""), ref: props.wrapRef },
3013
+ h(
3014
+ "button",
3015
+ {
3016
+ type: "button",
3017
+ className: "dfp-sectionHead",
3018
+ "aria-expanded": props.open,
3019
+ "aria-label": props.t(props.open ? "section.close" : "section.open", { name: props.title }),
3020
+ onClick: props.onToggle,
3021
+ },
3022
+ h("span", { className: "dfp-sectionTitle" }, props.title),
3023
+ // Collapsed: the summary fills the middle. Expanded: an empty spacer
3024
+ // takes its place so the chevron never leaves the far right edge.
3025
+ props.open
3026
+ ? h("span", { className: "dfp-sectionSpacer", "aria-hidden": "true" })
3027
+ : h("span", { className: "dfp-sectionSummary" }, props.summary),
3028
+ h(
3029
+ "svg",
3030
+ {
3031
+ className: "dfp-chevron" + (props.open ? " dfp-chevronOpen" : ""),
3032
+ width: 14,
3033
+ height: 14,
3034
+ viewBox: "0 0 14 14",
3035
+ fill: "none",
3036
+ "aria-hidden": "true",
3037
+ },
3038
+ h("path", { d: CHEVRON_PATH, fill: "currentColor" })
3039
+ )
3040
+ ),
3041
+ props.open ? h("div", { className: "dfp-sectionBody" }, props.children) : null
3042
+ );
3043
+ }
3044
+
1524
3045
  /**
1525
3046
  * A slider over a whole-number range with a live value readout.
1526
3047
  * Dragging only moves a local value; the change handler runs once the
1527
3048
  * pointer is released (or on blur/keyup), so intermediate steps never
1528
3049
  * rewrite the settings document — the page does not recompute per pixel.
1529
- * @param {object} props - range, value, labels, change handler and an
3050
+ * @param {object} props - range, step, value, labels, change handler and an
1530
3051
  * optional pendingText(v) formatting the readout while dragging.
1531
3052
  * @returns {object} the slider element.
1532
3053
  */
1533
3054
  function NumberSlider(props) {
1534
3055
  var [pending, setPending] = useState(null);
3056
+ var step = typeof props.step === "number" && props.step > 0 ? props.step : 1;
1535
3057
  // The value the host has not confirmed yet. Between the release and the
1536
3058
  // settings round-trip the committed prop is still the OLD number — clearing
1537
3059
  // the local value right away would show that old number for one frame
@@ -1592,7 +3114,7 @@ function NumberSlider(props) {
1592
3114
  className: "dfp-slider",
1593
3115
  min: props.min,
1594
3116
  max: props.max,
1595
- step: 1,
3117
+ step: step,
1596
3118
  value: shown,
1597
3119
  disabled: props.disabled,
1598
3120
  "aria-label": props.label,
@@ -1617,6 +3139,178 @@ function NumberSlider(props) {
1617
3139
  );
1618
3140
  }
1619
3141
 
3142
+ /**
3143
+ * One joined segmented control over a small fixed option list.
3144
+ * @param {object} props - options [{value,label}], the current value, the
3145
+ * change handler and an optional aria label.
3146
+ * @returns {object} the segmented control element.
3147
+ */
3148
+ function Segmented(props) {
3149
+ var options = props.options;
3150
+ return h(
3151
+ "div",
3152
+ { className: "dfp-modeSeg", role: "group", "aria-label": props.label },
3153
+ options.map(function (option, index) {
3154
+ var active = option.value === props.value;
3155
+ return h(
3156
+ "button",
3157
+ {
3158
+ type: "button",
3159
+ key: option.value,
3160
+ className: "dfp-modeButton" + (active ? " dfp-modeButtonActive" : ""),
3161
+ "aria-pressed": active,
3162
+ disabled: props.disabled,
3163
+ onClick: function () {
3164
+ props.onChange(option.value);
3165
+ },
3166
+ },
3167
+ option.label
3168
+ );
3169
+ })
3170
+ );
3171
+ }
3172
+
3173
+ /**
3174
+ * The preset dropdown: a trigger styled like a select box — the active
3175
+ * preset's name with the card's own chevron pointing down inside it on the
3176
+ * right — and a portaled menu listing the presets. Picking an entry applies
3177
+ * it, makes it the auto-save target and closes the menu. The menu is
3178
+ * portaled to `body` because the settings sheet's scroll container would
3179
+ * otherwise clip it.
3180
+ * @param {object} props - copy, the preset list, the active name, the pick
3181
+ * handler and the disabled flag.
3182
+ * @returns {object} the select element.
3183
+ */
3184
+ function PresetSelect(props) {
3185
+ var t = props.t;
3186
+ var anchorRef = useRef(null);
3187
+ var panelRef = useRef(null);
3188
+ var [open, setOpen] = useState(false);
3189
+ var [position, setPosition] = useState(null);
3190
+
3191
+ useEffect(
3192
+ function () {
3193
+ if (!open) return undefined;
3194
+ var place = function () {
3195
+ var anchor = anchorRef.current;
3196
+ var view = globalThis;
3197
+ if (!anchor || typeof view.innerWidth !== "number") return;
3198
+ var rect = anchor.getBoundingClientRect();
3199
+ var margin = 10;
3200
+ var left = Math.max(margin, Math.min(rect.left, view.innerWidth - rect.width - margin));
3201
+ var below = rect.bottom + 4;
3202
+ var top = below;
3203
+ if (below + 240 > view.innerHeight - margin) {
3204
+ top = Math.max(margin, rect.top - 244);
3205
+ }
3206
+ setPosition({ left: left, top: top, minWidth: Math.max(rect.width, 160) });
3207
+ };
3208
+ place();
3209
+ var view = globalThis;
3210
+ view.addEventListener("resize", place);
3211
+ view.addEventListener("scroll", place, true);
3212
+ return function () {
3213
+ view.removeEventListener("resize", place);
3214
+ view.removeEventListener("scroll", place, true);
3215
+ };
3216
+ },
3217
+ [open]
3218
+ );
3219
+
3220
+ useEffect(
3221
+ function () {
3222
+ if (!open) return undefined;
3223
+ var onPointerDown = function (event) {
3224
+ var anchor = anchorRef.current;
3225
+ var panel = panelRef.current;
3226
+ if (anchor && anchor.contains(event.target)) return;
3227
+ if (panel && panel.contains(event.target)) return;
3228
+ setOpen(false);
3229
+ };
3230
+ var onKeyDown = function (event) {
3231
+ if (event.key === "Escape") setOpen(false);
3232
+ };
3233
+ document.addEventListener("pointerdown", onPointerDown, true);
3234
+ document.addEventListener("keydown", onKeyDown);
3235
+ return function () {
3236
+ document.removeEventListener("pointerdown", onPointerDown, true);
3237
+ document.removeEventListener("keydown", onKeyDown);
3238
+ };
3239
+ },
3240
+ [open]
3241
+ );
3242
+
3243
+ var menu = null;
3244
+ if (open) {
3245
+ var items = props.presets.map(function (entry, index) {
3246
+ var active = entry.name === props.value;
3247
+ return h(
3248
+ "button",
3249
+ {
3250
+ type: "button",
3251
+ className: "dfp-option",
3252
+ key: entry.name + ":" + index,
3253
+ role: "option",
3254
+ "aria-selected": active,
3255
+ onClick: function () {
3256
+ setOpen(false);
3257
+ props.onPick(entry);
3258
+ },
3259
+ },
3260
+ h("span", { className: "dfp-optionLabel" }, entry.name),
3261
+ active ? h("span", { className: "dfp-optionCheck" }, "✓") : null
3262
+ );
3263
+ });
3264
+ menu = createPortal(
3265
+ h(
3266
+ "div",
3267
+ {
3268
+ className: "dfp-drop",
3269
+ ref: panelRef,
3270
+ role: "listbox",
3271
+ "aria-label": t("preset.select"),
3272
+ style: position === null ? undefined : { left: position.left, top: position.top, minWidth: position.minWidth },
3273
+ },
3274
+ items
3275
+ ),
3276
+ document.body
3277
+ );
3278
+ }
3279
+
3280
+ return h(
3281
+ "span",
3282
+ { className: "dfp-selectWrap", ref: anchorRef },
3283
+ h(
3284
+ "button",
3285
+ {
3286
+ type: "button",
3287
+ className: "dfp-select",
3288
+ "aria-haspopup": "listbox",
3289
+ "aria-expanded": open,
3290
+ "aria-label": t("preset.select"),
3291
+ disabled: props.disabled === true,
3292
+ onClick: function () {
3293
+ setOpen(!open);
3294
+ },
3295
+ },
3296
+ h("span", { className: "dfp-selectName" }, props.value),
3297
+ h(
3298
+ "svg",
3299
+ {
3300
+ className: "dfp-selectChevron" + (open ? " dfp-selectChevronOpen" : ""),
3301
+ width: 14,
3302
+ height: 14,
3303
+ viewBox: "0 0 14 14",
3304
+ fill: "none",
3305
+ "aria-hidden": "true",
3306
+ },
3307
+ h("path", { d: CHEVRON_PATH, fill: "currentColor" })
3308
+ )
3309
+ ),
3310
+ menu
3311
+ );
3312
+ }
3313
+
1620
3314
  /* ------------------------------------------------------------------ *
1621
3315
  * font enumeration
1622
3316
  * ------------------------------------------------------------------ */
@@ -1688,7 +3382,7 @@ function measureCJK(name) {
1688
3382
  var context = canvas.getContext("2d");
1689
3383
  if (context === null) return null;
1690
3384
  var probe = "中文字體测试";
1691
- context.font = '72px ' + quoteFamily(name) + ", monospace";
3385
+ context.font = "72px " + quoteFamily(name) + ", monospace";
1692
3386
  var withFamily = context.measureText(probe).width;
1693
3387
  context.font = "72px monospace";
1694
3388
  var without = context.measureText(probe).width;
@@ -2216,12 +3910,25 @@ function StackPicker(props) {
2216
3910
  }
2217
3911
 
2218
3912
  /* ------------------------------------------------------------------ *
2219
- * the card
3913
+ * card helpers
2220
3914
  * ------------------------------------------------------------------ */
2221
3915
 
2222
3916
  /** localStorage key of the card's view mode. */
2223
3917
  var MODE_KEY = "dsh-fonttune.mode.v1";
2224
3918
 
3919
+ /**
3920
+ * The five built-in preset slots, shown (and exported) until any preset has
3921
+ * been saved. They are data, not copy: the names stay as-is in every locale
3922
+ * and can be changed with the rename control.
3923
+ */
3924
+ var DEFAULT_PRESET_NAMES = [
3925
+ "默认配置1",
3926
+ "默认配置2",
3927
+ "默认配置3",
3928
+ "默认配置4",
3929
+ "默认配置5",
3930
+ ];
3931
+
2225
3932
  /**
2226
3933
  * Read the view mode the card opens in. Simple is the default; an unreadable
2227
3934
  * or missing storage (tests, private modes) must not break the card.
@@ -2311,10 +4018,73 @@ function SimpleFamilyField(props) {
2311
4018
  );
2312
4019
  }
2313
4020
 
4021
+ /**
4022
+ * Summarize one axis set into the short texts the collapsed sections show.
4023
+ * @param {object} axis - the effective (follow-applied) value set.
4024
+ * @param {boolean} uiFollows - whether the interface follows the conversation.
4025
+ * @param {(key: string, params?: object) => string} t - translate seat.
4026
+ * @returns {{ui: string, dialog: string, code: string}}
4027
+ */
4028
+ function sectionSummaries(axis, uiFollows, t) {
4029
+ var sign = function (value) {
4030
+ return value > 0 ? "+" + value : String(value);
4031
+ };
4032
+ // The conversation owns every axis, so its summary is always its values.
4033
+ var dialogParts = [];
4034
+ if (axis[STACK_DIALOG_FIELD] !== "") dialogParts.push(firstFamily(axis[STACK_DIALOG_FIELD]));
4035
+ if (axis[SIZE_DIALOG_FIELD] !== 0) dialogParts.push(sign(axis[SIZE_DIALOG_FIELD]) + "px");
4036
+ if (axis[LINE_HEIGHT_DIALOG_FIELD] !== LINE_HEIGHT_MIN) {
4037
+ dialogParts.push(axis[LINE_HEIGHT_DIALOG_FIELD] + "%");
4038
+ }
4039
+ if (axis[WEIGHT_DIALOG_FIELD] !== WEIGHT_UNSET) {
4040
+ dialogParts.push(String(axis[WEIGHT_DIALOG_FIELD]));
4041
+ }
4042
+ // The interface either follows (nothing of its own to report) or shows the
4043
+ // two axes it owns.
4044
+ var uiParts = [];
4045
+ if (!uiFollows) {
4046
+ var family = axis[SANS_FIELD] === "" ? null : firstFamily(axis[SANS_FIELD]);
4047
+ if (family !== null) uiParts.push(family);
4048
+ if (axis[WEIGHT_FIELD] !== WEIGHT_UNSET) uiParts.push(String(axis[WEIGHT_FIELD]));
4049
+ }
4050
+ var codeParts = [];
4051
+ if (axis[MONO_FIELD] !== "") codeParts.push(firstFamily(axis[MONO_FIELD]));
4052
+ if (axis[CODE_SIZE_FIELD] !== 0) codeParts.push(sign(axis[CODE_SIZE_FIELD]) + "px");
4053
+ if (axis[CODE_LINE_HEIGHT_FIELD] !== 0) codeParts.push(sign(axis[CODE_LINE_HEIGHT_FIELD]) + "px");
4054
+ if (axis[CODE_WEIGHT_FIELD] !== WEIGHT_UNSET) codeParts.push(String(axis[CODE_WEIGHT_FIELD]));
4055
+ if (axis[LIGATURES_FIELD] !== LIGATURES_DEFAULT) {
4056
+ codeParts.push(t(axis[LIGATURES_FIELD] === LIGATURES_OFF ? "lig.off" : "lig.on"));
4057
+ }
4058
+ return {
4059
+ ui: uiFollows ? t("ui.follow") : uiParts.length > 0 ? uiParts.join(" · ") : t("ui.own"),
4060
+ dialog: dialogParts.length > 0 ? dialogParts.join(" · ") : t("dialog.default"),
4061
+ code: codeParts.join(" · "),
4062
+ };
4063
+ }
4064
+
4065
+ /** The first family of one stack value, or null. */
4066
+ function firstFamily(stack) {
4067
+ var families = parseStack(stack);
4068
+ return families.length > 0 ? families[0] : null;
4069
+ }
4070
+
4071
+ /* ------------------------------------------------------------------ *
4072
+ * the card
4073
+ * ------------------------------------------------------------------ */
4074
+
2314
4075
  /**
2315
4076
  * Render the plugin's card: the settings section dispatches this component
2316
4077
  * under the namespace key, and the host has to serve that namespace for it to
2317
4078
  * appear at all.
4079
+ *
4080
+ * Structure: a top bar (edit mode), the preset bar (dropdown select +
4081
+ * rename/import/export — with a preset selected every edit auto-saves into
4082
+ * it), and four collapsed accordion sections — conversation / interface /
4083
+ * code / global fine-tuning. Only one section is open at a time, so the card
4084
+ * never sprawls; the interface section leads with the follow switch, and an
4085
+ * expanded section ends with its own inline preview of just that part. Light
4086
+ * and dark themes share one value set (the per-theme editor ships in a later
4087
+ * release).
2318
4088
  * @param {object} props - slot props (the injected face plus the locale seat).
2319
4089
  * @returns {object} the card element.
2320
4090
  */
@@ -2323,10 +4093,50 @@ function FontCard(props) {
2323
4093
  var scope = props.scope;
2324
4094
  var [open, setOpen] = useState(false);
2325
4095
  var [view, setView] = useState(readViewMode);
4096
+ var [expanded, setExpanded] = useState(null);
4097
+ var [presetName, setPresetName] = useState("");
4098
+ // The transient preset message: `{text, id}` — the id makes two identical
4099
+ // messages (two exports in a row) two distinct events, so the auto-hide
4100
+ // effect runs again instead of ignoring the second one.
4101
+ var [presetStatus, setPresetStatus] = useState({ text: "", id: 0 });
4102
+ var [statusShown, setStatusShown] = useState(false);
4103
+ var [renaming, setRenaming] = useState(false);
4104
+ var [importOpen, setImportOpen] = useState(false);
4105
+ /** True while the card itself writes a whole preset's values: the edits
4106
+ * must not mirror back into the very preset being applied. */
4107
+ var applyingRef = useRef(false);
4108
+ /** The pending auto-hide / fade-out timers of the preset message. */
4109
+ var statusIdRef = useRef(0);
4110
+ var statusTimerRef = useRef(null);
2326
4111
  var snapshot = useScopeSnapshot(scope);
2327
4112
  var config = normalizeConfig(snapshot.value);
4113
+ // Light and dark share one value set in this release: the stored flag (if
4114
+ // any) is ignored and the dark fields stay dormant in the document.
4115
+ config[PER_THEME_FIELD] = false;
4116
+ var sets = resolveAxes(config);
2328
4117
  var user = snapshot.user !== null && typeof snapshot.user === "object" ? snapshot.user : {};
2329
4118
  var writable = snapshot.writable === true;
4119
+ var presets = config[PRESETS_FIELD];
4120
+
4121
+ // The preset list the dropdown shows: the stored entries once anything has
4122
+ // been saved, otherwise the five built-in slots. `activePreset` falls back
4123
+ // to the first entry when unset or stale.
4124
+ var presetList =
4125
+ presets.length > 0
4126
+ ? presets
4127
+ : DEFAULT_PRESET_NAMES.map(function (name) {
4128
+ return { name: name, values: {}, savedAt: 0 };
4129
+ });
4130
+ var activeName = config[ACTIVE_PRESET_FIELD];
4131
+ var activeKnown = false;
4132
+ for (var presetIndex = 0; presetIndex < presetList.length; presetIndex += 1) {
4133
+ if (presetList[presetIndex].name === activeName) activeKnown = true;
4134
+ }
4135
+ if (!activeKnown) activeName = presetList[0].name;
4136
+
4137
+ // Which value set the controls below edit and display: light and dark share
4138
+ // one set, so it is always the light (flat) fields.
4139
+ var editing = sets.light;
2330
4140
 
2331
4141
  var changeView = function (next) {
2332
4142
  setView(next);
@@ -2336,28 +4146,47 @@ function FontCard(props) {
2336
4146
  // Unavailable storage only costs the persistence of the preference.
2337
4147
  }
2338
4148
  };
2339
- // The simple mode edits only the two front slots; the stack value itself is
2340
- // the single source of truth, and switching views writes nothing at all.
2341
- var pickSansWest = function (family) {
2342
- setField(SANS_FIELD, formatStack(setWestEntry(parseStack(config[SANS_FIELD]), family, classifyFamily)));
2343
- };
2344
- var pickSansEast = function (family) {
2345
- setField(SANS_FIELD, formatStack(setEastEntry(parseStack(config[SANS_FIELD]), family, classifyFamily)));
2346
- };
2347
- var dropSansEntry = function (family) {
2348
- setField(SANS_FIELD, formatStack(removeStackEntry(parseStack(config[SANS_FIELD]), family)));
2349
- };
2350
- var pickMonoWest = function (family) {
2351
- setField(MONO_FIELD, formatStack(setWestEntry(parseStack(config[MONO_FIELD]), family, classifyFamily)));
2352
- };
2353
- var pickMonoEast = function (family) {
2354
- setField(MONO_FIELD, formatStack(setEastEntry(parseStack(config[MONO_FIELD]), family, classifyFamily)));
2355
- };
2356
- var dropMonoEntry = function (family) {
2357
- setField(MONO_FIELD, formatStack(removeStackEntry(parseStack(config[MONO_FIELD]), family)));
4149
+
4150
+ /**
4151
+ * Mirror one axis value into the active preset's snapshot — the auto-save
4152
+ * contract: with a preset selected, every change the user makes IS that
4153
+ * preset from now on. Skipped while the card itself applies a whole preset.
4154
+ * @param {string} field - an axis field name.
4155
+ * @param {string|number|boolean} value - the value to store.
4156
+ */
4157
+ var mirrorPreset = function (field, value) {
4158
+ if (!writable || applyingRef.current === true) return;
4159
+ var index = -1;
4160
+ for (var i = 0; i < presetList.length; i += 1) {
4161
+ if (presetList[i].name === activeName) index = i;
4162
+ }
4163
+ if (index < 0) return;
4164
+ var entry = presetList[index];
4165
+ var values = {};
4166
+ for (var key in entry.values) {
4167
+ if (Object.prototype.hasOwnProperty.call(entry.values, key)) values[key] = entry.values[key];
4168
+ }
4169
+ values[field] = value;
4170
+ var next = presetList.slice();
4171
+ next[index] = { name: entry.name, values: values, savedAt: Date.now() };
4172
+ writePresets(next);
2358
4173
  };
2359
4174
 
4175
+ /**
4176
+ * Write one axis field to the durable flat fields, and mirror the same
4177
+ * change into the active preset (the auto-save contract).
4178
+ * @param {string} field - an axis field name.
4179
+ * @param {string|number|boolean} value - the new value.
4180
+ */
2360
4181
  var setField = function (field, value) {
4182
+ submit(field, value);
4183
+ mirrorPreset(field, value);
4184
+ };
4185
+ var resetField = function (field) {
4186
+ clear(field);
4187
+ mirrorPreset(field, DEFAULTS[field]);
4188
+ };
4189
+ var submit = function (field, value) {
2361
4190
  var result = scope.set(field, value);
2362
4191
  if (result && typeof result.catch === "function") {
2363
4192
  result.catch(function () {
@@ -2365,7 +4194,7 @@ function FontCard(props) {
2365
4194
  });
2366
4195
  }
2367
4196
  };
2368
- var resetField = function (field) {
4197
+ var clear = function (field) {
2369
4198
  var result = scope.unset(field);
2370
4199
  if (result && typeof result.catch === "function") {
2371
4200
  result.catch(function () {});
@@ -2375,42 +4204,114 @@ function FontCard(props) {
2375
4204
  return Object.prototype.hasOwnProperty.call(user, field);
2376
4205
  };
2377
4206
 
2378
- var offset = config[SIZE_FIELD];
2379
- var codeOffset = config[CODE_SIZE_FIELD];
2380
- var weight = config[WEIGHT_FIELD];
2381
- var codeWeight = config[CODE_WEIGHT_FIELD];
2382
- var offsetText = offset > 0 ? "+" + offset : String(offset);
2383
- var codeOffsetText = codeOffset > 0 ? "+" + codeOffset : String(codeOffset);
2384
-
2385
- /**
2386
- * One size slider, rendered directly under the family it resizes.
2387
- * @param {object} props - field, label keys, current value and text.
2388
- * @returns {object} the field shell wrapping the slider.
2389
- */
2390
- var sizeField = function (props) {
4207
+ // ---- interface axis handlers (legacy semantics kept) ----
4208
+ var pickWest = function (field, family) {
4209
+ setField(field, formatStack(setWestEntry(parseStack(editing[field]), family, classifyFamily)));
4210
+ };
4211
+ var pickEast = function (field, family) {
4212
+ setField(field, formatStack(setEastEntry(parseStack(edit[field]), family, classifyFamily)));
4213
+ };
4214
+ var dropEntry = function (field, family) {
4215
+ setField(field, formatStack(removeStackEntry(parseStack(edit[field]), family)));
4216
+ };
4217
+
4218
+ // ---- size slider ----
4219
+ var sizeField = function (props) {
4220
+ return h(
4221
+ FieldShell,
4222
+ {
4223
+ t: t,
4224
+ label: t(props.labelKey),
4225
+ hint: t(props.hintKey, { offset: props.text }),
4226
+ overridden: overridden(props.field) && props.value !== 0,
4227
+ disabled: !writable,
4228
+ onReset: function () {
4229
+ resetField(props.field);
4230
+ },
4231
+ },
4232
+ h(NumberSlider, {
4233
+ min: SIZE_MIN,
4234
+ max: SIZE_MAX,
4235
+ value: props.value,
4236
+ disabled: !writable,
4237
+ label: t(props.labelKey),
4238
+ readout: props.text + " " + t("size.unit"),
4239
+ minLabel: SIZE_MIN + " " + t("size.unit"),
4240
+ maxLabel: "+" + SIZE_MAX + " " + t("size.unit"),
4241
+ pendingText: function (value) {
4242
+ return (value > 0 ? "+" + value : String(value)) + " " + t("size.unit");
4243
+ },
4244
+ onChange: function (value) {
4245
+ setField(props.field, value);
4246
+ },
4247
+ })
4248
+ );
4249
+ };
4250
+
4251
+ // ---- weight slider ----
4252
+ var weightField = function (props) {
4253
+ return h(
4254
+ FieldShell,
4255
+ {
4256
+ t: t,
4257
+ label: t(props.labelKey),
4258
+ hint: t(props.hintKey),
4259
+ overridden: overridden(props.field) && props.value !== WEIGHT_UNSET,
4260
+ disabled: !writable,
4261
+ onReset: function () {
4262
+ resetField(props.field);
4263
+ },
4264
+ },
4265
+ h(NumberSlider, {
4266
+ min: WEIGHT_MIN,
4267
+ max: WEIGHT_MAX,
4268
+ value: props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value,
4269
+ disabled: !writable,
4270
+ label: t(props.labelKey),
4271
+ // "Unset" IS 400 here: that is the weight DSH uses for body text, the
4272
+ // slider already sits at 400, and the hint says 400 keeps DSH's own.
4273
+ // Printing "unset" made the readout disagree with the control.
4274
+ readout: String(props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value),
4275
+ minLabel: String(WEIGHT_MIN),
4276
+ maxLabel: String(WEIGHT_MAX),
4277
+ onChange: function (value) {
4278
+ // 400 is DSH's own body weight, so choosing it means "leave the axis
4279
+ // alone" rather than "write 400 on every element".
4280
+ if (value === NEUTRAL_WEIGHT) resetField(props.field);
4281
+ else setField(props.field, value);
4282
+ },
4283
+ })
4284
+ );
4285
+ };
4286
+
4287
+ // ---- interface/dialog line-height slider (percent ratio) ----
4288
+ var lineField = function (props) {
2391
4289
  return h(
2392
4290
  FieldShell,
2393
4291
  {
2394
4292
  t: t,
2395
4293
  label: t(props.labelKey),
2396
- hint: t(props.hintKey, { offset: props.text }),
2397
- overridden: overridden(props.field) && props.value !== 0,
4294
+ hint: t(props.hintKey, { ratio: (props.value / LINE_HEIGHT_MIN).toFixed(2) + "×" }),
4295
+ overridden: overridden(props.field) && props.value !== LINE_HEIGHT_MIN,
2398
4296
  disabled: !writable,
2399
4297
  onReset: function () {
2400
4298
  resetField(props.field);
2401
4299
  },
2402
4300
  },
2403
4301
  h(NumberSlider, {
2404
- min: SIZE_MIN,
2405
- max: SIZE_MAX,
4302
+ min: LINE_HEIGHT_MIN,
4303
+ max: LINE_HEIGHT_MAX,
4304
+ step: 5,
2406
4305
  value: props.value,
2407
4306
  disabled: !writable,
2408
4307
  label: t(props.labelKey),
2409
- readout: props.text + " " + t("size.unit"),
2410
- minLabel: SIZE_MIN + " " + t("size.unit"),
2411
- maxLabel: "+" + SIZE_MAX + " " + t("size.unit"),
2412
- pendingText: function (value) {
2413
- return (value > 0 ? "+" + value : String(value)) + " " + t("size.unit");
4308
+ readout: props.value + " " + t("line.unit"),
4309
+ minLabel: LINE_HEIGHT_MIN + " " + t("line.unit"),
4310
+ maxLabel: LINE_HEIGHT_MAX + " " + t("line.unit"),
4311
+ // The unit rides the pending readout too: without it the "%" vanished
4312
+ // while dragging and popped back on release.
4313
+ pendingText: function (pending) {
4314
+ return pending + " " + t("line.unit");
2414
4315
  },
2415
4316
  onChange: function (value) {
2416
4317
  setField(props.field, value);
@@ -2419,43 +4320,356 @@ function FontCard(props) {
2419
4320
  );
2420
4321
  };
2421
4322
 
4323
+ // ---- code line-height slider (additive px) ----
4324
+ var codeLineField = function () {
4325
+ var value = editing[CODE_LINE_HEIGHT_FIELD];
4326
+ return h(
4327
+ FieldShell,
4328
+ {
4329
+ t: t,
4330
+ label: t("line.codeLabel"),
4331
+ hint: t("line.codeHint", { offset: value > 0 ? "+" + value : String(value) }),
4332
+ overridden: overridden(CODE_LINE_HEIGHT_FIELD) && value !== 0,
4333
+ disabled: !writable,
4334
+ onReset: function () {
4335
+ resetField(CODE_LINE_HEIGHT_FIELD);
4336
+ },
4337
+ },
4338
+ h(NumberSlider, {
4339
+ min: CODE_LINE_HEIGHT_MIN,
4340
+ max: CODE_LINE_HEIGHT_MAX,
4341
+ value: value,
4342
+ disabled: !writable,
4343
+ label: t("line.codeLabel"),
4344
+ readout: (value > 0 ? "+" + value : String(value)) + "px",
4345
+ minLabel: CODE_LINE_HEIGHT_MIN + "px",
4346
+ maxLabel: "+" + CODE_LINE_HEIGHT_MAX + "px",
4347
+ pendingText: function (pending) {
4348
+ return (pending > 0 ? "+" + pending : String(pending)) + "px";
4349
+ },
4350
+ onChange: function (next) {
4351
+ setField(CODE_LINE_HEIGHT_FIELD, next);
4352
+ },
4353
+ })
4354
+ );
4355
+ };
4356
+
4357
+ // ---- ligature segmented control ----
4358
+ var ligatureField = function () {
4359
+ var value = editing[LIGATURES_FIELD];
4360
+ return h(
4361
+ FieldShell,
4362
+ {
4363
+ t: t,
4364
+ label: t("lig.label"),
4365
+ hint: t("lig.hint"),
4366
+ overridden: overridden(LIGATURES_FIELD) && value !== LIGATURES_DEFAULT,
4367
+ disabled: !writable,
4368
+ onReset: function () {
4369
+ resetField(LIGATURES_FIELD);
4370
+ },
4371
+ },
4372
+ h(Segmented, {
4373
+ label: t("lig.label"),
4374
+ value: value,
4375
+ options: [
4376
+ { value: LIGATURES_DEFAULT, label: t("lig.default") },
4377
+ { value: LIGATURES_ON, label: t("lig.on") },
4378
+ { value: LIGATURES_OFF, label: t("lig.off") },
4379
+ ],
4380
+ onChange: function (next) {
4381
+ setField(LIGATURES_FIELD, next);
4382
+ },
4383
+ })
4384
+ );
4385
+ };
4386
+
4387
+ // ---- advanced feature-settings text field ----
4388
+ var featuresField = function () {
4389
+ var value = editing[FEATURES_FIELD];
4390
+ return h(
4391
+ FieldShell,
4392
+ {
4393
+ t: t,
4394
+ label: t("feat.label"),
4395
+ hint: t("feat.hint"),
4396
+ overridden: overridden(FEATURES_FIELD) && value !== "",
4397
+ disabled: !writable,
4398
+ onReset: function () {
4399
+ resetField(FEATURES_FIELD);
4400
+ },
4401
+ },
4402
+ h("input", {
4403
+ type: "text",
4404
+ className: "dfp-search",
4405
+ spellCheck: false,
4406
+ value: value,
4407
+ disabled: !writable,
4408
+ "aria-label": t("feat.label"),
4409
+ placeholder: t("feat.placeholder"),
4410
+ onChange: function (event) {
4411
+ setField(FEATURES_FIELD, event.target.value);
4412
+ },
4413
+ })
4414
+ );
4415
+ };
4416
+
4417
+ // ---- synthesis switches ----
4418
+ var synthesisField = function (props) {
4419
+ var value = editing[props.field] === true;
4420
+ return h(
4421
+ "div",
4422
+ { className: "dfp-field" },
4423
+ h(
4424
+ "div",
4425
+ { className: "dfp-fieldHead", role: "group", "aria-label": t(props.labelKey) },
4426
+ h("span", { className: "dfp-fieldLabel" }, t(props.labelKey)),
4427
+ h(
4428
+ "div",
4429
+ { className: "dfp-inline" },
4430
+ h(Segmented, {
4431
+ label: t(props.labelKey),
4432
+ value: value ? "on" : "off",
4433
+ options: [
4434
+ { value: "on", label: t("common.on") },
4435
+ { value: "off", label: t("common.off") },
4436
+ ],
4437
+ disabled: !writable,
4438
+ onChange: function (next) {
4439
+ if (next === "on") setField(props.field, true);
4440
+ else resetField(props.field);
4441
+ },
4442
+ })
4443
+ )
4444
+ ),
4445
+ h("p", { className: "dfp-hint" }, t(props.hintKey))
4446
+ );
4447
+ };
4448
+
4449
+ // ---- presets ----
2422
4450
  /**
2423
- * One weight slider, under the family it reweights.
2424
- * @param {object} props - field, label keys and current value.
2425
- * @returns {object} the field shell wrapping the slider.
4451
+ * Show a transient preset message: it fades in on the same row as the
4452
+ * auto-save hint and fades out by itself after a moment, so it can never
4453
+ * pile up on screen and never pushes the sections below it down (the row it
4454
+ * lives in is always mounted, only its opacity changes).
4455
+ * @param {string} text - the message.
2426
4456
  */
2427
- var weightField = function (props) {
4457
+ var showStatus = function (text) {
4458
+ statusIdRef.current += 1;
4459
+ setPresetStatus({ text: text, id: statusIdRef.current });
4460
+ };
4461
+ useEffect(
4462
+ function () {
4463
+ if (presetStatus.text === "") return undefined;
4464
+ setStatusShown(true);
4465
+ var hide = globalThis.setTimeout(function () {
4466
+ setStatusShown(false); // the CSS transition fades it out
4467
+ statusTimerRef.current = globalThis.setTimeout(function () {
4468
+ statusTimerRef.current = null;
4469
+ setPresetStatus({ text: "", id: statusIdRef.current });
4470
+ }, 240);
4471
+ }, 2600);
4472
+ return function () {
4473
+ globalThis.clearTimeout(hide);
4474
+ if (statusTimerRef.current !== null) {
4475
+ globalThis.clearTimeout(statusTimerRef.current);
4476
+ statusTimerRef.current = null;
4477
+ }
4478
+ };
4479
+ },
4480
+ [presetStatus.id]
4481
+ );
4482
+
4483
+ var writePresets = function (next) {
4484
+ if (next.length > shared.MAX_PRESETS) next = next.slice(0, shared.MAX_PRESETS);
4485
+ submit(PRESETS_FIELD, JSON.stringify(next));
4486
+ };
4487
+
4488
+ /**
4489
+ * Switch presets: the entry becomes the auto-save target and its stored
4490
+ * values are applied over the set currently being edited — every axis the
4491
+ * entry does not store falls back to its neutral default, so a slot switch
4492
+ * is exact.
4493
+ * @param {{name: string, values: object}} entry - the picked preset.
4494
+ */
4495
+ var switchPreset = function (entry) {
4496
+ if (!writable || entry.name === activeName) return;
4497
+ applyingRef.current = true;
4498
+ try {
4499
+ for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
4500
+ var field = VALUE_FIELDS[index];
4501
+ var value = Object.prototype.hasOwnProperty.call(entry.values, field)
4502
+ ? entry.values[field]
4503
+ : DEFAULTS[field];
4504
+ setField(field, value);
4505
+ }
4506
+ submit(ACTIVE_PRESET_FIELD, entry.name);
4507
+ } finally {
4508
+ applyingRef.current = false;
4509
+ }
4510
+ showStatus(t("preset.applied", { name: entry.name }));
4511
+ };
4512
+
4513
+ /** Rename the active preset in place; its stored values ride along. */
4514
+ var renamePreset = function () {
4515
+ setRenaming(false);
4516
+ var name = presetName.trim().slice(0, shared.MAX_PRESET_NAME);
4517
+ setPresetName("");
4518
+ if (name === "" || !writable || name === activeName) return;
4519
+ var taken = false;
4520
+ var activeIndex = -1;
4521
+ for (var index = 0; index < presetList.length; index += 1) {
4522
+ if (presetList[index].name.toLowerCase() === name.toLowerCase()) taken = true;
4523
+ if (presetList[index].name === activeName) activeIndex = index;
4524
+ }
4525
+ if (taken || activeIndex < 0) {
4526
+ showStatus(t("preset.nameUsed"));
4527
+ return;
4528
+ }
4529
+ var next = presetList.slice();
4530
+ next[activeIndex] = {
4531
+ name: name,
4532
+ values: presetList[activeIndex].values,
4533
+ savedAt: presetList[activeIndex].savedAt,
4534
+ };
4535
+ writePresets(next);
4536
+ submit(ACTIVE_PRESET_FIELD, name);
4537
+ showStatus(t("preset.renamed", { name: name }));
4538
+ };
4539
+
4540
+ var exportPresets = function () {
4541
+ var text = JSON.stringify(presetList);
4542
+ var navigator = globalThis.navigator;
4543
+ if (navigator !== undefined && navigator.clipboard !== undefined) {
4544
+ navigator.clipboard
4545
+ .writeText(text)
4546
+ .then(function () {
4547
+ showStatus(t("preset.exported"));
4548
+ })
4549
+ .catch(function () {
4550
+ showStatus(text.slice(0, 200) + "…");
4551
+ });
4552
+ } else {
4553
+ showStatus(text.slice(0, 120) + "…");
4554
+ }
4555
+ };
4556
+ var importPresets = function (text) {
4557
+ var parsed = normalizePresets(text);
4558
+ if (parsed.length === 0) {
4559
+ showStatus(t("preset.importBad"));
4560
+ return;
4561
+ }
4562
+ // Imported presets merge into the list by name, imported values win.
4563
+ var merged = presetList.slice();
4564
+ for (var entryIndex = 0; entryIndex < parsed.length; entryIndex += 1) {
4565
+ var entry = parsed[entryIndex];
4566
+ var replaced = false;
4567
+ for (var existingIndex = 0; existingIndex < merged.length; existingIndex += 1) {
4568
+ if (merged[existingIndex].name.toLowerCase() === entry.name.toLowerCase()) {
4569
+ merged[existingIndex] = entry;
4570
+ replaced = true;
4571
+ break;
4572
+ }
4573
+ }
4574
+ if (!replaced) merged.push(entry);
4575
+ }
4576
+ writePresets(merged);
4577
+ setImportOpen(false);
4578
+ showStatus(t("preset.imported", { count: parsed.length }));
4579
+ };
4580
+
4581
+ // the family field, in either mode
4582
+ var familyField = function (props) {
4583
+ var stack = editing[props.field];
4584
+ if (view === "simple") {
4585
+ return h(SimpleFamilyField, {
4586
+ t: t,
4587
+ label: t(props.labelKey),
4588
+ westLabel: props.westLabel,
4589
+ eastLabel: props.eastLabel,
4590
+ slots: deriveSlots(stack),
4591
+ disabled: !writable,
4592
+ onPickWest: function (family) {
4593
+ pickWest(props.field, family);
4594
+ },
4595
+ onPickEast: function (family) {
4596
+ pickEast(props.field, family);
4597
+ },
4598
+ onRemoveWest: function () {
4599
+ var slots = deriveSlots(stack);
4600
+ if (slots.west !== null) dropEntry(props.field, slots.west);
4601
+ },
4602
+ onRemoveEast: function () {
4603
+ var slots = deriveSlots(stack);
4604
+ if (slots.east !== null) dropEntry(props.field, slots.east);
4605
+ },
4606
+ });
4607
+ }
2428
4608
  return h(
2429
4609
  FieldShell,
2430
4610
  {
2431
4611
  t: t,
2432
4612
  label: t(props.labelKey),
2433
4613
  hint: t(props.hintKey),
2434
- overridden: overridden(props.field) && props.value !== WEIGHT_UNSET,
4614
+ overridden: overridden(props.field),
2435
4615
  disabled: !writable,
2436
4616
  onReset: function () {
2437
4617
  resetField(props.field);
2438
4618
  },
2439
4619
  },
2440
- h(NumberSlider, {
2441
- min: WEIGHT_MIN,
2442
- max: WEIGHT_MAX,
2443
- value: props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value,
2444
- disabled: !writable,
4620
+ h(StackPicker, {
4621
+ t: t,
2445
4622
  label: t(props.labelKey),
2446
- readout: props.value === WEIGHT_UNSET ? t("weight.unset") : String(props.value),
2447
- minLabel: String(WEIGHT_MIN),
2448
- maxLabel: String(WEIGHT_MAX),
4623
+ value: stack,
2449
4624
  onChange: function (value) {
2450
- // 400 is DSH's own body weight, so choosing it means "leave the axis
2451
- // alone" rather than "write 400 on every element".
2452
- if (value === NEUTRAL_WEIGHT) resetField(props.field);
2453
- else setField(props.field, value);
4625
+ setField(props.field, value);
2454
4626
  },
2455
4627
  })
2456
4628
  );
2457
4629
  };
2458
4630
 
4631
+ // The interface follows the conversation (default): of the two axes the
4632
+ // interface owns, the conversation's value wins and its own stays the
4633
+ // fallback. The switch lives in the interface section.
4634
+ var uiFollows = config[UI_FOLLOWS_FIELD] !== false;
4635
+
4636
+ var summaries = sectionSummaries(editing, uiFollows, t);
4637
+
4638
+ // The interface owns one axis now (its family); its preview shows that and
4639
+ // nothing else — the weight, size and line height all come from the
4640
+ // conversation section.
4641
+ var previewUiStyle = {};
4642
+ if (editing[SANS_FIELD] !== "") {
4643
+ previewUiStyle.fontFamily = formatStack(parseStack(editing[SANS_FIELD]));
4644
+ }
4645
+ var previewDialogStyle = {};
4646
+ if (editing[STACK_DIALOG_FIELD] !== "") {
4647
+ previewDialogStyle.fontFamily = formatStack(parseStack(editing[STACK_DIALOG_FIELD]));
4648
+ } else {
4649
+ // Independent and unset: the conversation keeps DSH's own family, so the
4650
+ // preview shows the theme's default rather than the interface stack the
4651
+ // page rule would otherwise paint into this box.
4652
+ var dialogFallback = FALLBACK_TOKENS["--dsw-font-family"];
4653
+ if (typeof dialogFallback === "string" && dialogFallback !== "") {
4654
+ previewDialogStyle.fontFamily = dialogFallback;
4655
+ }
4656
+ }
4657
+ previewDialogStyle.fontSize = 13 + editing[SIZE_DIALOG_FIELD] + "px";
4658
+ if (editing[LINE_HEIGHT_DIALOG_FIELD] !== LINE_HEIGHT_MIN) {
4659
+ previewDialogStyle.lineHeight = String(editing[LINE_HEIGHT_DIALOG_FIELD] / 100);
4660
+ }
4661
+ if (editing[WEIGHT_DIALOG_FIELD] !== WEIGHT_UNSET) {
4662
+ previewDialogStyle.fontWeight = editing[WEIGHT_DIALOG_FIELD];
4663
+ }
4664
+ var previewCodeStyle = {};
4665
+ if (editing[MONO_FIELD] !== "") {
4666
+ previewCodeStyle.fontFamily = formatStack(parseStack(editing[MONO_FIELD]));
4667
+ }
4668
+ previewCodeStyle.fontSize = 13 + editing[CODE_SIZE_FIELD] + "px";
4669
+ if (editing[CODE_LINE_HEIGHT_FIELD] !== 0) {
4670
+ previewCodeStyle.lineHeight = "calc(20px + " + editing[CODE_LINE_HEIGHT_FIELD] + "px)";
4671
+ }
4672
+
2459
4673
  return h(
2460
4674
  "li",
2461
4675
  { className: "dfp-card" + (open ? " dfp-cardOpen" : "") },
@@ -2495,193 +4709,353 @@ function FontCard(props) {
2495
4709
  { className: "dfp-body" },
2496
4710
  writable ? null : h("p", { className: "dfp-readOnly", role: "status" }, t("card.readOnly")),
2497
4711
 
4712
+ // edit mode
4713
+ h(
4714
+ "div",
4715
+ { className: "dfp-field" },
4716
+ h(
4717
+ "div",
4718
+ { className: "dfp-fieldHead", role: "group", "aria-label": t("mode.label") },
4719
+ h("span", { className: "dfp-fieldLabel" }, t("mode.label")),
4720
+ h(
4721
+ "div",
4722
+ { className: "dfp-inline" },
4723
+ h(Segmented, {
4724
+ label: t("mode.label"),
4725
+ value: view,
4726
+ options: [
4727
+ { value: "simple", label: t("mode.simple") },
4728
+ { value: "advanced", label: t("mode.advanced") },
4729
+ ],
4730
+ onChange: changeView,
4731
+ })
4732
+ )
4733
+ )
4734
+ ),
4735
+
4736
+ // the preset bar: a dropdown select plus rename/import/export.
4737
+ // With a preset selected every edit auto-saves into it, so there is
4738
+ // no separate save step — the bar sits right under the edit mode.
2498
4739
  h(
2499
4740
  "div",
2500
- { className: "dfp-modeRow", role: "group", "aria-label": t("mode.label") },
2501
- h("span", { className: "dfp-modeLabel" }, t("mode.label")),
4741
+ { className: "dfp-field" },
4742
+ h(
4743
+ "div",
4744
+ { className: "dfp-fieldHead" },
4745
+ h("span", { className: "dfp-fieldLabel" }, t("preset.label"))
4746
+ ),
2502
4747
  h(
2503
4748
  "div",
2504
- { className: "dfp-modeSeg" },
4749
+ { className: "dfp-presetBar" },
4750
+ renaming
4751
+ ? h("input", {
4752
+ type: "text",
4753
+ className: "dfp-textInput",
4754
+ autoFocus: true,
4755
+ spellCheck: false,
4756
+ value: presetName,
4757
+ disabled: !writable,
4758
+ "aria-label": t("preset.rename"),
4759
+ placeholder: t("preset.renamePlaceholder"),
4760
+ onChange: function (event) {
4761
+ setPresetName(event.target.value);
4762
+ },
4763
+ onKeyDown: function (event) {
4764
+ if (event.key === "Enter") renamePreset();
4765
+ if (event.key === "Escape") {
4766
+ setRenaming(false);
4767
+ setPresetName("");
4768
+ }
4769
+ },
4770
+ onBlur: function () {
4771
+ if (presetName.trim() !== "") renamePreset();
4772
+ else setRenaming(false);
4773
+ },
4774
+ })
4775
+ : h(PresetSelect, {
4776
+ t: t,
4777
+ presets: presetList,
4778
+ value: activeName,
4779
+ disabled: !writable,
4780
+ onPick: switchPreset,
4781
+ }),
2505
4782
  h(
2506
4783
  "button",
2507
4784
  {
2508
4785
  type: "button",
2509
- className: "dfp-modeButton" + (view === "simple" ? " dfp-modeButtonActive" : ""),
2510
- "aria-pressed": view === "simple",
4786
+ className: "dfp-miniButton",
4787
+ disabled: !writable,
2511
4788
  onClick: function () {
2512
- changeView("simple");
4789
+ setPresetName(activeName);
4790
+ setRenaming(true);
2513
4791
  },
2514
4792
  },
2515
- t("mode.simple")
4793
+ t("preset.rename")
2516
4794
  ),
2517
4795
  h(
2518
4796
  "button",
2519
4797
  {
2520
4798
  type: "button",
2521
- className: "dfp-modeButton" + (view === "advanced" ? " dfp-modeButtonActive" : ""),
2522
- "aria-pressed": view === "advanced",
4799
+ className: "dfp-miniButton",
4800
+ disabled: !writable,
2523
4801
  onClick: function () {
2524
- changeView("advanced");
4802
+ setImportOpen(!importOpen);
2525
4803
  },
2526
4804
  },
2527
- t("mode.advanced")
4805
+ t("preset.import")
4806
+ ),
4807
+ h(
4808
+ "button",
4809
+ {
4810
+ type: "button",
4811
+ className: "dfp-miniButton",
4812
+ onClick: exportPresets,
4813
+ },
4814
+ t("preset.export")
2528
4815
  )
2529
- )
4816
+ ),
4817
+ // the hint and the transient message share one always-mounted row:
4818
+ // the message fades in on the right and never changes the row's
4819
+ // height, so nothing below it moves
4820
+ h(
4821
+ "div",
4822
+ { className: "dfp-presetMeta" },
4823
+ h("p", { className: "dfp-hint dfp-presetHint" }, t("preset.autoSave")),
4824
+ h(
4825
+ "span",
4826
+ {
4827
+ className: "dfp-status" + (statusShown ? " dfp-statusOn" : ""),
4828
+ role: "status",
4829
+ "aria-live": "polite",
4830
+ // The one-line clamp is deliberate; the full text (a long
4831
+ // clipboard fallback, say) stays readable on hover.
4832
+ title: presetStatus.text,
4833
+ },
4834
+ presetStatus.text
4835
+ )
4836
+ ),
4837
+ importOpen
4838
+ ? h("textarea", {
4839
+ className: "dfp-search",
4840
+ rows: 3,
4841
+ "aria-label": t("preset.import"),
4842
+ placeholder: t("preset.importPlaceholder"),
4843
+ onBlur: function (event) {
4844
+ // Import on blur, not per keystroke: typing a partial
4845
+ // paste must not be parsed as a bad export.
4846
+ var text = event.target.value.trim();
4847
+ if (text !== "") importPresets(text);
4848
+ },
4849
+ })
4850
+ : null
2530
4851
  ),
2531
4852
 
2532
- view === "simple"
2533
- ? h(SimpleFamilyField, {
2534
- t: t,
2535
- label: t("sans.label"),
2536
- westLabel: "sansWest.label",
2537
- eastLabel: "sansEast.label",
2538
- slots: deriveSlots(config[SANS_FIELD]),
2539
- disabled: !writable,
2540
- onPickWest: pickSansWest,
2541
- onPickEast: pickSansEast,
2542
- onRemoveWest: function () {
2543
- var slots = deriveSlots(config[SANS_FIELD]);
2544
- if (slots.west !== null) dropSansEntry(slots.west);
2545
- },
2546
- onRemoveEast: function () {
2547
- var slots = deriveSlots(config[SANS_FIELD]);
2548
- if (slots.east !== null) dropSansEntry(slots.east);
2549
- },
2550
- })
2551
- : h(
2552
- FieldShell,
4853
+ // the accordion, conversation first (it owns every axis), then the
4854
+ // interface (which follows it), then code
4855
+ h(
4856
+ Section,
2553
4857
  {
2554
4858
  t: t,
2555
- label: t("sans.label"),
2556
- hint: t("sans.hint"),
2557
- overridden: overridden(SANS_FIELD),
2558
- disabled: !writable,
2559
- onReset: function () {
2560
- resetField(SANS_FIELD);
4859
+ title: t("section.dialog"),
4860
+ summary: summaries.dialog,
4861
+ open: expanded === "dialog",
4862
+ onToggle: function () {
4863
+ setExpanded(expanded === "dialog" ? null : "dialog");
2561
4864
  },
2562
4865
  },
2563
- h(StackPicker, {
2564
- t: t,
2565
- label: t("sans.label"),
2566
- value: config[SANS_FIELD],
2567
- onChange: function (value) {
2568
- setField(SANS_FIELD, value);
2569
- },
2570
- })
4866
+ familyField({
4867
+ field: STACK_DIALOG_FIELD,
4868
+ labelKey: "dialog.label",
4869
+ hintKey: "dialog.hint",
4870
+ westLabel: "dialogWest.label",
4871
+ eastLabel: "dialogEast.label",
4872
+ }),
4873
+ sizeField({
4874
+ field: SIZE_DIALOG_FIELD,
4875
+ labelKey: "size.dialogLabel",
4876
+ hintKey: "size.dialogHint",
4877
+ value: editing[SIZE_DIALOG_FIELD],
4878
+ text:
4879
+ editing[SIZE_DIALOG_FIELD] > 0
4880
+ ? "+" + editing[SIZE_DIALOG_FIELD]
4881
+ : String(editing[SIZE_DIALOG_FIELD]),
4882
+ }),
4883
+ lineField({
4884
+ field: LINE_HEIGHT_DIALOG_FIELD,
4885
+ labelKey: "line.dialogLabel",
4886
+ hintKey: "line.dialogHint",
4887
+ value: editing[LINE_HEIGHT_DIALOG_FIELD],
4888
+ }),
4889
+ weightField({
4890
+ field: WEIGHT_DIALOG_FIELD,
4891
+ labelKey: "weight.dialogLabel",
4892
+ hintKey: "weight.dialogHint",
4893
+ value: editing[WEIGHT_DIALOG_FIELD],
4894
+ }),
4895
+ h(
4896
+ "div",
4897
+ { className: "dfp-previewBox" },
4898
+ h("div", { className: "dfp-previewCaption" }, t("preview.dialogCaption")),
4899
+ h(
4900
+ "div",
4901
+ { className: "dfp-previewText dfp-previewDialog", style: previewDialogStyle },
4902
+ t("preview.sample")
4903
+ )
4904
+ )
2571
4905
  ),
2572
-
2573
- sizeField({
2574
- field: SIZE_FIELD,
2575
- labelKey: "size.bodyLabel",
2576
- hintKey: "size.bodyHint",
2577
- value: offset,
2578
- text: offsetText,
2579
- }),
2580
-
2581
- weightField({
2582
- field: WEIGHT_FIELD,
2583
- labelKey: "weight.bodyLabel",
2584
- hintKey: "weight.bodyHint",
2585
- value: weight,
2586
- }),
2587
-
2588
- view === "simple"
2589
- ? h(SimpleFamilyField, {
2590
- t: t,
2591
- label: t("mono.label"),
2592
- westLabel: "monoWest.label",
2593
- eastLabel: "monoEast.label",
2594
- slots: deriveSlots(config[MONO_FIELD]),
2595
- disabled: !writable,
2596
- onPickWest: pickMonoWest,
2597
- onPickEast: pickMonoEast,
2598
- onRemoveWest: function () {
2599
- var slots = deriveSlots(config[MONO_FIELD]);
2600
- if (slots.west !== null) dropMonoEntry(slots.west);
2601
- },
2602
- onRemoveEast: function () {
2603
- var slots = deriveSlots(config[MONO_FIELD]);
2604
- if (slots.east !== null) dropMonoEntry(slots.east);
2605
- },
2606
- })
2607
- : h(
2608
- FieldShell,
4906
+ h(
4907
+ Section,
2609
4908
  {
2610
4909
  t: t,
2611
- label: t("mono.label"),
2612
- hint: t("mono.hint"),
2613
- overridden: overridden(MONO_FIELD),
2614
- disabled: !writable,
2615
- onReset: function () {
2616
- resetField(MONO_FIELD);
4910
+ title: t("section.ui"),
4911
+ summary: summaries.ui,
4912
+ open: expanded === "ui",
4913
+ onToggle: function () {
4914
+ setExpanded(expanded === "ui" ? null : "ui");
2617
4915
  },
2618
4916
  },
2619
- h(StackPicker, {
2620
- t: t,
2621
- label: t("mono.label"),
2622
- value: config[MONO_FIELD],
2623
- onChange: function (value) {
2624
- setField(MONO_FIELD, value);
2625
- },
2626
- })
2627
- ),
2628
-
2629
- sizeField({
2630
- field: CODE_SIZE_FIELD,
2631
- labelKey: "size.codeLabel",
2632
- hintKey: "size.codeHint",
2633
- value: codeOffset,
2634
- text: codeOffsetText,
2635
- }),
2636
-
2637
- weightField({
2638
- field: CODE_WEIGHT_FIELD,
2639
- labelKey: "weight.codeLabel",
2640
- hintKey: "weight.codeHint",
2641
- value: codeWeight,
2642
- }),
2643
-
2644
- h(
2645
- "div",
2646
- { className: "dfp-field dfp-fieldLast" },
4917
+ // The interface owns a family and a weight. While it follows the
4918
+ // conversation both come from the section above and only this row
4919
+ // shows; with the switch off its own two controls appear.
2647
4920
  h(
2648
4921
  "div",
2649
- { className: "dfp-fieldHead" },
2650
- h("span", { className: "dfp-fieldLabel" }, t("preview.label"))
4922
+ { className: "dfp-field" + (uiFollows ? " dfp-fieldLast" : "") },
4923
+ h(
4924
+ "div",
4925
+ { className: "dfp-fieldHead", role: "group", "aria-label": t("ui.follow") },
4926
+ h("span", { className: "dfp-fieldLabel" }, t("ui.follow")),
4927
+ h(
4928
+ "div",
4929
+ { className: "dfp-inline" },
4930
+ h(Segmented, {
4931
+ label: t("ui.follow"),
4932
+ value: uiFollows ? "on" : "off",
4933
+ options: [
4934
+ { value: "on", label: t("common.on") },
4935
+ { value: "off", label: t("common.off") },
4936
+ ],
4937
+ disabled: !writable,
4938
+ onChange: function (next) {
4939
+ if (next === "on") {
4940
+ setField(UI_FOLLOWS_FIELD, true);
4941
+ return;
4942
+ }
4943
+ // Turning the switch off must not change what the page
4944
+ // shows: the interface keeps the values it was following,
4945
+ // written into its own two fields, and the sliders start
4946
+ // from there. An axis the conversation does not set
4947
+ // (empty family, unset weight) clears the interface's own
4948
+ // field instead, so it stays on DSH's defaults.
4949
+ if (editing[SANS_FIELD] === "") resetField(SANS_FIELD);
4950
+ else if (editing[SANS_FIELD] !== config[SANS_FIELD]) {
4951
+ setField(SANS_FIELD, editing[SANS_FIELD]);
4952
+ }
4953
+ if (editing[WEIGHT_FIELD] === WEIGHT_UNSET) resetField(WEIGHT_FIELD);
4954
+ else if (editing[WEIGHT_FIELD] !== config[WEIGHT_FIELD]) {
4955
+ setField(WEIGHT_FIELD, editing[WEIGHT_FIELD]);
4956
+ }
4957
+ setField(UI_FOLLOWS_FIELD, false);
4958
+ },
4959
+ })
4960
+ )
4961
+ ),
4962
+ h("p", { className: "dfp-hint" }, t("ui.followHint"))
2651
4963
  ),
4964
+ uiFollows
4965
+ ? null
4966
+ : familyField({
4967
+ field: SANS_FIELD,
4968
+ labelKey: "sans.label",
4969
+ hintKey: "sans.hint",
4970
+ westLabel: "sansWest.label",
4971
+ eastLabel: "sansEast.label",
4972
+ }),
4973
+ uiFollows
4974
+ ? null
4975
+ : weightField({
4976
+ field: WEIGHT_FIELD,
4977
+ labelKey: "weight.uiLabel",
4978
+ hintKey: "weight.uiHint",
4979
+ value: editing[WEIGHT_FIELD],
4980
+ }),
2652
4981
  h(
2653
4982
  "div",
2654
4983
  { className: "dfp-previewBox" },
2655
4984
  h("div", { className: "dfp-previewCaption" }, t("preview.sansCaption")),
4985
+ h("div", { className: "dfp-previewText", style: previewUiStyle }, t("preview.sample"))
4986
+ )
4987
+ ),
4988
+ h(
4989
+ Section,
4990
+ {
4991
+ t: t,
4992
+ title: t("section.code"),
4993
+ summary: summaries.code,
4994
+ open: expanded === "code",
4995
+ onToggle: function () {
4996
+ setExpanded(expanded === "code" ? null : "code");
4997
+ },
4998
+ },
4999
+ familyField({
5000
+ field: MONO_FIELD,
5001
+ labelKey: "mono.label",
5002
+ hintKey: "mono.hint",
5003
+ westLabel: "monoWest.label",
5004
+ eastLabel: "monoEast.label",
5005
+ }),
5006
+ sizeField({
5007
+ field: CODE_SIZE_FIELD,
5008
+ labelKey: "size.codeLabel",
5009
+ hintKey: "size.codeHint",
5010
+ value: editing[CODE_SIZE_FIELD],
5011
+ text:
5012
+ editing[CODE_SIZE_FIELD] > 0
5013
+ ? "+" + editing[CODE_SIZE_FIELD]
5014
+ : String(editing[CODE_SIZE_FIELD]),
5015
+ }),
5016
+ codeLineField(),
5017
+ weightField({
5018
+ field: CODE_WEIGHT_FIELD,
5019
+ labelKey: "weight.codeLabel",
5020
+ hintKey: "weight.codeHint",
5021
+ value: editing[CODE_WEIGHT_FIELD],
5022
+ }),
5023
+ ligatureField(),
5024
+ view === "advanced" ? featuresField() : null,
5025
+ h(
5026
+ "div",
5027
+ { className: "dfp-previewBox" },
5028
+ h("div", { className: "dfp-previewCaption" }, t("preview.monoCaption")),
2656
5029
  h(
2657
5030
  "div",
2658
- {
2659
- className: "dfp-previewText",
2660
- style:
2661
- config[SANS_FIELD] === ""
2662
- ? undefined
2663
- : { fontFamily: formatStack(parseStack(config[SANS_FIELD])) },
2664
- },
2665
- t("preview.sample")
2666
- ),
2667
- h(
2668
- "div",
2669
- { className: "dfp-previewCaption", style: { marginTop: 10 } },
2670
- t("preview.monoCaption")
2671
- ),
2672
- h(
2673
- "div",
2674
- {
2675
- className: "dfp-previewText dfp-previewCode",
2676
- style:
2677
- config[MONO_FIELD] === ""
2678
- ? undefined
2679
- : { fontFamily: formatStack(parseStack(config[MONO_FIELD])) },
2680
- },
5031
+ { className: "dfp-previewText dfp-previewCode", style: previewCodeStyle },
2681
5032
  t("preview.code")
2682
5033
  )
2683
5034
  )
2684
5035
  ),
5036
+ // global fine-tuning, without an accordion: it is one switch (two in
5037
+ // advanced mode), so it sits directly below the three font sections
5038
+ view === "simple"
5039
+ ? synthesisField({
5040
+ field: NO_SYNTHETIC_ITALIC_FIELD,
5041
+ labelKey: "synth.simple",
5042
+ hintKey: "synth.simpleHint",
5043
+ simple: true,
5044
+ })
5045
+ : h(
5046
+ "div",
5047
+ null,
5048
+ synthesisField({
5049
+ field: NO_SYNTHETIC_ITALIC_FIELD,
5050
+ labelKey: "synth.italic",
5051
+ hintKey: "synth.italicHint",
5052
+ }),
5053
+ synthesisField({
5054
+ field: NO_SYNTHETIC_BOLD_FIELD,
5055
+ labelKey: "synth.bold",
5056
+ hintKey: "synth.boldHint",
5057
+ })
5058
+ ),
2685
5059
 
2686
5060
  h(
2687
5061
  "div",
@@ -2693,12 +5067,27 @@ function FontCard(props) {
2693
5067
  className: "dfp-resetAll",
2694
5068
  disabled: !writable,
2695
5069
  onClick: function () {
2696
- resetField(SANS_FIELD);
2697
- resetField(MONO_FIELD);
2698
- resetField(SIZE_FIELD);
2699
- resetField(CODE_SIZE_FIELD);
2700
- resetField(WEIGHT_FIELD);
2701
- resetField(CODE_WEIGHT_FIELD);
5070
+ for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
5071
+ clear(VALUE_FIELDS[index]);
5072
+ }
5073
+ clear(DARK_VALUES_FIELD);
5074
+ // Auto-save: the reset IS a change to the active preset, so
5075
+ // its snapshot follows back to the neutral defaults.
5076
+ if (writable && applyingRef.current !== true) {
5077
+ var activeIndex = -1;
5078
+ for (var i = 0; i < presetList.length; i += 1) {
5079
+ if (presetList[i].name === activeName) activeIndex = i;
5080
+ }
5081
+ if (activeIndex >= 0) {
5082
+ var next = presetList.slice();
5083
+ next[activeIndex] = {
5084
+ name: presetList[activeIndex].name,
5085
+ values: {},
5086
+ savedAt: Date.now(),
5087
+ };
5088
+ writePresets(next);
5089
+ }
5090
+ }
2702
5091
  },
2703
5092
  },
2704
5093
  t("card.resetAll")
@@ -2725,11 +5114,58 @@ function apply(ctx) {
2725
5114
  return tokens;
2726
5115
  });
2727
5116
  var scope = ctx.settingsScope.bind({ namespace: NAMESPACE });
5117
+ var appliedOverrides = "";
5118
+
5119
+ /**
5120
+ * Hand the family variables to the official theme layer, so the presenter
5121
+ * owns them on body's inline style and nothing — theme switches included —
5122
+ * can delete them. Only the two family pairs ride this; the size/weight
5123
+ * axes stay on the stylesheet, where their `!important` rules already win.
5124
+ * @param {unknown} config - the current configuration.
5125
+ */
5126
+ var applyTokenOverrides = function (config) {
5127
+ if (typeof ctx.get !== "function") return;
5128
+ var theme = ctx.get("theme");
5129
+ if (theme === undefined || theme === null) return;
5130
+ if (typeof theme.overrideTokens !== "function") return;
5131
+ var sets = resolveAxes(config);
5132
+ var tokens = {};
5133
+ var uiLight = formatStack(parseStack(sets.light[SANS_FIELD]));
5134
+ var uiDark = formatStack(parseStack(sets.dark[SANS_FIELD]));
5135
+ if (uiLight !== "" && uiDark !== "") {
5136
+ tokens["--dsw-font-family"] = { light: uiLight, dark: uiDark };
5137
+ }
5138
+ var monoLight = formatStack(parseStack(sets.light[MONO_FIELD]));
5139
+ var monoDark = formatStack(parseStack(sets.dark[MONO_FIELD]));
5140
+ if (monoLight !== "" || monoDark !== "") {
5141
+ var fallback = readDefaultFamily("--ds-font-family-code");
5142
+ var light = monoLight !== "" ? monoLight : fallback;
5143
+ var dark = monoDark !== "" ? monoDark : fallback;
5144
+ if (light !== "" && dark !== "") {
5145
+ tokens["--dsw-font-mono"] = { light: light, dark: dark };
5146
+ tokens["--ds-font-family-code"] = { light: light, dark: dark };
5147
+ }
5148
+ }
5149
+ var signature = JSON.stringify(tokens);
5150
+ if (signature === appliedOverrides) return;
5151
+ appliedOverrides = signature;
5152
+ try {
5153
+ theme.overrideTokens("dsh-fonttune", tokens);
5154
+ } catch (error) {
5155
+ // A teaching error (a bad pair shape) must not break the page; the
5156
+ // stylesheet rules below still apply the same values.
5157
+ }
5158
+ };
2728
5159
 
2729
5160
  var sync = function () {
2730
5161
  var snapshot = scope.getSnapshot();
2731
5162
  if (snapshot.value === undefined) return;
2732
- applyCss(snapshot.value);
5163
+ // Light and dark share one value set in this release: the stored flag (if
5164
+ // any) is ignored, so no prefixed dark rules are ever emitted.
5165
+ var unified = normalizeConfig(snapshot.value);
5166
+ unified[PER_THEME_FIELD] = false;
5167
+ applyCss(unified);
5168
+ applyTokenOverrides(unified);
2733
5169
  };
2734
5170
  ctx.effect(
2735
5171
  function () {
@@ -2741,7 +5177,11 @@ function apply(ctx) {
2741
5177
 
2742
5178
  // DSH writes its tokens (and its content font size) after this bundle
2743
5179
  // activates, and the host's own boot row only covers the first frame; the
2744
- // size axis therefore re-reads the live values and reapplies when they moved.
5180
+ // size axis therefore re-reads the live values and reapplies when they
5181
+ // moved. The theme/change event makes that immediate: the official
5182
+ // font-size preference flows through it, so a slider change is followed
5183
+ // within a frame instead of the polling interval.
5184
+ var scheduleRecheck = null;
2745
5185
  ctx.effect(
2746
5186
  function () {
2747
5187
  if (typeof document === "undefined") return undefined;
@@ -2762,6 +5202,7 @@ function apply(ctx) {
2762
5202
  }
2763
5203
  sync();
2764
5204
  };
5205
+ scheduleRecheck = recheck;
2765
5206
  var timer = globalThis.setTimeout(recheck, 500);
2766
5207
  var interval = globalThis.setInterval(recheck, 4000);
2767
5208
  return function () {
@@ -2771,6 +5212,17 @@ function apply(ctx) {
2771
5212
  },
2772
5213
  "dsh-fonttune: token refresh"
2773
5214
  );
5215
+ ctx.effect(
5216
+ function () {
5217
+ // Compositions without an event seat (the offline stand-ins) simply skip
5218
+ // this hook; the polling recheck still covers theme changes there.
5219
+ if (typeof ctx.on !== "function") return undefined;
5220
+ return ctx.on("theme/change", function () {
5221
+ if (scheduleRecheck !== null) scheduleRecheck();
5222
+ });
5223
+ },
5224
+ "dsh-fonttune: theme change adoption"
5225
+ );
2774
5226
 
2775
5227
  var t = function (key, params) {
2776
5228
  var locale = "en";
@@ -2802,6 +5254,9 @@ function apply(ctx) {
2802
5254
  });
2803
5255
  }
2804
5256
 
5257
+ exports.apply = apply;
5258
+ exports.inject = inject;
5259
+
2805
5260
  exports.apply = apply;
2806
5261
  exports.inject = inject;
2807
5262