dsh-fonttune 0.3.4 → 0.3.6

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
@@ -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)
package/lib/shared.cjs CHANGED
@@ -77,6 +77,23 @@ var WEIGHT_DIALOG_FIELD = "weightDialog";
77
77
  /** Field carrying the code font weight (0 = leave DSH alone). */
78
78
  var CODE_WEIGHT_FIELD = "weightCode";
79
79
 
80
+ /**
81
+ * Marks a document whose weight fields are OFFSETS, not absolute weights.
82
+ *
83
+ * Before 0.2.8 the three weight axes stored an absolute CSS weight (`300…600`,
84
+ * `400` = normal) and `clampWeightDelta` converted one on sight. That inference
85
+ * only works while offsets never land inside the absolute window, which capped
86
+ * the offset range at +200 — and +200 is not enough room for a step the eye can
87
+ * see on a family with few faces (measured: one 200-weight step is a +30% change
88
+ * on Noto Serif SC, one 100-weight step only +16%).
89
+ *
90
+ * So the document says which scale it is on, and the inference is borrowed for
91
+ * exactly one write: the first time 0.3.5+ writes a weight, the card converts any
92
+ * remaining absolute value, converts the saved presets the same way, and sets
93
+ * this field. From then on a stored weight is an offset, whatever its size.
94
+ */
95
+ var WEIGHT_OFFSETS_FIELD = "weightOffsets";
96
+
80
97
  /**
81
98
  * Field carrying the interface line-height ratio in percent (100 = untouched).
82
99
  * A ratio rather than a px offset: interface line heights form a design ladder
@@ -208,17 +225,18 @@ var WEIGHT_LADDER_TOP = DIALOG_WEIGHT_GROUPS.reduce(function (top, group) {
208
225
  /**
209
226
  * How far above its base an offset may reach at all.
210
227
  *
211
- * Every axis applies the offset to each element's OWN weight, and the heaviest
212
- * layer of the ladder sits at `WEIGHT_LADDER_TOP`; past `WEIGHT_CSS_MAX` minus
213
- * that, the heaviest layer cannot move any further, so a longer range would only
214
- * add positions that change nothing for the boldest text on screen.
228
+ * The ceiling comes from the layer a notch MUST move: body text sits at
229
+ * `WEIGHT_BASE`, so the range runs as far as the text a reader is reading can
230
+ * still change — measured up to +400 on a family whose faces reach 900. Bold text
231
+ * stops moving at +300 and headings at +200; that is accepted, because a heading
232
+ * already at its heaviest face stays there (see `WEIGHT_LADDER_STRONG`).
215
233
  *
216
- * Staying inside it has a second, load-bearing effect: offsets then never land in
217
- * `WEIGHT_MIN…WEIGHT_MAX`, the range an ABSOLUTE weight from a pre-relative
218
- * document occupies (see `clampWeightDelta`). The two meanings cannot be confused,
219
- * so a stored offset is never rescaled into a different one.
234
+ * It used to stop at `WEIGHT_CSS_MAX - WEIGHT_LADDER_TOP` (= +200) so an offset
235
+ * could never land inside `WEIGHT_MIN…WEIGHT_MAX`, the window an ABSOLUTE weight
236
+ * from a pre-relative document occupies. The document's own marker resolves that
237
+ * window now (`WEIGHT_OFFSETS_FIELD`), so the range is free to grow.
220
238
  */
221
- var WEIGHT_DELTA_UP_LIMIT = WEIGHT_CSS_MAX - WEIGHT_LADDER_TOP;
239
+ var WEIGHT_DELTA_UP_LIMIT = WEIGHT_CSS_MAX - WEIGHT_BASE;
222
240
 
223
241
  /**
224
242
  * The heaviest layer a notch still has to move: bold text.
@@ -314,9 +332,12 @@ var VALUE_FIELDS = [
314
332
  var RETIRED_FIELDS = [SIZE_FIELD, LINE_HEIGHT_FIELD];
315
333
 
316
334
  /**
317
- * Every durable axis field, live and retired: what normalization covers.
335
+ * Every durable field normalization covers: the live axes, the retired pair, and
336
+ * the scale marker (`WEIGHT_OFFSETS_FIELD`, which is deliberately NOT a value
337
+ * axis — a preset must never carry it, or applying that preset would re-label a
338
+ * document still holding absolute weights).
318
339
  */
