dsh-fonttune 0.3.3 → 0.3.5

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
@@ -86,6 +86,23 @@ var WEIGHT_DIALOG_FIELD = "weightDialog";
86
86
  /** Field carrying the code font weight (0 = leave DSH alone). */
87
87
  var CODE_WEIGHT_FIELD = "weightCode";
88
88
 
89
+ /**
90
+ * Marks a document whose weight fields are OFFSETS, not absolute weights.
91
+ *
92
+ * Before 0.2.8 the three weight axes stored an absolute CSS weight (`300…600`,
93
+ * `400` = normal) and `clampWeightDelta` converted one on sight. That inference
94
+ * only works while offsets never land inside the absolute window, which capped
95
+ * the offset range at +200 — and +200 is not enough room for a step the eye can
96
+ * see on a family with few faces (measured: one 200-weight step is a +30% change
97
+ * on Noto Serif SC, one 100-weight step only +16%).
98
+ *
99
+ * So the document says which scale it is on, and the inference is borrowed for
100
+ * exactly one write: the first time 0.3.5+ writes a weight, the card converts any
101
+ * remaining absolute value, converts the saved presets the same way, and sets
102
+ * this field. From then on a stored weight is an offset, whatever its size.
103
+ */
104
+ var WEIGHT_OFFSETS_FIELD = "weightOffsets";
105
+
89
106
  /**
90
107
  * Field carrying the interface line-height ratio in percent (100 = untouched).
91
108
  * A ratio rather than a px offset: interface line heights form a design ladder
@@ -217,17 +234,18 @@ var WEIGHT_LADDER_TOP = DIALOG_WEIGHT_GROUPS.reduce(function (top, group) {
217
234
  /**
218
235
  * How far above its base an offset may reach at all.
219
236
  *
220
- * Every axis applies the offset to each element's OWN weight, and the heaviest
221
- * layer of the ladder sits at `WEIGHT_LADDER_TOP`; past `WEIGHT_CSS_MAX` minus
222
- * that, the heaviest layer cannot move any further, so a longer range would only
223
- * add positions that change nothing for the boldest text on screen.
237
+ * The ceiling comes from the layer a notch MUST move: body text sits at
238
+ * `WEIGHT_BASE`, so the range runs as far as the text a reader is reading can
239
+ * still change — measured up to +400 on a family whose faces reach 900. Bold text
240
+ * stops moving at +300 and headings at +200; that is accepted, because a heading
241
+ * already at its heaviest face stays there (see `WEIGHT_LADDER_STRONG`).
224
242
  *
225
- * Staying inside it has a second, load-bearing effect: offsets then never land in
226
- * `WEIGHT_MIN…WEIGHT_MAX`, the range an ABSOLUTE weight from a pre-relative
227
- * document occupies (see `clampWeightDelta`). The two meanings cannot be confused,
228
- * so a stored offset is never rescaled into a different one.
243
+ * It used to stop at `WEIGHT_CSS_MAX - WEIGHT_LADDER_TOP` (= +200) so an offset
244
+ * could never land inside `WEIGHT_MIN…WEIGHT_MAX`, the window an ABSOLUTE weight
245
+ * from a pre-relative document occupies. The document's own marker resolves that
246
+ * window now (`WEIGHT_OFFSETS_FIELD`), so the range is free to grow.
229
247
  */
230
- var WEIGHT_DELTA_UP_LIMIT = WEIGHT_CSS_MAX - WEIGHT_LADDER_TOP;
248
+ var WEIGHT_DELTA_UP_LIMIT = WEIGHT_CSS_MAX - WEIGHT_BASE;
231
249
 
232
250
  /**
233
251
  * The heaviest layer a notch still has to move: bold text.
@@ -323,9 +341,12 @@ var VALUE_FIELDS = [
323
341
  var RETIRED_FIELDS = [SIZE_FIELD, LINE_HEIGHT_FIELD];
324
342
 
325
343
  /**
326
- * Every durable axis field, live and retired: what normalization covers.
344
+ * Every durable field normalization covers: the live axes, the retired pair, and
345
+ * the scale marker (`WEIGHT_OFFSETS_FIELD`, which is deliberately NOT a value
346
+ * axis — a preset must never carry it, or applying that preset would re-label a
347
+ * document still holding absolute weights).
327
348
  */
328
- var DURABLE_FIELDS = VALUE_FIELDS.concat(RETIRED_FIELDS);
349
+ var DURABLE_FIELDS = VALUE_FIELDS.concat(RETIRED_FIELDS, [WEIGHT_OFFSETS_FIELD]);
329
350
 
330
351
  /**
331
352
  * Longest accepted font stack, in characters (mirrored by the host schema).
@@ -444,7 +465,7 @@ var FEATURES_UNSAFE = /[^a-zA-Z0-9"' ,]/g;
444
465
  * ------------------------------------------------------------------ */
445
466
 
446
467
  /** Clamp/validate one field by name (shared by config and value sets). */
