dsh-fonttune 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to **dsh-fonttune** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.7.5] - 2026-11-01
6
+
7
+ ### Fixed
8
+ - **The floating dot covered the composer in a new session**: before conversation text appeared, the dot could remain over the input area; it now moves to the lower-left of the main content area, clear of both the composer and the sidebar.
9
+
10
+ ## [0.7.4] - 2026-10-30
11
+
12
+ ### Fixed
13
+ - **The weight slider moved the Latin and the Chinese text by different amounts**: when a Chinese family ships only a few faces, some notches changed only the Latin side and others made the browser thicken the Chinese one itself, so one script read heavier than the other. The notches are now taken from what both families can draw, and each one changes the two sides by a similar amount.
14
+
15
+ ### Changed
16
+ - **How many notches you get follows the faces the fonts ship**: a Chinese family with more faces offers more notches — three faces give at most three positions, seven give about twice that. The readout counts positions, so it changes with the font.
17
+
5
18
  ## [0.7.3] - 2026-10-29
6
19
 
7
20
  ### Fixed
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  **dsh-fonttune** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.7.5] - 2026-11-01
6
+
7
+ ### 修复
8
+ - **新会话中的悬浮圆点遮挡输入框**:对话正文尚未出现时,圆点可能停在输入框内;现在会将这类位置移至主内容区左下角,避开输入框与侧栏。
9
+
10
+ ## [0.7.4] - 2026-10-30
11
+
12
+ ### 修复
13
+ - **中英混排时两边字重不同步**:一套中文字体只带几个字面时,字重滑块有些档位只让西文变、中文不动,有些档位是浏览器把中文自动加粗,读起来一边太重一边太轻。现在档位取自两种字体都能真画出来的范围,每档两边变化幅度接近。
14
+
15
+ ### 变更
16
+ - **档位数量按字体能画的字面计算**:中文字面越多的字体,滑块给的档位越多——只有三个字面的中文字体最多三档,七个字面的约多一倍。读数是档数,换字体后档数会跟着变。
17
+
5
18
  ## [0.7.3] - 2026-10-29
6
19
 
7
20
  ### 修复
package/lib/client.js CHANGED
@@ -1238,6 +1238,23 @@ function floatBoundsFrom(rects, viewport) {
1238
1238
  return floatBoundsPick(rects, viewport).bounds;
1239
1239
  }
1240
1240
 
