dsh-fonttune 0.7.6 → 0.7.7

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/CHANGELOG.en.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to **dsh-fonttune** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.7.7] - 2026-11-05
6
+
7
+ ### Fixed
8
+ - **The stall when opening the font settings**: the weight notches are now measured in steps that stay out of the settings page's open and refresh, and a family is measured once — later opens read the result instead of measuring again.
9
+ - **A long unresponsive page with some very large font collections**: when the font file is exceptionally large the measurement falls back to coarse notches after a time limit instead of holding the interface for a long time.
10
+
11
+ ### Changed
12
+ - **DSH 0.2.2-rc.1 is supported**: the compatibility table in the README and the plugin manifest now list 0.2.1-rc.3, 0.2.1-rc.5 and 0.2.2-rc.1.
13
+
5
14
  ## [0.7.6] - 2026-11-04
6
15
 
7
16
  ### Fixed
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  **dsh-fonttune** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.7.7] - 2026-11-05
6
+
7
+ ### 修复
8
+ - **打开字体设置时的卡顿**:字体档位的测量改为分步进行,并避开设置页面的打开与刷新;同一套字体只需测量一次,之后打开设置不再重新计算。
9
+ - **部分超大字体集合下的长时间无响应**:字体文件特别大时,测量会在超时后改用粗略档位,不再长时间占住界面。
10
+
11
+ ### 变更
12
+ - **兼容 DSH 0.2.2-rc.1**:README 的兼容表与插件清单同步补上 0.2.1-rc.3、0.2.1-rc.5 与 0.2.2-rc.1。
13
+
5
14
  ## [0.7.6] - 2026-11-04
6
15
 
7
16
  ### 修复
package/README.en.md CHANGED
@@ -39,7 +39,7 @@ The floating tuning panel (left, with its "in force right now" readout) and the
39
39
 
40
40
  | Plugin version | Supported DSH versions |
41
41
  | --- | --- |
42
- | **0.7.x** (latest, 0.7.3) | 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1, 0.2.0-rc.2, 0.2.1-alpha.1, 0.2.1-alpha.2, 0.2.1-alpha.3, 0.2.1-alpha.4, 0.2.1-alpha.5, 0.2.1-rc.1, 0.2.1-rc.2, 0.2.1-rc.4, 0.2.2-alpha.2 |
42
+ | **0.7.x** (latest, 0.7.7) | 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1, 0.2.0-rc.2, 0.2.1-alpha.1, 0.2.1-alpha.2, 0.2.1-alpha.3, 0.2.1-alpha.4, 0.2.1-alpha.5, 0.2.1-rc.1, 0.2.1-rc.2, 0.2.1-rc.4, 0.2.2-alpha.2, 0.2.1-rc.3, 0.2.1-rc.5, 0.2.2-rc.1 |
43
43
  | 0.6.x (0.6.2) | 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1, 0.2.0-rc.2, 0.2.1-alpha.1, 0.2.1-alpha.2, 0.2.1-alpha.3, 0.2.1-alpha.4, 0.2.1-alpha.5, 0.2.1-rc.1, 0.2.1-rc.2 |
44
44
  | 0.5.x (0.5.0) | 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1, 0.2.0-rc.2, 0.2.1-alpha.1, 0.2.1-alpha.2 |
45
45
  | 0.4.x (0.4.2) | 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1, 0.2.0-rc.2, 0.2.1-alpha.1, 0.2.1-alpha.2 |
package/README.md CHANGED
@@ -39,7 +39,7 @@
39
39
 
40
40
  | 插件版本 | 支持的 DSH 版本 |
41
41
  | --- | --- |
42
- | **0.7.x**(最新,0.7.3) | 0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1、0.2.0-rc.2、0.2.1-alpha.1、0.2.1-alpha.2、0.2.1-alpha.3、0.2.1-alpha.4、0.2.1-alpha.5、0.2.1-rc.1、0.2.1-rc.2、0.2.1-rc.4、0.2.2-alpha.2 |
42
+ | **0.7.x**(最新,0.7.7) | 0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1、0.2.0-rc.2、0.2.1-alpha.1、0.2.1-alpha.2、0.2.1-alpha.3、0.2.1-alpha.4、0.2.1-alpha.5、0.2.1-rc.1、0.2.1-rc.2、0.2.1-rc.4、0.2.2-alpha.2、0.2.1-rc.3、0.2.1-rc.5、0.2.2-rc.1 |
43
43
  | 0.6.x(0.6.2) | 0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1、0.2.0-rc.2、0.2.1-alpha.1、0.2.1-alpha.2、0.2.1-alpha.3、0.2.1-alpha.4、0.2.1-alpha.5、0.2.1-rc.1、0.2.1-rc.2 |
44
44
  | 0.5.x(0.5.0) | 0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1、0.2.0-rc.2、0.2.1-alpha.1、0.2.1-alpha.2 |
45
45
  | 0.4.x(0.4.2) | 0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1、0.2.0-rc.2、0.2.1-alpha.1、0.2.1-alpha.2 |
