dsh-fonttune 0.7.6 → 0.7.8

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,24 @@
2
2
 
3
3
  All notable changes to **dsh-fonttune** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.7.8] - 2026-11-05
6
+
7
+ ### Fixed
8
+ - **The font picker opened slowly**: the list now builds one screenful, and the names are no longer drawn in each family's own font, so opening and scrolling stay quick on a machine with many installed fonts.
9
+ - **A pause when switching presets**: a preset remembers the notches that were measured for it, so switching uses them directly instead of measuring again at that moment.
10
+
11
+ ### Changed
12
+ - **Only verified host versions are declared**: 0.2.0-rc.1 through 0.2.2-rc.1 are listed one by one; older or untested versions are no longer claimed.
13
+
14
+ ## [0.7.7] - 2026-11-05
15
+
16
+ ### Fixed
17
+ - **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.
18
+ - **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.
19
+
20
+ ### Changed
21
+ - **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.
22
+
5
23
  ## [0.7.6] - 2026-11-04
6
24
 
7
25
  ### Fixed
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  **dsh-fonttune** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.7.8] - 2026-11-05
6
+
7
+ ### 修复
8
+ - **字体选择列表打开较慢**:列表现在只列出首屏,并且不再逐个用各自字体显示名称;机器上安装的字体很多时,打开与滚动都更快。
9
+ - **切换配置方案时的停顿**:方案会记住已测好的档位,切换时直接使用,切换的那一瞬间不再重新测量。
10
+
11
+ ### 变更
12
+ - **兼容范围只保留实测过的版本**:从 0.2.0-rc.1 到 0.2.2-rc.1 逐个列出,更早或未验证的版本不再声明支持。
13
+
14
+ ## [0.7.7] - 2026-11-05
15
+
16
+ ### 修复
17
+ - **打开字体设置时的卡顿**:字体档位的测量改为分步进行,并避开设置页面的打开与刷新;同一套字体只需测量一次,之后打开设置不再重新计算。
18
+ - **部分超大字体集合下的长时间无响应**:字体文件特别大时,测量会在超时后改用粗略档位,不再长时间占住界面。
19
+
20
+ ### 变更
21
+ - **兼容 DSH 0.2.2-rc.1**:README 的兼容表与插件清单同步补上 0.2.1-rc.3、0.2.1-rc.5 与 0.2.2-rc.1。
22
+
5
23
  ## [0.7.6] - 2026-11-04
6
24
 
7
25
  ### 修复
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.2.0-rc.1, 0.2.0-rc.2, 0.2.1-rc.1, 0.2.1-rc.2, 0.2.1-rc.3, 0.2.1-rc.4, 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.2.0-rc.1、0.2.0-rc.2、0.2.1-rc.1、0.2.1-rc.2、0.2.1-rc.3、0.2.1-rc.4、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);
@@ -4928,6 +5000,40 @@ function catalogRefreshDue(cache, now, retryMs) {
4928
5000
  return now - (cache.at || 0) >= retryMs;
4929
5001
  }
4930
5002
 