1241
+ /**
1242
+ * The fallback region to use when the conversation has no measurable text yet.
1243
+ *
1244
+ * The page's whole-window stand-in starts at x=0, which puts the dot over the left
1245
+ * navigation rail. On a host shell that exposes a main-column rectangle, using that
1246
+ * rectangle as the fallback puts the dot at the content column's left corner while
1247
+ * the conversation itself is mounting. If the shell supplies no usable region, the
1248
+ * viewport stays the fallback.
1249
+ * @param {unknown} region - the main content column, or null when unavailable.
1250
+ * @param {{width: number, height: number}} viewport - the window size.
1251
+ * @returns {{left:number,top:number,right:number,bottom:number,width:number,height:number}}
1252
+ * the clipped content box, or the whole viewport.
1253
+ */
1254
+ function floatFallbackBounds(region, viewport) {
1255
+ return floatBoundsPick(region === null || region === undefined ? [] : [region], viewport).bounds;
1256
+ }
1257
+
1241
1258
  /**
1242
1259
  * Whether a second reading of the winning rectangle is the same shape as the last.
1243
1260
  *
@@ -3263,6 +3280,294 @@ function ceilTo(value, step) {
3263
3280
  return Math.ceil(value / step) * step;
3264
3281
  }
3265
3282
 
3283
+ /**
3284
+ * How far apart two families may land on one notch before the stack refuses it.
3285
+ *
3286
+ * Measured on the reporter's pair (issue #4): one 100-weight notch adds +21.5%
3287
+ * ink to the Latin family and +23.9% to the Chinese one — 2.4 points apart, which
3288
+ * no reader notices. One 200-weight notch adds +44% and +51% — 7.4 points apart,
3289
+ * and that is the reported "one side suits, the other is too heavy". Five points
3290
+ * is the budget between those two observations.
3291
+ */
3292
+ var INK_BALANCE_SPREAD = 0.05;
3293
+
3294
+ /**
3295
+ * How far apart a pair may land when the strict budget would leave no control.
3296
+ *
3297
+ * Measured on the reporter's pair (issue #4): going one notch LIGHTER, the Latin
3298
+ * family loses 29.7% of its ink and the Chinese one 17.9% — 11.9 points apart, and
3299
+ * no finer notch makes it better, because the two families simply space their faces
3300
+ * differently. Under the strict budget the reader would get a weight control that
3301
+ * cannot go lighter at all (two positions). The wider budget is what keeps that
3302
+ * physical pair usable, and it is only consulted when the strict one is: a stack
3303
+ * that has four even positions never sees it.
3304
+ */
3305
+ var INK_BALANCE_SPREAD_WIDE = 0.15;
3306
+
3307
+ /** How many notches the evenness walk looks at, one way. */
3308
+ var WEIGHT_MAX_POSITIONS = 24;
3309
+
3310
+ /**
3311
+ * The least ink one notch has to add before a position is worth having.
3312
+ *
3313
+ * This is the bar `weightProfileFrom` already measured on this host: a 200-weight
3314
+ * step is +30% ink and a 100-weight step +16%, and 100 was not enough — a reader
3315
+ * could not see it. Five per cent would put a variable family's stack at twenty-odd
3316
+ * notches that each move a little (measured live: 29 positions of +6%), which is the
3317
+ * dead-control the coarse step exists to avoid. Fifteen keeps every pair the faces
3318
+ * do allow (the reported one tops out at +19%/+22%) and stops there.
3319
+ */
3320
+ var INK_BALANCE_STEP = 0.15;
3321
+
3322
+ /**
3323
+ * One family's ink at one weight.
3324
+ *
3325
+ * A family answers through `inkOf`, the sampler its own measurement already uses;
3326
+ * a weight it cannot measure — or a family that cannot be measured at all — answers
3327
+ * null and is left out of a comparison instead of counting as a zero.
3328
+ * @param {Object} entry - one measured family.
3329
+ * @param {number} weight - CSS weight.
3330
+ * @returns {number|null} the ink, or null when there is no measurement.
3331
+ */
3332
+ function entryInk(entry, weight) {
3333
+ if (entry === null || typeof entry !== "object") return null;
3334
+ if (typeof entry.inkOf !== "function") return null;
3335
+ var ink = null;
3336
+ try {
3337
+ ink = entry.inkOf(weight);
3338
+ } catch (error) {
3339
+ return null;
3340
+ }
3341
+ ink = Number(ink);
3342
+ if (!isFinite(ink) || ink <= 0) return null;
3343
+ return ink;
3344
+ }
3345
+
3346
+ /**
3347
+ * How much moving from one weight to another changes each family's ink.
3348
+ *
3349
+ * The share is relative to the family's OWN ink at `from`, so two families that
3350
+ * start out different sizes are still comparable: +20% means the same thing for
3351
+ * both. Families that cannot be measured on both sides are left out.
3352
+ * @param {Array<Object>} entries - the measured families.
3353
+ * @param {number} from - the weight the reader is at.
3354
+ * @param {number} to - the weight being compared.
3355
+ * @returns {number[]|null} the relative changes, or null when fewer than two
3356
+ * families can be measured.
3357
+ */
3358
+ function inkShares(entries, from, to) {
3359
+ var shares = [];
3360
+ for (var index = 0; index < entries.length; index += 1) {
3361
+ var before = entryInk(entries[index], from);
3362
+ var after = entryInk(entries[index], to);
3363
+ if (before === null || after === null) continue;
3364
+ shares.push((after - before) / before);
3365
+ }
3366
+ return shares.length < 2 ? null : shares;
3367
+ }
3368
+
3369
+ /**
3370
+ * How far apart the families land, and how little the quietest one moved.
3371
+ * @param {number[]} shares - one relative change per family.
3372
+ * @returns {{spread: number, smallest: number}} `spread` is the signed range, so
3373
+ * families moving in opposite directions count as far apart.
3374
+ */
3375
+ function shareSpread(shares) {
3376
+ var least = shares[0];
3377
+ var most = shares[0];
3378
+ var smallest = Math.abs(shares[0]);
3379
+ for (var index = 1; index < shares.length; index += 1) {
3380
+ if (shares[index] < least) least = shares[index];
3381
+ if (shares[index] > most) most = shares[index];
3382
+ var size = Math.abs(shares[index]);
3383
+ if (size < smallest) smallest = size;
3384
+ }
3385
+ return { spread: most - least, smallest: smallest };
3386
+ }
3387
+
3388
+ /**
3389
+ * The evenness verdict for one move of the stack.
3390
+ * @param {Array<Object>} entries - the measured families.
3391
+ * @param {number} from - the weight the move starts at.
3392
+ * @param {number} to - the weight it ends at.
3393
+ * @returns {{spread: number, smallest: number}|null} the verdict, or null when it
3394
+ * cannot be measured.
3395
+ */
3396
+ function evennessOf(entries, from, to) {
3397
+ var shares = inkShares(entries, from, to);
3398
+ if (shares === null) return null;
3399
+ return shareSpread(shares);
3400
+ }
3401
+
3402
+ /** Is one move of the stack both visible and even? */
3403
+ function moveIsEven(verdict, spread) {
3404
+ return verdict !== null && verdict.smallest >= INK_BALANCE_STEP && verdict.spread <= spread;
3405
+ }
3406
+
3407
+ /**
3408
+ * The shape a stack of families can follow together, chosen for EVENNESS.
3409
+ *
3410
+ * Issue #4 is not about the range: it is about two families being moved by one
3411
+ * offset and landing on faces that are not the same distance apart. The test is
3412
+ * therefore walked outwards from the neutral position, one notch at a time, and a
3413
+ * notch only survives when BOTH hold:
3414
+ * - every family's ink changes by at least `INK_BALANCE_STEP` (the notch is
3415
+ * visible), and
3416
+ * - the families land within `INK_BALANCE_SPREAD` of each other, measured both for
3417
+ * that one notch and for the whole way back to the neutral position — a stack
3418
+ * can be even notch by notch and still drift apart after two of them.
3419
+ *
3420
+ * The walk is what removes the synthetic-bold step without ever having to ask the
3421
+ * browser whether a weight is drawn or faked: above its last real face a family is
3422
+ * stroked thicker by the browser, which adds a much larger share of ink than a drawn
3423
+ * face does — so the notch where one family jumps and the other does not simply
3424
+ * fails the budget and the range stops in front of it.
3425
+ *
3426
+ * Among the shapes that survive, the one with the MOST positions wins: the visible
3427
+ * floor above is what makes a finer notch worth having, and more positions is more
3428
+ * control. (A single family is not put through this at all — it keeps the coarse
3429
+ * shape `weightProfileFrom` measured for it.)
3430
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}>} entries
3431
+ * one record per measured family.
3432
+ * @param {number} base - the weight the offset is added to (body text).
3433
+ * @param {number} low - the lowest offset every family can express.
3434
+ * @param {number} high - the highest offset every family can express.
3435
+ * @returns {{min: number, max: number, step: number}|null} the shape, or null when
3436
+ * no step leaves the stack two positions to move between.
3437
+ */
3438
+ function balancedStackShape(entries, base, low, high, spread) {
3439
+ var budget = typeof spread === "number" && spread > 0 ? spread : INK_BALANCE_SPREAD;
3440
+ var best = null;
3441
+ var bestCount = 1;
3442
+ var bestScore = Infinity;
3443
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3444
+ var step = WEIGHT_STEP_CANDIDATES[index];
3445
+ if (!(step > 0)) continue;
3446
+ var up = 0;
3447
+ var down = 0;
3448
+ // A walk is bounded: no useful shape has more than a couple of dozen
3449
+ // positions, and an unbounded one on a one-unit grid would cost hundreds of
3450
+ // canvas renders on the very first measurement.
3451
+ var limit = step * WEIGHT_MAX_POSITIONS;
3452
+ for (var position = step; position <= high && position <= limit; position += step) {
3453
+ if (!moveIsEven(evennessOf(entries, base + position - step, base + position), budget)) break;
3454
+ if (!moveIsEven(evennessOf(entries, base, base + position), budget)) break;
3455
+ up = position;
3456
+ }
3457
+ for (var back = step; back <= -low && back <= limit; back += step) {
3458
+ if (!moveIsEven(evennessOf(entries, base - back + step, base - back), budget)) break;
3459
+ if (!moveIsEven(evennessOf(entries, base, base - back), budget)) break;
3460
+ down = back;
3461
+ }
3462
+ var count = (up + down) / step + 1;
3463
+ if (count < 2) continue;
3464
+ // How close the two families are on one notch from the neutral position: a tie
3465
+ // between two shapes that move the stack over the same number of positions.
3466
+ var headroom = evennessOf(entries, base, base + step);
3467
+ if (headroom === null) headroom = evennessOf(entries, base, base - step);
3468
+ var score = headroom === null ? Infinity : headroom.spread;
3469
+ if (count > bestCount || (count === bestCount && score < bestScore)) {
3470
+ // -0 would read as a different number to a strict comparison.
3471
+ best = { min: down === 0 ? 0 : -down, max: up, step: step };
3472
+ bestCount = count;
3473
+ bestScore = score;
3474
+ }
3475
+ }
3476
+ return best;
3477
+ }
3478
+
3479
+ /**
3480
+ * One slider shape for a stack whose text can land on more than one family.
3481
+ *
3482
+ * A reader sets ONE offset; the browser then renders English with the first
3483
+ * family and Chinese with the fallback, each snapping that same requested value
3484
+ * to its own faces. When the fallback ships three faces and the primary is a
3485
+ * variable font, a step measured on the primary alone leaves the fallback
3486
+ * unchanged on most notches — the reported "one side suits, the other is too
3487
+ * heavy or too light" (issue #4: Inclusive Sans beside a three-face CJK family).
3488
+ *
3489
+ * The combined shape keeps every family moving on every notch, but the notch is
3490
+ * chosen for EVENNESS (see `balancedStackShape`): each family has to change by a
3491
+ * comparable share of its own ink, and the stack has to stay together over the
3492
+ * whole length of the range. Families that can report their ink (`inkOf`) get that
3493
+ * shape; a stack measured without a canvas keeps the plain shared-multiple shape,
3494
+ * which is also what a single family gets.
3495
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}|null>} profiles
3496
+ * one shape per family.
3497
+ * @param {{base?: number}} [options] `base` is the weight the offset is added to
3498
+ * (body text); it anchors the ink comparison.
3499
+ * @returns {{min: number, max: number, step: number}|null} the combined shape, or
3500
+ * null when nothing usable was measured.
3501
+ */
3502
+ function combineWeightProfiles(profiles, options) {
3503
+ var list = [];
3504
+ if (Array.isArray(profiles)) {
3505
+ for (var index = 0; index < profiles.length; index += 1) {
3506
+ var entry = profiles[index];
3507
+ if (entry === null || typeof entry !== "object") continue;
3508
+ if (!isFinite(entry.min) || !isFinite(entry.max) || !isFinite(entry.step)) continue;
3509
+ if (!(entry.step > 0)) continue;
3510
+ list.push(entry);
3511
+ }
3512
+ }
3513
+ if (list.length === 0) return null;
3514
+ if (list.length === 1) return { min: list[0].min, max: list[0].max, step: list[0].step };
3515
+ var step = list[0].step;
3516
+ for (var other = 1; other < list.length; other += 1) {
3517
+ step = commonStep(step, list[other].step);
3518
+ }
3519
+ var low = list[0].min;
3520
+ var high = list[0].max;
3521
+ for (var more = 1; more < list.length; more += 1) {
3522
+ low = Math.max(low, list[more].min);
3523
+ high = Math.min(high, list[more].max);
3524
+ }
3525
+ var settings = options === null || typeof options !== "object" ? {} : options;
3526
+ var base = isFinite(settings.base) ? Number(settings.base) : WEIGHT_BASE;
3527
+ // Every family that can report ink is put through the evenness walk; a stack whose
3528
+ // canvas could not answer keeps the plain shared-multiple shape.
3529
+ var measured = false;
3530
+ for (var scan = 0; scan < list.length; scan += 1) {
3531
+ if (typeof list[scan].inkOf === "function") measured = true;
3532
+ }
3533
+ if (measured) {
3534
+ var even = balancedStackShape(list, base, low, high);
3535
+ var positions = even === null ? 0 : (even.max - even.min) / even.step + 1;
3536
+ if (positions >= 3) return even;
3537
+ // The pair's own faces are spaced so unevenly that the strict budget leaves
3538
+ // no control; the wider one is what keeps a lighter position reachable.
3539
+ var relaxed = balancedStackShape(list, base, low, high, INK_BALANCE_SPREAD_WIDE);
3540
+ var relaxedPositions = relaxed === null ? 0 : (relaxed.max - relaxed.min) / relaxed.step + 1;
3541
+ if (relaxedPositions > positions) return relaxed;
3542
+ if (even !== null) return even;
3543
+ }
3544
+ var onGrid = { min: ceilTo(low, step), max: floorTo(high, step) };
3545
+ if (onGrid.max >= onGrid.min) return { min: onGrid.min, max: onGrid.max, step: step };
3546
+ var widest = list[0];
3547
+ for (var alt = 1; alt < list.length; alt += 1) {
3548
+ if (list[alt].max - list[alt].min > widest.max - widest.min) widest = list[alt];
3549
+ }
3550
+ return { min: widest.min, max: widest.max, step: widest.step };
3551
+ }
3552
+
3553
+ /**
3554
+ * The smallest candidate step that is a whole multiple of both inputs.
3555
+ *
3556
+ * Profiles come from one candidate list, so a shared multiple usually exists
3557
+ * there; two steps that share none fall back to the coarser one, which still
3558
+ * moves the coarser family on every notch.
3559
+ * @param {number} a - one step.
3560
+ * @param {number} b - another step.
3561
+ * @returns {number} the combined step.
3562
+ */
3563
+ function commonStep(a, b) {
3564
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3565
+ var candidate = WEIGHT_STEP_CANDIDATES[index];
3566
+ if (candidate % a === 0 && candidate % b === 0) return candidate;
3567
+ }
3568
+ return Math.max(a, b);
3569
+ }
3570
+
3266
3571
  /**
3267
3572
  * Does every position of `[min..max]` in `step` increments render differently?
3268
3573
  *
@@ -4678,6 +4983,7 @@ var shared = {
4678
4983
  floatBoxOf: floatBoxOf,
4679
4984
  floatBoundsPick: floatBoundsPick,
4680
4985
  floatBoundsFrom: floatBoundsFrom,
4986
+ floatFallbackBounds: floatFallbackBounds,
4681
4987
  sameWatchBounds: sameWatchBounds,
4682
4988
  floatTargetsConnected: floatTargetsConnected,
4683
4989
  floatQuadrantIn: floatQuadrantIn,
@@ -4846,6 +5152,16 @@ var shared = {
4846
5152
  WEIGHT_CSS_MIN: WEIGHT_CSS_MIN,
4847
5153
  WEIGHT_CSS_MAX: WEIGHT_CSS_MAX,
4848
5154
  weightProfileFrom: weightProfileFrom,
5155
+ combineWeightProfiles: combineWeightProfiles,
5156
+ balancedStackShape: balancedStackShape,
5157
+ evennessOf: evennessOf,
5158
+ INK_BALANCE_SPREAD: INK_BALANCE_SPREAD,
5159
+ INK_BALANCE_STEP: INK_BALANCE_STEP,
5160
+ INK_BALANCE_SPREAD_WIDE: INK_BALANCE_SPREAD_WIDE,
5161
+ WEIGHT_PROBE_LOW: WEIGHT_PROBE_LOW,
5162
+ WEIGHT_PROBE_HIGH: WEIGHT_PROBE_HIGH,
5163
+ WEIGHT_PROBE_STEP: WEIGHT_PROBE_STEP,
5164
+ GENERIC_FAMILIES: GENERIC_FAMILIES,
4849
5165
  weightStepRange: weightStepRange,
4850
5166
  isShorthandName: isShorthandName,
4851
5167
  isScaledToken: isScaledToken,
@@ -6167,6 +6483,114 @@ var weightProbeCanvas = null;
6167
6483
  /** Cache of measured slider shapes, keyed by the resolved family stack. */