package/lib/client.js CHANGED
@@ -3476,6 +3476,70 @@ function balancedStackShape(entries, base, low, high, spread) {
3476
3476
  return best;
3477
3477
  }
3478
3478
 
3479
+ /**
3480
+ * The FIRST weight of an arithmetic grid whose rendering matches a signature.
3481
+ *
3482
+ * The probe grid is monotone: below a family's lightest face every weight renders
3483
+ * the same (the lightest face), above its heaviest face every weight renders the
3484
+ * same (the heaviest face), and in between it walks the faces. So "renders like the
3485
+ * ceiling" is a predicate that is false, then true, and its boundary is worth
3486
+ * `log2` samples instead of one render per grid point — which is what the two
3487
+ * horizon scans used to cost (eighty-one renders per family, issue #3).
3488
+ * @param {(weight: number) => string|number} sample - renders a probe at one weight.
3489
+ * @param {string|number} signature - the signature the boundary shares.
3490
+ * @param {number} low - first weight of the grid.
3491
+ * @param {number} high - last weight of the grid.
3492
+ * @param {number} step - spacing of the grid.
3493
+ * @param {number|null} fallback - returned when no grid weight matches.
3494
+ * @returns {number|null} the weight, or the fallback.
3495
+ */
3496
+ function firstGridWeight(sample, signature, low, high, step, fallback) {
3497
+ var count = Math.floor((high - low) / step) + 1;
3498
+ if (count < 1 || !(step > 0)) return fallback;
3499
+ var at = function (index) {
3500
+ return low + index * step;
3501
+ };
3502
+ if (sample(at(count - 1)) !== signature) return fallback;
3503
+ var first = 0;
3504
+ var last = count - 1;
3505
+ while (first < last) {
3506
+ var middle = (first + last) >> 1;
3507
+ if (sample(at(middle)) === signature) last = middle;
3508
+ else first = middle + 1;
3509
+ }
3510
+ return at(first);
3511
+ }
3512
+
3513
+ /**
3514
+ * The LAST weight of an arithmetic grid whose rendering matches a signature.
3515
+ *
3516
+ * The mirror of `firstGridWeight`: "renders like the floor" is true, then false,
3517
+ * so the boundary is found from the other end.
3518
+ * @param {(weight: number) => string|number} sample - renders a probe at one weight.
3519
+ * @param {string|number} signature - the signature the boundary shares.
3520
+ * @param {number} low - first weight of the grid.
3521
+ * @param {number} high - last weight of the grid.
3522
+ * @param {number} step - spacing of the grid.
3523
+ * @param {number|null} fallback - returned when no grid weight matches.
3524
+ * @returns {number|null} the weight, or the fallback.
3525
+ */
3526
+ function lastGridWeight(sample, signature, low, high, step, fallback) {
3527
+ var count = Math.floor((high - low) / step) + 1;
3528
+ if (count < 1 || !(step > 0)) return fallback;
3529
+ var at = function (index) {
3530
+ return low + index * step;
3531
+ };
3532
+ if (sample(at(0)) !== signature) return fallback;
3533
+ var first = 0;
3534
+ var last = count - 1;
3535
+ while (first < last) {
3536
+ var middle = (first + last + 1) >> 1;
3537
+ if (sample(at(middle)) === signature) first = middle;
3538
+ else last = middle - 1;
3539
+ }
3540
+ return at(first);
3541
+ }
3542
+
3479
3543
  /**
3480
3544
  * One slider shape for a stack whose text can land on more than one family.
3481
3545
  *
@@ -3637,30 +3701,38 @@ function weightProfileFrom(sample, options) {
3637
3701
  var top = typeof settings.top === "number" ? settings.top : WEIGHT_LADDER_STRONG;
3638
3702
  var topSignature = sample(WEIGHT_PROBE_HIGH);
3639
3703
  var bottomSignature = sample(WEIGHT_PROBE_LOW);
3640
- var heaviest = WEIGHT_PROBE_HIGH;
3641
- for (var weight = WEIGHT_PROBE_LOW; weight <= WEIGHT_PROBE_HIGH; weight += WEIGHT_PROBE_STEP) {
3642
- if (sample(weight) === topSignature) {
3643
- heaviest = weight;
3644
- break;
3645
- }
3646
- }
3647
- var lightest = WEIGHT_PROBE_LOW;
3648
- for (var back = WEIGHT_PROBE_HIGH; back >= WEIGHT_PROBE_LOW; back -= WEIGHT_PROBE_STEP) {
3649
- if (sample(back) === bottomSignature) {
3650
- lightest = back;
3651
- break;
3652
- }
3653
- }
3704
+ // The two horizons used to be found by walking the whole probe grid from one end
3705
+ // — eighty-one canvas renders per family, and on a font collection of a few
3706
+ // hundred megabytes each render is the browser rasterizing a new weight (issue
3707
+ // #3: seconds of blocked main thread). The grid is monotone in weight: everything
3708
+ // from a family's heaviest face UP renders the same, so the horizon is a
3709
+ // boundary, and a bisection finds it in seven samples instead of eighty-one.
3710
+ var heaviest = firstGridWeight(
3711
+ sample,
3712
+ topSignature,
3713
+ WEIGHT_PROBE_LOW,
3714
+ WEIGHT_PROBE_HIGH,
3715
+ WEIGHT_PROBE_STEP,
3716
+ WEIGHT_PROBE_HIGH
3717
+ );
3718
+ var lightest = lastGridWeight(
3719
+ sample,
3720
+ bottomSignature,
3721
+ WEIGHT_PROBE_LOW,
3722
+ WEIGHT_PROBE_HIGH,
3723
+ WEIGHT_PROBE_STEP,
3724
+ WEIGHT_PROBE_LOW
3725
+ );
3654
3726
  // How far the offset can go before NO layer that must move can move any more:
3655
3727
  // the widest horizon among those bases, since a layer that could still change
3656
3728
  // must not be cut off by one that already sits at its heaviest face.
3657
3729
  var headroom = Math.max(0, heaviest - WEIGHT_BASE);
3658
3730
  if (top !== WEIGHT_BASE) {
3659
- for (var up = top; up <= WEIGHT_PROBE_HIGH; up += WEIGHT_PROBE_STEP) {
3660
- if (sample(up) === topSignature) {
3661
- headroom = Math.max(headroom, up - top);
3662
- break;
3663
- }
3731
+ // Same boundary, same bisection: the first weight at or above the heavy base
3732
+ // that already renders at the ceiling.
3733
+ var reached = firstGridWeight(sample, topSignature, top, WEIGHT_PROBE_HIGH, WEIGHT_PROBE_STEP, null);
3734
+ if (reached !== null) {
3735
+ headroom = Math.max(headroom, reached - top);
3664
3736
  }
3665
3737
  }
3666
3738
  var reachUp = Math.min(ceil, WEIGHT_DELTA_UP_LIMIT, headroom);
@@ -6483,6 +6555,18 @@ var weightProbeCanvas = null;
6483
6555
  /** Cache of measured slider shapes, keyed by the resolved family stack. */
