dsh-fonttune 0.4.2 → 0.6.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
@@ -318,6 +318,67 @@ var LINE_HEIGHT_MAX = 160;
318
318
  var CODE_LINE_HEIGHT_MIN = -4;
319
319
  var CODE_LINE_HEIGHT_MAX = 8;
320
320
 
321
+ /* ------------------------------------------------------------------ *
322
+ * conversation text shaping (measured against DSH's own markdown)
323
+ * ------------------------------------------------------------------ */
324
+
325
+ /**
326
+ * Field carrying the letter-spacing offset in px (0 = DSH's own `normal`).
327
+ * DSH leaves tracking at `normal` on the markdown scope (measured 2026-10-13), so
328
+ * the offset can be written as an absolute value: 0 emits no rule at all.
329
+ */
330
+ var LETTER_SPACING_DIALOG_FIELD = "letterSpacingDialog";
331
+
332
+ /**
333
+ * Field carrying the word-spacing offset in px (0 = DSH's own `0px`).
334
+ * Word spacing is measured 0px on the same scope, so it is an absolute value too.
335
+ */
336
+ var WORD_SPACING_DIALOG_FIELD = "wordSpacingDialog";
337
+
338
+ /**
339
+ * Field carrying the paragraph-gap offset in px, added on top of DSH's own 16px.
340
+ * The paragraphs' margins are NOT zero on the shipped markdown (first 0/16, the
341
+ * rest 16/16, headings 32/16 — measured 2026-10-13), so this axis is an offset
342
+ * built on that baseline rather than an absolute value.
343
+ */
344
+ var PARAGRAPH_SPACING_DIALOG_FIELD = "paragraphSpacingDialog";
345
+
346
+ /** Field carrying the first-line indent, in em (0 = DSH's own none). */
347
+ var INDENT_DIALOG_FIELD = "indentDialog";
348
+
349
+ /**
350
+ * Field asking for strict CJK line breaking (`line-break:strict`) in the
351
+ * conversation: no small kana, no dash starting a line. DSH leaves `auto`.
352
+ */
353
+ var LINE_BREAK_STRICT_FIELD = "lineBreakStrict";
354
+
355
+ /** Letter-spacing offset range, in px, and its notch (measured: 0.1px is visible). */
356
+ var LETTER_SPACING_MIN = -0.4;
357
+ var LETTER_SPACING_MAX = 2.0;
358
+ var LETTER_SPACING_STEP = 0.1;
359
+
360
+ /** Word-spacing offset range, in px, and its notch. */
361
+ var WORD_SPACING_MIN = -1.0;
362
+ var WORD_SPACING_MAX = 4.0;
363
+ var WORD_SPACING_STEP = 0.2;
364
+
365
+ /** Paragraph-gap offset range, in px, over DSH's own 16px. */
366
+ var PARAGRAPH_SPACING_MIN = -8;
367
+ var PARAGRAPH_SPACING_MAX = 24;
368
+ var PARAGRAPH_SPACING_STEP = 1;
369
+
370
+ /** First-line indent range, in em, and its notch. */
371
+ var INDENT_MIN = 0;
372
+ var INDENT_MAX = 4;
373
+ var INDENT_STEP = 0.25;
374
+
375
+ /**
376
+ * The paragraph gap DSH's own markdown ships, in px. Measured on the conversation
377
+ * scope (2026-10-13): paragraphs carry 16px top/bottom margins, so that is the
378
+ * baseline every offset is added to.
379
+ */
380
+ var PARAGRAPH_BASELINE_PX = 16;
381
+
321
382
  /** Ligature modes. */
322
383
  var LIGATURES_DEFAULT = 0;
323
384
  var LIGATURES_ON = 1;