6168
6484
  var weightProfileCache = {};
6169
6485
 
6486
+ /**
6487
+ * The families a stack can actually render text with.
6488
+ *
6489
+ * The first family draws the Latin text and a later one draws whatever the first
6490
+ * cannot (that is where the Chinese comes from), so the notch step has to be one
6491
+ * every one of them can follow. Generic keywords are dropped: `serif` is not a
6492
+ * family anyone ships faces for.
6493
+ * @param {string} stack - resolved `font-family` value.
6494
+ * @returns {string[]} the family names, primary first.
6495
+ */
6496
+ function stackFamilies(stack) {
6497
+ var parsed = shared.parseStack(String(stack));
6498
+ var out = [];
6499
+ for (var index = 0; index < parsed.length; index += 1) {
6500
+ var name = String(parsed[index]).trim();
6501
+ if (name === "") continue;
6502
+ if (shared.GENERIC_FAMILIES.indexOf(name.toLowerCase()) >= 0) continue;
6503
+ if (out.indexOf(name) >= 0) continue;
6504
+ out.push(name);
6505
+ }
6506
+ return out;
6507
+ }
6508
+
6509
+ /**
6510
+ * How many families of one stack are measured.
6511
+ *
6512
+ * Two covers the reported case — a Latin family with the CJK fallback behind it
6513
+ * — and keeps the first measurement to about twice what it was: the probe renders
6514
+ * the family on a canvas for every weight it asks about.
6515
+ */
6516
+ var WEIGHT_STACK_FAMILIES = 2;
6517
+
6518
+ /** Measured shapes per family, so a combined stack measures each family once. */
6519
+ var familyProfileCache = {};
6520
+
6521
+ /**
6522
+ * The text each family of a stack is measured on when the two have to move together.
6523
+ *
6524
+ * A stack is one Latin family followed by whatever draws the Chinese, and each of
6525
+ * them draws only part of a mixed sample: measured on mixed text the Latin family
6526
+ * borrows the Chinese from the browser's own fallback — a third family the reader
6527
+ * never sees beside it — and its ink curve would then partly describe that one. The
6528
+ * evenness comparison therefore measures each family on the script it renders.
6529
+ */
6530
+ var WEIGHT_SAMPLE_LATIN = "Hamburgefonstiv 0189";
6531
+ var WEIGHT_SAMPLE_FALLBACK = "对话字体粗细大小变化";
6532
+
6533
+ /**
6534
+ * One family's slider shape, measured through its alias when it is promoted.
6535
+ *
6536
+ * `null` means the canvas could not answer; the caller then falls back to the
6537
+ * nominal window instead of offering a control built on no data.
6538
+ *
6539
+ * When the stack has a second family, the record also carries `inkOf`, a sampler
6540
+ * for that family's ink on the script it draws. `combineWeightProfiles` walks it to
6541
+ * find the notch and range that move both families evenly (issue #4).
6542
+ * @param {string} family - the family name, unquoted.
6543
+ * @param {number} base - the heaviest weight the offset lands on.
6544
+ * @param {string} alias - the variable alias to measure through, "" when none.
6545
+ * @param {string|null} [inkText] - the text the ink is measured on, or null when the
6546
+ * stack has nothing to be even with.
6547
+ * @returns {{min: number, max: number, step: number, inkOf: Function|null}|null}
6548
+ * the shape plus the ink sampler.
6549
+ */
6550
+ function familyWeightProfile(family, base, alias, inkText) {
6551
+ var text = typeof inkText === "string" && inkText !== "" ? inkText : null;
6552
+ var key =
6553
+ family +
6554
+ "\u0000" +
6555
+ base +
6556
+ "\u0000" +
6557
+ (alias === "" ? "stepped" : "continuous") +
6558
+ "\u0000" +
6559
+ (text === null ? "plain" : "ink");
6560
+ if (Object.prototype.hasOwnProperty.call(familyProfileCache, key)) {
6561
+ return familyProfileCache[key];
6562
+ }
6563
+ var record = null;
6564
+ try {
6565
+ var target = '"' + (alias === "" ? family : alias) + '"';
6566
+ var seen = {};
6567
+ var sample = function (weight) {
6568
+ if (!Object.prototype.hasOwnProperty.call(seen, weight)) {
6569
+ seen[weight] = sampleWeightSignature(target, weight);
6570
+ }
6571
+ return seen[weight];
6572
+ };
6573
+ var shape =
6574
+ alias === ""
6575
+ ? weightProfileFrom(sample, { top: base })
6576
+ : weightProfileFrom(sample, { top: base, candidates: shared.CONTINUOUS_CANDIDATES });
6577
+ record = { min: shape.min, max: shape.max, step: shape.step, inkOf: null };
6578
+ if (text !== null) {
6579
+ var inks = {};
6580
+ record.inkOf = function (weight) {
6581
+ if (!Object.prototype.hasOwnProperty.call(inks, weight)) {
6582
+ inks[weight] = inkOfSignature(sampleWeightSignature(target, weight, text));
6583
+ }
6584
+ return inks[weight];
6585
+ };
6586
+ }
6587
+ } catch (error) {
6588
+ record = null;
6589
+ }
6590
+ familyProfileCache[key] = record;
6591
+ return record;
6592
+ }
6593
+
6170
6594
  /**
6171
6595
  * Values the card has moved but the settings document has not confirmed yet.
6172
6596
  *
@@ -6497,13 +6921,17 @@ function probeLog(kind, detail) {
6497
6921
  * — and report a coarse family where the weights are in fact continuous.
6498
6922
  * @param {string} stack - resolved `font-family` value.
6499
6923
  * @param {number} weight - CSS weight to draw with.
6924
+ * @param {string} [text] - the text to draw; the card's shared sample by default.
6500
6925
  * @returns {string} the signature; equal signatures mean identical rendering.
6501
6926
  */