6484
6556
  var weightProfileCache = {};
6485
6557
 
6558
+ /**
6559
+ * How slow one canvas render may be before the font is treated as too heavy to
6560
+ * sweep in detail (a few-hundred-megabyte collection answers in tens of ms).
6561
+ */
6562
+ var SLOW_SAMPLE_MS = 40;
6563
+
6564
+ /** How long after load the code axis is measured (the other two go first). */
6565
+ var WARM_CODE_DELAY_MS = 2500;
6566
+
6567
+ /** Thrown by the guarded sampler to abandon the fine sweep on a slow font. */
6568
+ var WEIGHT_TOO_SLOW = { tooSlow: true };
6569
+
6486
6570
  /**
6487
6571
  * The families a stack can actually render text with.
6488
6572
  *
@@ -6561,6 +6645,15 @@ function familyWeightProfile(family, base, alias, inkText) {
6561
6645
  return familyProfileCache[key];
6562
6646
  }
6563
6647
  var record = null;
6648
+ // A font collection of a few hundred megabytes makes EVERY render expensive (the
6649
+ // browser re-parses the collection for a weight it has not drawn yet), and the
6650
+ // sweep asks for a dozen of them in a row — measured at 20-50 ms each on such a
6651
+ // file, i.e. a second of blocked main thread per family (issue #3). One render
6652
+ // that costs more than SLOW_SAMPLE_MS is enough evidence: abandon the fine sweep
6653
+ // and answer with a coarse one, so the worst case stays bounded instead of
6654
+ // growing with the file size.
6655
+ var slow = false;
6656
+ var guarded = null;
6564
6657
  try {
6565
6658
  var target = '"' + (alias === "" ? family : alias) + '"';
6566
6659
  var seen = {};
@@ -6570,10 +6663,16 @@ function familyWeightProfile(family, base, alias, inkText) {
6570
6663
  }
6571
6664
  return seen[weight];
6572
6665
  };
6666
+ guarded = function (weight) {
6667
+ var started = Date.now();
6668
+ var signature = sample(weight);
6669
+ if (Date.now() - started >= SLOW_SAMPLE_MS) throw WEIGHT_TOO_SLOW;
6670
+ return signature;
6671
+ };
6573
6672
  var shape =
6574
6673
  alias === ""
6575
- ? weightProfileFrom(sample, { top: base })
6576
- : weightProfileFrom(sample, { top: base, candidates: shared.CONTINUOUS_CANDIDATES });
6674
+ ? weightProfileFrom(guarded, { top: base })
6675
+ : weightProfileFrom(guarded, { top: base, candidates: shared.CONTINUOUS_CANDIDATES });
6577
6676
  record = { min: shape.min, max: shape.max, step: shape.step, inkOf: null };
6578
6677
  if (text !== null) {
6579
6678
  var inks = {};
@@ -6585,7 +6684,31 @@ function familyWeightProfile(family, base, alias, inkText) {
6585
6684
  };
6586
6685
  }
6587
6686
  } catch (error) {
6588
- record = null;
6687
+ if (error !== WEIGHT_TOO_SLOW) {
6688
+ record = null;
6689
+ } else {
6690
+ // Too slow for the fine sweep: nine renders on the hundred grid describe the
6691
+ // family coarsely, which is still a control that moves in visible steps. No
6692
+ // ink comparison — that would be a second sweep of the same price.
6693
+ slow = true;
6694
+ }
6695
+ }
6696
+ if (slow) {
6697
+ try {
6698
+ var coarseSeen = {};
6699
+ var coarse = weightProfileFrom(
6700
+ function (weight) {
6701
+ if (!Object.prototype.hasOwnProperty.call(coarseSeen, weight)) {
6702
+ coarseSeen[weight] = sampleWeightSignature(target, Math.round(weight / 100) * 100);
6703
+ }
6704
+ return coarseSeen[weight];
6705
+ },
6706
+ { top: base, candidates: [100] }
6707
+ );
6708
+ record = { min: coarse.min, max: coarse.max, step: coarse.step, inkOf: null };
6709
+ } catch (error) {
6710
+ record = null;
6711
+ }
6589
6712
  }
6590
6713
  familyProfileCache[key] = record;
6591
6714
  return record;
@@ -6965,6 +7088,123 @@ function inkOfSignature(signature) {
6965
7088
  return isFinite(ink) ? ink : 0;
6966
7089
  }
6967
7090
 
7091
+ /**
7092
+ * Where a measured slider shape survives a page reload.
7093
+ *
7094
+ * Measuring one axis is around eighty canvas renders per family, and the cost per
7095
+ * render is the browser rasterizing a weight it has never drawn (measured: ~3 ms
7096
+ * for an 18 MB family, ~6 ms for a 35 MB one, and seconds per render on a font
7097
+ * collection of a few hundred megabytes — issue #3). The answer only changes when
7098
+ * the reader changes the fonts, so it is kept: the first visit pays, every later
7099
+ * one opens instantly. The TTL bounds how long a font file that was replaced
7100
+ * under the same family name may go unnoticed.
7101
+ */
7102
+ var SHAPE_STORE_KEY = "dsh-fonttune:weight-shapes:v1";
7103
+ var SHAPE_STORE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
7104
+ var SHAPE_STORE_LIMIT = 32;
7105
+
7106
+ /**
7107
+ * What a slider shows while its measurement has not landed yet.
7108
+ *
7109
+ * The nominal offset window, unit one: the same answer the code already gives when
7110
+ * a family cannot be measured at all, so nothing new appears in the document and a
7111
+ * stored offset stays reachable. Rendering is what used to MEASURE (a cold cache at
7112
+ * open = eighty canvas renders on the main thread), which is the "opening the
7113
+ * settings freezes" of issue #3; the real counts replace these the moment the idle
7114
+ * measurement finishes and the card redraws.
7115
+ */
7116
+ var PENDING_WEIGHT_SHAPE = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
7117
+
7118
+ /** The decoded store, or an empty one when storage is unavailable. */
7119
+ var shapeStore = null;
7120
+
7121
+ /**
7122
+ * The persistent shape store, read once.
7123
+ * @returns {Object} key -> `{min, max, step, at}`.
7124
+ */
7125
+ function readShapeStore() {
7126
+ if (shapeStore !== null) return shapeStore;
7127
+ shapeStore = {};
7128
+ try {
7129
+ var raw = globalThis.localStorage.getItem(SHAPE_STORE_KEY);
7130
+ var parsed = raw === null ? null : JSON.parse(raw);
7131
+ if (parsed !== null && typeof parsed === "object") {
7132
+ for (var key in parsed) {
7133
+ if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue;
7134
+ var entry = parsed[key];
7135
+ if (entry === null || typeof entry !== "object") continue;
7136
+ if (!isFinite(entry.min) || !isFinite(entry.max) || !isFinite(entry.step)) continue;
7137
+ if (!(entry.step > 0) || !isFinite(entry.at)) continue;
7138
+ shapeStore[key] = { min: entry.min, max: entry.max, step: entry.step, at: entry.at };
7139
+ }
7140
+ }
7141
+ } catch (error) {
7142
+ // Private mode, a disabled store, a corrupted value: measuring still works.
7143
+ }
7144
+ return shapeStore;
7145
+ }
7146
+
7147
+ /**
7148
+ * The stored shape for one cache key, when it is still fresh.
7149
+ * @param {string} key - the same key the memory cache uses.
7150
+ * @returns {{min: number, max: number, step: number}|null} the shape, or null.
7151
+ */
7152
+ function storedWeightShape(key) {
7153
+ var store = readShapeStore();
7154
+ if (!Object.prototype.hasOwnProperty.call(store, key)) return null;
7155
+ var entry = store[key];
7156
+ if (Date.now() - entry.at > SHAPE_STORE_TTL_MS) return null;
7157
+ return { min: entry.min, max: entry.max, step: entry.step };
7158
+ }
7159
+
7160
+ /**
7161
+ * Keep one measured shape for the next page load.
7162
+ *
7163
+ * Writes the whole (small) map back, dropping the oldest entries past the limit:
7164
+ * a reader who tries many browsing fonts must not grow this without bound.
7165
+ * @param {string} key - the same key the memory cache uses.
7166
+ * @param {{min: number, max: number, step: number}} profile - what was measured.
7167
+ */
7168
+ function rememberWeightShape(key, profile) {
7169
+ var store = readShapeStore();
7170
+ store[key] = { min: profile.min, max: profile.max, step: profile.step, at: Date.now() };
7171
+ var keys = Object.keys(store);
7172
+ if (keys.length > SHAPE_STORE_LIMIT) {
7173
+ keys.sort(function (left, right) {
7174
+ return store[left].at - store[right].at;
7175
+ });
7176
+ for (var index = 0; index < keys.length - SHAPE_STORE_LIMIT; index += 1) {
7177
+ delete store[keys[index]];
7178
+ }
7179
+ }
7180
+ try {
7181
+ globalThis.localStorage.setItem(SHAPE_STORE_KEY, JSON.stringify(store));
7182
+ } catch (error) {
7183
+ // A full or disabled store only costs the next load the measurement again.
7184
+ }
7185
+ }
7186
+
7187
+ /**
7188
+ * What to redraw when a measurement lands after the first paint.
7189
+ *
7190
+ * The card renders the count of positions, which is a property of the font: a
7191
+ * shape that arrives later (the idle warm-up, a slower family) has to reach the
7192
+ * mounted cards or they would keep showing the provisional counts.
7193
+ */
7194
+ var shapeListeners = [];
7195
+
7196
+ /** Ask every mounted card to re-read the shapes. */
7197
+ function notifyShapes() {
7198
+ var pending = shapeListeners.slice();
7199
+ for (var index = 0; index < pending.length; index += 1) {
7200
+ try {
7201
+ pending[index]();
7202
+ } catch (error) {
7203
+ // A listener must never break the measurement that notified it.
7204
+ }
7205
+ }
7206
+ }
7207
+
6968
7208
  /**
6969
7209
  * The slider shape to use for one family: its range and its notch.
6970
7210
  *
@@ -6972,15 +7212,19 @@ function inkOfSignature(signature) {
6972
7212
  * range is measured rather than fixed (`weightProfileFrom`), which also makes
6973
7213
  * every notch a change — for every layer of the ladder, so the measurement is
6974
7214
  * told which base is the heaviest one the offset lands on. Measured once per
6975
- * family and base and cached; any failure (no canvas, no 2D context) falls back
6976
- * to the nominal window with single units.
7215
+ * family and base and cached, in memory and across page loads; any failure (no
7216
+ * canvas, no 2D context) falls back to the nominal window with single units.
6977
7217
  * @param {string} stack - resolved `font-family` value, "" for the page default.
6978
7218
  * @param {number} [top] - the heaviest base the offset is added to; the ladder's
6979
7219
  * own top for the conversation and interface axes, the axis's base for code.
7220
+ * @param {{peek?: boolean}} [options] - `peek: true` never measures: it answers
7221
+ * with the stored shape when there is one and the provisional shape otherwise,
7222
+ * so a render can never block on a canvas sweep (see `PENDING_WEIGHT_SHAPE`).
6980
7223
  * @returns {{min: number, max: number, step: number}} the slider shape.
6981
7224
  */