447
- function normalizeField(field, value) {
468
+ function normalizeField(field, value, relative) {
448
469
  switch (field) {
449
470
  case SANS_FIELD:
450
471
  case STACK_DIALOG_FIELD:
@@ -457,7 +478,7 @@ function normalizeField(field, value) {
457
478
  case WEIGHT_FIELD:
458
479
  case WEIGHT_DIALOG_FIELD:
459
480
  case CODE_WEIGHT_FIELD:
460
- return clampWeightDelta(value);
481
+ return clampWeightDelta(value, relative);
461
482
  case LINE_HEIGHT_FIELD:
462
483
  case LINE_HEIGHT_DIALOG_FIELD:
463
484
  return clampRatio(value);
@@ -465,6 +486,9 @@ function normalizeField(field, value) {
465
486
  return clampLineOffset(value);
466
487
  case LIGATURES_FIELD:
467
488
  return clampLigatures(value);
489
+ case WEIGHT_OFFSETS_FIELD:
490
+ // The document's own scale marker: false/absent = the pre-relative scale.
491
+ return toBool(value);
468
492
  case FEATURES_FIELD:
469
493
  return sanitizeFeatures(value);
470
494
  case NO_SYNTHETIC_ITALIC_FIELD:
@@ -488,14 +512,17 @@ function normalizeField(field, value) {
488
512
  */
489
513
  function normalizeConfig(value) {
490
514
  var source = value !== null && typeof value === "object" ? value : {};
515
+ // The scale marker is read BEFORE the axes: it decides whether a weight inside
516
+ // `300…600` is a legacy absolute value or an offset (see `clampWeightDelta`).
517
+ var relative = toBool(source[WEIGHT_OFFSETS_FIELD]);
491
518
  var config = {};
492
519
  for (var index = 0; index < DURABLE_FIELDS.length; index += 1) {
493
520
  var field = DURABLE_FIELDS[index];
494
- config[field] = normalizeField(field, source[field]);
521
+ config[field] = normalizeField(field, source[field], relative);
495
522
  }
496
523
  config[PER_THEME_FIELD] = toBool(source[PER_THEME_FIELD]);
497
- config[DARK_VALUES_FIELD] = normalizeDarkValues(source[DARK_VALUES_FIELD]);
498
- config[PRESETS_FIELD] = normalizePresets(source[PRESETS_FIELD]);
524
+ config[DARK_VALUES_FIELD] = normalizeDarkValues(source[DARK_VALUES_FIELD], relative);
525
+ config[PRESETS_FIELD] = normalizePresets(source[PRESETS_FIELD], relative);
499
526
  config[ACTIVE_PRESET_FIELD] = sanitize(source[ACTIVE_PRESET_FIELD]).slice(0, MAX_PRESET_NAME);
500
527
  return config;
501
528
  }
@@ -533,21 +560,21 @@ function clampWeight(value) {
533
560
  * Clamp one RELATIVE weight to its allowed range, migrating an absolute value.
534
561
  *
535
562
  * A document written before the axes became relative stores an absolute weight
536
- * (`300…600`, `400` = normal). That window sits entirely outside the delta
537
- * window, so the conversion is unambiguous and idempotent: a value inside
538
- * `300…600` is old and becomes its distance from normal (`400 → 0`, `560 → +160`,
539
- * `300 → −100`), while a value already inside `−100…200` is a delta and is left
540
- * alone. The user's saved setup therefore keeps meaning "as bold as before"
541
- * instead of jumping to the top of the new scale.
563
+ * (`300…600`, `400` = normal) and has no marker, so a value inside `300…600` is
564
+ * converted to its distance from normal (`400 → 0`, `560 → +160`, `300 → −100`).
565
+ * A marked document is on the offset scale already and is never converted — that
566
+ * is what lets an offset grow past `+200` without being mistaken for one of those
567
+ * absolute values (the two windows overlap; see `WEIGHT_OFFSETS_FIELD`).
542
568
  *
543
569
  * @param {unknown} value - candidate offset, or a pre-0.2.8 absolute weight.
570
+ * @param {boolean} [relative] - true when the document carries the offset marker.
544
571
  * @returns {number} the offset the stylesheet may use.
545
572
  */
546
- function clampWeightDelta(value) {
573
+ function clampWeightDelta(value, relative) {
547
574
  var delta = Number(value);
548
575
  if (!isFinite(delta)) return WEIGHT_UNSET;
549
576
  delta = Math.round(delta);
550
- if (delta >= WEIGHT_MIN && delta <= WEIGHT_MAX) delta -= WEIGHT_BASE;
577
+ if (relative !== true && delta >= WEIGHT_MIN && delta <= WEIGHT_MAX) delta -= WEIGHT_BASE;
551
578
  // The slider's own range comes from the font (see `weightProfileFrom`) and can
552
579
  // reach further than the nominal one, so the hard bounds are the widest range
553
580
  // any font-derived slider may use.
@@ -595,13 +622,13 @@ function clampLigatures(value) {
595
622
  * @param {unknown} values - candidate map.
596
623
  * @returns {Record<string, string|number|boolean>} the safe subset.
597
624
  */
598
- function normalizeValueSet(values) {
625
+ function normalizeValueSet(values, relative) {
599
626
  var out = {};
600
627
  if (values === null || typeof values !== "object" || Array.isArray(values)) return out;
601
628
  for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
602
629
  var field = VALUE_FIELDS[index];
603
630
  if (!Object.prototype.hasOwnProperty.call(values, field)) continue;
604
- out[field] = normalizeField(field, values[field]);
631
+ out[field] = normalizeField(field, values[field], relative);
605
632
  }
606
633
  return out;
607
634
  }
@@ -611,7 +638,7 @@ function normalizeValueSet(values) {
611
638
  * @param {unknown} value - candidate JSON text or object.
612
639
  * @returns {Record<string, string|number|boolean>} the sparse override map.
613
640
  */
614
- function normalizeDarkValues(value) {
641
+ function normalizeDarkValues(value, relative) {
615
642
  if (typeof value === "string") {
616
643
  if (value.trim() === "") return {};
617
644
  try {
@@ -620,7 +647,70 @@ function normalizeDarkValues(value) {
620
647
  return {};
621
648
  }
622
649
  }
623
- return normalizeValueSet(value);
650
+ return normalizeValueSet(value, relative);
651
+ }
652
+
653
+ /** The three axes that share the weight scale (and its marker). */
654
+ var WEIGHT_FIELDS = [WEIGHT_FIELD, WEIGHT_DIALOG_FIELD, CODE_WEIGHT_FIELD];
655
+
656
+ /**
657
+ * Convert the weight fields of saved preset snapshots to offsets.
658
+ *
659
+ * Runs exactly once per document, right before the card sets
660
+ * `WEIGHT_OFFSETS_FIELD`: a snapshot saved before 0.2.8 holds an absolute weight,
661
+ * and once the document is marked a stored weight is an offset, so applying that
662
+ * snapshot later would read `450` as +450 instead of +50. Converting the snapshots
663
+ * in the same breath as the document keeps the two on one scale.
664
+ *
665
+ * Only values inside the legacy absolute window are touched, so a snapshot that
666
+ * already holds offsets (any 0.3.x one) comes back unchanged and `changed` stays
667
+ * false — the caller then writes nothing.
668
+ *
669
+ * @param {unknown} presets - normalized presets, or anything shaped like them.
670
+ * @returns {{presets: object[], changed: boolean}} the converted list.
671
+ */
672
+ function migratePresetWeights(presets) {
673
+ if (!Array.isArray(presets)) return { presets: [], changed: false };
674
+ var changed = false;
675
+ var out = [];
676
+ for (var index = 0; index < presets.length; index += 1) {
677
+ var entry = presets[index];
678
+ if (entry === null || typeof entry !== "object" || entry.values === null) {
679
+ out.push(entry);
680
+ continue;
681
+ }
682
+ var values = entry.values;
683
+ if (typeof values !== "object") {
684
+ out.push(entry);
685
+ continue;
686
+ }
687
+ var converted = null;
688
+ for (var axis = 0; axis < WEIGHT_FIELDS.length; axis += 1) {
689
+ var field = WEIGHT_FIELDS[axis];
690
+ if (!Object.prototype.hasOwnProperty.call(values, field)) continue;
691
+ var stored = Number(values[field]);
692
+ if (!isFinite(stored) || stored < WEIGHT_MIN || stored > WEIGHT_MAX) continue;
693
+ if (converted === null) {
694
+ converted = {};
695
+ for (var key in values) {
696
+ if (Object.prototype.hasOwnProperty.call(values, key)) converted[key] = values[key];
697
+ }
698
+ }
699
+ converted[field] = stored - WEIGHT_BASE;
700
+ }
701
+ if (converted === null) {
702
+ out.push(entry);
703
+ continue;
704
+ }
705
+ changed = true;
706
+ var copy = {};
707
+ for (var name in entry) {
708
+ if (Object.prototype.hasOwnProperty.call(entry, name)) copy[name] = entry[name];
709
+ }
710
+ copy.values = converted;
711
+ out.push(copy);
712
+ }
713
+ return { presets: out, changed: changed };
624
714
  }
625
715
 
626
716
  /**
@@ -628,7 +718,7 @@ function normalizeDarkValues(value) {
628
718
  * @param {unknown} value - candidate JSON text or array.
629
719
  * @returns {{name: string, values: object, savedAt: number}[]}
630
720
  */
631
- function normalizePresets(value) {
721
+ function normalizePresets(value, relative) {
632
722
  var list = value;
633
723
  if (typeof value === "string") {
634
724
  if (value.trim() === "") return [];
@@ -648,7 +738,7 @@ function normalizePresets(value) {
648
738
  var stamp = Math.round(Number(entry.savedAt));
649
739
  out.push({
650
740
  name: name,
651
- values: normalizeValueSet(entry.values),
741
+ values: normalizeValueSet(entry.values, relative),
652
742
  savedAt: isFinite(stamp) && stamp > 0 ? stamp : 0,
653
743
  });
654
744
  }
@@ -1701,6 +1791,17 @@ function resolveAxes(config) {
1701
1791
  */
1702
1792
  var WEIGHT_STEP_CANDIDATES = [1, 5, 10, 25, 50, 100, 200, 300];
1703
1793
 
1794
+ /**
1795
+ * How many positions a slider must still offer before a coarser step is refused.
1796
+ *
1797
+ * Coarseness is the point (see `weightProfileFrom`), but a control with two
1798
+ * positions is not a slider. Four is the smallest count that still lets a reader
1799
+ * go one notch either side of the neutral position and one further up — and on
1800
+ * the measured seven-face family it lands on a 200-weight step, where one notch is
1801
+ * a +30% change in ink, against +16% at 100 (which is why 100 was not enough).
1802
+ */
1803
+ var WEIGHT_MIN_POSITIONS = 4;
1804
+
1704
1805
  /** The weight grid the profile is measured on. */
1705
1806
  var WEIGHT_PROBE_LOW = 100;
1706
1807
  var WEIGHT_PROBE_HIGH = 900;
@@ -1742,19 +1843,28 @@ function everyPositionDiffers(sample, bases, min, max, step) {
1742
1843
  /**
1743
1844
  * The slider a family deserves: how far it can go, and by how much.
1744
1845
  *
1745
- * A font with six faces can be made lighter or heavier only as far as those faces
1746
- * reach, and only in steps between them; a family with a continuous axis gets a
1747
- * fine step and a wide range. Measured on a weight grid:
1846
+ * A font can be made lighter or heavier only as far as its faces reach, and only
1847
+ * in jumps between them. Measured on a weight grid:
1748
1848
  * - the downward reach is the highest weight that still renders as light as the
1749
1849
  * family can (asking for less cannot change anything);
1750
- * - the upward reach is the LOWEST weight at or above the layer a notch has to
1751
- * move that already renders as heavy as the family can, so the range ends where
1752
- * bold text stops changing instead of running past it (see `WEIGHT_DELTA_UP_LIMIT`
1753
- * for why that ceiling is also the widest one allowed);
1754
- * - the step is the finest candidate whose positions all differ at every base a
1755
- * notch has to move, so no notch is a no-op for body text or bold text. A family whose faces are unevenly spaced
1756
- * (a 700 next to a 900) may end up with one repeated position whichever uniform
1757
- * step is used — the coarsest candidate is then the honest answer, never a crash.
1850
+ * - the upward reach is the LOWEST weight at or above body text that already
1851
+ * renders as heavy as the family can, so the range ends where the text a reader
1852
+ * is reading stops changing (see `WEIGHT_DELTA_UP_LIMIT` for the ceiling);
1853
+ * - the step is the COARSEST candidate that still leaves `WEIGHT_MIN_POSITIONS`
1854
+ * positions with body text changing at every one of them. Coarse is the point:
1855
+ * a finer step splits one face into several positions that all render the same,
1856
+ * so the control feels dead (measured on Noto Serif SC, whose faces are
1857
+ * 100-250 / 300 / 400 / 500 / 600 / 700 / 800-900: one 100-weight step is a
1858
+ * +16% change in ink, one 200-weight step +30%).
1859
+ *
1860
+ * Bold text and headings are deliberately NOT required to change at every
1861
+ * position: the heaviest ladder layer sits at 600 and reaches the ceiling at +300,
1862
+ * so no range can move it further. Body text is what a reader reads, so it carries
1863
+ * the requirement, and a heading already at its boldest face stays there.
1864
+ *
1865
+ * Every position is a whole number of steps from 0, so the card's step counts map
1866
+ * back onto exactly the offsets the document stores.
1867
+ *
1758
1868
  * @param {(weight: number) => string|number} sample - renders a probe at one
1759
1869
  * weight and returns a signature; equal signatures mean identical rendering.
1760
1870
  * @param {{candidates?: number[], floor?: number, ceil?: number, top?: number}}
@@ -1802,27 +1912,49 @@ function weightProfileFrom(sample, options) {
1802
1912
  }
1803
1913
  var reachUp = Math.min(ceil, WEIGHT_DELTA_UP_LIMIT, headroom);
1804
1914
  var reachDown = Math.max(floor, Math.min(0, lightest - WEIGHT_BASE));
1805
- var bases = top === WEIGHT_BASE ? [WEIGHT_BASE] : [WEIGHT_BASE, top];
1806
- for (var index = 0; index < candidates.length; index += 1) {
1915
+ var cap = Math.min(reachUp, WEIGHT_DELTA_UP_LIMIT);
1916
+ /**
1917
+ * The shape one candidate step describes.
1918
+ *
1919
+ * Every position is a whole number of steps from 0, so the card's step counts
1920
+ * multiply back into exactly the offsets the document stores. The reach is
1921
+ * snapped to that grid without ever passing the hard floor or the ceiling —
1922
+ * rounding the top UP (which the first version did) put a position past the
1923
+ * widest allowed offset, and rounding the bottom DOWN could pass the floor.
1924
+ */
1925
+ var shapeAt = function (step) {
1926
+ var down = Math.min(ceilTo(-reachDown, step), floorTo(-floor, step));
1927
+ return { min: -down, max: floorTo(cap, step), step: step };
1928
+ };
1929
+ var best = null;
1930
+ var bestCount = 0;
1931
+ // Coarsest candidate first: the coarsest step that still leaves enough visible
1932
+ // positions is the one a reader can actually work with.
1933
+ for (var index = candidates.length - 1; index >= 0; index -= 1) {
1807
1934
  var step = candidates[index];
1808
1935
  if (!(step > 0)) continue;
1809
- var min = floorTo(reachDown, step);
1810
- // Rounding up to the step must not push the range past the limit: the widest
1811
- // offset has to stay out of the absolute-weight window.
1812
- var max = Math.min(ceilTo(reachUp, step), WEIGHT_DELTA_UP_LIMIT);
1813
- // A step wider than the range would leave only one reachable position.
1814
- if (max > min && step > max - min) continue;
1815
- if (everyPositionDiffers(sample, bases, min, max, step)) {
1816
- return { min: min, max: max, step: step };
1936
+ var shape = shapeAt(step);
1937
+ if (shape.max < shape.min) continue;
1938
+ if (!everyPositionDiffers(sample, [WEIGHT_BASE], shape.min, shape.max, step)) continue;
1939
+ var count = (shape.max - shape.min) / step + 1;
1940
+ if (count >= WEIGHT_MIN_POSITIONS) return shape;
1941
+ if (count > bestCount) {
1942
+ best = shape;
1943
+ bestCount = count;
1817
1944
  }
1818
1945
  }
1946
+ if (best !== null) {
1947
+ // Fewer positions than asked for is still usable; one position is the whole
1948
+ // range, and any step describes it, so use the finest and keep the unit small.
1949
+ if (best.max === best.min) return { min: best.min, max: best.max, step: 1 };
1950
+ return best;
1951
+ }
1952
+ // No step kept body text alive: the coarsest one that still fits is the honest
1953
+ // answer, never a step the control cannot reach.
1819
1954
  var coarsest = candidates[candidates.length - 1];
1820
- var min = floorTo(reachDown, coarsest);
1821
- var max = Math.min(ceilTo(reachUp, coarsest), WEIGHT_DELTA_UP_LIMIT);
1822
- // Nothing kept every position alive: the coarsest step that still fits is the
1823
- // honest answer, never a step the control cannot reach.
1824
- var span = max - min;
1825
- return { min: min, max: max, step: span > 0 ? Math.min(coarsest, span) : 1 };
1955
+ var plain = shapeAt(coarsest);
1956
+ var span = plain.max - plain.min;
1957
+ return { min: plain.min, max: plain.max, step: span > 0 ? Math.min(coarsest, span) : 1 };
1826
1958
  }
1827
1959
 
1828
1960
  /**
@@ -1905,9 +2037,11 @@ function isDormant(config) {
1905
2037
  sanitize(axis[MONO_FIELD]) === "" &&
1906
2038
  clampOffset(axis[SIZE_DIALOG_FIELD]) === 0 &&
1907
2039
  clampOffset(axis[CODE_SIZE_FIELD]) === 0 &&
1908
- clampWeightDelta(axis[WEIGHT_FIELD]) === WEIGHT_UNSET &&
1909
- clampWeightDelta(axis[WEIGHT_DIALOG_FIELD]) === WEIGHT_UNSET &&
1910
- clampWeightDelta(axis[CODE_WEIGHT_FIELD]) === WEIGHT_UNSET &&
2040
+ // These arrive normalized, so they are offsets already: converting them again
2041
+ // would rescale an offset that legitimately sits inside `300…600`.
2042
+ clampWeightDelta(axis[WEIGHT_FIELD], true) === WEIGHT_UNSET &&
2043
+ clampWeightDelta(axis[WEIGHT_DIALOG_FIELD], true) === WEIGHT_UNSET &&
2044
+ clampWeightDelta(axis[CODE_WEIGHT_FIELD], true) === WEIGHT_UNSET &&
1911
2045
  clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
1912
2046
  clampLineOffset(axis[CODE_LINE_HEIGHT_FIELD]) === 0 &&
1913
2047
  clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
@@ -2787,6 +2921,9 @@ var shared = {
2787
2921
  WEIGHT_FIELD: WEIGHT_FIELD,
2788
2922
  WEIGHT_DIALOG_FIELD: WEIGHT_DIALOG_FIELD,
2789
2923
  CODE_WEIGHT_FIELD: CODE_WEIGHT_FIELD,
2924
+ WEIGHT_OFFSETS_FIELD: WEIGHT_OFFSETS_FIELD,
2925
+ WEIGHT_FIELDS: WEIGHT_FIELDS,
2926
+ migratePresetWeights: migratePresetWeights,
2790
2927
  LINE_HEIGHT_FIELD: LINE_HEIGHT_FIELD,
2791
2928
  LINE_HEIGHT_DIALOG_FIELD: LINE_HEIGHT_DIALOG_FIELD,
2792
2929
  CODE_LINE_HEIGHT_FIELD: CODE_LINE_HEIGHT_FIELD,
@@ -2881,6 +3018,7 @@ var shared = {
2881
3018
  WEIGHT_STEP_CANDIDATES: WEIGHT_STEP_CANDIDATES,
2882
3019
  WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
2883
3020
  WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
3021
+ WEIGHT_MIN_POSITIONS: WEIGHT_MIN_POSITIONS,
2884
3022
  WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
2885
3023
  WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
2886
3024
  WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
@@ -2964,6 +3102,7 @@ var NO_SYNTHETIC_BOLD_FIELD = shared.NO_SYNTHETIC_BOLD_FIELD;
2964
3102
  var PER_THEME_FIELD = shared.PER_THEME_FIELD;
2965
3103
  var DARK_VALUES_FIELD = shared.DARK_VALUES_FIELD;
2966
3104
  var PRESETS_FIELD = shared.PRESETS_FIELD;
3105
+ var WEIGHT_OFFSETS_FIELD = shared.WEIGHT_OFFSETS_FIELD;
2967
3106
  var ACTIVE_PRESET_FIELD = shared.ACTIVE_PRESET_FIELD;
2968
3107
  var UI_FOLLOWS_FIELD = shared.UI_FOLLOWS_FIELD;
2969
3108
  var VALUE_FIELDS = shared.VALUE_FIELDS;
@@ -3213,13 +3352,13 @@ var DICTS = {
3213
3352
 
3214
3353
  "weight.uiLabel": "Interface font-weight offset",
3215
3354
  "weight.uiHint":
3216
- "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.",
3355
+ "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.",
3217
3356
  "weight.dialogLabel": "Conversation font-weight offset",
3218
3357
  "weight.dialogHint":
3219
- "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.",
3358
+ "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.",
3220
3359
  "weight.codeLabel": "Code font-weight offset",
3221
3360
  "weight.codeHint":
3222
- "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.",
3361
+ "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.",
3223
3362
 
3224
3363
  "line.dialogLabel": "Conversation line height",
3225
3364
  "line.dialogHint":
@@ -3242,13 +3381,13 @@ var DICTS = {
3242
3381
 
3243
3382
  "synth.italic": "No faux italic",
3244
3383
  "synth.italicHint":
3245
- "CJK faces have no italic, so the browser tilts them. On keeps marked text upright.",
3384
+ "CJK faces have no italic, so the browser tilts them. On keeps marked text upright. (Same as faux bold: one extra step at most.)",
3246
3385
  "synth.bold": "No faux bold",
3247
3386
  "synth.boldHint":
3248
- "For faces without a real bold: bold text stops being thickened artificially.",
3387
+ "For faces without a real bold: bold text stops being thickened artificially. (Disabling it lets the browser fake a bold face, which adds one extra step for a font that ships none — it cannot invent the middle ones.)",
3249
3388
  "synth.simple": "No faux italic / faux bold",
3250
3389
  "synth.simpleHint":
3251
- "Stops synthetic italic and synthetic bold everywhere; switch to Advanced to set them apart.",
3390
+ "Stops synthetic italic and synthetic bold everywhere; switch to Advanced to set them apart. (Both switches: one extra step at most, no middle ones.)",
3252
3391
 
3253
3392
  "section.ui": "Interface",
3254
3393
  "section.dialog": "Conversation",
@@ -3257,7 +3396,8 @@ var DICTS = {
3257
3396
  "section.close": "Collapse {name}",
3258
3397
 
3259
3398
  "ui.follow": "Follows the conversation",
3260
- "ui.followHint": "Font and weight",
3399
+ "ui.followHint": "Font and weight. While this is on, the interface section's own values are ignored.",
3400
+ "write.reverted": "The setting did not reach the document, so it fell back to what the document holds.",
3261
3401
  "ui.own": "Own values",
3262
3402
  "dialog.default": "DSH defaults",
3263
3403
 
@@ -3357,13 +3497,13 @@ var DICTS = {
3357
3497
 
3358
3498
  "weight.uiLabel": "界面字重偏移",
3359
3499
  "weight.uiHint":
3360
- "在整个界面自身的字重上叠加(侧栏、设置、按钮、标题):正数更粗、负数更细;对话 Markdown 不受影响。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。",
3500
+ "在整个界面自身的字重上叠加(侧栏、设置、按钮、标题):正数更粗、负数更细;对话 Markdown 不受影响。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
3361
3501
  "weight.dialogLabel": "对话字重偏移",
3362
3502
  "weight.dialogHint":
3363
- "在每个元素自身的字重上叠加,所以标题依旧比正文粗、加粗文字依旧加粗;未设置时保持 DSH 原本的粗细。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。",
3503
+ "在每个元素自身的字重上叠加,所以标题依旧比正文粗、加粗文字依旧加粗;未设置时保持 DSH 原本的粗细。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
3364
3504
  "weight.codeLabel": "代码字重偏移",
3365
3505
  "weight.codeHint":
3366
- "在每个代码面自身的字重上叠加(代码块、行内代码、终端输出);0 或未设置表示保持 DSH 原样。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。",
3506
+ "在每个代码面自身的字重上叠加(代码块、行内代码、终端输出);0 或未设置表示保持 DSH 原样。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
3367
3507
 
3368
3508
  "line.dialogLabel": "对话行高",
3369
3509
  "line.dialogHint": "把对话行高整体缩放为 {ratio};100% 表示保持 DSH 原样。",
@@ -3384,12 +3524,12 @@ var DICTS = {
3384
3524
 
3385
3525
  "synth.italic": "禁用伪斜体",
3386
3526
  "synth.italicHint":
3387
- "中文字体没有斜体,浏览器会把标记文本硬掰歪;开启后保持直立。",
3527
+ "中文字体没有斜体,浏览器会把标记文本硬掰歪;开启后保持直立。(与伪粗体同理:最多多出一档。)",
3388
3528
  "synth.bold": "禁用伪粗体",
3389
- "synth.boldHint": "没有真实粗体的字体不再被人为加粗。",
3529
+ "synth.boldHint": "没有真实粗体的字体不再被人为加粗。(关掉它,浏览器会为没有粗体字形的字体自己造粗体:能多出一档,但变不出中间档。)",
3390
3530
  "synth.simple": "禁用伪斜体/伪粗体",
3391
3531
  "synth.simpleHint":
3392
- "整页停用合成斜体与合成粗体;切到高级模式可分开设置。",
3532
+ "整页停用合成斜体与合成粗体;切到高级模式可分开设置。(两项一起关:最多多出一档,变不出中间档。)",
3393
3533
 
3394
3534
  "section.ui": "界面",
3395
3535
  "section.dialog": "对话",
@@ -3399,7 +3539,8 @@ var DICTS = {
3399
3539
 
3400
3540
 
3401
3541
  "ui.follow": "跟随对话设置",
3402
- "ui.followHint": "字体与字重",
3542
+ "ui.followHint": "字体与字重跟随开启时,界面分区自己的取值会被忽略。",
3543
+ "write.reverted": "设置没写进文档,已恢复为文档里的值。",
3403
3544
  "ui.own": "独立数值",
3404
3545
  "dialog.default": "DSH 默认",
3405
3546
 
@@ -3597,6 +3738,8 @@ var CARD_CSS = [
3597
3738
  ".dfp-slider{flex:1;min-width:0;height:20px;accent-color:var(--dsw-alias-brand-primary)}",
3598
3739
  ".dfp-value{flex:none;min-width:56px;text-align:right;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;font-variant-numeric:tabular-nums}",
3599
3740
  ".dfp-scale{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;display:flex;justify-content:space-between}",
3741
+ // a write the document never took: visible, but quieter than the field label
3742
+ ".dfp-sliderNotice{margin-top:6px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px}",
3600
3743
 
3601
3744
  // the per-section preview: an inline box at the end of the expanded
3602
3745
  // section body, showing only that section's own sample
@@ -4109,6 +4252,9 @@ function Section(props) {
4109
4252
  */
4110
4253
  function NumberSlider(props) {
4111
4254
  var [pending, setPending] = useState(null);
4255
+ // A write the document never took: the readout falls back to the document and
4256
+ // this says so once, instead of the thumb looking like it moved on its own.
4257
+ var [reverted, setReverted] = useState(false);
4112
4258
  var step = typeof props.step === "number" && props.step > 0 ? props.step : 1;
4113
4259
  // The value the host has not confirmed yet. Between the release and the
4114
4260
  // settings round-trip the committed prop is still the OLD number — clearing
@@ -4141,6 +4287,7 @@ function NumberSlider(props) {
4141
4287
  );
4142
4288
  var commit = function (value) {
4143
4289
  awaitingRef.current = value;
4290
+ setReverted(false);
4144
4291
  props.onChange(value);
4145
4292
  };
4146
4293
  /**
@@ -4160,6 +4307,8 @@ function NumberSlider(props) {
4160
4307
  var timer = setTimeout(function () {
4161
4308
  awaitingRef.current = null;
4162
4309
  setLocal(null);
4310
+ // The document is the truth and it never took this value: say so once.
4311
+ setReverted(true);
4163
4312
  }, COMMIT_PATIENCE_MS);
4164
4313
  return function () {
4165
4314
  clearTimeout(timer);
@@ -4221,6 +4370,9 @@ function NumberSlider(props) {
4221
4370
  }),
4222
4371
  h("span", { className: "dfp-value" }, readout)
4223
4372
  ),
4373
+ reverted && props.revertedText
4374
+ ? h("div", { className: "dfp-sliderNotice" }, props.revertedText)
4375
+ : null,
4224
4376
  h(
4225
4377
  "div",
4226
4378
  { className: "dfp-scale" },
@@ -5466,6 +5618,7 @@ function FontCard(props) {
5466
5618
  value: props.value,
5467
5619
  disabled: !writable,
5468
5620
  label: t(props.labelKey),
5621
+ revertedText: t("write.reverted"),
5469
5622
  readout: props.text + " " + t("size.unit"),
5470
5623
  minLabel: SIZE_MIN + " " + t("size.unit"),
5471
5624
  maxLabel: "+" + SIZE_MAX + " " + t("size.unit"),
@@ -5520,6 +5673,7 @@ function FontCard(props) {
5520
5673
  value: shape.value,
5521
5674
  disabled: !writable,
5522
5675
  label: t(props.labelKey),
5676
+ revertedText: t("write.reverted"),
5523
5677
  // One notch is one family step, so every notch changes what is on screen.
5524
5678
  step: 1,
5525
5679
  // The readout is the STEP COUNT, not the weight: "+2" is two faces
@@ -5532,6 +5686,8 @@ function FontCard(props) {
5532
5686
  // the document confirmed it).
5533
5687
  pendingText: text,
5534
5688
  onChange: function (count) {
5689
+ // Once per document, before anything is written on the new scale.
5690
+ adoptWeightScale();
5535
5691
  // The neutral position means "leave the axis alone" rather than "write
5536
5692
  // it on every element".
5537
5693
  if (count === 0) resetField(props.field);
@@ -5562,6 +5718,7 @@ function FontCard(props) {
5562
5718
  value: props.value,
5563
5719
  disabled: !writable,
5564
5720
  label: t(props.labelKey),
5721
+ revertedText: t("write.reverted"),
5565
5722
  readout: props.value + " " + t("line.unit"),
5566
5723
  minLabel: LINE_HEIGHT_MIN + " " + t("line.unit"),
5567
5724
  maxLabel: LINE_HEIGHT_MAX + " " + t("line.unit"),
@@ -5598,6 +5755,7 @@ function FontCard(props) {
5598
5755
  value: value,
5599
5756
  disabled: !writable,
5600
5757
  label: t("line.codeLabel"),
5758
+ revertedText: t("write.reverted"),
5601
5759
  readout: (value > 0 ? "+" + value : String(value)) + "px",
5602
5760
  minLabel: CODE_LINE_HEIGHT_MIN + "px",
5603
5761
  maxLabel: "+" + CODE_LINE_HEIGHT_MAX + "px",
@@ -5742,6 +5900,40 @@ function FontCard(props) {
5742
5900
  submit(PRESETS_FIELD, JSON.stringify(next));
5743
5901
  };
5744
5902
 
5903
+ /**
5904
+ * Adopt the offset scale, once per document.
5905
+ *
5906
+ * A document written before the weight axes became relative stores absolute
5907
+ * weights inside `300…600`, and once a document is marked a stored `450` means
5908
+ * +450 — the two scales overlap, so the conversion must happen exactly once and
5909
+ * never again. This is that moment: before the first weight this card writes,
5910
+ * convert whatever is still absolute (the three fields and every saved snapshot),
5911
+ * then set the marker.
5912
+ *
5913
+ * Values first, marker last: a stylesheet rebuild between the two still sees
5914
+ * values that render exactly as before, and a marked document is never left
5915
+ * holding an unconverted absolute weight.
5916
+ *
5917
+ * The three fields are read from `config`, not from the expanded `editing` set:
5918
+ * while the interface follows the conversation, `editing` mirrors the
5919
+ * conversation's weight into the interface's own field, and writing that back
5920
+ * would silently freeze the follow.
5921
+ */
5922
+ var adoptWeightScale = function () {
5923
+ if (config[WEIGHT_OFFSETS_FIELD] === true) return;
5924
+ for (var index = 0; index < shared.WEIGHT_FIELDS.length; index += 1) {
5925
+ var field = shared.WEIGHT_FIELDS[index];
5926
+ var stored = Number(config[field]);
5927
+ if (!isFinite(stored) || stored < shared.WEIGHT_MIN || stored > shared.WEIGHT_MAX) {
5928
+ continue;
5929
+ }
5930
+ submit(field, stored - shared.WEIGHT_BASE);
5931
+ }
5932
+ var migrated = shared.migratePresetWeights(config[PRESETS_FIELD]);
5933
+ if (migrated.changed) writePresets(migrated.presets);
5934
+ submit(WEIGHT_OFFSETS_FIELD, true);
5935
+ };
5936
+
5745
5937
  /**
5746
5938
  * Switch presets: the entry becomes the auto-save target and its stored
5747
5939
  * values are applied over the set currently being edited — every axis the
package/lib/index.js CHANGED
@@ -217,6 +217,12 @@ export const Config = volatileWhenSupported(z.object({
217
217
  .description(
218
218
  `Code font-weight offset; added to each code surface's own weight and independent of the interface weight (the slider spans whatever weights the chosen font can render), ${shared.WEIGHT_UNSET} keeps DSH's own weights. A legacy absolute weight (${shared.WEIGHT_MIN}..${shared.WEIGHT_MAX}) is accepted and converted to its offset.`
219
219
  ),
220
+ [shared.WEIGHT_OFFSETS_FIELD]: z
221
+ .boolean()
222
+ .default(false)
223
+ .description(
224
+ "Scale marker: the three weight fields hold offsets, not legacy absolute weights (set automatically the first time the card writes a weight)"
225
+ ),
220
226
  [LINE_HEIGHT_FIELD]: z
221
227
  .number()
222
228
  .min(shared.LINE_HEIGHT_MIN)