dsh-fonttune 0.5.0 → 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
  *
@@ -3436,6 +3563,11 @@ function isDormant(config) {
3436
3563
  clampWeightDelta(axis[CODE_WEIGHT_FIELD], true) === WEIGHT_UNSET &&
3437
3564
  clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
3438
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 &&
3439
3571
  clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
3440
3572
  sanitizeFeatures(axis[FEATURES_FIELD]) === "" &&
3441
3573
  toBool(axis[NO_SYNTHETIC_ITALIC_FIELD]) === false &&
@@ -3819,6 +3951,60 @@ function buildAxisCss(axis, baseTokens, isDark, hints, variable) {
3819
3951
  }
3820
3952
  }
3821
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
+
3822
4008
  // ---- code axes ----
3823
4009
  var codeSize = axis[CODE_SIZE_FIELD];
3824
4010
  var codeLine = axis[CODE_LINE_HEIGHT_FIELD];
@@ -4440,6 +4626,24 @@ var shared = {
4440
4626
  LINE_HEIGHT_MAX: LINE_HEIGHT_MAX,
4441
4627
  CODE_LINE_HEIGHT_MIN: CODE_LINE_HEIGHT_MIN,
4442
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,
4443
4647
  LIGATURES_DEFAULT: LIGATURES_DEFAULT,
4444
4648
  SMOOTHING_FIELD: SMOOTHING_FIELD,
4445
4649
  SMOOTHING_AUTO: SMOOTHING_AUTO,
@@ -4606,6 +4810,23 @@ var WEIGHT_DIALOG_FIELD = shared.WEIGHT_DIALOG_FIELD;
4606
4810
  var CODE_WEIGHT_FIELD = shared.CODE_WEIGHT_FIELD;
4607
4811
  var LINE_HEIGHT_DIALOG_FIELD = shared.LINE_HEIGHT_DIALOG_FIELD;
4608
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;
4609
4830
  var LIGATURES_FIELD = shared.LIGATURES_FIELD;
4610
4831
  var FEATURES_FIELD = shared.FEATURES_FIELD;
4611
4832
  var NO_SYNTHETIC_ITALIC_FIELD = shared.NO_SYNTHETIC_ITALIC_FIELD;
@@ -4808,7 +5029,7 @@ var DICTS = {
4808
5029
  en: {
4809
5030
  "card.title": "Font tune",
4810
5031
  "card.description":
4811
- "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",
4812
5033
  "card.expand": "Expand",
4813
5034
  "card.collapse": "Collapse",
4814
5035
  "card.resetAll": "Reset all",
@@ -4887,6 +5108,37 @@ var DICTS = {
4887
5108
  "Adds {offset} to code line heights only; 0 keeps DSH's own line heights.",
4888
5109
  "line.unit": "%",
4889
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
+
4890
5142
  "lig.label": "Code ligatures",
4891
5143
  "lig.hint":
4892
5144
  "Programming ligatures render multiple characters as one glyph (=> as an arrow, != as ≠). Browsers enable them by default.",
@@ -4954,6 +5206,7 @@ var DICTS = {
4954
5206
  "preview.monoCaption": "Code",
4955
5207
  "preview.sample":
4956
5208
  "The quick brown fox jumps over the lazy dog — 中文排版预览,标点符号,数字 0123456789。",
5209
+ "preview.sampleSecond": "第二段:段距与首行缩进在这里显现出来,正文其余部分不变。",
4957
5210
  "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
4958
5211
 
4959
5212
  "panel.dot": "Tuning panel",
@@ -4972,7 +5225,7 @@ var DICTS = {
4972
5225
  zh: {
4973
5226
  "card.title": "字体增强",
4974
5227
  "card.description":
4975
- "界面与对话字体、各自的字号/字重/行高,代码增强与预设方案",
5228
+ "界面与对话字体、字号/字重/行高,字距/段距/首行缩进等中文排印,代码增强与预设方案",
4976
5229
  "card.expand": "展开",
4977
5230
  "card.collapse": "收起",
4978
5231
  "card.resetAll": "全部重置",
@@ -5047,6 +5300,36 @@ var DICTS = {
5047
5300
  "line.codeHint": "只给代码行高加 {offset}px;0 表示保持原样。",
5048
5301
  "line.unit": "%",
5049
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
+
5050
5333
  "lig.label": "代码连字",
5051
5334
  "lig.hint":
5052
5335
  "把多个字符连成一个字形(如 => 变箭头、!= 变 ≠)。浏览器默认就是开启的。",
@@ -5113,6 +5396,8 @@ var DICTS = {
5113
5396
  "preview.monoCaption": "代码",
5114
5397
  "preview.sample":
5115
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.",
5116
5401
  "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
5117
5402
 
5118
5403
  "panel.dot": "调参面板",
@@ -5349,7 +5634,11 @@ var CARD_CSS = [
5349
5634
  // IS the card's corner: the clip, the scale and the fade still ride the card
5350
5635
  // (and every measurement still reads it), while the anchored edge, the size
5351
5636
  // and the shadow belong to the wrapper around it.
5352
- ".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}",
5353
5642
  // the enter frame: the card is clipped to the dot's own rectangle, a hair
5354
5643
  // smaller and dimmer, and the transition (started by dropping this class two
5355
5644
  // frames later) carries all three home at once. Once it has arrived the card
@@ -5405,6 +5694,16 @@ var CARD_CSS = [
5405
5694
  // the write-failure line takes no room at all while it carries no message
5406
5695
  ".dfp-floatStatus{margin-top:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
5407
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)}",
5408
5707
  ].join("");
5409
5708
 
5410
5709
  /**
@@ -6211,7 +6510,7 @@ function notifyPromoted() {
6211
6510
  * The client script is cached, and "I refreshed" has been wrong before: this is what
6212
6511
  * a live page reports about itself (the panel host carries it as `data-dfp-build`).
6213
6512
  */
6214
- var CLIENT_BUILD = "2026-10-12-3";
6513
+ var CLIENT_BUILD = "2026-10-15-1";
6215
6514
 
6216
6515
  /** The families this page promoted to continuous weight, in map order. */
6217
6516
  function continuousNames() {
@@ -6460,6 +6759,27 @@ function liveCodeFamily() {
6460
6759
  }
6461
6760
  }
6462
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
+
6463
6783
  /* ------------------------------------------------------------------ *
6464
6784
  * hooks and controls
6465
6785
  * ------------------------------------------------------------------ */
@@ -8549,6 +8869,102 @@ function FontCard(props) {
8549
8869
  );
8550
8870
  };
8551
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
+
8552
8968
  // ---- ligature segmented control ----
8553
8969
  var ligatureField = function () {
8554
8970
  var value = editing[LIGATURES_FIELD];
@@ -8982,6 +9398,29 @@ function FontCard(props) {
8982
9398
  // conversation does instead of one flat weight.
8983
9399
  previewDialogStyle["--dfp-wdelta"] = editing[WEIGHT_DIALOG_FIELD];
8984
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
+ }
8985
9424
  var previewCodeStyle = {};
8986
9425
  if (editing[MONO_FIELD] !== "") {
8987
9426
  previewCodeStyle.fontFamily = shared.withVariableFaces(formatStack(parseStack(editing[MONO_FIELD])), variableFamilies);
@@ -9233,6 +9672,49 @@ function FontCard(props) {
9233
9672
  value: editing[WEIGHT_DIALOG_FIELD],
9234
9673
  stack: dialogStack,
9235
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
+ }),
9236
9718
  h(
9237
9719
  "div",
9238
9720
  { className: "dfp-previewBox" },
@@ -9240,7 +9722,8 @@ function FontCard(props) {
9240
9722
  h(
9241
9723
  "div",
9242
9724
  { className: "dfp-previewText dfp-previewDialog", style: previewDialogStyle },
9243
- t("preview.sample")
9725
+ h("p", { style: { margin: "0" } }, t("preview.sample")),
9726
+ h("p", { style: previewSecondStyle }, t("preview.sampleSecond"))
9244
9727
  )
9245
9728
  )
9246
9729
  ),
@@ -9676,6 +10159,10 @@ function mountFloatPanel(scope, t, hostCtx) {
9676
10159
  var corner = null;
9677
10160
  // The live card size while resizing; otherwise the document's size rules.
9678
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;
9679
10166
  // The document value `pos` was last reconciled with: a snapshot carrying
9680
10167
  // anything else is an edit from elsewhere and is adopted. Our own writes
9681
10168
  // set `pos` first, so their echo always matches and is a no-op.
@@ -11059,6 +11546,128 @@ function mountFloatPanel(scope, t, hostCtx) {
11059
11546
  return { row: row, input: input, shown: shown };
11060
11547
  };
11061
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
+
11062
11671
  /**
11063
11672
  * Start a dot drag from a press on the dot or the card head.
11064
11673
  *
@@ -11431,9 +12040,16 @@ function mountFloatPanel(scope, t, hostCtx) {
11431
12040
  // a card sized on a big screen must still fit a small one. The card fills
11432
12041
  // the wrapper, so the wrapper is the box that gets the size.
11433
12042
  shadow.style.width = "min(" + sized.w + "px,calc(100vw - 32px))";
11434
- if (sized.h > 0) {
11435
- shadow.style.height = "min(" + sized.h + "px,calc(100vh - 24px))";
11436
- }
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 = "";
11437
12053
  }
11438
12054
  var head = el("div", "dfp-floatHead");
11439
12055
  // Plain padding on one side only: the two round buttons are out of flow in
@@ -11493,9 +12109,15 @@ function mountFloatPanel(scope, t, hostCtx) {
11493
12109
  startDrag(event, head, state.writable);
11494
12110
  });
11495
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
+
11496
12118
  var size = config[SIZE_DIALOG_FIELD];
11497
12119
  sliderRow(
11498
- card,
12120
+ body,
11499
12121
  t("size.dialogLabel"),
11500
12122
  function (next) {
11501
12123
  return (next > 0 ? "+" + next : String(next)) + " " + t("size.unit");
@@ -11511,7 +12133,7 @@ function mountFloatPanel(scope, t, hostCtx) {
11511
12133
 
11512
12134
  var line = config[LINE_HEIGHT_DIALOG_FIELD];
11513
12135
  sliderRow(
11514
- card,
12136
+ body,
11515
12137
  t("line.dialogLabel"),
11516
12138
  function (next) {
11517
12139
  return String(next) + " " + t("line.unit");
@@ -11575,10 +12197,107 @@ function mountFloatPanel(scope, t, hostCtx) {
11575
12197
  else commit({ weightDialog: count * shape.unit });
11576
12198
  });
11577
12199
  row.appendChild(input);
11578
- card.appendChild(row);
12200
+ body.appendChild(row);
11579
12201
  })();
11580
12202
 
11581
- 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);
11582
12301
  // Edge resize handles: right, bottom and the corner. The card follows the
11583
12302
  // pointer live; one size write lands on release (see `startResize`).
11584
12303
  (function () {