6502
- function sampleWeightSignature(stack, weight) {
6927
+ function sampleWeightSignature(stack, weight, text) {
6928
+ var sampleText = typeof text === "string" && text !== "" ? text : WEIGHT_SAMPLE_TEXT;
6929
+ // Wide and tall enough for the longest sample (a run of CJK glyphs at 32px) and
6930
+ // for the line box no family clips: a clipped glyph would undercount its ink.
6503
6931
  if (weightProbeCanvas === null) {
6504
6932
  weightProbeCanvas = document.createElement("canvas");
6505
- weightProbeCanvas.width = 260;
6506
- weightProbeCanvas.height = 56;
6933
+ weightProbeCanvas.width = 420;
6934
+ weightProbeCanvas.height = 64;
6507
6935
  }
6508
6936
  // `willReadFrequently` matters here: the profile probe reads the canvas back once
6509
6937
  // per sampled weight (~80 times on the first measurement), and without it the
@@ -6514,8 +6942,8 @@ var context = weightProbeCanvas.getContext("2d", { willReadFrequently: true });
6514
6942
  context.font = weight + " 32px " + stack;
6515
6943
  context.textBaseline = "top";
6516
6944
  context.fillStyle = "#000";
6517
- context.fillText(WEIGHT_SAMPLE_TEXT, 2, 6);
6518
- var advance = Math.round(context.measureText(WEIGHT_SAMPLE_TEXT).width * 1000);
6945
+ context.fillText(sampleText, 2, 6);
6946
+ var advance = Math.round(context.measureText(sampleText).width * 1000);
6519
6947
  var pixels = context.getImageData(0, 0, weightProbeCanvas.width, weightProbeCanvas.height).data;
6520
6948
  var ink = 0;
6521
6949
  for (var index = 3; index < pixels.length; index += 4) ink += pixels[index];
@@ -6569,21 +6997,38 @@ function weightProfileFor(stack, top) {
6569
6997
  var profile = promoted
6570
6998
  ? shared.continuousWeightProfile()
6571
6999
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
6572
- try {
6573
- // A continuous family is measured THROUGH ITS ALIAS, on the fine candidate grid:
6574
- // the axis answers at every notch, so the finest one that still moves body text
6575
- // wins, and the range stops where the family's own axis does instead of running
6576
- // past it into weights that render the same.
6577
- var target = promoted ? '"' + alias + '"' : resolved;
6578
- var seen = {};
6579
- var sample = function (weight) {
6580
- if (!Object.prototype.hasOwnProperty.call(seen, weight)) {
6581
- seen[weight] = sampleWeightSignature(target, weight);
6582
- }
6583
- return seen[weight];
6584
- };
6585
- profile = weightProfileFrom(sample, promoted ? { top: base, candidates: shared.CONTINUOUS_CANDIDATES } : { top: base });
6586
- } catch (error) {
7000
+ // EVERY family the text can land on has to follow the same notch: the reader
7001
+ // sets one offset, and a Chinese family with three faces behind a variable
7002
+ // Latin one only moves where the Latin one has moved five times (issue #4).
7003
+ // The shapes are combined into the coarsest shared step and the range they
7004
+ // can all still express; the primary family alone is the answer only when the
7005
+ // stack names one family.
7006
+ // The shapes are combined into one notch that moves BOTH families evenly: the
7007
+ // first family draws the Latin text and the one behind it draws the Chinese, so
7008
+ // each is measured on its own script (issue #4).
7009
+ var families = stackFamilies(resolved);
7010
+ var shapes = [];
7011
+ // Comparing the families' ink costs a second set of canvas renders, so it runs
7012
+ // only when there IS a second family to be even with.
7013
+ var keepEven = families.length > 1;
7014
+ for (var index = 0; index < families.length && index < WEIGHT_STACK_FAMILIES; index += 1) {
7015
+ // A continuous family is measured THROUGH ITS ALIAS, on the fine candidate
7016
+ // grid: the axis answers at every notch, so the finest one that still moves
7017
+ // body text wins, and the range stops where the family's own axis does.
7018
+ var shape = familyWeightProfile(
7019
+ families[index],
7020
+ base,
7021
+ index === 0 && promoted ? alias : "",
7022
+ keepEven ? (index === 0 ? WEIGHT_SAMPLE_LATIN : WEIGHT_SAMPLE_FALLBACK) : null
7023
+ );
7024
+ if (shape !== null) shapes.push(shape);
7025
+ }
7026
+ // `base` is body text: the weight the two families' ink is compared at.
7027
+ var combined = shared.combineWeightProfiles(shapes, { base: shared.WEIGHT_BASE });
7028
+ if (combined !== null) {
7029
+ profile = combined;
7030
+ } else if (families.length > 0) {
7031
+ // Nothing measured (no canvas, no 2D context): the nominal window it is.
6587
7032
  profile = promoted
6588
7033
  ? shared.continuousWeightProfile()
6589
7034
  : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
@@ -10934,9 +11379,9 @@ function mountFloatPanel(scope, t, hostCtx) {
10934
11379
  return null;
10935
11380
  };
10936
11381
 
10937
- // Whether the last region reading was a real region or the whole-window
10938
- // stand-in (see `floatBounds`). A stand-in never moves the dot: it means "no
10939
- // conversation to measure right now", not "the conversation is the window".
11382
+ // Whether the last bounds reading was a REAL conversation region or a stand-in
11383
+ // (main column, then viewport; see `floatBounds`). A stand-in means "no conversation
11384
+ // to measure right now", not "the conversation is that whole shell column".
10940
11385
  var boundsAreReal = false;
10941
11386
 
10942
11387
  /** The window size the bounds are cut to, with the shared fallback. */
@@ -10954,9 +11399,62 @@ function mountFloatPanel(scope, t, hostCtx) {
10954
11399
  * session had 36. The first in document order is usually an old turn scrolled
10955
11400
  * thousands of pixels off screen, and its scroll container can measure zero;
10956
11401
  * reading the bounds from that one element alone threw both of its candidates
10957
- * away and snapped the dot to the whole window instead of the conversation.
11402
+ * away and snapped the dot to the fallback area instead of the conversation.
10958
11403
  * @returns {Array<object>} the prose elements (possibly empty).
10959
11404
  */
11405
+ var EDITABLE_ANCESTOR =
11406
+ "[contenteditable]:not([contenteditable='false']),textarea,input,[role='textbox']";
11407
+
11408
+ /**
11409
+ * Whether a node sits inside something the reader types into.
11410
+ * @param {object} node - the element to test (it counts as its own ancestor).
11411
+ * @returns {boolean} true for the composer and anything inside it.
11412
+ */
11413
+ var insideEditable = function (node) {
11414
+ try {
11415
+ return node.closest(EDITABLE_ANCESTOR) !== null;
11416
+ } catch (error) {
11417
+ return false;
11418
+ }
11419
+ };
11420
+
11421
+ /**
11422
+ * Whether the dot's stored position lies over a field the reader types into.
11423
+ *
11424
+ * On a fresh session no conversation region is measurable, and a stand-in never
11425
+ * moves a dot that already has a position. A dot saved over the composer (the
11426
+ * reported bug: its region was the input's own box) would therefore stay inside
11427
+ * the input for good. That place is wrong whatever the region is, so this is the
11428
+ * one case the stand-in may relocate.
11429
+ * @returns {boolean} true when the stored square overlaps an editable field.
11430
+ */
11431
+ var strandedInField = function () {
11432
+ if (pos === null) return false;
11433
+ var left = pos.x;
11434
+ var top = pos.y;
11435
+ var right = pos.x + shared.FLOAT_DOT;
11436
+ var bottom = pos.y + shared.FLOAT_DOT;
11437
+ try {
11438
+ var fields = document.querySelectorAll(EDITABLE_ANCESTOR);
11439
+ for (var index = 0; index < fields.length; index += 1) {
11440
+ var box = fields[index].getBoundingClientRect();
11441
+ if (
11442
+ box.width > 0 &&
11443
+ box.height > 0 &&
11444
+ left < box.right &&
11445
+ right > box.left &&
11446
+ top < box.bottom &&
11447
+ bottom > box.top
11448
+ ) {
11449
+ return true;
11450
+ }
11451
+ }
11452
+ } catch (error) {
11453
+ return false;
11454
+ }
11455
+ return false;
11456
+ };
11457
+
10960
11458
  var proseElements = function () {
10961
11459
  var found = [];
10962
11460
  var add = function (nodes) {
@@ -10971,7 +11469,17 @@ function mountFloatPanel(scope, t, hostCtx) {
10971
11469
  } catch (error) {
10972
11470
  // A page without a queryable document keeps the empty list (the window).
10973
11471
  }
10974
- return found;
11472
+ // The composer is part of the conversation container on a fresh session, so a
11473
+ // paragraph the input holds matches the conversation selector too. Measured
11474
+ // live: one typed character left a `<p>` in the input, and the dot's region
11475
+ // became the input's own scroll box, which parked the dot inside the input. A
11476
+ // message is never typed into a field, so anything inside one is not the
11477
+ // conversation, whatever class or attribute it carries.
11478
+ var kept = [];
11479
+ for (var at = 0; at < found.length; at += 1) {
11480
+ if (!insideEditable(found[at])) kept.push(found[at]);
11481
+ }
11482
+ return kept;
10975
11483
  };
10976
11484
 
10977
11485
  /**
@@ -11063,25 +11571,71 @@ function mountFloatPanel(scope, t, hostCtx) {
11063
11571
  return true;
11064
11572
  };
11065
11573
 
11574
+ /**
11575
+ * The main content column while the conversation itself has no measurable prose.
11576
+ *
11577
+ * DSH's shell marks its bottom-row element with a stable data attribute and puts
11578
+ * it directly inside the three-column frame. The frame's direct children are the
11579
+ * left rail, the main column and the right rail; taking the main column's actual
11580
+ * rectangle means its left edge follows an open or collapsed sidebar without a
11581
+ * plugin-specific width guess, and its right edge leaves room for the right rail.
11582
+ * If the shell changes shape or is not present, the viewport remains the fallback.
11583
+ * @returns {object|null} the main column's rectangle, or null when it is not usable.
11584
+ */
11585
+ var shellMainColumnRect = function () {
11586
+ try {
11587
+ var bottom = document.querySelector("[data-shell-bottom]");
11588
+ var frame = bottom === null ? null : bottom.parentElement;
11589
+ if (frame === null || frame === undefined || frame.children.length < 2) return null;
11590
+ var sidebar = frame.children[0];
11591
+ var main = frame.children[1];
11592
+ if (
11593
+ main === null ||
11594
+ main === undefined ||
11595
+ main.hasAttribute("data-rightbar-col") ||
11596
+ main.hasAttribute("data-shell-bottom") ||
11597
+ main.hasAttribute("data-shell-overlay")
11598
+ ) {
11599
+ return null;
11600
+ }
11601
+ var sidebarRect = rectOf(sidebar);
11602
+ var mainRect = rectOf(main);
11603
+ if (
11604
+ sidebarRect === null ||
11605
+ mainRect === null ||
11606
+ !(sidebarRect.width > 0) ||
11607
+ !(mainRect.width > 0) ||
11608
+ !(mainRect.height > 0) ||
11609
+ mainRect.left + 1 < sidebarRect.right
11610
+ ) {
11611
+ return null;
11612
+ }
11613
+ return mainRect;
11614
+ } catch (error) {
11615
+ return null;
11616
+ }
11617
+ };
11618
+
11066
11619
  /**
11067
11620
  * The rectangle the dot snaps to, drags within and reads its own quadrant from.
11068
11621
  *
11069
- * The conversation when it can be found, the whole window when it cannot (a
11070
- * settings page has no prose at all): the window is what this used to be
11071
- * everywhere, and it stays the last resort rather than the rule.
11622
+ * The conversation when it can be found; when no conversation is measurable,
11623
+ * the host's main column stands in so the bottom-left home is just past the left
11624
+ * rail, rather than underneath its avatar. On pages that do not expose a main
11625
+ * column, the whole window remains the fallback.
11072
11626
  *
11073
- * WHICH one answered is remembered in `boundsAreReal`, because the two are not
11074
- * interchangeable for a MOVE: the whole window is a stand-in for "there is no
11075
- * conversation to measure at this instant" — a session still mounting, a
11076
- * message list laid out at 0×0 — and a dot parked on the window's corner over
11077
- * a page that does have a conversation is exactly the reported "it sits on the
11078
- * window's left edge instead of the conversation's corner".
11627
+ * WHICH one answered is remembered in `boundsAreReal`, because the main column
11628
+ * is still a stand-in, not a real conversation region: a stored dot only moves
11629
+ * within it when its saved position sits over an editable field (see
11630
+ * `strandedInField`).
11079
11631
  * @returns {object} the bounds, in viewport coordinates.
11080
11632
  */
11081
11633
  var floatBounds = function () {
11082
- var picked = shared.floatBoundsPick(floatBoundsRects(), floatViewport());
11634
+ var viewport = floatViewport();
11635
+ var picked = shared.floatBoundsPick(floatBoundsRects(), viewport);
11083
11636
  boundsAreReal = picked.index >= 0;
11084
- return picked.bounds;
11637
+ if (boundsAreReal) return picked.bounds;
11638
+ return shared.floatFallbackBounds(shellMainColumnRect(), viewport);
11085
11639
  };
11086
11640
 
11087
11641
  /**
@@ -11637,13 +12191,12 @@ function mountFloatPanel(scope, t, hostCtx) {
11637
12191
  */
11638
12192
  var snapToCorner = function (instant) {
11639
12193
  var bounds = floatBounds();
11640
- // A stand-in region is not a home: the window's corner is what the dot would
11641
- // land on over a page whose conversation is merely not measurable yet, which
11642
- // is the reported "it snaps to the window's left edge instead of the
11643
- // conversation's corner". A dot the document already placed keeps the corner
12194
+ // A stand-in region is not a home: the main-column corner is only where an
12195
+ // editable-field position can be re-homed while the conversation is not
12196
+ // measurable yet. A dot the document already placed keeps the corner
11644
12197
  // the last REAL region gave it; the observer settles it again the moment one
11645
12198
  // exists (see `reflowIntoBounds`).
11646
- if (!boundsAreReal && pos !== null) return pos;
12199
+ if (!boundsAreReal && pos !== null && !strandedInField()) return pos;
11647
12200
  var snapped;
11648
12201
  if (pos !== null) {
11649
12202
  snapped = cornerHome(bounds, pos);
@@ -11785,10 +12338,13 @@ function mountFloatPanel(scope, t, hostCtx) {
11785
12338
  var bounds = floatBounds();
11786
12339
  // Nothing real to settle onto: a shut panel on a settings page, or a
11787
12340
  // conversation that is not measurable at this instant, leaves the dot where
11788
- // it is — moving it onto the window's corner is the bug, not the fallback.
12341
+ // it is — moving it onto the fallback corner is the bug, not the fallback.
11789
12342
  // With nothing stored at all there is no home to keep, so the stylesheet's
11790
- // own corner is pinned as before.
11791
- if (!boundsAreReal && pos !== null) return false;
12343
+ // own corner is pinned as before. The one exception is a dot stored INSIDE a
12344
+ // field the reader types into (see `strandedInField`): that place is wrong
12345
+ // whatever the region is, so it is re-homed to the main column and persisted.
12346
+ var rehomeEditable = !boundsAreReal && pos !== null && strandedInField();
12347
+ if (!boundsAreReal && pos !== null && !rehomeEditable) return false;
11792
12348
  var at = pos;
11793
12349
  if (at === null) {
11794
12350
  var rect = rectOf(host);
@@ -11833,7 +12389,7 @@ function mountFloatPanel(scope, t, hostCtx) {
11833
12389
  // told which corner that is, in the same `mutate`. A region that changed
11834
12390
  // shape is the caller that has to store the move it just made, wherever the
11835
12391
  // dot was standing before (see the note above).
11836
- if (outside || naming) persistIfDirty();
12392
+ if (outside || naming || rehomeEditable) persistIfDirty();
11837
12393
  if (regionMoved === true) persistIfDirty();
11838
12394
  return true;
11839
12395
  };
package/lib/shared.cjs CHANGED
@@ -1229,6 +1229,23 @@ function floatBoundsFrom(rects, viewport) {
1229
1229
  return floatBoundsPick(rects, viewport).bounds;
1230
1230
  }
1231
1231
 
1232
+ /**
1233
+ * The fallback region to use when the conversation has no measurable text yet.
1234
+ *
1235
+ * The page's whole-window stand-in starts at x=0, which puts the dot over the left
1236
+ * navigation rail. On a host shell that exposes a main-column rectangle, using that
1237
+ * rectangle as the fallback puts the dot at the content column's left corner while
1238
+ * the conversation itself is mounting. If the shell supplies no usable region, the
1239
+ * viewport stays the fallback.
1240
+ * @param {unknown} region - the main content column, or null when unavailable.
1241
+ * @param {{width: number, height: number}} viewport - the window size.
1242
+ * @returns {{left:number,top:number,right:number,bottom:number,width:number,height:number}}
1243
+ * the clipped content box, or the whole viewport.
1244
+ */
1245
+ function floatFallbackBounds(region, viewport) {
1246
+ return floatBoundsPick(region === null || region === undefined ? [] : [region], viewport).bounds;
1247
+ }
1248
+
1232
1249
  /**
1233
1250
  * Whether a second reading of the winning rectangle is the same shape as the last.
1234
1251
  *
@@ -3254,6 +3271,294 @@ function ceilTo(value, step) {
3254
3271
  return Math.ceil(value / step) * step;
3255
3272
  }
3256
3273
 
3274
+ /**
3275
+ * How far apart two families may land on one notch before the stack refuses it.
3276
+ *
3277
+ * Measured on the reporter's pair (issue #4): one 100-weight notch adds +21.5%
3278
+ * ink to the Latin family and +23.9% to the Chinese one — 2.4 points apart, which
3279
+ * no reader notices. One 200-weight notch adds +44% and +51% — 7.4 points apart,
3280
+ * and that is the reported "one side suits, the other is too heavy". Five points
3281
+ * is the budget between those two observations.
3282
+ */
3283
+ var INK_BALANCE_SPREAD = 0.05;
3284
+
3285
+ /**
3286
+ * How far apart a pair may land when the strict budget would leave no control.
3287
+ *
3288
+ * Measured on the reporter's pair (issue #4): going one notch LIGHTER, the Latin
3289
+ * family loses 29.7% of its ink and the Chinese one 17.9% — 11.9 points apart, and
3290
+ * no finer notch makes it better, because the two families simply space their faces
3291
+ * differently. Under the strict budget the reader would get a weight control that
3292
+ * cannot go lighter at all (two positions). The wider budget is what keeps that
3293
+ * physical pair usable, and it is only consulted when the strict one is: a stack
3294
+ * that has four even positions never sees it.
3295
+ */
3296
+ var INK_BALANCE_SPREAD_WIDE = 0.15;
3297
+
3298
+ /** How many notches the evenness walk looks at, one way. */
3299
+ var WEIGHT_MAX_POSITIONS = 24;
3300
+
3301
+ /**
3302
+ * The least ink one notch has to add before a position is worth having.
3303
+ *
3304
+ * This is the bar `weightProfileFrom` already measured on this host: a 200-weight
3305
+ * step is +30% ink and a 100-weight step +16%, and 100 was not enough — a reader
3306
+ * could not see it. Five per cent would put a variable family's stack at twenty-odd
3307
+ * notches that each move a little (measured live: 29 positions of +6%), which is the
3308
+ * dead-control the coarse step exists to avoid. Fifteen keeps every pair the faces
3309
+ * do allow (the reported one tops out at +19%/+22%) and stops there.
3310
+ */
3311
+ var INK_BALANCE_STEP = 0.15;
3312
+
3313
+ /**
3314
+ * One family's ink at one weight.
3315
+ *
3316
+ * A family answers through `inkOf`, the sampler its own measurement already uses;
3317
+ * a weight it cannot measure — or a family that cannot be measured at all — answers
3318
+ * null and is left out of a comparison instead of counting as a zero.
3319
+ * @param {Object} entry - one measured family.
3320
+ * @param {number} weight - CSS weight.
3321
+ * @returns {number|null} the ink, or null when there is no measurement.
3322
+ */
3323
+ function entryInk(entry, weight) {
3324
+ if (entry === null || typeof entry !== "object") return null;
3325
+ if (typeof entry.inkOf !== "function") return null;
3326
+ var ink = null;
3327
+ try {
3328
+ ink = entry.inkOf(weight);
3329
+ } catch (error) {
3330
+ return null;
3331
+ }
3332
+ ink = Number(ink);
3333
+ if (!isFinite(ink) || ink <= 0) return null;
3334
+ return ink;
3335
+ }
3336
+
3337
+ /**
3338
+ * How much moving from one weight to another changes each family's ink.
3339
+ *
3340
+ * The share is relative to the family's OWN ink at `from`, so two families that
3341
+ * start out different sizes are still comparable: +20% means the same thing for
3342
+ * both. Families that cannot be measured on both sides are left out.
3343
+ * @param {Array<Object>} entries - the measured families.
3344
+ * @param {number} from - the weight the reader is at.
3345
+ * @param {number} to - the weight being compared.
3346
+ * @returns {number[]|null} the relative changes, or null when fewer than two
3347
+ * families can be measured.
3348
+ */
3349
+ function inkShares(entries, from, to) {
3350
+ var shares = [];
3351
+ for (var index = 0; index < entries.length; index += 1) {
3352
+ var before = entryInk(entries[index], from);
3353
+ var after = entryInk(entries[index], to);
3354
+ if (before === null || after === null) continue;
3355
+ shares.push((after - before) / before);
3356
+ }
3357
+ return shares.length < 2 ? null : shares;
3358
+ }
3359
+
3360
+ /**
3361
+ * How far apart the families land, and how little the quietest one moved.
3362
+ * @param {number[]} shares - one relative change per family.
3363
+ * @returns {{spread: number, smallest: number}} `spread` is the signed range, so
3364
+ * families moving in opposite directions count as far apart.
3365
+ */
3366
+ function shareSpread(shares) {
3367
+ var least = shares[0];
3368
+ var most = shares[0];
3369
+ var smallest = Math.abs(shares[0]);
3370
+ for (var index = 1; index < shares.length; index += 1) {
3371
+ if (shares[index] < least) least = shares[index];
3372
+ if (shares[index] > most) most = shares[index];
3373
+ var size = Math.abs(shares[index]);
3374
+ if (size < smallest) smallest = size;
3375
+ }
3376
+ return { spread: most - least, smallest: smallest };
3377
+ }
3378
+
3379
+ /**
3380
+ * The evenness verdict for one move of the stack.
3381
+ * @param {Array<Object>} entries - the measured families.
3382
+ * @param {number} from - the weight the move starts at.
3383
+ * @param {number} to - the weight it ends at.
3384
+ * @returns {{spread: number, smallest: number}|null} the verdict, or null when it
3385
+ * cannot be measured.
3386
+ */
3387
+ function evennessOf(entries, from, to) {
3388
+ var shares = inkShares(entries, from, to);
3389
+ if (shares === null) return null;
3390
+ return shareSpread(shares);
3391
+ }
3392
+
3393
+ /** Is one move of the stack both visible and even? */
3394
+ function moveIsEven(verdict, spread) {
3395
+ return verdict !== null && verdict.smallest >= INK_BALANCE_STEP && verdict.spread <= spread;
3396
+ }
3397
+
3398
+ /**
3399
+ * The shape a stack of families can follow together, chosen for EVENNESS.
3400
+ *
3401
+ * Issue #4 is not about the range: it is about two families being moved by one
3402
+ * offset and landing on faces that are not the same distance apart. The test is
3403
+ * therefore walked outwards from the neutral position, one notch at a time, and a
3404
+ * notch only survives when BOTH hold:
3405
+ * - every family's ink changes by at least `INK_BALANCE_STEP` (the notch is
3406
+ * visible), and
3407
+ * - the families land within `INK_BALANCE_SPREAD` of each other, measured both for
3408
+ * that one notch and for the whole way back to the neutral position — a stack
3409
+ * can be even notch by notch and still drift apart after two of them.
3410
+ *
3411
+ * The walk is what removes the synthetic-bold step without ever having to ask the
3412
+ * browser whether a weight is drawn or faked: above its last real face a family is
3413
+ * stroked thicker by the browser, which adds a much larger share of ink than a drawn
3414
+ * face does — so the notch where one family jumps and the other does not simply
3415
+ * fails the budget and the range stops in front of it.
3416
+ *
3417
+ * Among the shapes that survive, the one with the MOST positions wins: the visible
3418
+ * floor above is what makes a finer notch worth having, and more positions is more
3419
+ * control. (A single family is not put through this at all — it keeps the coarse
3420
+ * shape `weightProfileFrom` measured for it.)
3421
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}>} entries
3422
+ * one record per measured family.
3423
+ * @param {number} base - the weight the offset is added to (body text).
3424
+ * @param {number} low - the lowest offset every family can express.
3425
+ * @param {number} high - the highest offset every family can express.
3426
+ * @returns {{min: number, max: number, step: number}|null} the shape, or null when
3427
+ * no step leaves the stack two positions to move between.
3428
+ */
3429
+ function balancedStackShape(entries, base, low, high, spread) {
3430
+ var budget = typeof spread === "number" && spread > 0 ? spread : INK_BALANCE_SPREAD;
3431
+ var best = null;
3432
+ var bestCount = 1;
3433
+ var bestScore = Infinity;
3434
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3435
+ var step = WEIGHT_STEP_CANDIDATES[index];
3436
+ if (!(step > 0)) continue;
3437
+ var up = 0;
3438
+ var down = 0;
3439
+ // A walk is bounded: no useful shape has more than a couple of dozen
3440
+ // positions, and an unbounded one on a one-unit grid would cost hundreds of
3441
+ // canvas renders on the very first measurement.
3442
+ var limit = step * WEIGHT_MAX_POSITIONS;
3443
+ for (var position = step; position <= high && position <= limit; position += step) {
3444
+ if (!moveIsEven(evennessOf(entries, base + position - step, base + position), budget)) break;
3445
+ if (!moveIsEven(evennessOf(entries, base, base + position), budget)) break;
3446
+ up = position;
3447
+ }
3448
+ for (var back = step; back <= -low && back <= limit; back += step) {
3449
+ if (!moveIsEven(evennessOf(entries, base - back + step, base - back), budget)) break;
3450
+ if (!moveIsEven(evennessOf(entries, base, base - back), budget)) break;
3451
+ down = back;
3452
+ }
3453
+ var count = (up + down) / step + 1;
3454
+ if (count < 2) continue;
3455
+ // How close the two families are on one notch from the neutral position: a tie
3456
+ // between two shapes that move the stack over the same number of positions.
3457
+ var headroom = evennessOf(entries, base, base + step);
3458
+ if (headroom === null) headroom = evennessOf(entries, base, base - step);
3459
+ var score = headroom === null ? Infinity : headroom.spread;
3460
+ if (count > bestCount || (count === bestCount && score < bestScore)) {
3461
+ // -0 would read as a different number to a strict comparison.
3462
+ best = { min: down === 0 ? 0 : -down, max: up, step: step };
3463
+ bestCount = count;
3464
+ bestScore = score;
3465
+ }
3466
+ }
3467
+ return best;
3468
+ }
3469
+
3470
+ /**
3471
+ * One slider shape for a stack whose text can land on more than one family.
3472
+ *
3473
+ * A reader sets ONE offset; the browser then renders English with the first
3474
+ * family and Chinese with the fallback, each snapping that same requested value
3475
+ * to its own faces. When the fallback ships three faces and the primary is a
3476
+ * variable font, a step measured on the primary alone leaves the fallback
3477
+ * unchanged on most notches — the reported "one side suits, the other is too
3478
+ * heavy or too light" (issue #4: Inclusive Sans beside a three-face CJK family).
3479
+ *
3480
+ * The combined shape keeps every family moving on every notch, but the notch is
3481
+ * chosen for EVENNESS (see `balancedStackShape`): each family has to change by a
3482
+ * comparable share of its own ink, and the stack has to stay together over the
3483
+ * whole length of the range. Families that can report their ink (`inkOf`) get that
3484
+ * shape; a stack measured without a canvas keeps the plain shared-multiple shape,
3485
+ * which is also what a single family gets.
3486
+ * @param {Array<{min: number, max: number, step: number, inkOf?: Function}|null>} profiles
3487
+ * one shape per family.
3488
+ * @param {{base?: number}} [options] `base` is the weight the offset is added to
3489
+ * (body text); it anchors the ink comparison.
3490
+ * @returns {{min: number, max: number, step: number}|null} the combined shape, or
3491
+ * null when nothing usable was measured.
3492
+ */
3493
+ function combineWeightProfiles(profiles, options) {
3494
+ var list = [];
3495
+ if (Array.isArray(profiles)) {
3496
+ for (var index = 0; index < profiles.length; index += 1) {
3497
+ var entry = profiles[index];
3498
+ if (entry === null || typeof entry !== "object") continue;
3499
+ if (!isFinite(entry.min) || !isFinite(entry.max) || !isFinite(entry.step)) continue;
3500
+ if (!(entry.step > 0)) continue;
3501
+ list.push(entry);
3502
+ }
3503
+ }
3504
+ if (list.length === 0) return null;
3505
+ if (list.length === 1) return { min: list[0].min, max: list[0].max, step: list[0].step };
3506
+ var step = list[0].step;
3507
+ for (var other = 1; other < list.length; other += 1) {
3508
+ step = commonStep(step, list[other].step);
3509
+ }
3510
+ var low = list[0].min;
3511
+ var high = list[0].max;
3512
+ for (var more = 1; more < list.length; more += 1) {
3513
+ low = Math.max(low, list[more].min);
3514
+ high = Math.min(high, list[more].max);
3515
+ }
3516
+ var settings = options === null || typeof options !== "object" ? {} : options;
3517
+ var base = isFinite(settings.base) ? Number(settings.base) : WEIGHT_BASE;
3518
+ // Every family that can report ink is put through the evenness walk; a stack whose
3519
+ // canvas could not answer keeps the plain shared-multiple shape.
3520
+ var measured = false;
3521
+ for (var scan = 0; scan < list.length; scan += 1) {
3522
+ if (typeof list[scan].inkOf === "function") measured = true;
3523
+ }
3524
+ if (measured) {
3525
+ var even = balancedStackShape(list, base, low, high);
3526
+ var positions = even === null ? 0 : (even.max - even.min) / even.step + 1;
3527
+ if (positions >= 3) return even;
3528
+ // The pair's own faces are spaced so unevenly that the strict budget leaves
3529
+ // no control; the wider one is what keeps a lighter position reachable.
3530
+ var relaxed = balancedStackShape(list, base, low, high, INK_BALANCE_SPREAD_WIDE);
3531
+ var relaxedPositions = relaxed === null ? 0 : (relaxed.max - relaxed.min) / relaxed.step + 1;
3532
+ if (relaxedPositions > positions) return relaxed;
3533
+ if (even !== null) return even;
3534
+ }
3535
+ var onGrid = { min: ceilTo(low, step), max: floorTo(high, step) };
3536
+ if (onGrid.max >= onGrid.min) return { min: onGrid.min, max: onGrid.max, step: step };
3537
+ var widest = list[0];
3538
+ for (var alt = 1; alt < list.length; alt += 1) {
3539
+ if (list[alt].max - list[alt].min > widest.max - widest.min) widest = list[alt];
3540
+ }
3541
+ return { min: widest.min, max: widest.max, step: widest.step };
3542
+ }
3543
+
3544
+ /**
3545
+ * The smallest candidate step that is a whole multiple of both inputs.
3546
+ *
3547
+ * Profiles come from one candidate list, so a shared multiple usually exists
3548
+ * there; two steps that share none fall back to the coarser one, which still
3549
+ * moves the coarser family on every notch.
3550
+ * @param {number} a - one step.
3551
+ * @param {number} b - another step.
3552
+ * @returns {number} the combined step.
3553
+ */
3554
+ function commonStep(a, b) {
3555
+ for (var index = 0; index < WEIGHT_STEP_CANDIDATES.length; index += 1) {
3556
+ var candidate = WEIGHT_STEP_CANDIDATES[index];
3557
+ if (candidate % a === 0 && candidate % b === 0) return candidate;
3558
+ }
3559
+ return Math.max(a, b);
3560
+ }
3561
+
3257
3562
  /**
3258
3563
  * Does every position of `[min..max]` in `step` increments render differently?
3259
3564
  *
@@ -4669,6 +4974,7 @@ var shared = {
4669
4974
  floatBoxOf: floatBoxOf,
4670
4975
  floatBoundsPick: floatBoundsPick,
4671
4976
  floatBoundsFrom: floatBoundsFrom,
4977
+ floatFallbackBounds: floatFallbackBounds,
4672
4978
  sameWatchBounds: sameWatchBounds,
4673
4979
  floatTargetsConnected: floatTargetsConnected,
4674
4980
  floatQuadrantIn: floatQuadrantIn,
@@ -4837,6 +5143,16 @@ var shared = {
4837
5143
  WEIGHT_CSS_MIN: WEIGHT_CSS_MIN,
4838
5144
  WEIGHT_CSS_MAX: WEIGHT_CSS_MAX,
4839
5145
  weightProfileFrom: weightProfileFrom,
5146
+ combineWeightProfiles: combineWeightProfiles,
5147
+ balancedStackShape: balancedStackShape,
5148
+ evennessOf: evennessOf,
5149
+ INK_BALANCE_SPREAD: INK_BALANCE_SPREAD,
5150
+ INK_BALANCE_STEP: INK_BALANCE_STEP,
5151
+ INK_BALANCE_SPREAD_WIDE: INK_BALANCE_SPREAD_WIDE,
5152
+ WEIGHT_PROBE_LOW: WEIGHT_PROBE_LOW,
5153
+ WEIGHT_PROBE_HIGH: WEIGHT_PROBE_HIGH,
5154
+ WEIGHT_PROBE_STEP: WEIGHT_PROBE_STEP,
5155
+ GENERIC_FAMILIES: GENERIC_FAMILIES,
4840
5156
  weightStepRange: weightStepRange,
4841
5157
  isShorthandName: isShorthandName,
4842
5158
  isScaledToken: isScaledToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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",