319
- var DURABLE_FIELDS = VALUE_FIELDS.concat(RETIRED_FIELDS);
340
+ var DURABLE_FIELDS = VALUE_FIELDS.concat(RETIRED_FIELDS, [WEIGHT_OFFSETS_FIELD]);
320
341
 
321
342
  /**
322
343
  * Longest accepted font stack, in characters (mirrored by the host schema).
@@ -435,7 +456,7 @@ var FEATURES_UNSAFE = /[^a-zA-Z0-9"' ,]/g;
435
456
  * ------------------------------------------------------------------ */
436
457
 
437
458
  /** Clamp/validate one field by name (shared by config and value sets). */
438
- function normalizeField(field, value) {
459
+ function normalizeField(field, value, relative) {
439
460
  switch (field) {
440
461
  case SANS_FIELD:
441
462
  case STACK_DIALOG_FIELD:
@@ -448,7 +469,7 @@ function normalizeField(field, value) {
448
469
  case WEIGHT_FIELD:
449
470
  case WEIGHT_DIALOG_FIELD:
450
471
  case CODE_WEIGHT_FIELD:
451
- return clampWeightDelta(value);
472
+ return clampWeightDelta(value, relative);
452
473
  case LINE_HEIGHT_FIELD:
453
474
  case LINE_HEIGHT_DIALOG_FIELD:
454
475
  return clampRatio(value);
@@ -456,6 +477,9 @@ function normalizeField(field, value) {
456
477
  return clampLineOffset(value);
457
478
  case LIGATURES_FIELD:
458
479
  return clampLigatures(value);
480
+ case WEIGHT_OFFSETS_FIELD:
481
+ // The document's own scale marker: false/absent = the pre-relative scale.
482
+ return toBool(value);
459
483
  case FEATURES_FIELD:
460
484
  return sanitizeFeatures(value);
461
485
  case NO_SYNTHETIC_ITALIC_FIELD:
@@ -479,14 +503,17 @@ function normalizeField(field, value) {
479
503
  */
480
504
  function normalizeConfig(value) {
481
505
  var source = value !== null && typeof value === "object" ? value : {};
506
+ // The scale marker is read BEFORE the axes: it decides whether a weight inside
507
+ // `300…600` is a legacy absolute value or an offset (see `clampWeightDelta`).
508
+ var relative = toBool(source[WEIGHT_OFFSETS_FIELD]);
482
509
  var config = {};
483
510
  for (var index = 0; index < DURABLE_FIELDS.length; index += 1) {
484
511
  var field = DURABLE_FIELDS[index];
485
- config[field] = normalizeField(field, source[field]);
512
+ config[field] = normalizeField(field, source[field], relative);
486
513
  }
487
514
  config[PER_THEME_FIELD] = toBool(source[PER_THEME_FIELD]);
488
- config[DARK_VALUES_FIELD] = normalizeDarkValues(source[DARK_VALUES_FIELD]);
489
- config[PRESETS_FIELD] = normalizePresets(source[PRESETS_FIELD]);
515
+ config[DARK_VALUES_FIELD] = normalizeDarkValues(source[DARK_VALUES_FIELD], relative);
516
+ config[PRESETS_FIELD] = normalizePresets(source[PRESETS_FIELD], relative);
490
517
  config[ACTIVE_PRESET_FIELD] = sanitize(source[ACTIVE_PRESET_FIELD]).slice(0, MAX_PRESET_NAME);
491
518
  return config;
492
519
  }
@@ -524,21 +551,21 @@ function clampWeight(value) {
524
551
  * Clamp one RELATIVE weight to its allowed range, migrating an absolute value.
525
552
  *
526
553
  * A document written before the axes became relative stores an absolute weight
527
- * (`300…600`, `400` = normal). That window sits entirely outside the delta
528
- * window, so the conversion is unambiguous and idempotent: a value inside
529
- * `300…600` is old and becomes its distance from normal (`400 → 0`, `560 → +160`,
530
- * `300 → −100`), while a value already inside `−100…200` is a delta and is left
531
- * alone. The user's saved setup therefore keeps meaning "as bold as before"
532
- * instead of jumping to the top of the new scale.
554
+ * (`300…600`, `400` = normal) and has no marker, so a value inside `300…600` is
555
+ * converted to its distance from normal (`400 → 0`, `560 → +160`, `300 → −100`).
556
+ * A marked document is on the offset scale already and is never converted — that
557
+ * is what lets an offset grow past `+200` without being mistaken for one of those
558
+ * absolute values (the two windows overlap; see `WEIGHT_OFFSETS_FIELD`).
533
559
  *
534
560
  * @param {unknown} value - candidate offset, or a pre-0.2.8 absolute weight.
561
+ * @param {boolean} [relative] - true when the document carries the offset marker.
535
562
  * @returns {number} the offset the stylesheet may use.
536
563
  */
537
- function clampWeightDelta(value) {
564
+ function clampWeightDelta(value, relative) {
538
565
  var delta = Number(value);
539
566
  if (!isFinite(delta)) return WEIGHT_UNSET;
540
567
  delta = Math.round(delta);
541
- if (delta >= WEIGHT_MIN && delta <= WEIGHT_MAX) delta -= WEIGHT_BASE;
568
+ if (relative !== true && delta >= WEIGHT_MIN && delta <= WEIGHT_MAX) delta -= WEIGHT_BASE;
542
569
  // The slider's own range comes from the font (see `weightProfileFrom`) and can
543
570
  // reach further than the nominal one, so the hard bounds are the widest range
544
571
  // any font-derived slider may use.
@@ -586,13 +613,13 @@ function clampLigatures(value) {
586
613
  * @param {unknown} values - candidate map.
587
614
  * @returns {Record<string, string|number|boolean>} the safe subset.
588
615
  */
589
- function normalizeValueSet(values) {
616
+ function normalizeValueSet(values, relative) {
590
617
  var out = {};
591
618
  if (values === null || typeof values !== "object" || Array.isArray(values)) return out;
592
619
  for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
593
620
  var field = VALUE_FIELDS[index];
594
621
  if (!Object.prototype.hasOwnProperty.call(values, field)) continue;
595
- out[field] = normalizeField(field, values[field]);
622
+ out[field] = normalizeField(field, values[field], relative);
596
623
  }
597
624
  return out;
598
625
  }
@@ -602,7 +629,7 @@ function normalizeValueSet(values) {
602
629
  * @param {unknown} value - candidate JSON text or object.
603
630
  * @returns {Record<string, string|number|boolean>} the sparse override map.
604
631
  */
605
- function normalizeDarkValues(value) {
632
+ function normalizeDarkValues(value, relative) {
606
633
  if (typeof value === "string") {
607
634
  if (value.trim() === "") return {};
608
635
  try {
@@ -611,7 +638,133 @@ function normalizeDarkValues(value) {
611
638
  return {};
612
639
  }
613
640
  }
614
- return normalizeValueSet(value);
641
+ return normalizeValueSet(value, relative);
642
+ }
643
+
644
+ /** The three axes that share the weight scale (and its marker). */
645
+ var WEIGHT_FIELDS = [WEIGHT_FIELD, WEIGHT_DIALOG_FIELD, CODE_WEIGHT_FIELD];
646
+
647
+ /**
648
+ * Convert the weight fields of saved preset snapshots to offsets.
649
+ *
650
+ * Runs exactly once per document, right before the card sets
651
+ * `WEIGHT_OFFSETS_FIELD`: a snapshot saved before 0.2.8 holds an absolute weight,
652
+ * and once the document is marked a stored weight is an offset, so applying that
653
+ * snapshot later would read `450` as +450 instead of +50. Converting the snapshots
654
+ * in the same breath as the document keeps the two on one scale.
655
+ *
656
+ * Only values inside the legacy absolute window are touched, so a snapshot that
657
+ * already holds offsets (any 0.3.x one) comes back unchanged and `changed` stays
658
+ * false — the caller then writes nothing.
659
+ *
660
+ * @param {unknown} presets - normalized presets, or anything shaped like them.
661
+ * @returns {{presets: object[], changed: boolean}} the converted list.
662
+ */
663
+ function migratePresetWeights(presets) {
664
+ if (!Array.isArray(presets)) return { presets: [], changed: false };
665
+ var changed = false;
666
+ var out = [];
667
+ for (var index = 0; index < presets.length; index += 1) {
668
+ var entry = presets[index];
669
+ if (entry === null || typeof entry !== "object" || entry.values === null) {
670
+ out.push(entry);
671
+ continue;
672
+ }
673
+ var values = entry.values;
674
+ if (typeof values !== "object") {
675
+ out.push(entry);
676
+ continue;
677
+ }
678
+ var converted = null;
679
+ for (var axis = 0; axis < WEIGHT_FIELDS.length; axis += 1) {
680
+ var field = WEIGHT_FIELDS[axis];
681
+ if (!Object.prototype.hasOwnProperty.call(values, field)) continue;
682
+ var stored = Number(values[field]);
683
+ if (!isFinite(stored) || stored < WEIGHT_MIN || stored > WEIGHT_MAX) continue;
684
+ if (converted === null) {
685
+ converted = {};
686
+ for (var key in values) {
687
+ if (Object.prototype.hasOwnProperty.call(values, key)) converted[key] = values[key];
688
+ }
689
+ }
690
+ converted[field] = stored - WEIGHT_BASE;
691
+ }
692
+ if (converted === null) {
693
+ out.push(entry);
694
+ continue;
695
+ }
696
+ changed = true;
697
+ var copy = {};
698
+ for (var name in entry) {
699
+ if (Object.prototype.hasOwnProperty.call(entry, name)) copy[name] = entry[name];
700
+ }
701
+ copy.values = converted;
702
+ out.push(copy);
703
+ }
704
+ return { presets: out, changed: changed };
705
+ }
706
+
707
+ /** The two shapes one locally painted move takes. */
708
+ var PENDING_SET = "set";
709
+ var PENDING_UNSET = "unset";
710
+
711
+ /**
712
+ * Merge the values the card painted locally over the document's own values.
713
+ *
714
+ * The card paints a move the moment the user lets go, while the settings document
715
+ * only echoes it after the host's debounced write. Painting the DOCUMENT's value
716
+ * in the meantime would show the previous one again — and an echo of an older move
717
+ * arriving after a newer one would visibly step the page backwards. So the local
718
+ * values win until the document catches up (`reconcilePendingValues` decides when
719
+ * that is).
720
+ *
721
+ * @param {unknown} document - the raw settings value.
722
+ * @param {Record<string, {op: string, value: unknown}>} pending - local moves.
723
+ * @returns {Record<string, unknown>} the values to render with.
724
+ */
725
+ function overlayPendingValues(document, pending) {
726
+ var source = document !== null && typeof document === "object" ? document : {};
727
+ var merged = {};
728
+ for (var key in source) {
729
+ if (Object.prototype.hasOwnProperty.call(source, key)) merged[key] = source[key];
730
+ }
731
+ var local = pending !== null && typeof pending === "object" ? pending : {};
732
+ for (var field in local) {
733
+ if (!Object.prototype.hasOwnProperty.call(local, field)) continue;
734
+ if (local[field].op === PENDING_UNSET) delete merged[field];
735
+ else merged[field] = local[field].value;
736
+ }
737
+ return merged;
738
+ }
739
+
740
+ /**
741
+ * Keep only the local moves that are still worth painting.
742
+ *
743
+ * A paint is deliberately NOT retired when the document agrees with it: the host
744
+ * debounces its writes, so a patch built EARLIER can land after a newer one and
745
+ * drag the document back to an older value. Retiring on agreement made exactly
746
+ * that echo visible — the page jumped back and forth and only ended up right at
747
+ * the end. The card corrects the document instead (its re-assert), and the paint
748
+ * stays until the patience runs out. A refused write is retired by the caller,
749
+ * which learns about it before this rule sees the document again.
750
+ *
751
+ * @param {Record<string, {op: string, value: unknown, at: number}>} pending - local moves.
752
+ * @param {unknown} document - the raw settings value (kept for the caller's shape).
753
+ * @param {number} now - current time in ms.
754
+ * @param {number} patienceMs - how long to keep an unconfirmed move.
755
+ * @returns {Record<string, object>} the entries still worth painting.
756
+ */
757
+ function reconcilePendingValues(pending, document, now, patienceMs) {
758
+ var local = pending !== null && typeof pending === "object" ? pending : {};
759
+ var kept = {};
760
+ for (var field in local) {
761
+ if (!Object.prototype.hasOwnProperty.call(local, field)) continue;
762
+ var entry = local[field];
763
+ var stale = typeof entry.at === "number" && now - entry.at > patienceMs;
764
+ if (stale) continue;
765
+ kept[field] = entry;
766
+ }
767
+ return kept;
615
768
  }
616
769
 
617
770
  /**
@@ -619,7 +772,7 @@ function normalizeDarkValues(value) {
619
772
  * @param {unknown} value - candidate JSON text or array.
620
773
  * @returns {{name: string, values: object, savedAt: number}[]}
621
774
  */
622
- function normalizePresets(value) {
775
+ function normalizePresets(value, relative) {
623
776
  var list = value;
624
777
  if (typeof value === "string") {
625
778
  if (value.trim() === "") return [];
@@ -639,7 +792,7 @@ function normalizePresets(value) {
639
792
  var stamp = Math.round(Number(entry.savedAt));
640
793
  out.push({
641
794
  name: name,
642
- values: normalizeValueSet(entry.values),
795
+ values: normalizeValueSet(entry.values, relative),
643
796
  savedAt: isFinite(stamp) && stamp > 0 ? stamp : 0,
644
797
  });
645
798
  }
@@ -1692,6 +1845,17 @@ function resolveAxes(config) {
1692
1845
  */
1693
1846
  var WEIGHT_STEP_CANDIDATES = [1, 5, 10, 25, 50, 100, 200, 300];
1694
1847
 
1848
+ /**
1849
+ * How many positions a slider must still offer before a coarser step is refused.
1850
+ *
1851
+ * Coarseness is the point (see `weightProfileFrom`), but a control with two
1852
+ * positions is not a slider. Four is the smallest count that still lets a reader
1853
+ * go one notch either side of the neutral position and one further up — and on
1854
+ * the measured seven-face family it lands on a 200-weight step, where one notch is
1855
+ * a +30% change in ink, against +16% at 100 (which is why 100 was not enough).
1856
+ */
1857
+ var WEIGHT_MIN_POSITIONS = 4;
1858
+
1695
1859
  /** The weight grid the profile is measured on. */
1696
1860
  var WEIGHT_PROBE_LOW = 100;
1697
1861
  var WEIGHT_PROBE_HIGH = 900;
@@ -1733,19 +1897,28 @@ function everyPositionDiffers(sample, bases, min, max, step) {
1733
1897
  /**
1734
1898
  * The slider a family deserves: how far it can go, and by how much.
1735
1899
  *
1736
- * A font with six faces can be made lighter or heavier only as far as those faces
1737
- * reach, and only in steps between them; a family with a continuous axis gets a
1738
- * fine step and a wide range. Measured on a weight grid:
1900
+ * A font can be made lighter or heavier only as far as its faces reach, and only
1901
+ * in jumps between them. Measured on a weight grid:
1739
1902
  * - the downward reach is the highest weight that still renders as light as the
1740
1903
  * family can (asking for less cannot change anything);
1741
- * - the upward reach is the LOWEST weight at or above the layer a notch has to
1742
- * move that already renders as heavy as the family can, so the range ends where
1743
- * bold text stops changing instead of running past it (see `WEIGHT_DELTA_UP_LIMIT`
1744
- * for why that ceiling is also the widest one allowed);
1745
- * - the step is the finest candidate whose positions all differ at every base a
1746
- * notch has to move, so no notch is a no-op for body text or bold text. A family whose faces are unevenly spaced
1747
- * (a 700 next to a 900) may end up with one repeated position whichever uniform
1748
- * step is used — the coarsest candidate is then the honest answer, never a crash.
1904
+ * - the upward reach is the LOWEST weight at or above body text that already
1905
+ * renders as heavy as the family can, so the range ends where the text a reader
1906
+ * is reading stops changing (see `WEIGHT_DELTA_UP_LIMIT` for the ceiling);
1907
+ * - the step is the COARSEST candidate that still leaves `WEIGHT_MIN_POSITIONS`
1908
+ * positions with body text changing at every one of them. Coarse is the point:
1909
+ * a finer step splits one face into several positions that all render the same,
1910
+ * so the control feels dead (measured on Noto Serif SC, whose faces are
1911
+ * 100-250 / 300 / 400 / 500 / 600 / 700 / 800-900: one 100-weight step is a
1912
+ * +16% change in ink, one 200-weight step +30%).
1913
+ *
1914
+ * Bold text and headings are deliberately NOT required to change at every
1915
+ * position: the heaviest ladder layer sits at 600 and reaches the ceiling at +300,
1916
+ * so no range can move it further. Body text is what a reader reads, so it carries
1917
+ * the requirement, and a heading already at its boldest face stays there.
1918
+ *
1919
+ * Every position is a whole number of steps from 0, so the card's step counts map
1920
+ * back onto exactly the offsets the document stores.
1921
+ *
1749
1922
  * @param {(weight: number) => string|number} sample - renders a probe at one
1750
1923
  * weight and returns a signature; equal signatures mean identical rendering.
1751
1924
  * @param {{candidates?: number[], floor?: number, ceil?: number, top?: number}}
@@ -1793,27 +1966,49 @@ function weightProfileFrom(sample, options) {
1793
1966
  }
1794
1967
  var reachUp = Math.min(ceil, WEIGHT_DELTA_UP_LIMIT, headroom);
1795
1968
  var reachDown = Math.max(floor, Math.min(0, lightest - WEIGHT_BASE));
1796
- var bases = top === WEIGHT_BASE ? [WEIGHT_BASE] : [WEIGHT_BASE, top];
1797
- for (var index = 0; index < candidates.length; index += 1) {
1969
+ var cap = Math.min(reachUp, WEIGHT_DELTA_UP_LIMIT);
1970
+ /**
1971
+ * The shape one candidate step describes.
1972
+ *
1973
+ * Every position is a whole number of steps from 0, so the card's step counts
1974
+ * multiply back into exactly the offsets the document stores. The reach is
1975
+ * snapped to that grid without ever passing the hard floor or the ceiling —
1976
+ * rounding the top UP (which the first version did) put a position past the
1977
+ * widest allowed offset, and rounding the bottom DOWN could pass the floor.
1978
+ */
1979
+ var shapeAt = function (step) {
1980
+ var down = Math.min(ceilTo(-reachDown, step), floorTo(-floor, step));
1981
+ return { min: -down, max: floorTo(cap, step), step: step };
1982
+ };
1983
+ var best = null;
1984
+ var bestCount = 0;
1985
+ // Coarsest candidate first: the coarsest step that still leaves enough visible
1986
+ // positions is the one a reader can actually work with.
1987
+ for (var index = candidates.length - 1; index >= 0; index -= 1) {
1798
1988
  var step = candidates[index];
1799
1989
  if (!(step > 0)) continue;
1800
- var min = floorTo(reachDown, step);
1801
- // Rounding up to the step must not push the range past the limit: the widest
1802
- // offset has to stay out of the absolute-weight window.
1803
- var max = Math.min(ceilTo(reachUp, step), WEIGHT_DELTA_UP_LIMIT);
1804
- // A step wider than the range would leave only one reachable position.
1805
- if (max > min && step > max - min) continue;
1806
- if (everyPositionDiffers(sample, bases, min, max, step)) {
1807
- return { min: min, max: max, step: step };
1990
+ var shape = shapeAt(step);
1991
+ if (shape.max < shape.min) continue;
1992
+ if (!everyPositionDiffers(sample, [WEIGHT_BASE], shape.min, shape.max, step)) continue;
1993
+ var count = (shape.max - shape.min) / step + 1;
1994
+ if (count >= WEIGHT_MIN_POSITIONS) return shape;
1995
+ if (count > bestCount) {
1996
+ best = shape;
1997
+ bestCount = count;
1808
1998
  }
1809
1999
  }
2000
+ if (best !== null) {
2001
+ // Fewer positions than asked for is still usable; one position is the whole
2002
+ // range, and any step describes it, so use the finest and keep the unit small.
2003
+ if (best.max === best.min) return { min: best.min, max: best.max, step: 1 };
2004
+ return best;
2005
+ }
2006
+ // No step kept body text alive: the coarsest one that still fits is the honest
2007
+ // answer, never a step the control cannot reach.
1810
2008
  var coarsest = candidates[candidates.length - 1];
1811
- var min = floorTo(reachDown, coarsest);
1812
- var max = Math.min(ceilTo(reachUp, coarsest), WEIGHT_DELTA_UP_LIMIT);
1813
- // Nothing kept every position alive: the coarsest step that still fits is the
1814
- // honest answer, never a step the control cannot reach.
1815
- var span = max - min;
1816
- return { min: min, max: max, step: span > 0 ? Math.min(coarsest, span) : 1 };
2009
+ var plain = shapeAt(coarsest);
2010
+ var span = plain.max - plain.min;
2011
+ return { min: plain.min, max: plain.max, step: span > 0 ? Math.min(coarsest, span) : 1 };
1817
2012
  }
1818
2013
 
1819
2014
  /**
@@ -1896,9 +2091,11 @@ function isDormant(config) {
1896
2091
  sanitize(axis[MONO_FIELD]) === "" &&
1897
2092
  clampOffset(axis[SIZE_DIALOG_FIELD]) === 0 &&
1898
2093
  clampOffset(axis[CODE_SIZE_FIELD]) === 0 &&
1899
- clampWeightDelta(axis[WEIGHT_FIELD]) === WEIGHT_UNSET &&
1900
- clampWeightDelta(axis[WEIGHT_DIALOG_FIELD]) === WEIGHT_UNSET &&
1901
- clampWeightDelta(axis[CODE_WEIGHT_FIELD]) === WEIGHT_UNSET &&
2094
+ // These arrive normalized, so they are offsets already: converting them again
2095
+ // would rescale an offset that legitimately sits inside `300…600`.
2096
+ clampWeightDelta(axis[WEIGHT_FIELD], true) === WEIGHT_UNSET &&
2097
+ clampWeightDelta(axis[WEIGHT_DIALOG_FIELD], true) === WEIGHT_UNSET &&
2098
+ clampWeightDelta(axis[CODE_WEIGHT_FIELD], true) === WEIGHT_UNSET &&
1902
2099
  clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
1903
2100
  clampLineOffset(axis[CODE_LINE_HEIGHT_FIELD]) === 0 &&
1904
2101
  clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
@@ -2778,6 +2975,13 @@ var shared = {
2778
2975
  WEIGHT_FIELD: WEIGHT_FIELD,
2779
2976
  WEIGHT_DIALOG_FIELD: WEIGHT_DIALOG_FIELD,
2780
2977
  CODE_WEIGHT_FIELD: CODE_WEIGHT_FIELD,
2978
+ WEIGHT_OFFSETS_FIELD: WEIGHT_OFFSETS_FIELD,
2979
+ WEIGHT_FIELDS: WEIGHT_FIELDS,
2980
+ PENDING_SET: PENDING_SET,
2981
+ PENDING_UNSET: PENDING_UNSET,
2982
+ overlayPendingValues: overlayPendingValues,
2983
+ reconcilePendingValues: reconcilePendingValues,
2984
+ migratePresetWeights: migratePresetWeights,
2781
2985
  LINE_HEIGHT_FIELD: LINE_HEIGHT_FIELD,
2782
2986
  LINE_HEIGHT_DIALOG_FIELD: LINE_HEIGHT_DIALOG_FIELD,
2783
2987
  CODE_LINE_HEIGHT_FIELD: CODE_LINE_HEIGHT_FIELD,
@@ -2872,6 +3076,7 @@ var shared = {
2872
3076
  WEIGHT_STEP_CANDIDATES: WEIGHT_STEP_CANDIDATES,
2873
3077
  WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
2874
3078
  WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
3079
+ WEIGHT_MIN_POSITIONS: WEIGHT_MIN_POSITIONS,
2875
3080
  WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
2876
3081
  WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
2877
3082
  WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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",
@@ -34,7 +34,8 @@
34
34
  "0.1.7-alpha.2": "compatible",
35
35
  "0.1.7-rc.1": "compatible",
36
36
  "0.1.7-rc.2": "compatible",
37
- "0.2.0-rc.1": "compatible"
37
+ "0.2.0-rc.1": "compatible",
38
+ "0.2.0-rc.2": "compatible"
38
39
  }
39
40
  }
40
41
  },