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/index.js CHANGED
@@ -247,6 +247,44 @@ export const Config = volatileWhenSupported(z.object({
247
247
  .description(
248
248
  "Code line-height offset in px (0 keeps DSH's own line heights)"
249
249
  ),
250
+ [shared.LETTER_SPACING_DIALOG_FIELD]: z
251
+ .number()
252
+ .min(shared.LETTER_SPACING_MIN)
253
+ .max(shared.LETTER_SPACING_MAX)
254
+ .default(0)
255
+ .description(
256
+ "Conversation letter-spacing offset in px (0 keeps DSH's own tracking)"
257
+ ),
258
+ [shared.WORD_SPACING_DIALOG_FIELD]: z
259
+ .number()
260
+ .min(shared.WORD_SPACING_MIN)
261
+ .max(shared.WORD_SPACING_MAX)
262
+ .default(0)
263
+ .description(
264
+ "Conversation word-spacing offset in px (0 keeps DSH's own word gaps)"
265
+ ),
266
+ [shared.PARAGRAPH_SPACING_DIALOG_FIELD]: z
267
+ .number()
268
+ .min(shared.PARAGRAPH_SPACING_MIN)
269
+ .max(shared.PARAGRAPH_SPACING_MAX)
270
+ .default(0)
271
+ .description(
272
+ "Conversation paragraph-gap offset in px, added to DSH's own paragraph spacing (0 keeps it)"
273
+ ),
274
+ [shared.INDENT_DIALOG_FIELD]: z
275
+ .number()
276
+ .min(shared.INDENT_MIN)
277
+ .max(shared.INDENT_MAX)
278
+ .default(0)
279
+ .description(
280
+ "Conversation first-line indent in em (0 keeps DSH's own flush-left paragraphs)"
281
+ ),
282
+ [shared.LINE_BREAK_STRICT_FIELD]: z
283
+ .boolean()
284
+ .default(false)
285
+ .description(
286
+ "Strict CJK line breaking in the conversation: keeps punctuation and small kana off the start of a line"
287
+ ),
250
288
  [LIGATURES_FIELD]: z
251
289
  .number()
252
290
  .min(0)
package/lib/shared.cjs CHANGED
@@ -309,6 +309,67 @@ var LINE_HEIGHT_MAX = 160;
309
309
  var CODE_LINE_HEIGHT_MIN = -4;
310
310
  var CODE_LINE_HEIGHT_MAX = 8;
311
311
 
312
+ /* ------------------------------------------------------------------ *
313
+ * conversation text shaping (measured against DSH's own markdown)
314
+ * ------------------------------------------------------------------ */
315
+
316
+ /**
317
+ * Field carrying the letter-spacing offset in px (0 = DSH's own `normal`).
318
+ * DSH leaves tracking at `normal` on the markdown scope (measured 2026-10-13), so
319
+ * the offset can be written as an absolute value: 0 emits no rule at all.
320
+ */
321
+ var LETTER_SPACING_DIALOG_FIELD = "letterSpacingDialog";
322
+
323
+ /**
324
+ * Field carrying the word-spacing offset in px (0 = DSH's own `0px`).
325
+ * Word spacing is measured 0px on the same scope, so it is an absolute value too.
326
+ */
327
+ var WORD_SPACING_DIALOG_FIELD = "wordSpacingDialog";
328
+
329
+ /**
330
+ * Field carrying the paragraph-gap offset in px, added on top of DSH's own 16px.
331
+ * The paragraphs' margins are NOT zero on the shipped markdown (first 0/16, the
332
+ * rest 16/16, headings 32/16 — measured 2026-10-13), so this axis is an offset
333
+ * built on that baseline rather than an absolute value.
334
+ */
335
+ var PARAGRAPH_SPACING_DIALOG_FIELD = "paragraphSpacingDialog";
336
+
337
+ /** Field carrying the first-line indent, in em (0 = DSH's own none). */
338
+ var INDENT_DIALOG_FIELD = "indentDialog";
339
+
340
+ /**
341
+ * Field asking for strict CJK line breaking (`line-break:strict`) in the
342
+ * conversation: no small kana, no dash starting a line. DSH leaves `auto`.
343
+ */
344
+ var LINE_BREAK_STRICT_FIELD = "lineBreakStrict";
345
+
346
+ /** Letter-spacing offset range, in px, and its notch (measured: 0.1px is visible). */
347
+ var LETTER_SPACING_MIN = -0.4;
348
+ var LETTER_SPACING_MAX = 2.0;
349
+ var LETTER_SPACING_STEP = 0.1;
350
+
351
+ /** Word-spacing offset range, in px, and its notch. */
352
+ var WORD_SPACING_MIN = -1.0;
353
+ var WORD_SPACING_MAX = 4.0;
354
+ var WORD_SPACING_STEP = 0.2;
355
+
356
+ /** Paragraph-gap offset range, in px, over DSH's own 16px. */
357
+ var PARAGRAPH_SPACING_MIN = -8;
358
+ var PARAGRAPH_SPACING_MAX = 24;
359
+ var PARAGRAPH_SPACING_STEP = 1;
360
+
361
+ /** First-line indent range, in em, and its notch. */
362
+ var INDENT_MIN = 0;
363
+ var INDENT_MAX = 4;
364
+ var INDENT_STEP = 0.25;
365
+
366
+ /**
367
+ * The paragraph gap DSH's own markdown ships, in px. Measured on the conversation
368
+ * scope (2026-10-13): paragraphs carry 16px top/bottom margins, so that is the
369
+ * baseline every offset is added to.
370
+ */
371
+ var PARAGRAPH_BASELINE_PX = 16;
372
+
312
373
  /** Ligature modes. */
313
374
  var LIGATURES_DEFAULT = 0;
314
375
  var LIGATURES_ON = 1;
@@ -334,6 +395,11 @@ var DEFAULTS = {
334
395
  lineHeight: LINE_HEIGHT_MIN,
335
396
  lineHeightDialog: LINE_HEIGHT_MIN,
336
397
  lineHeightCode: 0,
398
+ letterSpacingDialog: 0,
399
+ wordSpacingDialog: 0,
400
+ paragraphSpacingDialog: 0,
401
+ indentDialog: 0,
402
+ lineBreakStrict: false,
337
403
  codeLigatures: LIGATURES_DEFAULT,
338
404
  codeFeatures: "",
339
405
  noSyntheticItalic: false,
@@ -365,6 +431,11 @@ var VALUE_FIELDS = [
365
431
  CODE_WEIGHT_FIELD,
366
432
  LINE_HEIGHT_DIALOG_FIELD,
367
433
  CODE_LINE_HEIGHT_FIELD,
434
+ LETTER_SPACING_DIALOG_FIELD,
435
+ WORD_SPACING_DIALOG_FIELD,
436
+ PARAGRAPH_SPACING_DIALOG_FIELD,
437
+ INDENT_DIALOG_FIELD,
438
+ LINE_BREAK_STRICT_FIELD,
368
439
  LIGATURES_FIELD,
369
440
  FEATURES_FIELD,
370
441
  NO_SYNTHETIC_ITALIC_FIELD,
@@ -1507,6 +1578,16 @@ function normalizeField(field, value, relative) {
1507
1578
  return clampRatio(value);
1508
1579
  case CODE_LINE_HEIGHT_FIELD:
1509
1580
  return clampLineOffset(value);
1581
+ case LETTER_SPACING_DIALOG_FIELD:
1582
+ return clampSpacing(value, LETTER_SPACING_MIN, LETTER_SPACING_MAX);
1583
+ case WORD_SPACING_DIALOG_FIELD:
1584
+ return clampSpacing(value, WORD_SPACING_MIN, WORD_SPACING_MAX);
1585
+ case PARAGRAPH_SPACING_DIALOG_FIELD:
1586
+ return clampSpacing(value, PARAGRAPH_SPACING_MIN, PARAGRAPH_SPACING_MAX);
1587
+ case INDENT_DIALOG_FIELD:
1588
+ return clampIndent(value);
1589
+ case LINE_BREAK_STRICT_FIELD:
1590
+ return toBool(value);
1510
1591
  case LIGATURES_FIELD:
1511
1592
  return clampLigatures(value);
1512
1593
  case WEIGHT_OFFSETS_FIELD:
@@ -1590,6 +1671,52 @@ function clampWeight(value) {
1590
1671
  return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weight));
1591
1672
  }
1592
1673
 
1674
+ /**
1675
+ * Clamp one spacing offset (letter, word) to its allowed range, in px.
1676
+ *
1677
+ * These axes sit on top of a DSH default that is already zero (`normal` / `0px`,
1678
+ * measured on the conversation scope), so no migration and no baseline is involved:
1679
+ * the offset is written as it stands, and 0 means "emit nothing".
1680
+ * @param {unknown} value - candidate offset.
1681
+ * @param {number} min - the lowest allowed offset.
1682
+ * @param {number} max - the highest allowed offset.
1683
+ * @returns {number} the offset the stylesheet may use.
1684
+ */
1685
+ function clampSpacing(value, min, max) {
1686
+ var offset = Number(value);
1687
+ if (!isFinite(offset)) return 0;
1688
+ // Rounded to two decimals: the notches are tenths, and the sum of a few of them
1689
+ // must not turn into 0.30000000000000004 in the emitted CSS.
1690
+ offset = Math.round(offset * 100) / 100;
1691
+ if (offset > max) return max;
1692
+ if (offset < min) return min;
1693
+ return offset;
1694
+ }
1695
+
1696
+ /**
1697
+ * Clamp the first-line indent to its allowed range, in em.
1698
+ * @param {unknown} value - candidate indent.
1699
+ * @returns {number} the indent the stylesheet may use.
1700
+ */
1701
+ function clampIndent(value) {
1702
+ var indent = Number(value);
1703
+ if (!isFinite(indent)) return 0;
1704
+ indent = Math.round(indent * 100) / 100;
1705
+ if (indent > INDENT_MAX) return INDENT_MAX;
1706
+ if (indent < INDENT_MIN) return INDENT_MIN;
1707
+ return indent;
1708
+ }
1709
+
1710
+ /**
1711
+ * Format a spacing value for CSS, without scientific notation or float noise.
1712
+ * @param {number} value - a clamped px value.
1713
+ * @returns {string} the value as a px length.
1714
+ */
1715
+ function pxText(value) {
1716
+ var rounded = Math.round(value * 100) / 100;
1717
+ return String(rounded) + "px";
1718
+ }
1719
+
1593
1720
  /**
1594
1721
  * Clamp one RELATIVE weight to its allowed range, migrating an absolute value.
1595
1722
  *
@@ -2329,6 +2456,27 @@ function rebuildShorthand(parts) {
2329
2456
  * token classification
2330
2457
  * ------------------------------------------------------------------ */
2331
2458
 
2459
+ /**
2460
+ * Whether a CSS selector declares something for the WHOLE page.
2461
+ *
2462
+ * The typography tokens live on `:root`, `html` or `body`. A selector scoped to a class
2463
+ * or an attribute declares a LOCAL value: another plugin's module stylesheet may reuse a
2464
+ * token name for its own compact panel (measured: `dsh-better-sidebar` sets
2465
+ * `--dsw-font-markdown-h1` to `600 13px/18px` on `.…_taskMarkdown`), and reading that as
2466
+ * the base hands the conversation a size that panel picked for itself.
2467
+ * @param {string} selector - a rule's selector text.
2468
+ * @returns {boolean} true when every selector in the list is a root scope.
2469
+ */
2470
+ function isRootScopeSelector(selector) {
2471
+ if (typeof selector !== "string" || selector.trim() === "") return false;
2472
+ var parts = selector.split(",");
2473
+ for (var index = 0; index < parts.length; index += 1) {
2474
+ var part = parts[index].trim().toLowerCase();
2475
+ if (part !== ":root" && part !== "html" && part !== "body" && part !== "*") return false;
2476
+ }
2477
+ return true;
2478
+ }
2479
+
2332
2480
  /**
2333
2481
  * Match DSH's typography PART tokens: the design system's sizes plus the
2334
2482
  * content size the theme plugin writes on `body` (whose secondary variant
@@ -3169,6 +3317,180 @@ function weightStepRange(profile, value) {
3169
3317
  return { min: min, max: max, unit: unit, value: count };
3170
3318
  }
3171
3319
 
3320
+ /* ------------------------------------------------------------------ *
3321
+ * continuous weight (FT-060)
3322
+ * ------------------------------------------------------------------ */
3323
+
3324
+ /** Prefix of the alias family a continuous family is drawn through. */
3325
+ var VARIABLE_ALIAS_PREFIX = "dfp-vf-";
3326
+
3327
+ /**
3328
+ * The weights the continuity probe lays out. Dense on purpose: a variable font
3329
+ * that moves in small steps shows a width change at every one of them, while a
3330
+ * family that snaps to named instances repeats its width across each stretch.
3331
+ */
3332
+ var CONTINUITY_WEIGHTS = [400, 420, 440, 460, 480, 500, 550, 600, 650, 700, 750, 800, 900];
3333
+
3334
+ /**
3335
+ * The share of adjacent probe pairs that must differ before a family's laid-out
3336
+ * WIDTH counts as continuous. Measured: a variable family drawn through its alias
3337
+ * changes width at every sampled weight (share 1.0), while a family snapping to
3338
+ * named instances repeats its width across each stretch (share 0.25–0.42).
3339
+ */
3340
+ var CONTINUOUS_MIN_SHARE = 0.9;
3341
+
3342
+ /**
3343
+ * The notches offered for a continuous family, finest first.
3344
+ *
3345
+ * The shape is still measured (the alias is drawn on the same weight grid as any
3346
+ * other family), but the candidates are fine: a continuous axis can answer at every
3347
+ * one of them, which is the whole point of the mode. `weightProfileFrom` walks this
3348
+ * list from the END, so the finest candidate that still yields enough visible
3349
+ * positions wins.
3350
+ */
3351
+ var CONTINUOUS_CANDIDATES = [10, 20, 25];
3352
+
3353
+ /**
3354
+ * The nominal continuous slider, used when the measured shape cannot be taken
3355
+ * (no canvas, a refused probe). One notch is 20 weight units; the range is the
3356
+ * widest offset the ceiling allows either way.
3357
+ */
3358
+ var CONTINUOUS_STEP = 20;
3359
+ var CONTINUOUS_RANGE = 200;
3360
+
3361
+ /**
3362
+ * How many different values a list of measurements holds.
3363
+ * @param {number[]} values - the measurements.
3364
+ * @returns {number} the count of distinct values.
3365
+ */
3366
+ function distinctCount(values) {
3367
+ if (!Array.isArray(values)) return 0;
3368
+ var seen = [];
3369
+ for (var index = 0; index < values.length; index += 1) {
3370
+ if (seen.indexOf(values[index]) < 0) seen.push(values[index]);
3371
+ }
3372
+ return seen.length;
3373
+ }
3374
+
3375
+ /**
3376
+ * Does the same family answer more finely through its alias than through itself?
3377
+ *
3378
+ * This is the discriminator the continuity probe trusts, and it needs no threshold:
3379
+ * a family that snaps to named instances renders IDENTICALLY either way, so both
3380
+ * responses hold the same number of distinct values; a variable family drawn through
3381
+ * the alias moves at weights where the system path already stopped. Comparing the
3382
+ * family against ITSELF also keeps the answer honest for a narrow axis, where a
3383
+ * share-of-neighbours rule would read saturation as "static".
3384
+ * @param {number[]} throughAlias - measurements taken through the alias face.
3385
+ * @param {number[]} plain - the same measurements taken through the family itself.
3386
+ * @returns {boolean} true when the alias resolves finer detail.
3387
+ */
3388
+ function respondsFinerThan(throughAlias, plain) {
3389
+ if (!Array.isArray(throughAlias) || !Array.isArray(plain)) return false;
3390
+ var fine = distinctCount(throughAlias);
3391
+ return fine >= 3 && fine > distinctCount(plain);
3392
+ }
3393
+
3394
+
3395
+ /**
3396
+ * The alias family name for one family. A hash, not the family itself, so the
3397
+ * name is a plain identifier whatever the family is called.
3398
+ * @param {string} family - a family name.
3399
+ * @returns {string} the alias, stable across sessions.
3400
+ */
3401
+ function variableAliasFor(family) {
3402
+ var text = sanitizeFamily(family);
3403
+ var hash = 0;
3404
+ for (var index = 0; index < text.length; index += 1) {
3405
+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
3406
+ }
3407
+ return VARIABLE_ALIAS_PREFIX + hash.toString(36);
3408
+ }
3409
+
3410
+ /**
3411
+ * The `@font-face` rule that draws one family through its own variable axis.
3412
+ *
3413
+ * `local()` points the face at the installed family, and the weight RANGE is the
3414
+ * part that matters: it tells the engine the family is continuous, so `font-weight`
3415
+ * picks a point on the axis instead of the nearest named instance. (Without the
3416
+ * range the same family snaps; `font-variation-settings` does not help a system
3417
+ * family at all — measured, see FT-060.)
3418
+ * @param {string} family - the installed family name.
3419
+ * @param {string} alias - the alias from `variableAliasFor`.
3420
+ * @returns {string} one CSS rule.
3421
+ */
3422
+ function variableFaceRule(family, alias) {
3423
+ var text = sanitizeFamily(family).replace(/"/g, "");
3424
+ return '@font-face{font-family:"' + alias + '";src:local("' + text + '");font-weight:100 900;}';
3425
+ }
3426
+
3427
+ /**
3428
+ * Every `@font-face` rule for a map of family → alias.
3429
+ * @param {Record<string, string>} variable - the families that measured continuous.
3430
+ * @returns {string} the rules, one per line; "" when there are none.
3431
+ */
3432
+ function buildVariableFaces(variable) {
3433
+ var rules = [];
3434
+ if (variable === null || typeof variable !== "object") return "";
3435
+ for (var family in variable) {
3436
+ if (Object.prototype.hasOwnProperty.call(variable, family)) {
3437
+ rules.push(variableFaceRule(family, variable[family]));
3438
+ }
3439
+ }
3440
+ return rules.join("\n");
3441
+ }
3442
+
3443
+ /**
3444
+ * Put the alias IN FRONT of every continuous family a stack names.
3445
+ *
3446
+ * The family stays in the list after its alias, so a glyph the alias lacks still
3447
+ * falls through to the same face the reader chose, and a stack that names no
3448
+ * continuous family is returned unchanged.
3449
+ * @param {string} stack - a formatted font-family value.
3450
+ * @param {Record<string, string>} variable - family → alias map.
3451
+ * @returns {string} the stack, with aliases prepended where they apply.
3452
+ */
3453
+ function withVariableFaces(stack, variable) {
3454
+ if (typeof stack !== "string" || stack === "" || variable === null || typeof variable !== "object") {
3455
+ return stack;
3456
+ }
3457
+ // The stack may already carry its alias — a family read back from the page's own
3458
+ // computed style does, once the mode is on. Prepending it again would stack up a
3459
+ // duplicate on every rebuild.
3460
+ var names = parseStack(stack);
3461
+ var lead = names.length > 0 ? names[0] : "";
3462
+ var prefix = [];
3463
+ for (var family in variable) {
3464
+ if (!Object.prototype.hasOwnProperty.call(variable, family)) continue;
3465
+ if (stack.indexOf(quoteFamily(family)) < 0) continue;
3466
+ if (lead === variable[family]) continue;
3467
+ prefix.push('"' + variable[family] + '"');
3468
+ }
3469
+ return prefix.length === 0 ? stack : prefix.join(", ") + ", " + stack;
3470
+ }
3471
+
3472
+ /**
3473
+ * Did a family respond continuously to weight in the probe?
3474
+ * @param {number[]} widths - laid-out widths, in `CONTINUITY_WEIGHTS` order.
3475
+ * @returns {boolean} true when enough neighbours differ.
3476
+ */
3477
+ function isContinuousResponse(widths) {
3478
+ if (!Array.isArray(widths) || widths.length < 3) return false;
3479
+ var changes = 0;
3480
+ for (var index = 1; index < widths.length; index += 1) {
3481
+ if (widths[index] !== widths[index - 1]) changes += 1;
3482
+ }
3483
+ return changes / (widths.length - 1) >= CONTINUOUS_MIN_SHARE;
3484
+ }
3485
+
3486
+ /**
3487
+ * The slider shape for a continuous family: the finest notch, centred on 0.
3488
+ * @returns {{min: number, max: number, step: number}} the profile, in weight units.
3489
+ */
3490
+ function continuousWeightProfile() {
3491
+ return { min: -CONTINUOUS_RANGE, max: CONTINUOUS_RANGE, step: CONTINUOUS_STEP };
3492
+ }
3493
+
3172
3494
  /**
3173
3495
  * What a slider's local (unconfirmed) value should do when a value arrives from
3174
3496
  * the settings document.
@@ -3232,6 +3554,11 @@ function isDormant(config) {
3232
3554
  clampWeightDelta(axis[CODE_WEIGHT_FIELD], true) === WEIGHT_UNSET &&
3233
3555
  clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
3234
3556
  clampLineOffset(axis[CODE_LINE_HEIGHT_FIELD]) === 0 &&
3557
+ clampSpacing(axis[LETTER_SPACING_DIALOG_FIELD], LETTER_SPACING_MIN, LETTER_SPACING_MAX) === 0 &&
3558
+ clampSpacing(axis[WORD_SPACING_DIALOG_FIELD], WORD_SPACING_MIN, WORD_SPACING_MAX) === 0 &&
3559
+ clampSpacing(axis[PARAGRAPH_SPACING_DIALOG_FIELD], PARAGRAPH_SPACING_MIN, PARAGRAPH_SPACING_MAX) === 0 &&
3560
+ clampIndent(axis[INDENT_DIALOG_FIELD]) === 0 &&
3561
+ toBool(axis[LINE_BREAK_STRICT_FIELD]) === false &&
3235
3562
  clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
3236
3563
  sanitizeFeatures(axis[FEATURES_FIELD]) === "" &&
3237
3564
  toBool(axis[NO_SYNTHETIC_ITALIC_FIELD]) === false &&
@@ -3256,10 +3583,12 @@ function isDormant(config) {
3256
3583
  * @param {boolean} isDark - prefix every rule with the dark-theme attribute.
3257
3584
  * @returns {string} declarations for one `<style>` element.
3258
3585
  */
3259
- function buildAxisCss(axis, baseTokens, isDark, hints) {
3260
- var sans = formatStack(parseStack(axis[SANS_FIELD]));
3261
- var mono = formatStack(parseStack(axis[MONO_FIELD]));
3262
- var dialog = formatStack(parseStack(axis[STACK_DIALOG_FIELD]));
3586
+ function buildAxisCss(axis, baseTokens, isDark, hints, variable) {
3587
+ // A continuous family leads its stack with its alias (see `withVariableFaces`);
3588
+ // with no map, every stack stays exactly as written.
3589
+ var sans = withVariableFaces(formatStack(parseStack(axis[SANS_FIELD])), variable);
3590
+ var mono = withVariableFaces(formatStack(parseStack(axis[MONO_FIELD])), variable);
3591
+ var dialog = withVariableFaces(formatStack(parseStack(axis[STACK_DIALOG_FIELD])), variable);
3263
3592
  // Normalised once: an expanded axis always carries a number, but a caller
3264
3593
  // handing in a raw object may leave the field out, and "absent" has to mean
3265
3594
  // "unset" rather than "write the string undefined". The conversation's value
@@ -3613,6 +3942,60 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
3613
3942
  }
3614
3943
  }
3615
3944
 
3945
+ // ---- conversation text shaping (tracking, word gap, paragraph gap, indent) ----
3946
+ //
3947
+ // Every value here is written on the CONVERSATION scope, and the measurements that
3948
+ // fixed the shape were taken there (2026-10-13): the markdown's tracking is `normal`
3949
+ // and its word gap `0px`, so those two axes are absolute and 0 emits nothing; the
3950
+ // paragraphs carry 16px margins, so the paragraph axis is an offset over that
3951
+ // baseline. Code surfaces are excluded by the same scope rule the weight ladder uses,
3952
+ // so a code block's own tracking is never rewritten by a conversation axis.
3953
+ var letterSpacing = clampSpacing(axis[LETTER_SPACING_DIALOG_FIELD], LETTER_SPACING_MIN, LETTER_SPACING_MAX);
3954
+ var wordSpacing = clampSpacing(axis[WORD_SPACING_DIALOG_FIELD], WORD_SPACING_MIN, WORD_SPACING_MAX);
3955
+ var paragraphSpacing = clampSpacing(
3956
+ axis[PARAGRAPH_SPACING_DIALOG_FIELD],
3957
+ PARAGRAPH_SPACING_MIN,
3958
+ PARAGRAPH_SPACING_MAX
3959
+ );
3960
+ var indent = clampIndent(axis[INDENT_DIALOG_FIELD]);
3961
+ if (letterSpacing !== 0) {
3962
+ declarations.push(weightScopeRule("letter-spacing:" + pxText(letterSpacing) + " !important"));
3963
+ }
3964
+ if (wordSpacing !== 0) {
3965
+ declarations.push(weightScopeRule("word-spacing:" + pxText(wordSpacing) + " !important"));
3966
+ }
3967
+ if (indent !== 0) {
3968
+ // A first-line indent is a PROSE convention, so it belongs to paragraphs. Emitted for
3969
+ // the whole conversation scope it inherited into every block under it; each of these
3970
+ // was measured on the page with the axis at 2em (32px on a 16px base):
3971
+ // - a heading sat 32px in, where no Chinese reader expects one;
3972
+ // - an ordered-list item's TEXT moved 32px while the number stayed at the content
3973
+ // edge (the marker sits outside the content box), so the number ended up in front
3974
+ // of the indent the text had taken;
3975
+ // - inline code is `display: inline-flex` in DSH's markdown — a block container of
3976
+ // its own — so the indent landed INSIDE the chip as blank space before its own
3977
+ // text, wherever the chip sat in the line;
3978
+ // - a code block's first line shifted the same way.
3979
+ // Paragraphs are the only place the convention exists, and their descendants are
3980
+ // zeroed so a chip that happens to be its own block still draws flush.
3981
+ var indentDeclarations = "text-indent:" + String(indent) + "em !important";
3982
+ declarations.push(weightScopeRule("p{" + indentDeclarations + "}"));
3983
+ declarations.push(weightScopeRule("p *{text-indent:0 !important}"));
3984
+ }
3985
+ if (paragraphSpacing !== 0) {
3986
+ // Both margins move together: adjacent siblings collapse to the LARGER of the two,
3987
+ // so shifting only `margin-top` would silently ignore every negative offset.
3988
+ var gap =
3989
+ paragraphSpacing > 0
3990
+ ? "calc(" + PARAGRAPH_BASELINE_PX + "px + " + pxText(paragraphSpacing) + ")"
3991
+ : "calc(" + PARAGRAPH_BASELINE_PX + "px - " + pxText(-paragraphSpacing) + ")";
3992
+ declarations.push(weightScopeRule("p{margin-bottom:" + gap + " !important}"));
3993
+ declarations.push(weightScopeRule("p:not(:first-child){margin-top:" + gap + " !important}"));
3994
+ }
3995
+ if (toBool(axis[LINE_BREAK_STRICT_FIELD])) {
3996
+ declarations.push(weightScopeRule("line-break:strict !important"));
3997
+ }
3998
+
3616
3999
  // ---- code axes ----
3617
4000
  var codeSize = axis[CODE_SIZE_FIELD];
3618
4001
  var codeLine = axis[CODE_LINE_HEIGHT_FIELD];
@@ -3659,7 +4042,28 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
3659
4042
  if (noBold) declarations.push(bodyRule("font-synthesis-weight:none !important"));
3660
4043
  }
3661
4044
 
3662
- return declarations.join("\n");
4045
+ return hardenDeltaRefs(declarations.join("\n"));
4046
+ }
4047
+
4048
+ /**
4049
+ * Give every reference to the content-size delta a fallback value.
4050
+ *
4051
+ * A heading's `font` shorthand is the markdown token, and DSH's own text for it reads
4052
+ * `calc(21px + var(--dsh-content-font-delta))` — a reference with NO fallback. Nothing
4053
+ * outside this plugin defines that property, so any rebuild that emits the reference
4054
+ * without the definition leaves it resolving to nothing, which makes the WHOLE shorthand
4055
+ * invalid at computed-value time: every font longhand falls back to `inherit`, and the
4056
+ * heading is painted at body size. Measured on a live page: h1 23px → 16px the moment the
4057
+ * property resolved to nothing, back to 23px when it returned.
4058
+ *
4059
+ * The fallback cannot change a page that defines the property (the definition wins), and
4060
+ * where the definition is missing it keeps the design's own size instead of collapsing.
4061
+ * @param {string} css - generated declarations.
4062
+ * @returns {string} the same declarations, with the reference made safe.
4063
+ */
4064
+ function hardenDeltaRefs(css) {
4065
+ if (typeof css !== "string" || css === "") return css;
4066
+ return css.split("var(--dsh-content-font-delta)").join("var(--dsh-content-font-delta, 0px)");
3663
4067
  }
3664
4068
 
3665
4069
  /**
@@ -3751,7 +4155,7 @@ function scaleCodeTokens(baseTokens, scale, lineOffset) {
3751
4155
  * correct everywhere and costs an attribute scan per element per rule.
3752
4156
  * @returns {string} declarations for one `<style>` element ("" when dormant).
3753
4157
  */
3754
- function buildFontCss(config, baseTokens, hints) {
4158
+ function buildFontCss(config, baseTokens, hints, variable) {
3755
4159
  var tokens = baseTokens === undefined || baseTokens === null ? FALLBACK_TOKENS : baseTokens;
3756
4160
  var sets = resolveAxes(config);
3757
4161
  // The rendering preference is NOT an axis: it must paint even when every
@@ -3759,16 +4163,16 @@ function buildFontCss(config, baseTokens, hints) {
3759
4163
  // use case with everything else untouched.
3760
4164
  var smoothing = smoothingRule(config !== null && typeof config === "object" ? config[SMOOTHING_FIELD] : "");
3761
4165
  if (isDormant(sets.light) && isDormant(sets.dark)) return smoothing;
3762
- var light = buildAxisCss(sets.light, tokens, false, hints);
4166
+ var light = buildAxisCss(sets.light, tokens, false, hints, variable);
3763
4167
  if (sameValueSet(sets.light, sets.dark)) {
3764
- return smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing;
4168
+ return hardenDeltaRefs(smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing);
3765
4169
  }
3766
- var dark = buildAxisCss(sets.dark, tokens, true, hints);
4170
+ var dark = buildAxisCss(sets.dark, tokens, true, hints, variable);
3767
4171
  var parts = [];
3768
4172
  if (light !== "") parts.push(light);
3769
4173
  if (dark !== "") parts.push(dark);
3770
4174
  if (smoothing !== "") parts.push(smoothing);
3771
- return parts.join("\n");
4175
+ return hardenDeltaRefs(parts.join("\n"));
3772
4176
  }
3773
4177
 
3774
4178
  /**
@@ -4213,6 +4617,24 @@ var shared = {
4213
4617
  LINE_HEIGHT_MAX: LINE_HEIGHT_MAX,
4214
4618
  CODE_LINE_HEIGHT_MIN: CODE_LINE_HEIGHT_MIN,
4215
4619
  CODE_LINE_HEIGHT_MAX: CODE_LINE_HEIGHT_MAX,
4620
+ LETTER_SPACING_DIALOG_FIELD: LETTER_SPACING_DIALOG_FIELD,
4621
+ WORD_SPACING_DIALOG_FIELD: WORD_SPACING_DIALOG_FIELD,
4622
+ PARAGRAPH_SPACING_DIALOG_FIELD: PARAGRAPH_SPACING_DIALOG_FIELD,
4623
+ INDENT_DIALOG_FIELD: INDENT_DIALOG_FIELD,
4624
+ LINE_BREAK_STRICT_FIELD: LINE_BREAK_STRICT_FIELD,
4625
+ LETTER_SPACING_MIN: LETTER_SPACING_MIN,
4626
+ LETTER_SPACING_MAX: LETTER_SPACING_MAX,
4627
+ LETTER_SPACING_STEP: LETTER_SPACING_STEP,
4628
+ WORD_SPACING_MIN: WORD_SPACING_MIN,
4629
+ WORD_SPACING_MAX: WORD_SPACING_MAX,
4630
+ WORD_SPACING_STEP: WORD_SPACING_STEP,
4631
+ PARAGRAPH_SPACING_MIN: PARAGRAPH_SPACING_MIN,
4632
+ PARAGRAPH_SPACING_MAX: PARAGRAPH_SPACING_MAX,
4633
+ PARAGRAPH_SPACING_STEP: PARAGRAPH_SPACING_STEP,
4634
+ INDENT_MIN: INDENT_MIN,
4635
+ INDENT_MAX: INDENT_MAX,
4636
+ INDENT_STEP: INDENT_STEP,
4637
+ PARAGRAPH_BASELINE_PX: PARAGRAPH_BASELINE_PX,
4216
4638
  LIGATURES_DEFAULT: LIGATURES_DEFAULT,
4217
4639
  SMOOTHING_FIELD: SMOOTHING_FIELD,
4218
4640
  SMOOTHING_AUTO: SMOOTHING_AUTO,
@@ -4290,6 +4712,19 @@ var shared = {
4290
4712
  WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
4291
4713
  WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
4292
4714
  WEIGHT_MIN_POSITIONS: WEIGHT_MIN_POSITIONS,
4715
+ VARIABLE_ALIAS_PREFIX: VARIABLE_ALIAS_PREFIX,
4716
+ CONTINUITY_WEIGHTS: CONTINUITY_WEIGHTS,
4717
+ isRootScopeSelector: isRootScopeSelector,
4718
+ hardenDeltaRefs: hardenDeltaRefs,
4719
+ CONTINUOUS_CANDIDATES: CONTINUOUS_CANDIDATES,
4720
+ distinctCount: distinctCount,
4721
+ respondsFinerThan: respondsFinerThan,
4722
+ variableAliasFor: variableAliasFor,
4723
+ variableFaceRule: variableFaceRule,
4724
+ buildVariableFaces: buildVariableFaces,
4725
+ withVariableFaces: withVariableFaces,
4726
+ isContinuousResponse: isContinuousResponse,
4727
+ continuousWeightProfile: continuousWeightProfile,
4293
4728
  WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
4294
4729
  WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
4295
4730
  WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Font tune (dsh-fonttune)",
4
- "description": "Interface and conversation fonts, a size, a weight and a line height for each, code extras and presets"
4
+ "description": "Interface and conversation fonts, size, weight and line height, CJK type shaping (tracking, paragraph gaps, first-line indent), code extras and presets"
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "字体增强(dsh-fonttune)",
4
- "description": "界面与对话字体、各自的字号/字重/行高,代码增强与预设方案"
4
+ "description": "界面与对话字体、字号/字重/行高,字距/段距/首行缩进等中文排印,代码增强与预设方案"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Font plugin for the DeepSeek Harness Web GUI: the conversation gets its own font, size, line height and weight, the interface font and weight follow it, code keeps its own axis with ligature control, and whole setups save as presets.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -18,7 +18,9 @@
18
18
  "README.en.md",
19
19
  "CHANGELOG.md",
20
20
  "CHANGELOG.en.md",
21
- "LICENSE"
21
+ "LICENSE",
22
+ "docs/panel.jpg",
23
+ "docs/card-shaping.jpg"
22
24
  ],
23
25
  "engines": {
24
26
  "dsh": ">=0.1.7-alpha.1 <0.3.0-0"