@@ -343,6 +404,11 @@ var DEFAULTS = {
343
404
  lineHeight: LINE_HEIGHT_MIN,
344
405
  lineHeightDialog: LINE_HEIGHT_MIN,
345
406
  lineHeightCode: 0,
407
+ letterSpacingDialog: 0,
408
+ wordSpacingDialog: 0,
409
+ paragraphSpacingDialog: 0,
410
+ indentDialog: 0,
411
+ lineBreakStrict: false,
346
412
  codeLigatures: LIGATURES_DEFAULT,
347
413
  codeFeatures: "",
348
414
  noSyntheticItalic: false,
@@ -374,6 +440,11 @@ var VALUE_FIELDS = [
374
440
  CODE_WEIGHT_FIELD,
375
441
  LINE_HEIGHT_DIALOG_FIELD,
376
442
  CODE_LINE_HEIGHT_FIELD,
443
+ LETTER_SPACING_DIALOG_FIELD,
444
+ WORD_SPACING_DIALOG_FIELD,
445
+ PARAGRAPH_SPACING_DIALOG_FIELD,
446
+ INDENT_DIALOG_FIELD,
447
+ LINE_BREAK_STRICT_FIELD,
377
448
  LIGATURES_FIELD,
378
449
  FEATURES_FIELD,
379
450
  NO_SYNTHETIC_ITALIC_FIELD,
@@ -1516,6 +1587,16 @@ function normalizeField(field, value, relative) {
1516
1587
  return clampRatio(value);
1517
1588
  case CODE_LINE_HEIGHT_FIELD:
1518
1589
  return clampLineOffset(value);
1590
+ case LETTER_SPACING_DIALOG_FIELD:
1591
+ return clampSpacing(value, LETTER_SPACING_MIN, LETTER_SPACING_MAX);
1592
+ case WORD_SPACING_DIALOG_FIELD:
1593
+ return clampSpacing(value, WORD_SPACING_MIN, WORD_SPACING_MAX);
1594
+ case PARAGRAPH_SPACING_DIALOG_FIELD:
1595
+ return clampSpacing(value, PARAGRAPH_SPACING_MIN, PARAGRAPH_SPACING_MAX);
1596
+ case INDENT_DIALOG_FIELD:
1597
+ return clampIndent(value);
1598
+ case LINE_BREAK_STRICT_FIELD:
1599
+ return toBool(value);
1519
1600
  case LIGATURES_FIELD:
1520
1601
  return clampLigatures(value);
1521
1602
  case WEIGHT_OFFSETS_FIELD:
@@ -1599,6 +1680,52 @@ function clampWeight(value) {
1599
1680
  return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weight));
1600
1681
  }
1601
1682
 
1683
+ /**
1684
+ * Clamp one spacing offset (letter, word) to its allowed range, in px.
1685
+ *
1686
+ * These axes sit on top of a DSH default that is already zero (`normal` / `0px`,
1687
+ * measured on the conversation scope), so no migration and no baseline is involved:
1688
+ * the offset is written as it stands, and 0 means "emit nothing".
1689
+ * @param {unknown} value - candidate offset.
1690
+ * @param {number} min - the lowest allowed offset.
1691
+ * @param {number} max - the highest allowed offset.
1692
+ * @returns {number} the offset the stylesheet may use.
1693
+ */
1694
+ function clampSpacing(value, min, max) {
1695
+ var offset = Number(value);
1696
+ if (!isFinite(offset)) return 0;
1697
+ // Rounded to two decimals: the notches are tenths, and the sum of a few of them
1698
+ // must not turn into 0.30000000000000004 in the emitted CSS.
1699
+ offset = Math.round(offset * 100) / 100;
1700
+ if (offset > max) return max;
1701
+ if (offset < min) return min;
1702
+ return offset;
1703
+ }
1704
+
1705
+ /**
1706
+ * Clamp the first-line indent to its allowed range, in em.
1707
+ * @param {unknown} value - candidate indent.
1708
+ * @returns {number} the indent the stylesheet may use.
1709
+ */
1710
+ function clampIndent(value) {
1711
+ var indent = Number(value);
1712
+ if (!isFinite(indent)) return 0;
1713
+ indent = Math.round(indent * 100) / 100;
1714
+ if (indent > INDENT_MAX) return INDENT_MAX;
1715
+ if (indent < INDENT_MIN) return INDENT_MIN;
1716
+ return indent;
1717
+ }
1718
+
1719
+ /**
1720
+ * Format a spacing value for CSS, without scientific notation or float noise.
1721
+ * @param {number} value - a clamped px value.
1722
+ * @returns {string} the value as a px length.
1723
+ */
1724
+ function pxText(value) {
1725
+ var rounded = Math.round(value * 100) / 100;
1726
+ return String(rounded) + "px";
1727
+ }
1728
+
1602
1729
  /**
1603
1730
  * Clamp one RELATIVE weight to its allowed range, migrating an absolute value.
1604
1731
  *
@@ -2338,6 +2465,27 @@ function rebuildShorthand(parts) {
2338
2465
  * token classification
2339
2466
  * ------------------------------------------------------------------ */
2340
2467
 
2468
+ /**
2469
+ * Whether a CSS selector declares something for the WHOLE page.
2470
+ *
2471
+ * The typography tokens live on `:root`, `html` or `body`. A selector scoped to a class
2472
+ * or an attribute declares a LOCAL value: another plugin's module stylesheet may reuse a
2473
+ * token name for its own compact panel (measured: `dsh-better-sidebar` sets
2474
+ * `--dsw-font-markdown-h1` to `600 13px/18px` on `.…_taskMarkdown`), and reading that as
2475
+ * the base hands the conversation a size that panel picked for itself.
2476
+ * @param {string} selector - a rule's selector text.
2477
+ * @returns {boolean} true when every selector in the list is a root scope.
2478
+ */
2479
+ function isRootScopeSelector(selector) {
2480
+ if (typeof selector !== "string" || selector.trim() === "") return false;
2481
+ var parts = selector.split(",");
2482
+ for (var index = 0; index < parts.length; index += 1) {
2483
+ var part = parts[index].trim().toLowerCase();
2484
+ if (part !== ":root" && part !== "html" && part !== "body" && part !== "*") return false;
2485
+ }
2486
+ return true;
2487
+ }
2488
+
2341
2489
  /**
2342
2490
  * Match DSH's typography PART tokens: the design system's sizes plus the
2343
2491
  * content size the theme plugin writes on `body` (whose secondary variant
@@ -3178,6 +3326,180 @@ function weightStepRange(profile, value) {
3178
3326
  return { min: min, max: max, unit: unit, value: count };
3179
3327
  }
3180
3328
 
3329
+ /* ------------------------------------------------------------------ *
3330
+ * continuous weight (FT-060)
3331
+ * ------------------------------------------------------------------ */
3332
+
3333
+ /** Prefix of the alias family a continuous family is drawn through. */
3334
+ var VARIABLE_ALIAS_PREFIX = "dfp-vf-";
3335
+
3336
+ /**
3337
+ * The weights the continuity probe lays out. Dense on purpose: a variable font
3338
+ * that moves in small steps shows a width change at every one of them, while a
3339
+ * family that snaps to named instances repeats its width across each stretch.
3340
+ */
3341
+ var CONTINUITY_WEIGHTS = [400, 420, 440, 460, 480, 500, 550, 600, 650, 700, 750, 800, 900];
3342
+
3343
+ /**
3344
+ * The share of adjacent probe pairs that must differ before a family's laid-out
3345
+ * WIDTH counts as continuous. Measured: a variable family drawn through its alias
3346
+ * changes width at every sampled weight (share 1.0), while a family snapping to
3347
+ * named instances repeats its width across each stretch (share 0.25–0.42).
3348
+ */
3349
+ var CONTINUOUS_MIN_SHARE = 0.9;
3350
+
3351
+ /**
3352
+ * The notches offered for a continuous family, finest first.
3353
+ *
3354
+ * The shape is still measured (the alias is drawn on the same weight grid as any
3355
+ * other family), but the candidates are fine: a continuous axis can answer at every
3356
+ * one of them, which is the whole point of the mode. `weightProfileFrom` walks this
3357
+ * list from the END, so the finest candidate that still yields enough visible
3358
+ * positions wins.
3359
+ */
3360
+ var CONTINUOUS_CANDIDATES = [10, 20, 25];
3361
+
3362
+ /**
3363
+ * The nominal continuous slider, used when the measured shape cannot be taken
3364
+ * (no canvas, a refused probe). One notch is 20 weight units; the range is the
3365
+ * widest offset the ceiling allows either way.
3366
+ */
3367
+ var CONTINUOUS_STEP = 20;
3368
+ var CONTINUOUS_RANGE = 200;
3369
+
3370
+ /**
3371
+ * How many different values a list of measurements holds.
3372
+ * @param {number[]} values - the measurements.
3373
+ * @returns {number} the count of distinct values.
3374
+ */
3375
+ function distinctCount(values) {
3376
+ if (!Array.isArray(values)) return 0;
3377
+ var seen = [];
3378
+ for (var index = 0; index < values.length; index += 1) {
3379
+ if (seen.indexOf(values[index]) < 0) seen.push(values[index]);
3380
+ }
3381
+ return seen.length;
3382
+ }
3383
+
3384
+ /**
3385
+ * Does the same family answer more finely through its alias than through itself?
3386
+ *
3387
+ * This is the discriminator the continuity probe trusts, and it needs no threshold:
3388
+ * a family that snaps to named instances renders IDENTICALLY either way, so both
3389
+ * responses hold the same number of distinct values; a variable family drawn through
3390
+ * the alias moves at weights where the system path already stopped. Comparing the
3391
+ * family against ITSELF also keeps the answer honest for a narrow axis, where a
3392
+ * share-of-neighbours rule would read saturation as "static".
3393
+ * @param {number[]} throughAlias - measurements taken through the alias face.
3394
+ * @param {number[]} plain - the same measurements taken through the family itself.
3395
+ * @returns {boolean} true when the alias resolves finer detail.
3396
+ */
3397
+ function respondsFinerThan(throughAlias, plain) {
3398
+ if (!Array.isArray(throughAlias) || !Array.isArray(plain)) return false;
3399
+ var fine = distinctCount(throughAlias);
3400
+ return fine >= 3 && fine > distinctCount(plain);
3401
+ }
3402
+
3403
+
3404
+ /**
3405
+ * The alias family name for one family. A hash, not the family itself, so the
3406
+ * name is a plain identifier whatever the family is called.
3407
+ * @param {string} family - a family name.
3408
+ * @returns {string} the alias, stable across sessions.
3409
+ */
3410
+ function variableAliasFor(family) {
3411
+ var text = sanitizeFamily(family);
3412
+ var hash = 0;
3413
+ for (var index = 0; index < text.length; index += 1) {
3414
+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
3415
+ }
3416
+ return VARIABLE_ALIAS_PREFIX + hash.toString(36);
3417
+ }
3418
+
3419
+ /**
3420
+ * The `@font-face` rule that draws one family through its own variable axis.
3421
+ *
3422
+ * `local()` points the face at the installed family, and the weight RANGE is the
3423
+ * part that matters: it tells the engine the family is continuous, so `font-weight`
3424
+ * picks a point on the axis instead of the nearest named instance. (Without the
3425
+ * range the same family snaps; `font-variation-settings` does not help a system
3426
+ * family at all — measured, see FT-060.)
3427
+ * @param {string} family - the installed family name.
3428
+ * @param {string} alias - the alias from `variableAliasFor`.
3429
+ * @returns {string} one CSS rule.
3430
+ */
3431
+ function variableFaceRule(family, alias) {
3432
+ var text = sanitizeFamily(family).replace(/"/g, "");
3433
+ return '@font-face{font-family:"' + alias + '";src:local("' + text + '");font-weight:100 900;}';
3434
+ }
3435
+
3436
+ /**
3437
+ * Every `@font-face` rule for a map of family → alias.
3438
+ * @param {Record<string, string>} variable - the families that measured continuous.
3439
+ * @returns {string} the rules, one per line; "" when there are none.
3440
+ */
3441
+ function buildVariableFaces(variable) {
3442
+ var rules = [];
3443
+ if (variable === null || typeof variable !== "object") return "";
3444
+ for (var family in variable) {
3445
+ if (Object.prototype.hasOwnProperty.call(variable, family)) {
3446
+ rules.push(variableFaceRule(family, variable[family]));
3447
+ }
3448
+ }
3449
+ return rules.join("\n");
3450
+ }
3451
+
3452
+ /**
3453
+ * Put the alias IN FRONT of every continuous family a stack names.
3454
+ *
3455
+ * The family stays in the list after its alias, so a glyph the alias lacks still
3456
+ * falls through to the same face the reader chose, and a stack that names no
3457
+ * continuous family is returned unchanged.
3458
+ * @param {string} stack - a formatted font-family value.
3459
+ * @param {Record<string, string>} variable - family → alias map.
3460
+ * @returns {string} the stack, with aliases prepended where they apply.
3461
+ */
3462
+ function withVariableFaces(stack, variable) {
3463
+ if (typeof stack !== "string" || stack === "" || variable === null || typeof variable !== "object") {
3464
+ return stack;
3465
+ }
3466
+ // The stack may already carry its alias — a family read back from the page's own
3467
+ // computed style does, once the mode is on. Prepending it again would stack up a
3468
+ // duplicate on every rebuild.
3469
+ var names = parseStack(stack);
3470
+ var lead = names.length > 0 ? names[0] : "";
3471
+ var prefix = [];
3472
+ for (var family in variable) {
3473
+ if (!Object.prototype.hasOwnProperty.call(variable, family)) continue;
3474
+ if (stack.indexOf(quoteFamily(family)) < 0) continue;
3475
+ if (lead === variable[family]) continue;
3476
+ prefix.push('"' + variable[family] + '"');
3477
+ }
3478
+ return prefix.length === 0 ? stack : prefix.join(", ") + ", " + stack;
3479
+ }
3480
+
3481
+ /**
3482
+ * Did a family respond continuously to weight in the probe?
3483
+ * @param {number[]} widths - laid-out widths, in `CONTINUITY_WEIGHTS` order.
3484
+ * @returns {boolean} true when enough neighbours differ.
3485
+ */
3486
+ function isContinuousResponse(widths) {
3487
+ if (!Array.isArray(widths) || widths.length < 3) return false;
3488
+ var changes = 0;
3489
+ for (var index = 1; index < widths.length; index += 1) {
3490
+ if (widths[index] !== widths[index - 1]) changes += 1;
3491
+ }
3492
+ return changes / (widths.length - 1) >= CONTINUOUS_MIN_SHARE;
3493
+ }
3494
+
3495
+ /**
3496
+ * The slider shape for a continuous family: the finest notch, centred on 0.
3497
+ * @returns {{min: number, max: number, step: number}} the profile, in weight units.
3498
+ */
3499
+ function continuousWeightProfile() {
3500
+ return { min: -CONTINUOUS_RANGE, max: CONTINUOUS_RANGE, step: CONTINUOUS_STEP };
3501
+ }
3502
+
3181
3503
  /**
3182
3504
  * What a slider's local (unconfirmed) value should do when a value arrives from
3183
3505
  * the settings document.
@@ -3241,6 +3563,11 @@ function isDormant(config) {
3241
3563
  clampWeightDelta(axis[CODE_WEIGHT_FIELD], true) === WEIGHT_UNSET &&
3242
3564
  clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
3243
3565
  clampLineOffset(axis[CODE_LINE_HEIGHT_FIELD]) === 0 &&
3566
+ clampSpacing(axis[LETTER_SPACING_DIALOG_FIELD], LETTER_SPACING_MIN, LETTER_SPACING_MAX) === 0 &&
3567
+ clampSpacing(axis[WORD_SPACING_DIALOG_FIELD], WORD_SPACING_MIN, WORD_SPACING_MAX) === 0 &&
3568
+ clampSpacing(axis[PARAGRAPH_SPACING_DIALOG_FIELD], PARAGRAPH_SPACING_MIN, PARAGRAPH_SPACING_MAX) === 0 &&
3569
+ clampIndent(axis[INDENT_DIALOG_FIELD]) === 0 &&
3570
+ toBool(axis[LINE_BREAK_STRICT_FIELD]) === false &&
3244
3571
  clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
3245
3572
  sanitizeFeatures(axis[FEATURES_FIELD]) === "" &&
3246
3573
  toBool(axis[NO_SYNTHETIC_ITALIC_FIELD]) === false &&
@@ -3265,10 +3592,12 @@ function isDormant(config) {
3265
3592
  * @param {boolean} isDark - prefix every rule with the dark-theme attribute.
3266
3593
  * @returns {string} declarations for one `<style>` element.
3267
3594
  */
3268
- function buildAxisCss(axis, baseTokens, isDark, hints) {
3269
- var sans = formatStack(parseStack(axis[SANS_FIELD]));
3270
- var mono = formatStack(parseStack(axis[MONO_FIELD]));
3271
- var dialog = formatStack(parseStack(axis[STACK_DIALOG_FIELD]));
3595
+ function buildAxisCss(axis, baseTokens, isDark, hints, variable) {
3596
+ // A continuous family leads its stack with its alias (see `withVariableFaces`);
3597
+ // with no map, every stack stays exactly as written.
3598
+ var sans = withVariableFaces(formatStack(parseStack(axis[SANS_FIELD])), variable);
3599
+ var mono = withVariableFaces(formatStack(parseStack(axis[MONO_FIELD])), variable);
3600
+ var dialog = withVariableFaces(formatStack(parseStack(axis[STACK_DIALOG_FIELD])), variable);
3272
3601
  // Normalised once: an expanded axis always carries a number, but a caller
3273
3602
  // handing in a raw object may leave the field out, and "absent" has to mean
3274
3603
  // "unset" rather than "write the string undefined". The conversation's value
@@ -3622,6 +3951,60 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
3622
3951
  }
3623
3952
  }
3624
3953
 
3954
+ // ---- conversation text shaping (tracking, word gap, paragraph gap, indent) ----
3955
+ //
3956
+ // Every value here is written on the CONVERSATION scope, and the measurements that
3957
+ // fixed the shape were taken there (2026-10-13): the markdown's tracking is `normal`
3958
+ // and its word gap `0px`, so those two axes are absolute and 0 emits nothing; the
3959
+ // paragraphs carry 16px margins, so the paragraph axis is an offset over that
3960
+ // baseline. Code surfaces are excluded by the same scope rule the weight ladder uses,
3961
+ // so a code block's own tracking is never rewritten by a conversation axis.
3962
+ var letterSpacing = clampSpacing(axis[LETTER_SPACING_DIALOG_FIELD], LETTER_SPACING_MIN, LETTER_SPACING_MAX);
3963
+ var wordSpacing = clampSpacing(axis[WORD_SPACING_DIALOG_FIELD], WORD_SPACING_MIN, WORD_SPACING_MAX);
3964
+ var paragraphSpacing = clampSpacing(
3965
+ axis[PARAGRAPH_SPACING_DIALOG_FIELD],
3966
+ PARAGRAPH_SPACING_MIN,
3967
+ PARAGRAPH_SPACING_MAX
3968
+ );
3969
+ var indent = clampIndent(axis[INDENT_DIALOG_FIELD]);
3970
+ if (letterSpacing !== 0) {
3971
+ declarations.push(weightScopeRule("letter-spacing:" + pxText(letterSpacing) + " !important"));
3972
+ }
3973
+ if (wordSpacing !== 0) {
3974
+ declarations.push(weightScopeRule("word-spacing:" + pxText(wordSpacing) + " !important"));
3975
+ }
3976
+ if (indent !== 0) {
3977
+ // A first-line indent is a PROSE convention, so it belongs to paragraphs. Emitted for
3978
+ // the whole conversation scope it inherited into every block under it; each of these
3979
+ // was measured on the page with the axis at 2em (32px on a 16px base):
3980
+ // - a heading sat 32px in, where no Chinese reader expects one;
3981
+ // - an ordered-list item's TEXT moved 32px while the number stayed at the content
3982
+ // edge (the marker sits outside the content box), so the number ended up in front
3983
+ // of the indent the text had taken;
3984
+ // - inline code is `display: inline-flex` in DSH's markdown — a block container of
3985
+ // its own — so the indent landed INSIDE the chip as blank space before its own
3986
+ // text, wherever the chip sat in the line;
3987
+ // - a code block's first line shifted the same way.
3988
+ // Paragraphs are the only place the convention exists, and their descendants are
3989
+ // zeroed so a chip that happens to be its own block still draws flush.
3990
+ var indentDeclarations = "text-indent:" + String(indent) + "em !important";
3991
+ declarations.push(weightScopeRule("p{" + indentDeclarations + "}"));
3992
+ declarations.push(weightScopeRule("p *{text-indent:0 !important}"));
3993
+ }
3994
+ if (paragraphSpacing !== 0) {
3995
+ // Both margins move together: adjacent siblings collapse to the LARGER of the two,
3996
+ // so shifting only `margin-top` would silently ignore every negative offset.
3997
+ var gap =
3998
+ paragraphSpacing > 0
3999
+ ? "calc(" + PARAGRAPH_BASELINE_PX + "px + " + pxText(paragraphSpacing) + ")"
4000
+ : "calc(" + PARAGRAPH_BASELINE_PX + "px - " + pxText(-paragraphSpacing) + ")";
4001
+ declarations.push(weightScopeRule("p{margin-bottom:" + gap + " !important}"));
4002
+ declarations.push(weightScopeRule("p:not(:first-child){margin-top:" + gap + " !important}"));
4003
+ }
4004
+ if (toBool(axis[LINE_BREAK_STRICT_FIELD])) {
4005
+ declarations.push(weightScopeRule("line-break:strict !important"));
4006
+ }
4007
+
3625
4008
  // ---- code axes ----
3626
4009
  var codeSize = axis[CODE_SIZE_FIELD];
3627
4010
  var codeLine = axis[CODE_LINE_HEIGHT_FIELD];
@@ -3668,7 +4051,28 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
3668
4051
  if (noBold) declarations.push(bodyRule("font-synthesis-weight:none !important"));
3669
4052
  }
3670
4053
 
3671
- return declarations.join("\n");
4054
+ return hardenDeltaRefs(declarations.join("\n"));
4055
+ }
4056
+
4057
+ /**
4058
+ * Give every reference to the content-size delta a fallback value.
4059
+ *
4060
+ * A heading's `font` shorthand is the markdown token, and DSH's own text for it reads
4061
+ * `calc(21px + var(--dsh-content-font-delta))` — a reference with NO fallback. Nothing
4062
+ * outside this plugin defines that property, so any rebuild that emits the reference
4063
+ * without the definition leaves it resolving to nothing, which makes the WHOLE shorthand
4064
+ * invalid at computed-value time: every font longhand falls back to `inherit`, and the
4065
+ * heading is painted at body size. Measured on a live page: h1 23px → 16px the moment the
4066
+ * property resolved to nothing, back to 23px when it returned.
4067
+ *
4068
+ * The fallback cannot change a page that defines the property (the definition wins), and
4069
+ * where the definition is missing it keeps the design's own size instead of collapsing.
4070
+ * @param {string} css - generated declarations.
4071
+ * @returns {string} the same declarations, with the reference made safe.
4072
+ */
4073
+ function hardenDeltaRefs(css) {
4074
+ if (typeof css !== "string" || css === "") return css;
4075
+ return css.split("var(--dsh-content-font-delta)").join("var(--dsh-content-font-delta, 0px)");
3672
4076
  }
