dsh-fonttune 0.1.6 → 0.2.0

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