6982
- function weightProfileFor(stack, top) {
7225
+ function weightProfileFor(stack, top, options) {
6983
7226
  var base = typeof top === "number" ? top : shared.WEIGHT_LADDER_TOP;
7227
+ var peek = options !== null && typeof options === "object" && options.peek === true;
6984
7228
  var resolved = String(stack) === "" ? liveFamily() : String(stack);
6985
7229
  // `hasOwnProperty` rather than a truthiness test: the alias map is keyed by family
6986
7230
  // name, and a family called "constructor" must not read as promoted.
@@ -6994,6 +7238,15 @@ function weightProfileFor(stack, top) {
6994
7238
  if (Object.prototype.hasOwnProperty.call(weightProfileCache, key)) {
6995
7239
  return weightProfileCache[key];
6996
7240
  }
7241
+ // Measured on a previous visit: use it and skip the sweep entirely.
7242
+ var stored = storedWeightShape(key);
7243
+ if (stored !== null) {
7244
+ weightProfileCache[key] = stored;
7245
+ return stored;
7246
+ }
7247
+ // A render must never wait for eighty canvas renders: answer with the
7248
+ // provisional shape now, and let the idle warm-up measure and redraw.
7249
+ if (peek) return PENDING_WEIGHT_SHAPE;
6997
7250
  var profile = promoted
6998
7251
  ? shared.continuousWeightProfile()
6999
7252
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
@@ -7034,6 +7287,20 @@ function weightProfileFor(stack, top) {
7034
7287
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
7035
7288
  }
7036
7289
  weightProfileCache[key] = profile;
7290
+ // A measured answer is worth a reload's freedom from the sweep, and the count it
7291
+ // changes has to reach whatever is already on screen with the provisional one.
7292
+ // Only when it actually differs: a family nothing could measure (no canvas) lands
7293
+ // on exactly the provisional window, and redrawing for that would be noise.
7294
+ rememberWeightShape(key, profile);
7295
+ // Nothing distinguishable measured (no canvas, a family the page cannot draw)
7296
+ // leaves the degenerate shape, which is not worth a redraw.
7297
+ var changed =
7298
+ profile.min !== 0 || profile.max !== 0
7299
+ ? profile.min !== PENDING_WEIGHT_SHAPE.min ||
7300
+ profile.max !== PENDING_WEIGHT_SHAPE.max ||
7301
+ profile.step !== PENDING_WEIGHT_SHAPE.step
7302
+ : false;
7303
+ if (changed) notifyShapes();
7037
7304
  return profile;
7038
7305
  }
7039
7306
 
@@ -7086,6 +7353,9 @@ function deferWeightWarm(job) {
7086
7353
  * @param {object} editing - the value set the axes read.
7087
7354
  */
7088
7355
  var warmSignature = null;
7356
+
7357
+ /** The code stack the deferred slice was last scheduled for (scheduled once). */
7358
+ var codeWarmSignature = null;
7089
7359
  var scheduleWeightWarm = function (editing) {
7090
7360
  var stacks = weightAxisStacks(editing);
7091
7361
  var signature = stacks.dialog + "\u0000" + stacks.ui + "\u0000" + stacks.code;
@@ -7102,11 +7372,6 @@ var scheduleWeightWarm = function (editing) {
7102
7372
  weightProfileFor(stacks.ui, shared.WEIGHT_LADDER_STRONG);
7103
7373
  probeLog("weight-warm", { axis: "ui", stack: stacks.ui });
7104
7374
  },
7105
- function () {
7106
- probeContinuity(stacks.code);
7107
- weightProfileFor(stacks.code, shared.WEIGHT_BASE);
7108
- probeLog("weight-warm", { axis: "code", stack: stacks.code });
7109
- },
7110
7375
  ];
7111
7376
  var runNext = function () {
7112
7377
  var job = jobs.shift();
@@ -7115,6 +7380,20 @@ var scheduleWeightWarm = function (editing) {
7115
7380
  deferWeightWarm(runNext);
7116
7381
  };
7117
7382
  deferWeightWarm(runNext);
7383
+ // The code axis is measured LAST and later still: its family is usually a
7384
+ // monospace the reader rarely touches, and on a slow machine the two axes above
7385
+ // already keep the main thread busy. Deferring it keeps the first seconds after
7386
+ // load (when a reader is likeliest to open the settings) free of its sweep.
7387
+ var codeSignature = stacks.code;
7388
+ if (codeSignature === codeWarmSignature) return;
7389
+ codeWarmSignature = codeSignature;
7390
+ globalThis.setTimeout(function () {
7391
+ deferWeightWarm(function () {
7392
+ probeContinuity(stacks.code);
7393
+ weightProfileFor(stacks.code, shared.WEIGHT_BASE);
7394
+ probeLog("weight-warm", { axis: "code", stack: stacks.code });
7395
+ });
7396
+ }, WARM_CODE_DELAY_MS);
7118
7397
  };
7119
7398
 
7120
7399
  /**
@@ -9453,7 +9732,8 @@ function FontCard(props) {
9453
9732
  var weightField = function (props) {
9454
9733
  var profile = weightProfileFor(
9455
9734
  props.stack === undefined ? "" : props.stack,
9456
- props.top === undefined ? shared.WEIGHT_LADDER_STRONG : props.top
9735
+ props.top === undefined ? shared.WEIGHT_LADDER_STRONG : props.top,
9736
+ { peek: true }
9457
9737
  );
9458
9738
  var stored = props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value;
9459
9739
  // A stored offset outside the measured range (the font changed since it was
@@ -10070,7 +10350,7 @@ function FontCard(props) {
10070
10350
  var isCode = field === CODE_WEIGHT_FIELD;
10071
10351
  var stack =
10072
10352
  field === WEIGHT_DIALOG_FIELD ? dialogStack : isCode ? codeStack : uiStack;
10073
- var profile = weightProfileFor(stack, isCode ? shared.WEIGHT_BASE : shared.WEIGHT_LADDER_STRONG);
10353
+ var profile = weightProfileFor(stack, isCode ? shared.WEIGHT_BASE : shared.WEIGHT_LADDER_STRONG, { peek: true });
10074
10354
  return weightStepRange(profile, value === WEIGHT_UNSET ? 0 : value).value;
10075
10355
  };
10076
10356
 
@@ -10141,6 +10421,23 @@ function FontCard(props) {
10141
10421
  previewCodeStyle.lineHeight = "calc(20px + " + editing[CODE_LINE_HEIGHT_FIELD] + "px)";
10142
10422
  }
10143
10423
 
10424
+ // A shape measured after this render changes the counts the rows above show, so
10425
+ // the card redraws once one lands (see `notifyShapes`). Declared last on purpose:
10426
+ // the hook order of everything before it stays exactly as it was.
10427
+ var [, setShapeRevision] = useState(0);
10428
+ useEffect(function () {
10429
+ var listener = function () {
10430
+ setShapeRevision(function (count) {
10431
+ return count + 1;
10432
+ });
10433
+ };
10434
+ shapeListeners.push(listener);
10435
+ return function () {
10436
+ var at = shapeListeners.indexOf(listener);
10437
+ if (at >= 0) shapeListeners.splice(at, 1);
10438
+ };
10439
+ }, []);
10440
+
10144
10441
  return h(
10145
10442
  "li",
10146
10443
  { className: "dfp-card" + (open ? " dfp-cardOpen" : "") },
@@ -13483,7 +13780,7 @@ function mountFloatPanel(scope, t, hostCtx) {
13483
13780
  var dialogStack = weightAxisStacks(config).dialog;
13484
13781
  var profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
13485
13782
  try {
13486
- profile = weightProfileFor(dialogStack, shared.WEIGHT_LADDER_STRONG);
13783
+ profile = weightProfileFor(dialogStack, shared.WEIGHT_LADDER_STRONG, { peek: true });
13487
13784
  } catch (profileError) {
13488
13785
  // The nominal window stands in when the family cannot be measured.
13489
13786
  }
@@ -13859,13 +14156,18 @@ function mountFloatPanel(scope, t, hostCtx) {
13859
14156
  }
13860
14157
 
13861
14158
  // A promotion changes the weight row's shape; the panel redraws itself when one
13862
- // lands, and stops listening when it goes away.
14159
+ // lands, and stops listening when it goes away. A shape that only lands with the
14160
+ // idle measurement is the same story: the row was rendered with the provisional
14161
+ // counts, so it has to be redrawn once the real ones exist.
13863
14162
  promotionListeners.push(render);
14163
+ shapeListeners.push(render);
13864
14164
 
13865
14165
  return {
13866
14166
  destroy: function () {
13867
14167
  var at = promotionListeners.indexOf(render);
13868
14168
  if (at >= 0) promotionListeners.splice(at, 1);
14169
+ var shapeAt = shapeListeners.indexOf(render);
14170
+ if (shapeAt >= 0) shapeListeners.splice(shapeAt, 1);
13869
14171
  if (statusTimer !== null) globalThis.clearTimeout(statusTimer);
13870
14172
  if (closeTimer !== null) globalThis.clearTimeout(closeTimer);
13871
14173
  if (reflowTimer !== null) globalThis.clearTimeout(reflowTimer);
package/lib/shared.cjs CHANGED
@@ -3467,6 +3467,70 @@ function balancedStackShape(entries, base, low, high, spread) {
3467
3467
  return best;
3468
3468
  }
3469
3469
 
3470
+ /**
3471
+ * The FIRST weight of an arithmetic grid whose rendering matches a signature.
3472
+ *
3473
+ * The probe grid is monotone: below a family's lightest face every weight renders
3474
+ * the same (the lightest face), above its heaviest face every weight renders the
3475
+ * same (the heaviest face), and in between it walks the faces. So "renders like the
3476
+ * ceiling" is a predicate that is false, then true, and its boundary is worth
3477
+ * `log2` samples instead of one render per grid point — which is what the two
3478
+ * horizon scans used to cost (eighty-one renders per family, issue #3).
3479
+ * @param {(weight: number) => string|number} sample - renders a probe at one weight.
3480
+ * @param {string|number} signature - the signature the boundary shares.
3481
+ * @param {number} low - first weight of the grid.
3482
+ * @param {number} high - last weight of the grid.
3483
+ * @param {number} step - spacing of the grid.
3484
+ * @param {number|null} fallback - returned when no grid weight matches.
3485
+ * @returns {number|null} the weight, or the fallback.
3486
+ */
3487
+ function firstGridWeight(sample, signature, low, high, step, fallback) {
3488
+ var count = Math.floor((high - low) / step) + 1;
3489
+ if (count < 1 || !(step > 0)) return fallback;
3490
+ var at = function (index) {
3491
+ return low + index * step;
3492
+ };
3493
+ if (sample(at(count - 1)) !== signature) return fallback;
3494
+ var first = 0;
3495
+ var last = count - 1;
3496
+ while (first < last) {
3497
+ var middle = (first + last) >> 1;
3498
+ if (sample(at(middle)) === signature) last = middle;
3499
+ else first = middle + 1;
3500
+ }
3501
+ return at(first);
3502
+ }
3503
+
3504
+ /**
3505
+ * The LAST weight of an arithmetic grid whose rendering matches a signature.
3506
+ *
3507
+ * The mirror of `firstGridWeight`: "renders like the floor" is true, then false,
3508
+ * so the boundary is found from the other end.
3509
+ * @param {(weight: number) => string|number} sample - renders a probe at one weight.
3510
+ * @param {string|number} signature - the signature the boundary shares.
3511
+ * @param {number} low - first weight of the grid.
3512
+ * @param {number} high - last weight of the grid.
3513
+ * @param {number} step - spacing of the grid.
3514
+ * @param {number|null} fallback - returned when no grid weight matches.
3515
+ * @returns {number|null} the weight, or the fallback.
3516
+ */
3517
+ function lastGridWeight(sample, signature, low, high, step, fallback) {
3518
+ var count = Math.floor((high - low) / step) + 1;
3519
+ if (count < 1 || !(step > 0)) return fallback;
3520
+ var at = function (index) {
3521
+ return low + index * step;
3522
+ };
3523
+ if (sample(at(0)) !== signature) return fallback;
3524
+ var first = 0;
3525
+ var last = count - 1;
3526
+ while (first < last) {
3527
+ var middle = (first + last + 1) >> 1;
3528
+ if (sample(at(middle)) === signature) first = middle;
3529
+ else last = middle - 1;
3530
+ }
3531
+ return at(first);
3532
+ }
3533
+
3470
3534
  /**
3471
3535
  * One slider shape for a stack whose text can land on more than one family.
3472
3536
  *
@@ -3628,30 +3692,38 @@ function weightProfileFrom(sample, options) {
3628
3692
  var top = typeof settings.top === "number" ? settings.top : WEIGHT_LADDER_STRONG;
3629
3693
  var topSignature = sample(WEIGHT_PROBE_HIGH);
3630
3694
  var bottomSignature = sample(WEIGHT_PROBE_LOW);
3631
- var heaviest = WEIGHT_PROBE_HIGH;
3632
- for (var weight = WEIGHT_PROBE_LOW; weight <= WEIGHT_PROBE_HIGH; weight += WEIGHT_PROBE_STEP) {
3633
- if (sample(weight) === topSignature) {
3634
- heaviest = weight;
3635
- break;
3636
- }
3637
- }
3638
- var lightest = WEIGHT_PROBE_LOW;
3639
- for (var back = WEIGHT_PROBE_HIGH; back >= WEIGHT_PROBE_LOW; back -= WEIGHT_PROBE_STEP) {
3640
- if (sample(back) === bottomSignature) {
3641
- lightest = back;
3642
- break;
3643
- }
3644
- }
3695
+ // The two horizons used to be found by walking the whole probe grid from one end
3696
+ // — eighty-one canvas renders per family, and on a font collection of a few
3697
+ // hundred megabytes each render is the browser rasterizing a new weight (issue
3698
+ // #3: seconds of blocked main thread). The grid is monotone in weight: everything
3699
+ // from a family's heaviest face UP renders the same, so the horizon is a
3700
+ // boundary, and a bisection finds it in seven samples instead of eighty-one.
3701
+ var heaviest = firstGridWeight(
3702
+ sample,
3703
+ topSignature,
3704
+ WEIGHT_PROBE_LOW,
3705
+ WEIGHT_PROBE_HIGH,
3706
+ WEIGHT_PROBE_STEP,
3707
+ WEIGHT_PROBE_HIGH
3708
+ );
3709
+ var lightest = lastGridWeight(
3710
+ sample,
3711
+ bottomSignature,
3712
+ WEIGHT_PROBE_LOW,
3713
+ WEIGHT_PROBE_HIGH,
3714
+ WEIGHT_PROBE_STEP,
3715
+ WEIGHT_PROBE_LOW
3716
+ );
3645
3717
  // How far the offset can go before NO layer that must move can move any more:
3646
3718
  // the widest horizon among those bases, since a layer that could still change
3647
3719
  // must not be cut off by one that already sits at its heaviest face.
3648
3720
  var headroom = Math.max(0, heaviest - WEIGHT_BASE);
3649
3721
  if (top !== WEIGHT_BASE) {
3650
- for (var up = top; up <= WEIGHT_PROBE_HIGH; up += WEIGHT_PROBE_STEP) {
3651
- if (sample(up) === topSignature) {
3652
- headroom = Math.max(headroom, up - top);
3653
- break;
3654
- }
3722
+ // Same boundary, same bisection: the first weight at or above the heavy base
3723
+ // that already renders at the ceiling.
3724
+ var reached = firstGridWeight(sample, topSignature, top, WEIGHT_PROBE_HIGH, WEIGHT_PROBE_STEP, null);
3725
+ if (reached !== null) {
3726
+ headroom = Math.max(headroom, reached - top);
3655
3727
  }
3656
3728
  }
3657
3729
  var reachUp = Math.min(ceil, WEIGHT_DELTA_UP_LIMIT, headroom);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
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
  "icon": "icon.svg",
@@ -50,7 +50,10 @@
50
50
  "0.2.1-rc.1": "compatible",
51
51
  "0.2.1-rc.2": "compatible",
52
52
  "0.2.1-rc.4": "compatible",
53
- "0.2.2-alpha.2": "compatible"
53
+ "0.2.1-rc.3": "compatible",
54
+ "0.2.1-rc.5": "compatible",
55
+ "0.2.2-alpha.2": "compatible",
56
+ "0.2.2-rc.1": "compatible"
54
57
  }
55
58
  }
56
59
  },