3673
4077
 
3674
4078
  /**
@@ -3760,7 +4164,7 @@ function scaleCodeTokens(baseTokens, scale, lineOffset) {
3760
4164
  * correct everywhere and costs an attribute scan per element per rule.
3761
4165
  * @returns {string} declarations for one `<style>` element ("" when dormant).
3762
4166
  */
3763
- function buildFontCss(config, baseTokens, hints) {
4167
+ function buildFontCss(config, baseTokens, hints, variable) {
3764
4168
  var tokens = baseTokens === undefined || baseTokens === null ? FALLBACK_TOKENS : baseTokens;
3765
4169
  var sets = resolveAxes(config);
3766
4170
  // The rendering preference is NOT an axis: it must paint even when every
@@ -3768,16 +4172,16 @@ function buildFontCss(config, baseTokens, hints) {
3768
4172
  // use case with everything else untouched.
3769
4173
  var smoothing = smoothingRule(config !== null && typeof config === "object" ? config[SMOOTHING_FIELD] : "");
3770
4174
  if (isDormant(sets.light) && isDormant(sets.dark)) return smoothing;
3771
- var light = buildAxisCss(sets.light, tokens, false, hints);
4175
+ var light = buildAxisCss(sets.light, tokens, false, hints, variable);
3772
4176
  if (sameValueSet(sets.light, sets.dark)) {
3773
- return smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing;
4177
+ return hardenDeltaRefs(smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing);
3774
4178
  }
3775
- var dark = buildAxisCss(sets.dark, tokens, true, hints);
4179
+ var dark = buildAxisCss(sets.dark, tokens, true, hints, variable);
3776
4180
  var parts = [];
3777
4181
  if (light !== "") parts.push(light);
3778
4182
  if (dark !== "") parts.push(dark);
3779
4183
  if (smoothing !== "") parts.push(smoothing);
3780
- return parts.join("\n");
4184
+ return hardenDeltaRefs(parts.join("\n"));
3781
4185
  }
3782
4186
 
3783
4187
  /**
@@ -4222,6 +4626,24 @@ var shared = {
4222
4626
  LINE_HEIGHT_MAX: LINE_HEIGHT_MAX,
4223
4627
  CODE_LINE_HEIGHT_MIN: CODE_LINE_HEIGHT_MIN,
4224
4628
  CODE_LINE_HEIGHT_MAX: CODE_LINE_HEIGHT_MAX,
4629
+ LETTER_SPACING_DIALOG_FIELD: LETTER_SPACING_DIALOG_FIELD,
4630
+ WORD_SPACING_DIALOG_FIELD: WORD_SPACING_DIALOG_FIELD,
4631
+ PARAGRAPH_SPACING_DIALOG_FIELD: PARAGRAPH_SPACING_DIALOG_FIELD,
4632
+ INDENT_DIALOG_FIELD: INDENT_DIALOG_FIELD,
4633
+ LINE_BREAK_STRICT_FIELD: LINE_BREAK_STRICT_FIELD,
4634
+ LETTER_SPACING_MIN: LETTER_SPACING_MIN,
4635
+ LETTER_SPACING_MAX: LETTER_SPACING_MAX,
4636
+ LETTER_SPACING_STEP: LETTER_SPACING_STEP,
4637
+ WORD_SPACING_MIN: WORD_SPACING_MIN,
4638
+ WORD_SPACING_MAX: WORD_SPACING_MAX,
4639
+ WORD_SPACING_STEP: WORD_SPACING_STEP,
4640
+ PARAGRAPH_SPACING_MIN: PARAGRAPH_SPACING_MIN,
4641
+ PARAGRAPH_SPACING_MAX: PARAGRAPH_SPACING_MAX,
4642
+ PARAGRAPH_SPACING_STEP: PARAGRAPH_SPACING_STEP,
4643
+ INDENT_MIN: INDENT_MIN,
4644
+ INDENT_MAX: INDENT_MAX,
4645
+ INDENT_STEP: INDENT_STEP,
4646
+ PARAGRAPH_BASELINE_PX: PARAGRAPH_BASELINE_PX,
4225
4647
  LIGATURES_DEFAULT: LIGATURES_DEFAULT,
4226
4648
  SMOOTHING_FIELD: SMOOTHING_FIELD,
4227
4649
  SMOOTHING_AUTO: SMOOTHING_AUTO,
@@ -4299,6 +4721,19 @@ var shared = {
4299
4721
  WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
4300
4722
  WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
4301
4723
  WEIGHT_MIN_POSITIONS: WEIGHT_MIN_POSITIONS,
4724
+ VARIABLE_ALIAS_PREFIX: VARIABLE_ALIAS_PREFIX,
4725
+ CONTINUITY_WEIGHTS: CONTINUITY_WEIGHTS,
4726
+ isRootScopeSelector: isRootScopeSelector,
4727
+ hardenDeltaRefs: hardenDeltaRefs,
4728
+ CONTINUOUS_CANDIDATES: CONTINUOUS_CANDIDATES,
4729
+ distinctCount: distinctCount,
4730
+ respondsFinerThan: respondsFinerThan,
4731
+ variableAliasFor: variableAliasFor,
4732
+ variableFaceRule: variableFaceRule,
4733
+ buildVariableFaces: buildVariableFaces,
4734
+ withVariableFaces: withVariableFaces,
4735
+ isContinuousResponse: isContinuousResponse,
4736
+ continuousWeightProfile: continuousWeightProfile,
4302
4737
  WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
4303
4738
  WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
4304
4739
  WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
@@ -4375,6 +4810,23 @@ var WEIGHT_DIALOG_FIELD = shared.WEIGHT_DIALOG_FIELD;
4375
4810
  var CODE_WEIGHT_FIELD = shared.CODE_WEIGHT_FIELD;
4376
4811
  var LINE_HEIGHT_DIALOG_FIELD = shared.LINE_HEIGHT_DIALOG_FIELD;
4377
4812
  var CODE_LINE_HEIGHT_FIELD = shared.CODE_LINE_HEIGHT_FIELD;
4813
+ var LETTER_SPACING_DIALOG_FIELD = shared.LETTER_SPACING_DIALOG_FIELD;
4814
+ var WORD_SPACING_DIALOG_FIELD = shared.WORD_SPACING_DIALOG_FIELD;
4815
+ var PARAGRAPH_SPACING_DIALOG_FIELD = shared.PARAGRAPH_SPACING_DIALOG_FIELD;
4816
+ var INDENT_DIALOG_FIELD = shared.INDENT_DIALOG_FIELD;
4817
+ var LINE_BREAK_STRICT_FIELD = shared.LINE_BREAK_STRICT_FIELD;
4818
+ var LETTER_SPACING_MIN = shared.LETTER_SPACING_MIN;
4819
+ var LETTER_SPACING_MAX = shared.LETTER_SPACING_MAX;
4820
+ var LETTER_SPACING_STEP = shared.LETTER_SPACING_STEP;
4821
+ var WORD_SPACING_MIN = shared.WORD_SPACING_MIN;
4822
+ var WORD_SPACING_MAX = shared.WORD_SPACING_MAX;
4823
+ var WORD_SPACING_STEP = shared.WORD_SPACING_STEP;
4824
+ var PARAGRAPH_SPACING_MIN = shared.PARAGRAPH_SPACING_MIN;
4825
+ var PARAGRAPH_SPACING_MAX = shared.PARAGRAPH_SPACING_MAX;
4826
+ var PARAGRAPH_SPACING_STEP = shared.PARAGRAPH_SPACING_STEP;
4827
+ var INDENT_MIN = shared.INDENT_MIN;
4828
+ var INDENT_MAX = shared.INDENT_MAX;
4829
+ var INDENT_STEP = shared.INDENT_STEP;
4378
4830
  var LIGATURES_FIELD = shared.LIGATURES_FIELD;
4379
4831
  var FEATURES_FIELD = shared.FEATURES_FIELD;
4380
4832
  var NO_SYNTHETIC_ITALIC_FIELD = shared.NO_SYNTHETIC_ITALIC_FIELD;
@@ -4577,7 +5029,7 @@ var DICTS = {
4577
5029
  en: {
4578
5030
  "card.title": "Font tune",
4579
5031
  "card.description":
4580
- "Interface and conversation fonts, a size, a weight and a line height for each, code extras and presets",
5032
+ "Interface and conversation fonts, size, weight and line height, CJK type shaping, code extras and presets",
4581
5033
  "card.expand": "Expand",
4582
5034
  "card.collapse": "Collapse",
4583
5035
  "card.resetAll": "Reset all",
@@ -4640,13 +5092,13 @@ var DICTS = {
4640
5092
 
4641
5093
  "weight.uiLabel": "Interface font-weight offset",
4642
5094
  "weight.uiHint":
4643
- "Adds to the weight of the whole interface (sidebars, settings, buttons, headings): a positive value is bolder, a negative one lighter. The conversation keeps its own. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch swaps in a different face for body text and headings alike, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
5095
+ "Adds to the weight of the whole interface (sidebars, settings, buttons, headings): a positive value is bolder, a negative one lighter. The conversation keeps its own. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch makes body text and headings a little bolder or lighter, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4644
5096
  "weight.dialogLabel": "Conversation font-weight offset",
4645
5097
  "weight.dialogHint":
4646
- "Adds to the weight of each conversation element, so headings stay bolder than body text and bold text stays bold. Unset keeps DSH's own weights. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch swaps in a different face for body text and headings alike, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
5098
+ "Adds to the weight of each conversation element, so headings stay bolder than body text and bold text stays bold. Unset keeps DSH's own weights. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch makes body text and headings a little bolder or lighter, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4647
5099
  "weight.codeLabel": "Code font-weight offset",
4648
5100
  "weight.codeHint":
4649
- "Adds to the weight of each code surface (code blocks, inline code, terminal output); 0 or unset keeps DSH's own weight. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch swaps in a different face for body text and headings alike, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
5101
+ "Adds to the weight of each code surface (code blocks, inline code, terminal output); 0 or unset keeps DSH's own weight. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch makes body text and headings a little bolder or lighter, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4650
5102
 
4651
5103
  "line.dialogLabel": "Conversation line height",
4652
5104
  "line.dialogHint":
@@ -4656,6 +5108,37 @@ var DICTS = {
4656
5108
  "Adds {offset} to code line heights only; 0 keeps DSH's own line heights.",
4657
5109
  "line.unit": "%",
4658
5110
 
5111
+ "shape.letterLabel": "Letter spacing",
5112
+ "shape.letterHint":
5113
+ "Adds {offset} to the conversation's own letter spacing; 0 keeps DSH's own tracking. Positive values open the text up, negative values tighten it.",
5114
+ "shape.wordLabel": "Word spacing",
5115
+ "shape.wordHint":
5116
+ "Adds {offset} to the gaps between words in the conversation; 0 keeps DSH's own word gaps. Only Latin text has word gaps to widen.",
5117
+ "shape.paragraphLabel": "Paragraph spacing",
5118
+ "shape.paragraphHint":
5119
+ "Adds {offset} to the space between conversation paragraphs; 0 keeps DSH's own paragraph spacing. Headings keep their own spacing either way.",
5120
+ "shape.indentLabel": "First-line indent",
5121
+ "shape.indentHint":
5122
+ "Indents the first line of each conversation paragraph by {offset}; 0 leaves paragraphs flush left. Paragraphs only — headings, lists and code keep their own alignment.",
5123
+ "shape.breakLabel": "Strict line breaking",
5124
+ "shape.breakHint":
5125
+ "Keeps Chinese punctuation and small kana from starting a line when the text wraps.",
5126
+ "shape.off": "Off",
5127
+ "shape.on": "On",
5128
+
5129
+ "status.title": "In force",
5130
+ "status.dialog": "Conversation",
5131
+ "status.ui": "Interface",
5132
+ "status.code": "Code",
5133
+ "status.weight": "Weight slider",
5134
+ "status.steps": "{count} steps",
5135
+ "status.continuous": "fine",
5136
+ "status.hostDefault": "DSH's own",
5137
+ "status.followDialog": "follows the conversation",
5138
+ "status.usesHost": "not set here: DSH's own font",
5139
+ "status.usesInterface": "not set on its own: uses the interface font",
5140
+ "status.uiFollowsNote": "the interface uses the conversation's font",
5141
+
4659
5142
  "lig.label": "Code ligatures",
4660
5143
  "lig.hint":
4661
5144
  "Programming ligatures render multiple characters as one glyph (=> as an arrow, != as ≠). Browsers enable them by default.",
@@ -4723,6 +5206,7 @@ var DICTS = {
4723
5206
  "preview.monoCaption": "Code",
4724
5207
  "preview.sample":
4725
5208
  "The quick brown fox jumps over the lazy dog — 中文排版预览,标点符号,数字 0123456789。",
5209
+ "preview.sampleSecond": "第二段:段距与首行缩进在这里显现出来,正文其余部分不变。",
4726
5210
  "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
4727
5211
 
4728
5212
  "panel.dot": "Tuning panel",
@@ -4741,7 +5225,7 @@ var DICTS = {
4741
5225
  zh: {
4742
5226
  "card.title": "字体增强",
4743
5227
  "card.description":
4744
- "界面与对话字体、各自的字号/字重/行高,代码增强与预设方案",
5228
+ "界面与对话字体、字号/字重/行高,字距/段距/首行缩进等中文排印,代码增强与预设方案",
4745
5229
  "card.expand": "展开",
4746
5230
  "card.collapse": "收起",
4747
5231
  "card.resetAll": "全部重置",
@@ -4802,13 +5286,13 @@ var DICTS = {
4802
5286
 
4803
5287
  "weight.uiLabel": "界面字重偏移",
4804
5288
  "weight.uiHint":
4805
- "在整个界面自身的字重上叠加(侧栏、设置、按钮、标题):正数更粗、负数更细;对话 Markdown 不受影响。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
5289
+ "在整个界面自身的字重上叠加(侧栏、设置、按钮、标题):正数更粗、负数更细;对话 Markdown 不受影响。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会变粗或变细;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
4806
5290
  "weight.dialogLabel": "对话字重偏移",
4807
5291
  "weight.dialogHint":
4808
- "在每个元素自身的字重上叠加,所以标题依旧比正文粗、加粗文字依旧加粗;未设置时保持 DSH 原本的粗细。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
5292
+ "在每个元素自身的字重上叠加,所以标题依旧比正文粗、加粗文字依旧加粗;未设置时保持 DSH 原本的粗细。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会变粗或变细;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
4809
5293
  "weight.codeLabel": "代码字重偏移",
4810
5294
  "weight.codeHint":
4811
- "在每个代码面自身的字重上叠加(代码块、行内代码、终端输出);0 或未设置表示保持 DSH 原样。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
5295
+ "在每个代码面自身的字重上叠加(代码块、行内代码、终端输出);0 或未设置表示保持 DSH 原样。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会变粗或变细;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
4812
5296
 
4813
5297
  "line.dialogLabel": "对话行高",
4814
5298
  "line.dialogHint": "把对话行高整体缩放为 {ratio};100% 表示保持 DSH 原样。",
@@ -4816,6 +5300,36 @@ var DICTS = {
4816
5300
  "line.codeHint": "只给代码行高加 {offset}px;0 表示保持原样。",
4817
5301
  "line.unit": "%",
4818
5302
 
5303
+ "shape.letterLabel": "字距",
5304
+ "shape.letterHint":
5305
+ "给对话自身的字距加 {offset};0 表示保持 DSH 原样。正值更疏、负值更紧。",
5306
+ "shape.wordLabel": "词距",
5307
+ "shape.wordHint":
5308
+ "给对话里词与词之间的间隔加 {offset};0 表示保持 DSH 原样。只有西文有词间空隙可调。",
5309
+ "shape.paragraphLabel": "段距",
5310
+ "shape.paragraphHint":
5311
+ "给对话段落之间的间隔加 {offset};0 表示保持 DSH 原样。标题自身的间距不受影响。",
5312
+ "shape.indentLabel": "首行缩进",
5313
+ "shape.indentHint":
5314
+ "把对话每段的第一行缩进 {offset};0 表示与 DSH 一样顶格。只缩段落——标题、列表与代码保持原样对齐。",
5315
+ "shape.breakLabel": "严格避头尾",
5316
+ "shape.breakHint": "换行时不让中文标点、小假名等出现在行首。",
5317
+ "shape.off": "关闭",
5318
+ "shape.on": "开启",
5319
+
5320
+ "status.title": "当前生效",
5321
+ "status.dialog": "对话",
5322
+ "status.ui": "界面",
5323
+ "status.code": "代码",
5324
+ "status.weight": "字重滑块",
5325
+ "status.steps": "{count} 档",
5326
+ "status.continuous": "细档",
5327
+ "status.hostDefault": "DSH 自带",
5328
+ "status.followDialog": "跟随对话",
5329
+ "status.usesHost": "本插件未设置,用 DSH 自带的字体",
5330
+ "status.usesInterface": "未单独设置,沿用界面字体",
5331
+ "status.uiFollowsNote": "界面沿用对话的字体",
5332
+
4819
5333
  "lig.label": "代码连字",
4820
5334
  "lig.hint":
4821
5335
  "把多个字符连成一个字形(如 => 变箭头、!= 变 ≠)。浏览器默认就是开启的。",
@@ -4882,6 +5396,8 @@ var DICTS = {
4882
5396
  "preview.monoCaption": "代码",
4883
5397
  "preview.sample":
4884
5398
  "The quick brown fox jumps over the lazy dog —— 中文排版预览,标点符号,数字 0123456789。",
5399
+ "preview.sampleSecond":
5400
+ "Second paragraph: the paragraph gap and the first-line indent show up here, and nothing else in the text changes.",
4885
5401
  "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
4886
5402
 
4887
5403
  "panel.dot": "调参面板",
@@ -5118,7 +5634,11 @@ var CARD_CSS = [
5118
5634
  // IS the card's corner: the clip, the scale and the fade still ride the card
5119
5635
  // (and every measurement still reads it), while the anchored edge, the size
5120
5636
  // and the shadow belong to the wrapper around it.
5121
- ".dfp-floatCard{position:relative;box-sizing:border-box;width:100%;height:100%;max-height:calc(100vh - 24px);overflow:hidden;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:16px;padding:10px 12px 12px;pointer-events:auto;clip-path:inset(0px 0px 0px 0px round 16px);transition:clip-path .28s cubic-bezier(.22,1,.28,1),opacity .28s cubic-bezier(.22,1,.28,1),transform .28s cubic-bezier(.22,1,.28,1)}",
5637
+ ".dfp-floatCard{position:relative;box-sizing:border-box;display:flex;flex-direction:column;width:100%;height:100%;max-height:calc(100vh - 24px);overflow:hidden;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:16px;padding:10px 12px 12px;pointer-events:auto;clip-path:inset(0px 0px 0px 0px round 16px);transition:clip-path .28s cubic-bezier(.22,1,.28,1),opacity .28s cubic-bezier(.22,1,.28,1),transform .28s cubic-bezier(.22,1,.28,1)}",
5638
+ // The body owns the scrolling: seven sliders and the status block do not fit a panel
5639
+ // that was resized to its minimum, and a clipped slider is worse than a scrollbar.
5640
+ ".dfp-floatHead{flex:0 0 auto}",
5641
+ ".dfp-floatBody{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column}",
5122
5642
  // the enter frame: the card is clipped to the dot's own rectangle, a hair
5123
5643
  // smaller and dimmer, and the transition (started by dropping this class two
5124
5644
  // frames later) carries all three home at once. Once it has arrived the card
@@ -5174,6 +5694,16 @@ var CARD_CSS = [
5174
5694
  // the write-failure line takes no room at all while it carries no message
5175
5695
  ".dfp-floatStatus{margin-top:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
5176
5696
  ".dfp-floatStatus:empty{display:none}",
5697
+ ".dfp-floatInfo{margin-top:2px;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
5698
+ ".dfp-floatInfoHead{display:flex;align-items:center;justify-content:space-between;gap:6px;width:100%;margin:0;padding:2px 0;border:0;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;text-align:left}",
5699
+ ".dfp-floatInfoHead:hover .dfp-chevron,.dfp-floatInfoHead:hover .dfp-floatInfoTitle{color:var(--dsw-alias-label-secondary)}",
5700
+ ".dfp-floatInfoClip{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .24s cubic-bezier(.22,1,.28,1),opacity .24s cubic-bezier(.22,1,.28,1)}",
5701
+ ".dfp-floatInfoOpen .dfp-floatInfoClip{grid-template-rows:1fr;opacity:1}",
5702
+ ".dfp-floatInfoLines{min-height:0;overflow:hidden}",
5703
+ ".dfp-floatInfoLine{display:flex;align-items:baseline;gap:6px;margin-top:2px}",
5704
+ ".dfp-floatInfoKey{flex:0 0 auto;opacity:.75}",
5705
+ ".dfp-floatInfoValue{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}",
5706
+ ".dfp-floatInfoFlag{flex:0 0 auto;padding:0 6px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}",
5177
5707
  ].join("");
5178
5708
 
5179
5709
  /**
@@ -5223,6 +5753,24 @@ function isOwnSheet(sheet) {
5223
5753
  }
5224
5754
  }
5225
5755
 
5756
+ /**
5757
+ * Whether a style rule declares its custom properties for the WHOLE page.
5758
+ *
5759
+ * The design tokens live on `:root`, `html` or `body`. A rule scoped to a class
5760
+ * declares a LOCAL value — another plugin's module stylesheet may reuse one of the
5761
+ * token names for its own compact panel, and reading that as the base hands the
5762
+ * conversation a size that panel chose for itself.
5763
+ * @param {CSSStyleRule} rule - any rule from a document stylesheet.
5764
+ * @returns {boolean} true when every selector in the rule is a root scope.
5765
+ */
5766
+ function isRootScopedRule(rule) {
5767
+ try {
5768
+ return shared.isRootScopeSelector(rule.selectorText);
5769
+ } catch (error) {
5770
+ return false;
5771
+ }
5772
+ }
5773
+
5226
5774
  /**
5227
5775
  * Snapshot the typography tokens the size offsets scale.
5228
5776
  *
@@ -5238,6 +5786,13 @@ function isOwnSheet(sheet) {
5238
5786
  * collected too: they are what the shipped stylesheets actually consume, and
5239
5787
  * the line-height axes rebuild their inline heights.
5240
5788
  *
5789
+ * Declarations come in two ranks, because a token name is not owned by the design
5790
+ * system alone: `dsh-better-sidebar` redefines `--dsw-font-markdown-h1` as a compact
5791
+ * `600 13px/18px` on its own panel class, and reading that as the base is how every
5792
+ * conversation heading shrank once the line-height axis rebuilt those shorthands. A
5793
+ * ROOT-scoped declaration is the page's value; a locally scoped one only fills a gap
5794
+ * (a host line that declares its tokens nowhere else still gets them).
5795
+ *
5241
5796
  * @returns {Record<string, string>} token name to its untouched value.
5242
5797
  */
5243
5798
  function readBaseTokens() {
@@ -5252,6 +5807,11 @@ function readBaseTokens() {
5252
5807
 
5253
5808
  try {
5254
5809
  var sheets = document.styleSheets;
5810
+ // A local (class-scoped) declaration is kept APART and only fills gaps: another
5811
+ // plugin may reuse a token name for its own panel, so it must never override the
5812
+ // page's own value — but a host line that declares its tokens nowhere else still
5813
+ // needs them. Root-scoped declarations always win.
5814
+ var local = {};
5255
5815
  for (var sheetIndex = 0; sheetIndex < sheets.length; sheetIndex += 1) {
5256
5816
  if (isOwnSheet(sheets[sheetIndex])) continue;
5257
5817
  var rules = null;
@@ -5262,16 +5822,24 @@ function readBaseTokens() {
5262
5822
  }
5263
5823
  if (!rules) continue;
5264
5824
  for (var ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
5265
- var style = rules[ruleIndex].style;
5825
+ var rule = rules[ruleIndex];
5826
+ var style = rule.style;
5266
5827
  if (!style) continue;
5828
+ var rootScoped = isRootScopedRule(rule);
5267
5829
  for (var propertyIndex = 0; propertyIndex < style.length; propertyIndex += 1) {
5268
5830
  var property = style[propertyIndex];
5269
5831
  if (property.slice(0, 2) !== "--" || !isScaledToken(property)) continue;
5270
5832
  var declared = style.getPropertyValue(property).trim();
5271
- if (declared !== "") out[property] = declared;
5833
+ if (declared === "") continue;
5834
+ if (rootScoped) out[property] = declared;
5835
+ else local[property] = declared;
5272
5836
  }
5273
5837
  }
5274
5838
  }
5839
+ for (name in local) {
5840
+ if (!Object.prototype.hasOwnProperty.call(local, name)) continue;
5841
+ if (!Object.prototype.hasOwnProperty.call(out, name)) out[name] = local[name];
5842
+ }
5275
5843
  } catch (error) {
5276
5844
  // Keep what the earlier sources produced.
5277
5845
  }
@@ -5345,7 +5913,11 @@ function createStylesheet(tokens) {
5345
5913
  var lastCss = null;
5346
5914
  return function apply(config) {
5347
5915
  if (typeof document === "undefined") return;
5348
- var css = buildFontCss(config, tokens(), harvestScope());
5916
+ // Continuous families (FT-060) bring their own faces: the aliases are declared
5917
+ // here, and `buildFontCss` leads the matching stacks with them.
5918
+ var faces = shared.buildVariableFaces(variableFamilies);
5919
+ var body = buildFontCss(config, tokens(), harvestScope(), variableFamilies);
5920
+ var css = faces === "" ? body : faces + "\n" + body;
5349
5921
  if (css === lastCss && tag !== null && tag.isConnected) return;
5350
5922
  lastCss = css;
5351
5923
  if (tag === null || !tag.isConnected) {
@@ -5748,6 +6320,21 @@ var context = weightProbeCanvas.getContext("2d", { willReadFrequently: true });
5748
6320
  return ink + "/" + advance;
5749
6321
  }
5750
6322
 
6323
+ /**
6324
+ * The ink share of a weight signature, as a number.
6325
+ *
6326
+ * `sampleWeightSignature` answers "<ink>/<advance>": the advance is the laid-out
6327
+ * width, which a monospace family holds constant, so ink is the signal that still
6328
+ * moves for those. Unreadable signatures answer 0, which simply never counts as a
6329
+ * change.
6330
+ * @param {string} signature - a signature from `sampleWeightSignature`.
6331
+ * @returns {number} the summed alpha, or 0.
6332
+ */
6333
+ function inkOfSignature(signature) {
6334
+ var ink = Number(String(signature).split("/")[0]);
6335
+ return isFinite(ink) ? ink : 0;
6336
+ }
6337
+
5751
6338
  /**
5752
6339
  * The slider shape to use for one family: its range and its notch.
5753
6340
  *
@@ -5764,18 +6351,40 @@ var context = weightProbeCanvas.getContext("2d", { willReadFrequently: true });
5764
6351
  */
5765
6352
  function weightProfileFor(stack, top) {
5766
6353
  var base = typeof top === "number" ? top : shared.WEIGHT_LADDER_TOP;
5767
- var key = String(stack) + "\u0000" + base;
6354
+ var resolved = String(stack) === "" ? liveFamily() : String(stack);
6355
+ // `hasOwnProperty` rather than a truthiness test: the alias map is keyed by family
6356
+ // name, and a family called "constructor" must not read as promoted.
6357
+ var promoted = Object.prototype.hasOwnProperty.call(variableFamilies, primaryFamilyOf(resolved));
6358
+ var alias = promoted ? variableFamilies[primaryFamilyOf(resolved)] : "";
6359
+ // The promotion state is part of the key. The warm-up measures a family BEFORE the
6360
+ // probe has finished (the probe awaits a font load), so without it the stepped shape
6361
+ // measured first would be served from the cache forever after the family was
6362
+ // promoted — the card then keeps offering the coarse notches the mode replaced.
6363
+ var key = String(stack) + "\u0000" + base + "\u0000" + (promoted ? "continuous" : "stepped");
5768
6364
  if (Object.prototype.hasOwnProperty.call(weightProfileCache, key)) {
5769
6365
  return weightProfileCache[key];
5770
6366
  }
5771
- var profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
6367
+ var profile = promoted
6368
+ ? shared.continuousWeightProfile()
6369
+ : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
5772
6370
  try {
5773
- var resolved = String(stack) === "" ? liveFamily() : String(stack);
5774
- profile = weightProfileFrom(function (weight) {
5775
- return sampleWeightSignature(resolved, weight);
5776
- }, { top: base });
6371
+ // A continuous family is measured THROUGH ITS ALIAS, on the fine candidate grid:
6372
+ // the axis answers at every notch, so the finest one that still moves body text
6373
+ // wins, and the range stops where the family's own axis does instead of running
6374
+ // past it into weights that render the same.
6375
+ var target = promoted ? '"' + alias + '"' : resolved;
6376
+ var seen = {};
6377
+ var sample = function (weight) {
6378
+ if (!Object.prototype.hasOwnProperty.call(seen, weight)) {
6379
+ seen[weight] = sampleWeightSignature(target, weight);
6380
+ }
6381
+ return seen[weight];
6382
+ };
6383
+ profile = weightProfileFrom(sample, promoted ? { top: base, candidates: shared.CONTINUOUS_CANDIDATES } : { top: base });
5777
6384
  } catch (error) {
5778
- profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
6385
+ profile = promoted
6386
+ ? shared.continuousWeightProfile()
6387
+ : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
5779
6388
  }
5780
6389
  weightProfileCache[key] = profile;
5781
6390
  return profile;
@@ -5837,14 +6446,17 @@ var scheduleWeightWarm = function (editing) {
5837
6446
  warmSignature = signature;
5838
6447
  var jobs = [
5839
6448
  function () {
6449
+ probeContinuity(stacks.dialog);
5840
6450
  weightProfileFor(stacks.dialog, shared.WEIGHT_LADDER_STRONG);
5841
6451
  probeLog("weight-warm", { axis: "dialog", stack: stacks.dialog });
5842
6452
  },
5843
6453
  function () {
6454
+ probeContinuity(stacks.ui);
5844
6455
  weightProfileFor(stacks.ui, shared.WEIGHT_LADDER_STRONG);
5845
6456
  probeLog("weight-warm", { axis: "ui", stack: stacks.ui });
5846
6457
  },
5847
6458
  function () {
6459
+ probeContinuity(stacks.code);
5848
6460
  weightProfileFor(stacks.code, shared.WEIGHT_BASE);
5849
6461
  probeLog("weight-warm", { axis: "code", stack: stacks.code });
5850
6462
  },
@@ -5858,6 +6470,201 @@ var scheduleWeightWarm = function (editing) {
5858
6470
  deferWeightWarm(runNext);
5859
6471
  };
5860
6472
 
6473
+ /**
6474
+ * Families measured as continuous, family → alias (see FT-060).
6475
+ *
6476
+ * Filled by `probeContinuity` once a family has proven, in THIS page's own
6477
+ * rendering, that it moves in small steps through its variable axis. A family
6478
+ * that is not in here keeps the measured notch shape it always had.
6479
+ */
6480
+ var variableFamilies = {};
6481
+ var continuityProbed = {};
6482
+
6483
+ /**
6484
+ * What to re-render when a family is promoted.
6485
+ *
6486
+ * The promotion lands a second or so after load, while the floating panel may already
6487
+ * be on screen with its weight row built from the stepped shape. Rebuilding the
6488
+ * stylesheet is not enough — the row caches the shape it was rendered with, so the
6489
+ * panel would keep offering the coarse notches until something else happened to
6490
+ * redraw it. Each mounted panel pushes its own render here and takes it back on
6491
+ * destroy.
6492
+ */
6493
+ var promotionListeners = [];
6494
+
6495
+ /** Redraw whatever is showing a weight shape that the promotion just changed. */
6496
+ function notifyPromoted() {
6497
+ var pending = promotionListeners.slice();
6498
+ for (var index = 0; index < pending.length; index += 1) {
6499
+ try {
6500
+ pending[index]();
6501
+ } catch (error) {
6502
+ // A listener must never break the probe.
6503
+ }
6504
+ }
6505
+ }
6506
+
6507
+ /**
6508
+ * A stamp for the build the page is running.
6509
+ *
6510
+ * The client script is cached, and "I refreshed" has been wrong before: this is what
6511
+ * a live page reports about itself (the panel host carries it as `data-dfp-build`).
6512
+ */
6513
+ var CLIENT_BUILD = "2026-10-15-1";
6514
+
6515
+ /** The families this page promoted to continuous weight, in map order. */
6516
+ function continuousNames() {
6517
+ return Object.keys(variableFamilies);
6518
+ }
6519
+
6520
+ /**
6521
+ * Publish the promoted families on the panel host, when one is on screen.
6522
+ *
6523
+ * Promotion happens AFTER the first render (the probe awaits a font load), so the
6524
+ * attribute set during render is re-stamped here: the host element is then the one
6525
+ * place a reader — or a support session — can look without opening the console.
6526
+ */
6527
+ function announceContinuous() {
6528
+ if (typeof document === "undefined") return;
6529
+ try {
6530
+ var nodes = document.querySelectorAll("[data-dfp-build]");
6531
+ var names = continuousNames().join(", ");
6532
+ for (var index = 0; index < nodes.length; index += 1) {
6533
+ if (names === "") nodes[index].removeAttribute("data-dfp-continuous");
6534
+ else nodes[index].setAttribute("data-dfp-continuous", names);
6535
+ }
6536
+ } catch (error) {
6537
+ // Diagnostics only: a host without attributes costs nothing.
6538
+ }
6539
+ }
6540
+
6541
+ /**
6542
+ * The first family a stack names that is a REAL family, or "" when there is none.
6543
+ *
6544
+ * Our own alias families are skipped: once a family is promoted, any stack read back
6545
+ * from the page (`liveFamily()`) leads with the alias, and treating that as the
6546
+ * family name would miss the promotion map and hand back the stepped shape — which is
6547
+ * how the floating panel kept offering the coarse notches after the card moved on.
6548
+ * @param {string} stack - a formatted font-family value.
6549
+ * @returns {string} the family the stack really draws with.
6550
+ */
6551
+ function primaryFamilyOf(stack) {
6552
+ var names = parseStack(stack);
6553
+ for (var index = 0; index < names.length; index += 1) {
6554
+ if (names[index].indexOf(shared.VARIABLE_ALIAS_PREFIX) !== 0) return names[index];
6555
+ }
6556
+ return "";
6557
+ }
6558
+
6559
+ /**
6560
+ * Ask the page whether a family is continuous under weight, once per family.
6561
+ *
6562
+ * The probe does what the page does: it installs the alias face for the family
6563
+ * (`local()` plus a weight range) and waits for the browser to load it. It then
6564
+ * asks TWO ways, both through that alias:
6565
+ *
6566
+ * * the laid-out width, in the real DOM — what a reader's eye follows in prose;
6567
+ * * the canvas ink, which is the only signal a monospace family ever moves on,
6568
+ * since its advance is the same at every weight.
6569
+ *
6570
+ * A family that answers continuously on either signal is promoted to
6571
+ * `variableFamilies`; the stylesheet is then rebuilt so the alias leads its
6572
+ * stacks. Anything that cannot be measured (no font loading API, a rejected load)
6573
+ * leaves the family as it was.
6574
+ * @param {string} stack - the axis's formatted font-family value.
6575
+ */
6576
+ function probeContinuity(stack) {
6577
+ var family = primaryFamilyOf(stack);
6578
+ if (family === "" || continuityProbed[family] === true) return;
6579
+ continuityProbed[family] = true;
6580
+ if (typeof document === "undefined" || !document.fonts || typeof document.fonts.load !== "function") return;
6581
+ var alias = shared.variableAliasFor(family);
6582
+ var style = document.createElement("style");
6583
+ style.setAttribute("data-dfp-continuity", alias);
6584
+ style.textContent = shared.variableFaceRule(family, alias);
6585
+ document.head.appendChild(style);
6586
+ var host = document.createElement("span");
6587
+ host.style.cssText =
6588
+ "position:fixed;left:-10000px;top:0;white-space:nowrap;font-size:64px;font-family:\"" + alias + "\"";
6589
+ host.textContent = WEIGHT_SAMPLE_TEXT;
6590
+ document.body.appendChild(host);
6591
+ var keepFace = false;
6592
+ var finish = function () {
6593
+ host.remove();
6594
+ // A promoted family keeps its face: the stylesheet will declare the same alias,
6595
+ // and the weight profile is measured THROUGH it after promotion, so removing the
6596
+ // rule here would leave that measurement drawing with the fallback.
6597
+ if (!keepFace) style.remove();
6598
+ };
6599
+ // Two signals, because neither covers every family on its own: the DOM width
6600
+ // moves for a proportional font, while a monospace font never changes width and
6601
+ // can only be seen in the canvas ink. The ink is also read through the FAMILY
6602
+ // itself, and the two responses are compared: a family that snaps to named
6603
+ // instances answers identically either way, while a variable family moves through
6604
+ // its alias at weights where its own path already stopped.
6605
+ //
6606
+ // All values equal means the face has not taken effect yet (the page is still
6607
+ // drawing with its fallback), not that the family is stepped: a stepped family
6608
+ // still changes between its faces. Wait a beat and measure again, a few times at
6609
+ // most, so a slow face is not mistaken for a static one.
6610
+ var attempts = 0;
6611
+ var measure = function () {
6612
+ attempts += 1;
6613
+ // `!important` on purpose: the page's own weight rules are `!important`, and a
6614
+ // plain inline weight loses to them, which measured every width as one value.
6615
+ var widths = shared.CONTINUITY_WEIGHTS.map(function (weight) {
6616
+ host.style.setProperty("font-weight", String(weight), "important");
6617
+ return host.getBoundingClientRect().width;
6618
+ });
6619
+ var inks = shared.CONTINUITY_WEIGHTS.map(function (weight) {
6620
+ return inkOfSignature(sampleWeightSignature('"' + alias + '"', weight));
6621
+ });
6622
+ var plainInks = shared.CONTINUITY_WEIGHTS.map(function (weight) {
6623
+ return inkOfSignature(sampleWeightSignature('"' + family + '"', weight));
6624
+ });
6625
+ var unchanged = function (list) {
6626
+ return list.every(function (value) {
6627
+ return value === list[0];
6628
+ });
6629
+ };
6630
+ if (unchanged(widths) && unchanged(inks) && attempts < 8) {
6631
+ globalThis.setTimeout(measure, 80);
6632
+ return;
6633
+ }
6634
+ // The alias must answer more finely than the family itself. A family that snaps
6635
+ // to named instances cannot: both responses hold the same values.
6636
+ var finer = shared.respondsFinerThan(inks, plainInks);
6637
+ var continuous = finer || shared.isContinuousResponse(widths);
6638
+ keepFace = continuous;
6639
+ finish();
6640
+ probeLog("continuity", {
6641
+ family: family,
6642
+ continuous: continuous,
6643
+ attempts: attempts,
6644
+ byWidth: shared.isContinuousResponse(widths),
6645
+ aliasInk: shared.distinctCount(inks),
6646
+ plainInk: shared.distinctCount(plainInks),
6647
+ widths: widths.map(function (width) {
6648
+ return Math.round(width * 100) / 100;
6649
+ }),
6650
+ });
6651
+ if (!continuous) return;
6652
+ variableFamilies[family] = alias;
6653
+ announceContinuous();
6654
+ notifyPromoted();
6655
+ if (typeof repaint === "function") repaint();
6656
+ };
6657
+ document.fonts.load('400 64px "' + alias + '"').then(
6658
+ function () {
6659
+ measure();
6660
+ },
6661
+ function () {
6662
+ finish();
6663
+ probeLog("continuity", { family: family, continuous: false, error: "load" });
6664
+ }
6665
+ );
6666
+ }
6667
+
5861
6668
  /**
5862
6669
  * The family the page actually renders with, as a `font-family` value.
5863
6670
  * @returns {string} the computed family, or "" when unreadable.
@@ -5952,6 +6759,27 @@ function liveCodeFamily() {
5952
6759
  }
5953
6760
  }
5954
6761
 
6762
+ /**
6763
+ * The family the conversation renders with when the conversation axis sets none.
6764
+ *
6765
+ * Read from a real conversation surface rather than from `body`: the two can differ (the
6766
+ * interface's family is its own axis), and a panel that named the body's family while the
6767
+ * conversation drew with something else would be reporting the wrong surface.
6768
+ * @returns {string} the computed family, or "" when unreadable.
6769
+ */
6770
+ function liveDialogFamily() {
6771
+ if (typeof document === "undefined") return "";
6772
+ try {
6773
+ // `dialogSelector()` is what the stylesheet itself scopes the conversation with, so
6774
+ // the sample is the same kind of element the rules land on.
6775
+ var sample = document.querySelector(shared.dialogSelector(null));
6776
+ if (sample === null) return liveFamily();
6777
+ return document.defaultView.getComputedStyle(sample).fontFamily || liveFamily();
6778
+ } catch (error) {
6779
+ return "";
6780
+ }
6781
+ }
6782
+
5955
6783
  /* ------------------------------------------------------------------ *
5956
6784
  * hooks and controls
5957
6785
  * ------------------------------------------------------------------ */
@@ -8041,6 +8869,102 @@ function FontCard(props) {
8041
8869
  );
8042
8870
  };
8043
8871
 
8872
+ // ---- conversation text shaping: tracking, word gap, paragraph gap, indent ----
8873
+ //
8874
+ // Four axes that move the shape of the conversation text rather than its face. Every
8875
+ // value here is an OFFSET over what DSH already ships, measured on the conversation
8876
+ // scope (2026-10-13): tracking `normal`, word gap `0px`, paragraph margins 16px, no
8877
+ // indent. 0 therefore means "leave DSH alone" and emits no rule at all, exactly like
8878
+ // the size and weight offsets.
8879
+ var spacingField = function (props) {
8880
+ var value = editing[props.field];
8881
+ var neutral = props.neutral === undefined ? 0 : props.neutral;
8882
+ var within = value !== neutral;
8883
+ var shown = function (number) {
8884
+ var text = props.decimals === 2 ? number.toFixed(2) : String(number);
8885
+ return (number > 0 ? "+" : "") + text + props.unit;
8886
+ };
8887
+ return h(
8888
+ FieldShell,
8889
+ {
8890
+ t: t,
8891
+ label: t(props.labelKey),
8892
+ // The hint names the value the reader is looking at: without the parameter the
8893
+ // literal `{offset}` ships to the screen (caught by a screenshot, not by a test).
8894
+ hint: t(props.hintKey, { offset: shown(value) }),
8895
+ overridden: overridden(props.field) && within,
8896
+ disabled: !writable,
8897
+ onReset: function () {
8898
+ resetField(props.field);
8899
+ },
8900
+ },
8901
+ h(NumberSlider, {
8902
+ min: props.min,
8903
+ max: props.max,
8904
+ step: props.step,
8905
+ value: value,
8906
+ disabled: !writable,
8907
+ label: t(props.labelKey),
8908
+ revertedText: t("write.reverted"),
8909
+ refusedToken: refused[props.field] || 0,
8910
+ readout: shown(value),
8911
+ minLabel: shown(props.min),
8912
+ maxLabel: shown(props.max),
8913
+ pendingText: shown,
8914
+ onChange: function (next) {
8915
+ setField(props.field, next);
8916
+ },
8917
+ })
8918
+ );
8919
+ };
8920
+
8921
+ /**
8922
+ * A switch for a property DSH itself leaves alone, so "off" writes nothing.
8923
+ * @param {{field: string, labelKey: string, hintKey: string}} props - the axis.
8924
+ * @returns {object} the row.
8925
+ */
8926
+ var switchField = function (props) {
8927
+ var value = editing[props.field] === true;
8928
+ return h(
8929
+ FieldShell,
8930
+ {
8931
+ t: t,
8932
+ label: t(props.labelKey),
8933
+ hint: t(props.hintKey),
8934
+ overridden: overridden(props.field) && value,
8935
+ disabled: !writable,
8936
+ onReset: function () {
8937
+ resetField(props.field);
8938
+ },
8939
+ inline: true,
8940
+ },
8941
+ h(Segmented, {
8942
+ label: t(props.labelKey),
8943
+ value: value ? 1 : 0,
8944
+ options: [
8945
+ { value: 0, label: t("shape.off") },
8946
+ { value: 1, label: t("shape.on") },
8947
+ ],
8948
+ onChange: function (next) {
8949
+ setField(props.field, next === 1);
8950
+ },
8951
+ })
8952
+ );
8953
+ };
8954
+
8955
+ /**
8956
+ * Strict CJK line breaking, as a switch: DSH leaves `line-break` at `auto`, so
8957
+ * "off" writes nothing and the reader keeps DSH's own breaking.
8958
+ * @returns {object} the row.
8959
+ */
8960
+ var lineBreakField = function () {
8961
+ return switchField({
8962
+ field: LINE_BREAK_STRICT_FIELD,
8963
+ labelKey: "shape.breakLabel",
8964
+ hintKey: "shape.breakHint",
8965
+ });
8966
+ };
8967
+
8044
8968
  // ---- ligature segmented control ----
8045
8969
  var ligatureField = function () {
8046
8970
  var value = editing[LIGATURES_FIELD];
@@ -8445,12 +9369,17 @@ function FontCard(props) {
8445
9369
  // nothing else — the weight, size and line height all come from the
8446
9370
  // conversation section.
8447
9371
  var previewUiStyle = {};
8448
- if (uiStack !== "") previewUiStyle.fontFamily = uiStack;
9372
+ if (uiStack !== "") previewUiStyle.fontFamily = shared.withVariableFaces(uiStack, variableFamilies);
8449
9373
  var previewDialogStyle = {};
8450
9374
  // Independent and unset: the conversation keeps DSH's own family, so the
8451
9375
  // preview shows the theme's default rather than the interface stack the page
8452
9376
  // rule would otherwise paint into this box.
8453
- if (dialogStack !== "") previewDialogStyle.fontFamily = dialogStack;
9377
+ //
9378
+ // The alias leads these stacks exactly as it leads the real surfaces: without it
9379
+ // the preview would draw through the system family, where the fine notches snap to
9380
+ // the same named instance — so a notch that visibly moves the conversation would
9381
+ // do nothing here.
9382
+ if (dialogStack !== "") previewDialogStyle.fontFamily = shared.withVariableFaces(dialogStack, variableFamilies);
8454
9383
  previewDialogStyle.fontSize = 13 + editing[SIZE_DIALOG_FIELD] + "px";
8455
9384
  // The axis is a percentage OF DSH's own line height, so the preview multiplies the
8456
9385
  // measured base by the same percentage — and does it at every notch including 100,
@@ -8469,9 +9398,32 @@ function FontCard(props) {
8469
9398
  // conversation does instead of one flat weight.
8470
9399
  previewDialogStyle["--dfp-wdelta"] = editing[WEIGHT_DIALOG_FIELD];
8471
9400
  }
9401
+ // The shaping axes ride the preview inline, for the same reason the weight offset
9402
+ // does: the reader has to see the same change here that the conversation gets. The
9403
+ // paragraph gap needs two paragraphs to be visible at all, so the preview renders a
9404
+ // second, shorter one — the pair is what makes the axis honest.
9405
+ if (editing[LETTER_SPACING_DIALOG_FIELD] !== 0) {
9406
+ previewDialogStyle.letterSpacing = String(editing[LETTER_SPACING_DIALOG_FIELD]) + "px";
9407
+ }
9408
+ if (editing[WORD_SPACING_DIALOG_FIELD] !== 0) {
9409
+ previewDialogStyle.wordSpacing = String(editing[WORD_SPACING_DIALOG_FIELD]) + "px";
9410
+ }
9411
+ if (editing[INDENT_DIALOG_FIELD] !== 0) {
9412
+ previewDialogStyle.textIndent = String(editing[INDENT_DIALOG_FIELD]) + "em";
9413
+ }
9414
+ if (editing[LINE_BREAK_STRICT_FIELD] === true) {
9415
+ previewDialogStyle.lineBreak = "strict";
9416
+ }
9417
+ var previewDialogGap = editing[PARAGRAPH_SPACING_DIALOG_FIELD];
9418
+ // The preview paragraph keeps DSH's own 16px gap as its baseline, exactly like the
9419
+ // conversation rule does, so a "+6px" reads as the same change in both places.
9420
+ var previewSecondStyle = { marginTop: String(shared.PARAGRAPH_BASELINE_PX + previewDialogGap) + "px" };
9421
+ if (editing[INDENT_DIALOG_FIELD] !== 0) {
9422
+ previewSecondStyle.textIndent = String(editing[INDENT_DIALOG_FIELD]) + "em";
9423
+ }
8472
9424
  var previewCodeStyle = {};
8473
9425
  if (editing[MONO_FIELD] !== "") {
8474
- previewCodeStyle.fontFamily = formatStack(parseStack(editing[MONO_FIELD]));
9426
+ previewCodeStyle.fontFamily = shared.withVariableFaces(formatStack(parseStack(editing[MONO_FIELD])), variableFamilies);
8475
9427
  }
8476
9428
  previewCodeStyle.fontSize = 13 + editing[CODE_SIZE_FIELD] + "px";
8477
9429
  if (editing[CODE_LINE_HEIGHT_FIELD] !== 0) {
@@ -8720,6 +9672,49 @@ function FontCard(props) {
8720
9672
  value: editing[WEIGHT_DIALOG_FIELD],
8721
9673
  stack: dialogStack,
8722
9674
  }),
9675
+ // Text shaping: the four spacing axes, then the line-breaking switch. Each
9676
+ // one leaves DSH's own value alone at its neutral position.
9677
+ lineBreakField(),
9678
+ spacingField({
9679
+ field: LETTER_SPACING_DIALOG_FIELD,
9680
+ labelKey: "shape.letterLabel",
9681
+ hintKey: "shape.letterHint",
9682
+ min: LETTER_SPACING_MIN,
9683
+ max: LETTER_SPACING_MAX,
9684
+ step: LETTER_SPACING_STEP,
9685
+ unit: "px",
9686
+ decimals: 2,
9687
+ }),
9688
+ spacingField({
9689
+ field: WORD_SPACING_DIALOG_FIELD,
9690
+ labelKey: "shape.wordLabel",
9691
+ hintKey: "shape.wordHint",
9692
+ min: WORD_SPACING_MIN,
9693
+ max: WORD_SPACING_MAX,
9694
+ step: WORD_SPACING_STEP,
9695
+ unit: "px",
9696
+ decimals: 2,
9697
+ }),
9698
+ spacingField({
9699
+ field: PARAGRAPH_SPACING_DIALOG_FIELD,
9700
+ labelKey: "shape.paragraphLabel",
9701
+ hintKey: "shape.paragraphHint",
9702
+ min: PARAGRAPH_SPACING_MIN,
9703
+ max: PARAGRAPH_SPACING_MAX,
9704
+ step: PARAGRAPH_SPACING_STEP,
9705
+ unit: "px",
9706
+ decimals: 2,
9707
+ }),
9708
+ spacingField({
9709
+ field: INDENT_DIALOG_FIELD,
9710
+ labelKey: "shape.indentLabel",
9711
+ hintKey: "shape.indentHint",
9712
+ min: INDENT_MIN,
9713
+ max: INDENT_MAX,
9714
+ step: INDENT_STEP,
9715
+ unit: "em",
9716
+ decimals: 2,
9717
+ }),
8723
9718
  h(
8724
9719
  "div",
8725
9720
  { className: "dfp-previewBox" },
@@ -8727,7 +9722,8 @@ function FontCard(props) {
8727
9722
  h(
8728
9723
  "div",
8729
9724
  { className: "dfp-previewText dfp-previewDialog", style: previewDialogStyle },
8730
- t("preview.sample")
9725
+ h("p", { style: { margin: "0" } }, t("preview.sample")),
9726
+ h("p", { style: previewSecondStyle }, t("preview.sampleSecond"))
8731
9727
  )
8732
9728
  )
8733
9729
  ),
@@ -9163,6 +10159,10 @@ function mountFloatPanel(scope, t, hostCtx) {
9163
10159
  var corner = null;
9164
10160
  // The live card size while resizing; otherwise the document's size rules.
9165
10161
  var liveSize = null;
10162
+ // Whether the panel's "in force" block is unfolded. It is view state, not a setting:
10163
+ // it lives for the session and is deliberately not written to the document, because a
10164
+ // fold the reader chose is not a configuration anyone should have to sync.
10165
+ var infoOpen = true;
9166
10166
  // The document value `pos` was last reconciled with: a snapshot carrying
9167
10167
  // anything else is an edit from elsewhere and is adopted. Our own writes
9168
10168
  // set `pos` first, so their echo always matches and is a no-op.
@@ -10546,6 +11546,128 @@ function mountFloatPanel(scope, t, hostCtx) {
10546
11546
  return { row: row, input: input, shown: shown };
10547
11547
  };
10548
11548
 
11549
+ /**
11550
+ * The panel's "in force right now" block: what is actually on the page.
11551
+ *
11552
+ * Three rounds of "it did not take effect" reports were settled by reading the panel's
11553
+ * own attributes in DevTools, which is not a thing a reader should have to do. This
11554
+ * prints the same facts where the reader already is, and it prints the FAMILY each
11555
+ * surface renders with rather than a relationship: an unset conversation shows the name
11556
+ * it actually uses plus "DSH default", and the only place the word "follows" appears is
11557
+ * the interface following the conversation, which is the one relationship the plugin
11558
+ * ships by default.
11559
+ *
11560
+ * The block folds away, because a panel that is being used to drag a slider does not
11561
+ * need four lines of prose under it.
11562
+ * @param {HTMLElement} body - the card body.
11563
+ * @param {object} state - the resolved families, the notch count and the fold state.
11564
+ * @returns {HTMLElement} the block.
11565
+ */
11566
+ var infoBlock = function (body, state) {
11567
+ var block = el("div", "dfp-floatInfo" + (state.open ? " dfp-floatInfoOpen" : ""));
11568
+ var head = el("button", "dfp-floatInfoHead");
11569
+ head.type = "button";
11570
+ head.setAttribute("aria-expanded", state.open ? "true" : "false");
11571
+ head.appendChild(el("span", "dfp-floatInfoTitle", t("status.title")));
11572
+ // The card's own chevron, so the two surfaces fold the same way round: it starts
11573
+ // pointing down (there is something below) and turns over when the block is open.
11574
+ // Built with the SVG helper, not with `h`: the panel is plain DOM, and a React
11575
+ // element appended to it is not a node at all.
11576
+ var glyph = svgEl("svg", {
11577
+ class: "dfp-chevron" + (state.open ? " dfp-chevronOpen" : ""),
11578
+ viewBox: "0 0 14 14",
11579
+ width: "14",
11580
+ height: "14",
11581
+ fill: "none",
11582
+ "aria-hidden": "true",
11583
+ focusable: "false",
11584
+ });
11585
+ glyph.appendChild(svgEl("path", { d: CHEVRON_PATH, fill: "currentColor" }));
11586
+ head.appendChild(glyph);
11587
+ head.addEventListener("click", function () {
11588
+ infoOpen = !infoOpen;
11589
+ block.classList.toggle("dfp-floatInfoOpen", infoOpen);
11590
+ head.setAttribute("aria-expanded", infoOpen ? "true" : "false");
11591
+ glyph.setAttribute("class", "dfp-chevron" + (infoOpen ? " dfp-chevronOpen" : ""));
11592
+ });
11593
+ block.appendChild(head);
11594
+ // The grid-row transition needs a single child that clips: 0fr cannot be animated on
11595
+ // the row container itself.
11596
+ var clip = el("div", "dfp-floatInfoClip");
11597
+ var lines = el("div", "dfp-floatInfoLines");
11598
+ /**
11599
+ * One fact: a key, the family it resolves to, and — only when the family is NOT this
11600
+ * surface's own setting — where it came from. The reason lives in the tooltip: the
11601
+ * visible line stays short, and "why" is one hover away.
11602
+ * @param {string} key - the localized label.
11603
+ * @param {string} value - the family name.
11604
+ * @param {string} tag - the provenance tag, or "" for none.
11605
+ * @param {string} note - the tooltip sentence, or "" for none.
11606
+ * @returns {HTMLElement} the line.
11607
+ */
11608
+ var line = function (key, value, tag, note) {
11609
+ var row = el("div", "dfp-floatInfoLine");
11610
+ row.appendChild(el("span", "dfp-floatInfoKey", key));
11611
+ var shown = el("span", "dfp-floatInfoValue", value);
11612
+ if (note !== "") shown.title = note;
11613
+ row.appendChild(shown);
11614
+ if (tag !== "") row.appendChild(el("span", "dfp-floatInfoFlag", tag));
11615
+ return row;
11616
+ };
11617
+ var hostDefault = t("status.hostDefault");
11618
+ var usesHost = t("status.usesHost");
11619
+ // "Set by this plugin" means the surface's own field OR — for the conversation — the
11620
+ // interface font it takes when its own field is empty (`expandFollow`). Naming a family
11621
+ // the reader chose "DSH's own" was simply wrong: measured on the reporting machine the
11622
+ // conversation field is empty, the interface font is "Noto Serif SC", and the
11623
+ // conversation really does draw with it. The flags come in through `state` because
11624
+ // `config` belongs to the caller.
11625
+ lines.appendChild(
11626
+ line(
11627
+ t("status.dialog"),
11628
+ state.dialog.family,
11629
+ state.dialog.own ? "" : hostDefault,
11630
+ // The note follows where the family CAME FROM, not whether it is the reader's:
11631
+ // a conversation field left empty while the interface font is set is still the
11632
+ // reader's own font, and the tooltip is the only place that says so.
11633
+ state.dialog.fromInterface
11634
+ ? t("status.usesInterface")
11635
+ : state.dialog.own
11636
+ ? ""
11637
+ : usesHost
11638
+ )
11639
+ );
11640
+ lines.appendChild(
11641
+ line(
11642
+ t("status.ui"),
11643
+ state.ui.family,
11644
+ state.ui.follows ? t("status.followDialog") : state.ui.own ? "" : hostDefault,
11645
+ state.ui.follows ? t("status.uiFollowsNote") : state.ui.own ? "" : usesHost
11646
+ )
11647
+ );
11648
+ lines.appendChild(
11649
+ line(
11650
+ t("status.code"),
11651
+ state.code.family,
11652
+ state.code.own ? "" : hostDefault,
11653
+ state.code.own ? "" : usesHost
11654
+ )
11655
+ );
11656
+ if (state.notches > 0) {
11657
+ var weight = line(
11658
+ t("status.weight"),
11659
+ t("status.steps", { count: state.notches }),
11660
+ state.continuous === true ? t("status.continuous") : "",
11661
+ ""
11662
+ );
11663
+ lines.appendChild(weight);
11664
+ }
11665
+ clip.appendChild(lines);
11666
+ block.appendChild(clip);
11667
+ body.appendChild(block);
11668
+ return block;
11669
+ };
11670
+
10549
11671
  /**
10550
11672
  * Start a dot drag from a press on the dot or the card head.
10551
11673
  *
@@ -10818,8 +11940,13 @@ function mountFloatPanel(scope, t, hostCtx) {
10818
11940
  host.textContent = "";
10819
11941
  // One line in the console answers "which build is live": with a linked plugin
10820
11942
  // and a cached client script, "I refreshed" is not evidence on its own.
10821
- host.setAttribute("data-dfp-build", "2026-10-01-2");
10822
- var dot = el("button", "dfp-dot");
11943
+ host.setAttribute("data-dfp-build", CLIENT_BUILD);
11944
+ // And which families this build promoted to continuous weight, so a report of
11945
+ // "the slider still has four notches" can be answered without a console session:
11946
+ // an empty attribute means the probe has not (yet) promoted the reader's family,
11947
+ // a missing one means the page is running a build from before the mode existed.
11948
+ if (continuousNames().length > 0) host.setAttribute("data-dfp-continuous", continuousNames().join(", "));
11949
+ else host.removeAttribute("data-dfp-continuous"); var dot = el("button", "dfp-dot");
10823
11950
  dot.type = "button";
10824
11951
  dot.setAttribute("aria-label", t("panel.dot"));
10825
11952
  dot.title = t("panel.dot");
@@ -10913,9 +12040,16 @@ function mountFloatPanel(scope, t, hostCtx) {
10913
12040
  // a card sized on a big screen must still fit a small one. The card fills
10914
12041
  // the wrapper, so the wrapper is the box that gets the size.
10915
12042
  shadow.style.width = "min(" + sized.w + "px,calc(100vw - 32px))";
10916
- if (sized.h > 0) {
10917
- shadow.style.height = "min(" + sized.h + "px,calc(100vh - 24px))";
10918
- }
12043
+ // The stored HEIGHT is a cap on the card, not a fixed box for it. As a fixed box it
12044
+ // left a hole wherever the content had been: folding the panel's own block kept the
12045
+ // card at its stored height with 260px of nothing under the sliders (measured). As a
12046
+ // cap the panel is exactly as tall as its content up to that height, so folding gives
12047
+ // the space back — while a panel dragged shorter than its content still scrolls,
12048
+ // because the card is a flex column whose body owns the overflow. The wrapper is left
12049
+ // to size itself, except mid-drag, where the pointer's own height is in charge.
12050
+ card.style.maxHeight =
12051
+ sized.h > 0 ? "min(" + sized.h + "px,calc(100vh - 24px))" : "";
12052
+ if (resize === null) shadow.style.height = "";
10919
12053
  }
10920
12054
  var head = el("div", "dfp-floatHead");
10921
12055
  // Plain padding on one side only: the two round buttons are out of flow in
@@ -10975,9 +12109,15 @@ function mountFloatPanel(scope, t, hostCtx) {
10975
12109
  startDrag(event, head, state.writable);
10976
12110
  });
10977
12111
 
12112
+ // Every axis lives here, in the same order the card uses, inside a body that
12113
+ // scrolls when the panel has been resized smaller than its content: seven sliders
12114
+ // and a status block do not fit a 160px-tall panel, and a clipped slider is worse
12115
+ // than a scrollbar.
12116
+ var body = el("div", "dfp-floatBody");
12117
+
10978
12118
  var size = config[SIZE_DIALOG_FIELD];
10979
12119
  sliderRow(
10980
- card,
12120
+ body,
10981
12121
  t("size.dialogLabel"),
10982
12122
  function (next) {
10983
12123
  return (next > 0 ? "+" + next : String(next)) + " " + t("size.unit");
@@ -10993,7 +12133,7 @@ function mountFloatPanel(scope, t, hostCtx) {
10993
12133
 
10994
12134
  var line = config[LINE_HEIGHT_DIALOG_FIELD];
10995
12135
  sliderRow(
10996
- card,
12136
+ body,
10997
12137
  t("line.dialogLabel"),
10998
12138
  function (next) {
10999
12139
  return String(next) + " " + t("line.unit");
@@ -11009,7 +12149,13 @@ function mountFloatPanel(scope, t, hostCtx) {
11009
12149
 
11010
12150
  // The dialog weight in the family's own steps, like the card's control:
11011
12151
  // the document keeps the offset, the readout counts steps.
11012
- var dialogStack = formatStack(parseStack(config[STACK_DIALOG_FIELD]));
12152
+ //
12153
+ // The stack comes from the SAME helper the card and the warm-up use. Resolving it
12154
+ // here instead (the configured field alone) meant an unset stack fell through to
12155
+ // the page's computed family, which carries our alias once a family is promoted —
12156
+ // so the panel missed the promotion and kept showing the coarse notches while the
12157
+ // card showed the fine ones.
12158
+ var dialogStack = weightAxisStacks(config).dialog;
11013
12159
  var profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
11014
12160
  try {
11015
12161
  profile = weightProfileFor(dialogStack, shared.WEIGHT_LADDER_STRONG);
@@ -11021,6 +12167,13 @@ function mountFloatPanel(scope, t, hostCtx) {
11021
12167
  { min: Math.min(profile.min, storedWeight), max: Math.max(profile.max, storedWeight), step: profile.step },
11022
12168
  storedWeight
11023
12169
  );
12170
+ probeLog("panel-weight", {
12171
+ stack: dialogStack,
12172
+ profile: profile,
12173
+ stored: storedWeight,
12174
+ shape: shape,
12175
+ promoted: continuousNames(),
12176
+ });
11024
12177
  (function () {
11025
12178
  var row = el("div", "dfp-floatRow");
11026
12179
  var label = el("div", "dfp-floatLabel");
@@ -11044,10 +12197,107 @@ function mountFloatPanel(scope, t, hostCtx) {
11044
12197
  else commit({ weightDialog: count * shape.unit });
11045
12198
  });
11046
12199
  row.appendChild(input);
11047
- card.appendChild(row);
12200
+ body.appendChild(row);
11048
12201
  })();
11049
12202
 
11050
- card.appendChild(el("div", "dfp-floatStatus", ""));
12203
+ // Every one of the four shaping axes lives here too, in the card's own order, so the
12204
+ // panel can tune the whole conversation without the settings page.
12205
+ sliderRow(
12206
+ body,
12207
+ t("shape.letterLabel"),
12208
+ function (next) {
12209
+ return (next > 0 ? "+" : "") + Number(next).toFixed(2) + "px";
12210
+ },
12211
+ LETTER_SPACING_MIN,
12212
+ LETTER_SPACING_MAX,
12213
+ LETTER_SPACING_STEP,
12214
+ config[LETTER_SPACING_DIALOG_FIELD],
12215
+ function (next) {
12216
+ commit({ letterSpacingDialog: next });
12217
+ }
12218
+ );
12219
+ sliderRow(
12220
+ body,
12221
+ t("shape.wordLabel"),
12222
+ function (next) {
12223
+ return (next > 0 ? "+" : "") + Number(next).toFixed(2) + "px";
12224
+ },
12225
+ WORD_SPACING_MIN,
12226
+ WORD_SPACING_MAX,
12227
+ WORD_SPACING_STEP,
12228
+ config[WORD_SPACING_DIALOG_FIELD],
12229
+ function (next) {
12230
+ commit({ wordSpacingDialog: next });
12231
+ }
12232
+ );
12233
+ sliderRow(
12234
+ body,
12235
+ t("shape.paragraphLabel"),
12236
+ function (next) {
12237
+ return (next > 0 ? "+" : "") + Number(next).toFixed(2) + "px";
12238
+ },
12239
+ PARAGRAPH_SPACING_MIN,
12240
+ PARAGRAPH_SPACING_MAX,
12241
+ PARAGRAPH_SPACING_STEP,
12242
+ config[PARAGRAPH_SPACING_DIALOG_FIELD],
12243
+ function (next) {
12244
+ commit({ paragraphSpacingDialog: next });
12245
+ }
12246
+ );
12247
+ sliderRow(
12248
+ body,
12249
+ t("shape.indentLabel"),
12250
+ function (next) {
12251
+ return Number(next).toFixed(2) + "em";
12252
+ },
12253
+ INDENT_MIN,
12254
+ INDENT_MAX,
12255
+ INDENT_STEP,
12256
+ config[INDENT_DIALOG_FIELD],
12257
+ function (next) {
12258
+ commit({ indentDialog: next });
12259
+ }
12260
+ );
12261
+
12262
+ // What is actually in force, in the panel itself: the three families the page is
12263
+ // really rendering with, then the weight control's shape. An axis that sets no family
12264
+ // reports the one the page hands that surface — measured from the surface itself, not
12265
+ // from `body`, because the two really can differ (the interface is its own axis).
12266
+ var resolved = weightAxisStacks(config);
12267
+ var dialogConfigured = config[STACK_DIALOG_FIELD] !== "";
12268
+ var dialogFamily = primaryFamilyOf(
12269
+ dialogConfigured ? resolved.dialog : liveDialogFamily()
12270
+ );
12271
+ var uiConfigured = config[SANS_FIELD] !== "";
12272
+ var uiFollows = config[UI_FOLLOWS_FIELD] !== false;
12273
+ infoBlock(body, {
12274
+ open: infoOpen,
12275
+ dialog: {
12276
+ family: dialogFamily,
12277
+ // Own: the conversation's own field. From the interface: its field is empty and
12278
+ // the interface font is the one filling it in — still the reader's own choice.
12279
+ own: dialogConfigured || uiConfigured,
12280
+ fromInterface: !dialogConfigured && uiConfigured,
12281
+ },
12282
+ ui: {
12283
+ // Following the conversation means showing the conversation's family: the same
12284
+ // fallback the stylesheet resolves, so the two lines can never disagree.
12285
+ family: uiFollows
12286
+ ? dialogFamily
12287
+ : primaryFamilyOf(uiConfigured ? resolved.ui : liveFamily()),
12288
+ follows: uiFollows,
12289
+ own: uiConfigured,
12290
+ },
12291
+ code: {
12292
+ family: primaryFamilyOf(
12293
+ config[MONO_FIELD] !== "" ? resolved.code : liveCodeFamily()
12294
+ ),
12295
+ own: config[MONO_FIELD] !== "",
12296
+ },
12297
+ continuous: continuousNames().indexOf(dialogFamily) >= 0,
12298
+ notches: shape.max - shape.min + 1,
12299
+ });
12300
+ card.appendChild(body);
11051
12301
  // Edge resize handles: right, bottom and the corner. The card follows the
11052
12302
  // pointer live; one size write lands on release (see `startResize`).
11053
12303
  (function () {
@@ -11233,8 +12483,14 @@ function mountFloatPanel(scope, t, hostCtx) {
11233
12483
  // A document without events still gets the interval.
11234
12484
  }
11235
12485
 
12486
+ // A promotion changes the weight row's shape; the panel redraws itself when one
12487
+ // lands, and stops listening when it goes away.
12488
+ promotionListeners.push(render);
12489
+
11236
12490
  return {
11237
12491
  destroy: function () {
12492
+ var at = promotionListeners.indexOf(render);
12493
+ if (at >= 0) promotionListeners.splice(at, 1);
11238
12494
  if (statusTimer !== null) globalThis.clearTimeout(statusTimer);
11239
12495
  if (closeTimer !== null) globalThis.clearTimeout(closeTimer);
11240
12496
  if (reflowTimer !== null) globalThis.clearTimeout(reflowTimer);