5003
+ /**
5004
+ * The rows one picker group actually renders.
5005
+ *
5006
+ * A machine with a huge font collection — or a Super TTC, which exposes every
5007
+ * weight and variant as its own family — can enumerate hundreds of names, and
5008
+ * building a row for each one is a visible stall when the list opens (issue #3:
5009
+ * the reporter's second symptom). The list therefore renders a screenful unless
5010
+ * the reader is searching, and a family that is already selected is always
5011
+ * rendered so its check mark never falls outside the window.
5012
+ * @param {string[]} families - the group's families, in display order.
5013
+ * @param {string} query - the search text (empty means "browse the whole list").
5014
+ * @param {Object<string, boolean>} selectedLower - lower-cased names already picked.
5015
+ * @param {number} [limit] - how many rows a browse renders.
5016
+ * @returns {string[]} the families to render.
5017
+ */
5018
+ function pickerRows(families, query, selectedLower, limit) {
5019
+ var list = Array.isArray(families) ? families : [];
5020
+ var searching = typeof query === "string" && query.trim() !== "";
5021
+ if (searching) return list.slice();
5022
+ var cap = typeof limit === "number" && limit > 0 ? Math.floor(limit) : 60;
5023
+ var picked = selectedLower !== null && typeof selectedLower === "object" ? selectedLower : {};
5024
+ var rows = [];
5025
+ var shown = 0;
5026
+ for (var index = 0; index < list.length; index += 1) {
5027
+ var name = list[index];
5028
+ var isSelected = picked[String(name).toLowerCase()] === true;
5029
+ if (isSelected || shown < cap) {
5030
+ rows.push(name);
5031
+ shown += 1;
5032
+ }
5033
+ }
5034
+ return rows;
5035
+ }
5036
+
4931
5037
  var shared = {
4932
5038
  NAMESPACE: NAMESPACE,
4933
5039
  SANS_FIELD: SANS_FIELD,
@@ -5075,6 +5181,7 @@ var shared = {
5075
5181
  PRESETS: PRESETS,
5076
5182
  PRESET_GROUPS: PRESET_GROUPS,
5077
5183
  MAX_LOCAL_FONTS: MAX_LOCAL_FONTS,
5184
+ pickerRows: pickerRows,
5078
5185
  pickerGroups: pickerGroups,
5079
5186
  catalogRefreshDue: catalogRefreshDue,
5080
5187
  MARKDOWN_SELECTOR: MARKDOWN_SELECTOR,
@@ -6483,6 +6590,18 @@ var weightProbeCanvas = null;
6483
6590
  /** Cache of measured slider shapes, keyed by the resolved family stack. */
6484
6591
  var weightProfileCache = {};
6485
6592
 
6593
+ /**
6594
+ * How slow one canvas render may be before the font is treated as too heavy to
6595
+ * sweep in detail (a few-hundred-megabyte collection answers in tens of ms).
6596
+ */
6597
+ var SLOW_SAMPLE_MS = 40;
6598
+
6599
+ /** How long after load the code axis is measured (the other two go first). */
6600
+ var WARM_CODE_DELAY_MS = 2500;
6601
+
6602
+ /** Thrown by the guarded sampler to abandon the fine sweep on a slow font. */
6603
+ var WEIGHT_TOO_SLOW = { tooSlow: true };
6604
+
6486
6605
  /**
6487
6606
  * The families a stack can actually render text with.
6488
6607
  *
@@ -6561,6 +6680,15 @@ function familyWeightProfile(family, base, alias, inkText) {
6561
6680
  return familyProfileCache[key];
6562
6681
  }
6563
6682
  var record = null;
6683
+ // A font collection of a few hundred megabytes makes EVERY render expensive (the
6684
+ // browser re-parses the collection for a weight it has not drawn yet), and the
6685
+ // sweep asks for a dozen of them in a row — measured at 20-50 ms each on such a
6686
+ // file, i.e. a second of blocked main thread per family (issue #3). One render
6687
+ // that costs more than SLOW_SAMPLE_MS is enough evidence: abandon the fine sweep
6688
+ // and answer with a coarse one, so the worst case stays bounded instead of
6689
+ // growing with the file size.
6690
+ var slow = false;
6691
+ var guarded = null;
6564
6692
  try {
6565
6693
  var target = '"' + (alias === "" ? family : alias) + '"';
6566
6694
  var seen = {};
@@ -6570,10 +6698,16 @@ function familyWeightProfile(family, base, alias, inkText) {
6570
6698
  }
6571
6699
  return seen[weight];
6572
6700
  };
6701
+ guarded = function (weight) {
6702
+ var started = Date.now();
6703
+ var signature = sample(weight);
6704
+ if (Date.now() - started >= SLOW_SAMPLE_MS) throw WEIGHT_TOO_SLOW;
6705
+ return signature;
6706
+ };
6573
6707
  var shape =
6574
6708
  alias === ""
6575
- ? weightProfileFrom(sample, { top: base })
6576
- : weightProfileFrom(sample, { top: base, candidates: shared.CONTINUOUS_CANDIDATES });
6709
+ ? weightProfileFrom(guarded, { top: base })
6710
+ : weightProfileFrom(guarded, { top: base, candidates: shared.CONTINUOUS_CANDIDATES });
6577
6711
  record = { min: shape.min, max: shape.max, step: shape.step, inkOf: null };
6578
6712
  if (text !== null) {
6579
6713
  var inks = {};
@@ -6585,7 +6719,31 @@ function familyWeightProfile(family, base, alias, inkText) {
6585
6719
  };
6586
6720
  }
6587
6721
  } catch (error) {
6588
- record = null;
6722
+ if (error !== WEIGHT_TOO_SLOW) {
6723
+ record = null;
6724
+ } else {
6725
+ // Too slow for the fine sweep: nine renders on the hundred grid describe the
6726
+ // family coarsely, which is still a control that moves in visible steps. No
6727
+ // ink comparison — that would be a second sweep of the same price.
6728
+ slow = true;
6729
+ }
6730
+ }
6731
+ if (slow) {
6732
+ try {
6733
+ var coarseSeen = {};
6734
+ var coarse = weightProfileFrom(
6735
+ function (weight) {
6736
+ if (!Object.prototype.hasOwnProperty.call(coarseSeen, weight)) {
6737
+ coarseSeen[weight] = sampleWeightSignature(target, Math.round(weight / 100) * 100);
6738
+ }
6739
+ return coarseSeen[weight];
6740
+ },
6741
+ { top: base, candidates: [100] }
6742
+ );
6743
+ record = { min: coarse.min, max: coarse.max, step: coarse.step, inkOf: null };
6744
+ } catch (error) {
6745
+ record = null;
6746
+ }
6589
6747
  }
6590
6748
  familyProfileCache[key] = record;
6591
6749
  return record;
@@ -6965,6 +7123,123 @@ function inkOfSignature(signature) {
6965
7123
  return isFinite(ink) ? ink : 0;
6966
7124
  }
6967
7125
 
7126
+ /**
7127
+ * Where a measured slider shape survives a page reload.
7128
+ *
7129
+ * Measuring one axis is around eighty canvas renders per family, and the cost per
7130
+ * render is the browser rasterizing a weight it has never drawn (measured: ~3 ms
7131
+ * for an 18 MB family, ~6 ms for a 35 MB one, and seconds per render on a font
7132
+ * collection of a few hundred megabytes — issue #3). The answer only changes when
7133
+ * the reader changes the fonts, so it is kept: the first visit pays, every later
7134
+ * one opens instantly. The TTL bounds how long a font file that was replaced
7135
+ * under the same family name may go unnoticed.
7136
+ */
7137
+ var SHAPE_STORE_KEY = "dsh-fonttune:weight-shapes:v1";
7138
+ var SHAPE_STORE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
7139
+ var SHAPE_STORE_LIMIT = 32;
7140
+
7141
+ /**
7142
+ * What a slider shows while its measurement has not landed yet.
7143
+ *
7144
+ * The nominal offset window, unit one: the same answer the code already gives when
7145
+ * a family cannot be measured at all, so nothing new appears in the document and a
7146
+ * stored offset stays reachable. Rendering is what used to MEASURE (a cold cache at
7147
+ * open = eighty canvas renders on the main thread), which is the "opening the
7148
+ * settings freezes" of issue #3; the real counts replace these the moment the idle
7149
+ * measurement finishes and the card redraws.
7150
+ */
7151
+ var PENDING_WEIGHT_SHAPE = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
7152
+
7153
+ /** The decoded store, or an empty one when storage is unavailable. */
7154
+ var shapeStore = null;
7155
+
7156
+ /**
7157
+ * The persistent shape store, read once.
7158
+ * @returns {Object} key -> `{min, max, step, at}`.
7159
+ */
7160
+ function readShapeStore() {
7161
+ if (shapeStore !== null) return shapeStore;
7162
+ shapeStore = {};
7163
+ try {
7164
+ var raw = globalThis.localStorage.getItem(SHAPE_STORE_KEY);
7165
+ var parsed = raw === null ? null : JSON.parse(raw);
7166
+ if (parsed !== null && typeof parsed === "object") {
7167
+ for (var key in parsed) {
7168
+ if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue;
7169
+ var entry = parsed[key];
7170
+ if (entry === null || typeof entry !== "object") continue;
7171
+ if (!isFinite(entry.min) || !isFinite(entry.max) || !isFinite(entry.step)) continue;
7172
+ if (!(entry.step > 0) || !isFinite(entry.at)) continue;
7173
+ shapeStore[key] = { min: entry.min, max: entry.max, step: entry.step, at: entry.at };
7174
+ }
7175
+ }
7176
+ } catch (error) {
7177
+ // Private mode, a disabled store, a corrupted value: measuring still works.
7178
+ }
7179
+ return shapeStore;
7180
+ }
7181
+
7182
+ /**
7183
+ * The stored shape for one cache key, when it is still fresh.
7184
+ * @param {string} key - the same key the memory cache uses.
7185
+ * @returns {{min: number, max: number, step: number}|null} the shape, or null.
7186
+ */
7187
+ function storedWeightShape(key) {
7188
+ var store = readShapeStore();
7189
+ if (!Object.prototype.hasOwnProperty.call(store, key)) return null;
7190
+ var entry = store[key];
7191
+ if (Date.now() - entry.at > SHAPE_STORE_TTL_MS) return null;
7192
+ return { min: entry.min, max: entry.max, step: entry.step };
7193
+ }
7194
+
7195
+ /**
7196
+ * Keep one measured shape for the next page load.
7197
+ *
7198
+ * Writes the whole (small) map back, dropping the oldest entries past the limit:
7199
+ * a reader who tries many browsing fonts must not grow this without bound.
7200
+ * @param {string} key - the same key the memory cache uses.
7201
+ * @param {{min: number, max: number, step: number}} profile - what was measured.
7202
+ */
7203
+ function rememberWeightShape(key, profile) {
7204
+ var store = readShapeStore();
7205
+ store[key] = { min: profile.min, max: profile.max, step: profile.step, at: Date.now() };
7206
+ var keys = Object.keys(store);
7207
+ if (keys.length > SHAPE_STORE_LIMIT) {
7208
+ keys.sort(function (left, right) {
7209
+ return store[left].at - store[right].at;
7210
+ });
7211
+ for (var index = 0; index < keys.length - SHAPE_STORE_LIMIT; index += 1) {
7212
+ delete store[keys[index]];
7213
+ }
7214
+ }
7215
+ try {
7216
+ globalThis.localStorage.setItem(SHAPE_STORE_KEY, JSON.stringify(store));
7217
+ } catch (error) {
7218
+ // A full or disabled store only costs the next load the measurement again.
7219
+ }
7220
+ }
7221
+
7222
+ /**
7223
+ * What to redraw when a measurement lands after the first paint.
7224
+ *
7225
+ * The card renders the count of positions, which is a property of the font: a
7226
+ * shape that arrives later (the idle warm-up, a slower family) has to reach the
7227
+ * mounted cards or they would keep showing the provisional counts.
7228
+ */
7229
+ var shapeListeners = [];
7230
+
7231
+ /** Ask every mounted card to re-read the shapes. */
7232
+ function notifyShapes() {
7233
+ var pending = shapeListeners.slice();
7234
+ for (var index = 0; index < pending.length; index += 1) {
7235
+ try {
7236
+ pending[index]();
7237
+ } catch (error) {
7238
+ // A listener must never break the measurement that notified it.
7239
+ }
7240
+ }
7241
+ }
7242
+
6968
7243
  /**
6969
7244
  * The slider shape to use for one family: its range and its notch.
6970
7245
  *
@@ -6972,15 +7247,19 @@ function inkOfSignature(signature) {
6972
7247
  * range is measured rather than fixed (`weightProfileFrom`), which also makes
6973
7248
  * every notch a change — for every layer of the ladder, so the measurement is
6974
7249
  * 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.
7250
+ * family and base and cached, in memory and across page loads; any failure (no
7251
+ * canvas, no 2D context) falls back to the nominal window with single units.
6977
7252
  * @param {string} stack - resolved `font-family` value, "" for the page default.
6978
7253
  * @param {number} [top] - the heaviest base the offset is added to; the ladder's
6979
7254
  * own top for the conversation and interface axes, the axis's base for code.
7255
+ * @param {{peek?: boolean}} [options] - `peek: true` never measures: it answers
7256
+ * with the stored shape when there is one and the provisional shape otherwise,
7257
+ * so a render can never block on a canvas sweep (see `PENDING_WEIGHT_SHAPE`).
6980
7258
  * @returns {{min: number, max: number, step: number}} the slider shape.
6981
7259
  */
6982
- function weightProfileFor(stack, top) {
7260
+ function weightProfileFor(stack, top, options) {
6983
7261
  var base = typeof top === "number" ? top : shared.WEIGHT_LADDER_TOP;
7262
+ var peek = options !== null && typeof options === "object" && options.peek === true;
6984
7263
  var resolved = String(stack) === "" ? liveFamily() : String(stack);
6985
7264
  // `hasOwnProperty` rather than a truthiness test: the alias map is keyed by family
6986
7265
  // name, and a family called "constructor" must not read as promoted.
@@ -6994,6 +7273,15 @@ function weightProfileFor(stack, top) {
6994
7273
  if (Object.prototype.hasOwnProperty.call(weightProfileCache, key)) {
6995
7274
  return weightProfileCache[key];
6996
7275
  }
7276
+ // Measured on a previous visit: use it and skip the sweep entirely.
7277
+ var stored = storedWeightShape(key);
7278
+ if (stored !== null) {
7279
+ weightProfileCache[key] = stored;
7280
+ return stored;
7281
+ }
7282
+ // A render must never wait for eighty canvas renders: answer with the
7283
+ // provisional shape now, and let the idle warm-up measure and redraw.
7284
+ if (peek) return PENDING_WEIGHT_SHAPE;
6997
7285
  var profile = promoted
6998
7286
  ? shared.continuousWeightProfile()
6999
7287
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
@@ -7034,6 +7322,20 @@ function weightProfileFor(stack, top) {
7034
7322
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
7035
7323
  }
7036
7324
  weightProfileCache[key] = profile;
7325
+ // A measured answer is worth a reload's freedom from the sweep, and the count it
7326
+ // changes has to reach whatever is already on screen with the provisional one.
7327
+ // Only when it actually differs: a family nothing could measure (no canvas) lands
7328
+ // on exactly the provisional window, and redrawing for that would be noise.
7329
+ rememberWeightShape(key, profile);
7330
+ // Nothing distinguishable measured (no canvas, a family the page cannot draw)
7331
+ // leaves the degenerate shape, which is not worth a redraw.
7332
+ var changed =
7333
+ profile.min !== 0 || profile.max !== 0
7334
+ ? profile.min !== PENDING_WEIGHT_SHAPE.min ||
7335
+ profile.max !== PENDING_WEIGHT_SHAPE.max ||
7336
+ profile.step !== PENDING_WEIGHT_SHAPE.step
7337
+ : false;
7338
+ if (changed) notifyShapes();
7037
7339
  return profile;
7038
7340
  }
7039
7341
 
@@ -7086,7 +7388,22 @@ function deferWeightWarm(job) {
7086
7388
  * @param {object} editing - the value set the axes read.
7087
7389
  */
7088
7390
  var warmSignature = null;
7391
+
7392
+ /** When a preset was applied: its warm-up waits so the switch itself feels instant. */
7393
+ var presetSwitchAt = 0;
7394
+
7395
+ /** The code stack the deferred slice was last scheduled for (scheduled once). */
7396
+ var codeWarmSignature = null;
7089
7397
  var scheduleWeightWarm = function (editing) {
7398
+ // A preset switch changes several stacks at once. Hydrated shapes make the rows
7399
+ // correct immediately, so the sweep those stacks would trigger can wait: measuring
7400
+ // inside the switch is what made switching stutter on a slow font.
7401
+ if (Date.now() - presetSwitchAt < 1200) {
7402
+ globalThis.setTimeout(function () {
7403
+ scheduleWeightWarm(editing);
7404
+ }, 1200);
7405
+ return;
7406
+ }
7090
7407
  var stacks = weightAxisStacks(editing);
7091
7408
  var signature = stacks.dialog + "\u0000" + stacks.ui + "\u0000" + stacks.code;
7092
7409
  if (signature === warmSignature) return;
@@ -7102,11 +7419,6 @@ var scheduleWeightWarm = function (editing) {
7102
7419
  weightProfileFor(stacks.ui, shared.WEIGHT_LADDER_STRONG);
7103
7420
  probeLog("weight-warm", { axis: "ui", stack: stacks.ui });
7104
7421
  },
7105
- function () {
7106
- probeContinuity(stacks.code);
7107
- weightProfileFor(stacks.code, shared.WEIGHT_BASE);
7108
- probeLog("weight-warm", { axis: "code", stack: stacks.code });
7109
- },
7110
7422
  ];
7111
7423
  var runNext = function () {
7112
7424
  var job = jobs.shift();
@@ -7115,6 +7427,20 @@ var scheduleWeightWarm = function (editing) {
7115
7427
  deferWeightWarm(runNext);
7116
7428
  };
7117
7429
  deferWeightWarm(runNext);
7430
+ // The code axis is measured LAST and later still: its family is usually a
7431
+ // monospace the reader rarely touches, and on a slow machine the two axes above
7432
+ // already keep the main thread busy. Deferring it keeps the first seconds after
7433
+ // load (when a reader is likeliest to open the settings) free of its sweep.
7434
+ var codeSignature = stacks.code;
7435
+ if (codeSignature === codeWarmSignature) return;
7436
+ codeWarmSignature = codeSignature;
7437
+ globalThis.setTimeout(function () {
7438
+ deferWeightWarm(function () {
7439
+ probeContinuity(stacks.code);
7440
+ weightProfileFor(stacks.code, shared.WEIGHT_BASE);
7441
+ probeLog("weight-warm", { axis: "code", stack: stacks.code });
7442
+ });
7443
+ }, WARM_CODE_DELAY_MS);
7118
7444
  };
7119
7445
 
7120
7446
  /**
@@ -8268,11 +8594,18 @@ function StackPicker(props) {
8268
8594
  var panel = null;
8269
8595
  if (open) {
8270
8596
  var rows = [];
8597
+ // A machine with a huge font collection (or a Super TTC, which exposes every
8598
+ // weight and variant as its own family) can enumerate hundreds of names. Only
8599
+ // the first screenful is built unless the reader is searching, and a selected
8600
+ // family is always among them — drawing every row was a second of layout on
8601
+ // such a machine. The subsetting itself is pure and covered offline.
8602
+ var RENDER_LIMIT = 60;
8271
8603
  for (var groupRender = 0; groupRender < options.length; groupRender += 1) {
8272
8604
  var group = options[groupRender];
8273
8605
  rows.push(h("div", { className: "dfp-group", key: "g:" + group.label }, t(group.label)));
8274
- for (var rowIndex = 0; rowIndex < group.families.length; rowIndex += 1) {
8275
- var name = group.families[rowIndex];
8606
+ var visible = shared.pickerRows(group.families, typed, selectedLower, RENDER_LIMIT);
8607
+ for (var rowIndex = 0; rowIndex < visible.length; rowIndex += 1) {
8608
+ var name = visible[rowIndex];
8276
8609
  var isSelected = selectedLower[name.toLowerCase()] === true;
8277
8610
  rows.push(
8278
8611
  h(
@@ -8281,12 +8614,13 @@ function StackPicker(props) {
8281
8614
  type: "button",
8282
8615
  className: "dfp-option",
8283
8616
  key: "o:" + group.label + ":" + name,
8284
- style: { fontFamily: quoteFamily(name) },
8285
8617
  "data-family": name,
8286
8618
  onClick: function (event) {
8287
8619
  toggle(event.currentTarget.dataset.family);
8288
8620
  },
8289
8621
  },
8622
+ // The rows are NOT drawn in their own family: on a large collection that
8623
+ // loaded one font per row, which is exactly the stall this list is about.
8290
8624
  h("span", { className: "dfp-optionLabel" }, name),
8291
8625
  isSelected ? h("span", { className: "dfp-optionCheck" }, "✓") : null
8292
8626
  )
@@ -9066,7 +9400,9 @@ function FontCard(props) {
9066
9400
  var next = presetList.slice();
9067
9401
  for (var index = 0; index < next.length; index += 1) {
9068
9402
  if (next[index].name === entry.name) {
9403
+ var shapes = shapeSnapshotFor(values);
9069
9404
  next[index] = { name: entry.name, values: values, savedAt: Date.now() };
9405
+ if (Object.keys(shapes).length > 0) next[index].shapes = shapes;
9070
9406
  }
9071
9407
  }
9072
9408
  return { op: shared.PENDING_SET, path: [PRESETS_FIELD], value: JSON.stringify(next) };
@@ -9453,7 +9789,8 @@ function FontCard(props) {
9453
9789
  var weightField = function (props) {
9454
9790
  var profile = weightProfileFor(
9455
9791
  props.stack === undefined ? "" : props.stack,
9456
- props.top === undefined ? shared.WEIGHT_LADDER_STRONG : props.top
9792
+ props.top === undefined ? shared.WEIGHT_LADDER_STRONG : props.top,
9793
+ { peek: true }
9457
9794
  );
9458
9795
  var stored = props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value;
9459
9796
  // A stored offset outside the measured range (the font changed since it was
@@ -9909,10 +10246,63 @@ function FontCard(props) {
9909
10246
  * is exact.
9910
10247
  * @param {{name: string, values: object}} entry - the picked preset.
9911
10248
  */
10249
+ /**
10250
+ * The measured shapes a preset can carry.
10251
+ *
10252
+ * Switching presets changes the font stacks, which invalidates the slider shapes:
10253
+ * the new stacks would be measured again (a second of canvas work on a huge font
10254
+ * collection) the moment they are applied. A preset therefore remembers the
10255
+ * shapes it was saved with — three small records — and applying it restores them
10256
+ * instead of measuring (issue #3 follow-up: "switching presets stutters").
10257
+ * @param {object} values - the preset's value set.
10258
+ * @returns {object} cache key -> shape, empty when nothing is known yet.
10259
+ */
10260
+ var shapeSnapshotFor = function (values) {
10261
+ var stacks = weightAxisStacks(values);
10262
+ var pairs = [
10263
+ [stacks.dialog, shared.WEIGHT_LADDER_STRONG],
10264
+ [stacks.ui, shared.WEIGHT_LADDER_STRONG],
10265
+ [stacks.code, shared.WEIGHT_BASE],
10266
+ ];
10267
+ var snapshot = {};
10268
+ for (var index = 0; index < pairs.length; index += 1) {
10269
+ var stack = pairs[index][0];
10270
+ var base = pairs[index][1];
10271
+ var key = String(stack) + "\u0000" + base + "\u0000stepped";
10272
+ if (Object.prototype.hasOwnProperty.call(weightProfileCache, key)) {
10273
+ snapshot[key] = weightProfileCache[key];
10274
+ } else {
10275
+ var stored = storedWeightShape(key);
10276
+ if (stored !== null) snapshot[key] = stored;
10277
+ }
10278
+ }
10279
+ return snapshot;
10280
+ };
10281
+
10282
+ /**
10283
+ * Put a preset's remembered shapes back into the cache.
10284
+ * @param {object} shapes - what `shapeSnapshotFor` recorded.
10285
+ */
10286
+ var hydrateShapes = function (shapes) {
10287
+ if (shapes === null || typeof shapes !== "object") return;
10288
+ for (var key in shapes) {
10289
+ if (!Object.prototype.hasOwnProperty.call(shapes, key)) continue;
10290
+ var shape = shapes[key];
10291
+ if (shape === null || typeof shape !== "object") continue;
10292
+ if (!isFinite(shape.min) || !isFinite(shape.max) || !isFinite(shape.step)) continue;
10293
+ if (Object.prototype.hasOwnProperty.call(weightProfileCache, key)) continue;
10294
+ weightProfileCache[key] = { min: shape.min, max: shape.max, step: shape.step };
10295
+ }
10296
+ };
10297
+
9912
10298
  var switchPreset = function (entry) {
9913
10299
  if (!writable || entry.name === activeName) return;
10300
+ // Stamp the switch BEFORE anything is written: the sync that follows it would
10301
+ // otherwise start measuring the new stacks in the same task.
10302
+ presetSwitchAt = Date.now();
9914
10303
  applyingRef.current = true;
9915
10304
  try {
10305
+ hydrateShapes(entry.shapes);
9916
10306
  for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
9917
10307
  var field = VALUE_FIELDS[index];
9918
10308
  var value = Object.prototype.hasOwnProperty.call(entry.values, field)
@@ -10070,7 +10460,7 @@ function FontCard(props) {
10070
10460
  var isCode = field === CODE_WEIGHT_FIELD;
10071
10461
  var stack =
10072
10462
  field === WEIGHT_DIALOG_FIELD ? dialogStack : isCode ? codeStack : uiStack;
10073
- var profile = weightProfileFor(stack, isCode ? shared.WEIGHT_BASE : shared.WEIGHT_LADDER_STRONG);
10463
+ var profile = weightProfileFor(stack, isCode ? shared.WEIGHT_BASE : shared.WEIGHT_LADDER_STRONG, { peek: true });
10074
10464
  return weightStepRange(profile, value === WEIGHT_UNSET ? 0 : value).value;
10075
10465
  };
10076
10466
 
@@ -10141,6 +10531,23 @@ function FontCard(props) {
10141
10531
  previewCodeStyle.lineHeight = "calc(20px + " + editing[CODE_LINE_HEIGHT_FIELD] + "px)";
10142
10532
  }
10143
10533
 
10534
+ // A shape measured after this render changes the counts the rows above show, so
10535
+ // the card redraws once one lands (see `notifyShapes`). Declared last on purpose:
10536
+ // the hook order of everything before it stays exactly as it was.
10537
+ var [, setShapeRevision] = useState(0);
10538
+ useEffect(function () {
10539
+ var listener = function () {
10540
+ setShapeRevision(function (count) {
10541
+ return count + 1;
10542
+ });
10543
+ };
10544
+ shapeListeners.push(listener);
10545
+ return function () {
10546
+ var at = shapeListeners.indexOf(listener);
10547
+ if (at >= 0) shapeListeners.splice(at, 1);
10548
+ };
10549
+ }, []);
10550
+
10144
10551
  return h(
10145
10552
  "li",
10146
10553
  { className: "dfp-card" + (open ? " dfp-cardOpen" : "") },
@@ -13483,7 +13890,7 @@ function mountFloatPanel(scope, t, hostCtx) {
13483
13890
  var dialogStack = weightAxisStacks(config).dialog;
13484
13891
  var profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
13485
13892
  try {
13486
- profile = weightProfileFor(dialogStack, shared.WEIGHT_LADDER_STRONG);
13893
+ profile = weightProfileFor(dialogStack, shared.WEIGHT_LADDER_STRONG, { peek: true });
13487
13894
  } catch (profileError) {
13488
13895
  // The nominal window stands in when the family cannot be measured.
13489
13896
  }
@@ -13859,13 +14266,18 @@ function mountFloatPanel(scope, t, hostCtx) {
13859
14266
  }
13860
14267
 
13861
14268
  // A promotion changes the weight row's shape; the panel redraws itself when one
13862
- // lands, and stops listening when it goes away.
14269
+ // lands, and stops listening when it goes away. A shape that only lands with the
14270
+ // idle measurement is the same story: the row was rendered with the provisional
14271
+ // counts, so it has to be redrawn once the real ones exist.
13863
14272
  promotionListeners.push(render);
14273
+ shapeListeners.push(render);
13864
14274
 
13865
14275
  return {
13866
14276
  destroy: function () {
13867
14277
  var at = promotionListeners.indexOf(render);
13868
14278
  if (at >= 0) promotionListeners.splice(at, 1);
14279
+ var shapeAt = shapeListeners.indexOf(render);
14280
+ if (shapeAt >= 0) shapeListeners.splice(shapeAt, 1);
13869
14281
  if (statusTimer !== null) globalThis.clearTimeout(statusTimer);
13870
14282
  if (closeTimer !== null) globalThis.clearTimeout(closeTimer);
13871
14283
  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);
@@ -4919,6 +4991,40 @@ function catalogRefreshDue(cache, now, retryMs) {
4919
4991
  return now - (cache.at || 0) >= retryMs;
4920
4992
  }
4921
4993
 
4994
+ /**
4995
+ * The rows one picker group actually renders.
4996
+ *
4997
+ * A machine with a huge font collection — or a Super TTC, which exposes every
4998
+ * weight and variant as its own family — can enumerate hundreds of names, and
4999
+ * building a row for each one is a visible stall when the list opens (issue #3:
5000
+ * the reporter's second symptom). The list therefore renders a screenful unless
5001
+ * the reader is searching, and a family that is already selected is always
5002
+ * rendered so its check mark never falls outside the window.
5003
+ * @param {string[]} families - the group's families, in display order.
5004
+ * @param {string} query - the search text (empty means "browse the whole list").
5005
+ * @param {Object<string, boolean>} selectedLower - lower-cased names already picked.
5006
+ * @param {number} [limit] - how many rows a browse renders.
5007
+ * @returns {string[]} the families to render.
5008
+ */
5009
+ function pickerRows(families, query, selectedLower, limit) {
5010
+ var list = Array.isArray(families) ? families : [];
5011
+ var searching = typeof query === "string" && query.trim() !== "";
5012
+ if (searching) return list.slice();
5013
+ var cap = typeof limit === "number" && limit > 0 ? Math.floor(limit) : 60;
5014
+ var picked = selectedLower !== null && typeof selectedLower === "object" ? selectedLower : {};
5015
+ var rows = [];
5016
+ var shown = 0;
5017
+ for (var index = 0; index < list.length; index += 1) {
5018
+ var name = list[index];
5019
+ var isSelected = picked[String(name).toLowerCase()] === true;
5020
+ if (isSelected || shown < cap) {
5021
+ rows.push(name);
5022
+ shown += 1;
5023
+ }
5024
+ }
5025
+ return rows;
5026
+ }
5027
+
4922
5028
  var shared = {
4923
5029
  NAMESPACE: NAMESPACE,
4924
5030
  SANS_FIELD: SANS_FIELD,
@@ -5066,6 +5172,7 @@ var shared = {
5066
5172
  PRESETS: PRESETS,
5067
5173
  PRESET_GROUPS: PRESET_GROUPS,
5068
5174
  MAX_LOCAL_FONTS: MAX_LOCAL_FONTS,
5175
+ pickerRows: pickerRows,
5069
5176
  pickerGroups: pickerGroups,
5070
5177
  catalogRefreshDue: catalogRefreshDue,
5071
5178
  MARKDOWN_SELECTOR: MARKDOWN_SELECTOR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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",
@@ -25,7 +25,7 @@
25
25
  "docs/card-shaping.jpg"
26
26
  ],
27
27
  "engines": {
28
- "dsh": ">=0.1.7-alpha.1 <0.3.0-0"
28
+ "dsh": ">=0.2.0-rc.1 <0.2.2-rc.2"
29
29
  },
30
30
  "dsh": {
31
31
  "bundle": {
@@ -36,21 +36,14 @@
36
36
  },
37
37
  "compatibility": {
38
38
  "dshReleases": {
39
- "0.1.7-alpha.1": "compatible",
40
- "0.1.7-alpha.2": "compatible",
41
- "0.1.7-rc.1": "compatible",
42
- "0.1.7-rc.2": "compatible",
43
39
  "0.2.0-rc.1": "compatible",
44
40
  "0.2.0-rc.2": "compatible",
45
- "0.2.1-alpha.1": "compatible",
46
- "0.2.1-alpha.2": "compatible",
47
- "0.2.1-alpha.3": "compatible",
48
- "0.2.1-alpha.4": "compatible",
49
- "0.2.1-alpha.5": "compatible",
50
41
  "0.2.1-rc.1": "compatible",
51
42
  "0.2.1-rc.2": "compatible",
43
+ "0.2.1-rc.3": "compatible",
52
44
  "0.2.1-rc.4": "compatible",
53
- "0.2.2-alpha.2": "compatible"
45
+ "0.2.1-rc.5": "compatible",
46
+ "0.2.2-rc.1": "compatible"
54
47
  }
55
48
  }
56
49
  },