dsh-fonttune 0.4.2 → 0.5.0

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.5.0] - 2026-10-13
6
+
7
+ ### Added
8
+ - **Finer weight steps on fonts with a continuous weight range**: for a font that supports one, the weight slider of the conversation, the interface and code goes from a handful of steps to a couple of dozen, and every notch changes the text. Machines whose fonts do not support it keep the steps they had.
9
+
10
+ ### Fixed
11
+ - **Headings shrank when the conversation line height was adjusted**: the axis now moves the spacing alone, leaving the size and the hierarchy of headings and body text untouched.
12
+ - **The floating panel offered different weight steps than the settings card**: both now read and write the same steps and the same range.
13
+ - **The preview box did not match the real conversation**: moving the weight by one step now shows in the preview as well.
14
+
15
+ ### Changed
16
+ - **The weight hints no longer describe a change of face per step**: on a font with a continuous weight range that is not what happens, so they now say every notch makes the text a little bolder or lighter.
17
+
5
18
  ## [0.4.2] - 2026-10-12
6
19
 
7
20
  ### Changed
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  **dsh-fonttune** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.5.0] - 2026-10-13
6
+
7
+ ### 新增
8
+ - **字重滑块在无级字重的字体上更细**:字体本身支持无级字重时,对话、界面、代码三处的字重滑块从原来的几档变为二十几档,每挪一格文字都有变化;字体不支持的机器上档位与之前相同。
9
+
10
+ ### 修复
11
+ - **调整对话行高时标题字号变小**:现在只改变行距,标题与正文的字号与层级保持不变。
12
+ - **浮动面板的字重档位与设置页不一致**:两处显示与写入的档位、范围现在相同。
13
+ - **预览框与真实对话的字体表现不一致**:预览里挪动字重看不出变化的问题不再出现。
14
+
15
+ ### 变更
16
+ - **字重提示文字不再描述"每一格都换一张字形"**:在无级字重的字体上这句话不成立,改为说明每挪一格文字都会变粗或变细。
17
+
5
18
  ## [0.4.2] - 2026-10-12
6
19
 
7
20
  ### 变更
package/README.en.md CHANGED
@@ -28,7 +28,8 @@
28
28
 
29
29
  | Plugin version | Supported DSH versions |
30
30
  | --- | --- |
31
- | **0.4.x** (latest, 0.4.1) | 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 |
31
+ | **0.5.x** (latest, 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 |
32
+ | 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 |
32
33
  | 0.3.x (0.3.6) | 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 |
33
34
  | 0.2.x (0.2.8) | 0.1.5-rc.3, 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2 |
34
35
 
package/README.md CHANGED
@@ -28,7 +28,8 @@
28
28
 
29
29
  | 插件版本 | 支持的 DSH 版本 |
30
30
  | --- | --- |
31
- | **0.4.x**(最新,0.4.1) | 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 |
31
+ | **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 |
32
+ | 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 |
32
33
  | 0.3.x(0.3.6) | 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 |
33
34
  | 0.2.x(0.2.8) | 0.1.5-rc.3、0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2 |
34
35
 
package/lib/client.js CHANGED
@@ -2338,6 +2338,27 @@ function rebuildShorthand(parts) {
2338
2338
  * token classification
2339
2339
  * ------------------------------------------------------------------ */
2340
2340
 
2341
+ /**
2342
+ * Whether a CSS selector declares something for the WHOLE page.
2343
+ *
2344
+ * The typography tokens live on `:root`, `html` or `body`. A selector scoped to a class
2345
+ * or an attribute declares a LOCAL value: another plugin's module stylesheet may reuse a
2346
+ * token name for its own compact panel (measured: `dsh-better-sidebar` sets
2347
+ * `--dsw-font-markdown-h1` to `600 13px/18px` on `.…_taskMarkdown`), and reading that as
2348
+ * the base hands the conversation a size that panel picked for itself.
2349
+ * @param {string} selector - a rule's selector text.
2350
+ * @returns {boolean} true when every selector in the list is a root scope.
2351
+ */
2352
+ function isRootScopeSelector(selector) {
2353
+ if (typeof selector !== "string" || selector.trim() === "") return false;
2354
+ var parts = selector.split(",");
2355
+ for (var index = 0; index < parts.length; index += 1) {
2356
+ var part = parts[index].trim().toLowerCase();
2357
+ if (part !== ":root" && part !== "html" && part !== "body" && part !== "*") return false;
2358
+ }
2359
+ return true;
2360
+ }
2361
+
2341
2362
  /**
2342
2363
  * Match DSH's typography PART tokens: the design system's sizes plus the
2343
2364
  * content size the theme plugin writes on `body` (whose secondary variant
@@ -3178,6 +3199,180 @@ function weightStepRange(profile, value) {
3178
3199
  return { min: min, max: max, unit: unit, value: count };
3179
3200
  }
3180
3201
 
3202
+ /* ------------------------------------------------------------------ *
3203
+ * continuous weight (FT-060)
3204
+ * ------------------------------------------------------------------ */
3205
+
3206
+ /** Prefix of the alias family a continuous family is drawn through. */
3207
+ var VARIABLE_ALIAS_PREFIX = "dfp-vf-";
3208
+
3209
+ /**
3210
+ * The weights the continuity probe lays out. Dense on purpose: a variable font
3211
+ * that moves in small steps shows a width change at every one of them, while a
3212
+ * family that snaps to named instances repeats its width across each stretch.
3213
+ */
3214
+ var CONTINUITY_WEIGHTS = [400, 420, 440, 460, 480, 500, 550, 600, 650, 700, 750, 800, 900];
3215
+
3216
+ /**
3217
+ * The share of adjacent probe pairs that must differ before a family's laid-out
3218
+ * WIDTH counts as continuous. Measured: a variable family drawn through its alias
3219
+ * changes width at every sampled weight (share 1.0), while a family snapping to
3220
+ * named instances repeats its width across each stretch (share 0.25–0.42).
3221
+ */
3222
+ var CONTINUOUS_MIN_SHARE = 0.9;
3223
+
3224
+ /**
3225
+ * The notches offered for a continuous family, finest first.
3226
+ *
3227
+ * The shape is still measured (the alias is drawn on the same weight grid as any
3228
+ * other family), but the candidates are fine: a continuous axis can answer at every
3229
+ * one of them, which is the whole point of the mode. `weightProfileFrom` walks this
3230
+ * list from the END, so the finest candidate that still yields enough visible
3231
+ * positions wins.
3232
+ */
3233
+ var CONTINUOUS_CANDIDATES = [10, 20, 25];
3234
+
3235
+ /**
3236
+ * The nominal continuous slider, used when the measured shape cannot be taken
3237
+ * (no canvas, a refused probe). One notch is 20 weight units; the range is the
3238
+ * widest offset the ceiling allows either way.
3239
+ */
3240
+ var CONTINUOUS_STEP = 20;
3241
+ var CONTINUOUS_RANGE = 200;
3242
+
3243
+ /**
3244
+ * How many different values a list of measurements holds.
3245
+ * @param {number[]} values - the measurements.
3246
+ * @returns {number} the count of distinct values.
3247
+ */
3248
+ function distinctCount(values) {
3249
+ if (!Array.isArray(values)) return 0;
3250
+ var seen = [];
3251
+ for (var index = 0; index < values.length; index += 1) {
3252
+ if (seen.indexOf(values[index]) < 0) seen.push(values[index]);
3253
+ }
3254
+ return seen.length;
3255
+ }
3256
+
3257
+ /**
3258
+ * Does the same family answer more finely through its alias than through itself?
3259
+ *
3260
+ * This is the discriminator the continuity probe trusts, and it needs no threshold:
3261
+ * a family that snaps to named instances renders IDENTICALLY either way, so both
3262
+ * responses hold the same number of distinct values; a variable family drawn through
3263
+ * the alias moves at weights where the system path already stopped. Comparing the
3264
+ * family against ITSELF also keeps the answer honest for a narrow axis, where a
3265
+ * share-of-neighbours rule would read saturation as "static".
3266
+ * @param {number[]} throughAlias - measurements taken through the alias face.
3267
+ * @param {number[]} plain - the same measurements taken through the family itself.
3268
+ * @returns {boolean} true when the alias resolves finer detail.
3269
+ */
3270
+ function respondsFinerThan(throughAlias, plain) {
3271
+ if (!Array.isArray(throughAlias) || !Array.isArray(plain)) return false;
3272
+ var fine = distinctCount(throughAlias);
3273
+ return fine >= 3 && fine > distinctCount(plain);
3274
+ }
3275
+
3276
+
3277
+ /**
3278
+ * The alias family name for one family. A hash, not the family itself, so the
3279
+ * name is a plain identifier whatever the family is called.
3280
+ * @param {string} family - a family name.
3281
+ * @returns {string} the alias, stable across sessions.
3282
+ */
3283
+ function variableAliasFor(family) {
3284
+ var text = sanitizeFamily(family);
3285
+ var hash = 0;
3286
+ for (var index = 0; index < text.length; index += 1) {
3287
+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
3288
+ }
3289
+ return VARIABLE_ALIAS_PREFIX + hash.toString(36);
3290
+ }
3291
+
3292
+ /**
3293
+ * The `@font-face` rule that draws one family through its own variable axis.
3294
+ *
3295
+ * `local()` points the face at the installed family, and the weight RANGE is the
3296
+ * part that matters: it tells the engine the family is continuous, so `font-weight`
3297
+ * picks a point on the axis instead of the nearest named instance. (Without the
3298
+ * range the same family snaps; `font-variation-settings` does not help a system
3299
+ * family at all — measured, see FT-060.)
3300
+ * @param {string} family - the installed family name.
3301
+ * @param {string} alias - the alias from `variableAliasFor`.
3302
+ * @returns {string} one CSS rule.
3303
+ */
3304
+ function variableFaceRule(family, alias) {
3305
+ var text = sanitizeFamily(family).replace(/"/g, "");
3306
+ return '@font-face{font-family:"' + alias + '";src:local("' + text + '");font-weight:100 900;}';
3307
+ }
3308
+
3309
+ /**
3310
+ * Every `@font-face` rule for a map of family → alias.
3311
+ * @param {Record<string, string>} variable - the families that measured continuous.
3312
+ * @returns {string} the rules, one per line; "" when there are none.
3313
+ */
3314
+ function buildVariableFaces(variable) {
3315
+ var rules = [];
3316
+ if (variable === null || typeof variable !== "object") return "";
3317
+ for (var family in variable) {
3318
+ if (Object.prototype.hasOwnProperty.call(variable, family)) {
3319
+ rules.push(variableFaceRule(family, variable[family]));
3320
+ }
3321
+ }
3322
+ return rules.join("\n");
3323
+ }
3324
+
3325
+ /**
3326
+ * Put the alias IN FRONT of every continuous family a stack names.
3327
+ *
3328
+ * The family stays in the list after its alias, so a glyph the alias lacks still
3329
+ * falls through to the same face the reader chose, and a stack that names no
3330
+ * continuous family is returned unchanged.
3331
+ * @param {string} stack - a formatted font-family value.
3332
+ * @param {Record<string, string>} variable - family → alias map.
3333
+ * @returns {string} the stack, with aliases prepended where they apply.
3334
+ */
3335
+ function withVariableFaces(stack, variable) {
3336
+ if (typeof stack !== "string" || stack === "" || variable === null || typeof variable !== "object") {
3337
+ return stack;
3338
+ }
3339
+ // The stack may already carry its alias — a family read back from the page's own
3340
+ // computed style does, once the mode is on. Prepending it again would stack up a
3341
+ // duplicate on every rebuild.
3342
+ var names = parseStack(stack);
3343
+ var lead = names.length > 0 ? names[0] : "";
3344
+ var prefix = [];
3345
+ for (var family in variable) {
3346
+ if (!Object.prototype.hasOwnProperty.call(variable, family)) continue;
3347
+ if (stack.indexOf(quoteFamily(family)) < 0) continue;
3348
+ if (lead === variable[family]) continue;
3349
+ prefix.push('"' + variable[family] + '"');
3350
+ }
3351
+ return prefix.length === 0 ? stack : prefix.join(", ") + ", " + stack;
3352
+ }
3353
+
3354
+ /**
3355
+ * Did a family respond continuously to weight in the probe?
3356
+ * @param {number[]} widths - laid-out widths, in `CONTINUITY_WEIGHTS` order.
3357
+ * @returns {boolean} true when enough neighbours differ.
3358
+ */
3359
+ function isContinuousResponse(widths) {
3360
+ if (!Array.isArray(widths) || widths.length < 3) return false;
3361
+ var changes = 0;
3362
+ for (var index = 1; index < widths.length; index += 1) {
3363
+ if (widths[index] !== widths[index - 1]) changes += 1;
3364
+ }
3365
+ return changes / (widths.length - 1) >= CONTINUOUS_MIN_SHARE;
3366
+ }
3367
+
3368
+ /**
3369
+ * The slider shape for a continuous family: the finest notch, centred on 0.
3370
+ * @returns {{min: number, max: number, step: number}} the profile, in weight units.
3371
+ */
3372
+ function continuousWeightProfile() {
3373
+ return { min: -CONTINUOUS_RANGE, max: CONTINUOUS_RANGE, step: CONTINUOUS_STEP };
3374
+ }
3375
+
3181
3376
  /**
3182
3377
  * What a slider's local (unconfirmed) value should do when a value arrives from
3183
3378
  * the settings document.
@@ -3265,10 +3460,12 @@ function isDormant(config) {
3265
3460
  * @param {boolean} isDark - prefix every rule with the dark-theme attribute.
3266
3461
  * @returns {string} declarations for one `<style>` element.
3267
3462
  */
3268
- function buildAxisCss(axis, baseTokens, isDark, hints) {
3269
- var sans = formatStack(parseStack(axis[SANS_FIELD]));
3270
- var mono = formatStack(parseStack(axis[MONO_FIELD]));
3271
- var dialog = formatStack(parseStack(axis[STACK_DIALOG_FIELD]));
3463
+ function buildAxisCss(axis, baseTokens, isDark, hints, variable) {
3464
+ // A continuous family leads its stack with its alias (see `withVariableFaces`);
3465
+ // with no map, every stack stays exactly as written.
3466
+ var sans = withVariableFaces(formatStack(parseStack(axis[SANS_FIELD])), variable);
3467
+ var mono = withVariableFaces(formatStack(parseStack(axis[MONO_FIELD])), variable);
3468
+ var dialog = withVariableFaces(formatStack(parseStack(axis[STACK_DIALOG_FIELD])), variable);
3272
3469
  // Normalised once: an expanded axis always carries a number, but a caller
3273
3470
  // handing in a raw object may leave the field out, and "absent" has to mean
3274
3471
  // "unset" rather than "write the string undefined". The conversation's value
@@ -3668,7 +3865,28 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
3668
3865
  if (noBold) declarations.push(bodyRule("font-synthesis-weight:none !important"));
3669
3866
  }
3670
3867
 
3671
- return declarations.join("\n");
3868
+ return hardenDeltaRefs(declarations.join("\n"));
3869
+ }
3870
+
3871
+ /**
3872
+ * Give every reference to the content-size delta a fallback value.
3873
+ *
3874
+ * A heading's `font` shorthand is the markdown token, and DSH's own text for it reads
3875
+ * `calc(21px + var(--dsh-content-font-delta))` — a reference with NO fallback. Nothing
3876
+ * outside this plugin defines that property, so any rebuild that emits the reference
3877
+ * without the definition leaves it resolving to nothing, which makes the WHOLE shorthand
3878
+ * invalid at computed-value time: every font longhand falls back to `inherit`, and the
3879
+ * heading is painted at body size. Measured on a live page: h1 23px → 16px the moment the
3880
+ * property resolved to nothing, back to 23px when it returned.
3881
+ *
3882
+ * The fallback cannot change a page that defines the property (the definition wins), and
3883
+ * where the definition is missing it keeps the design's own size instead of collapsing.
3884
+ * @param {string} css - generated declarations.
3885
+ * @returns {string} the same declarations, with the reference made safe.
3886
+ */
3887
+ function hardenDeltaRefs(css) {
3888
+ if (typeof css !== "string" || css === "") return css;
3889
+ return css.split("var(--dsh-content-font-delta)").join("var(--dsh-content-font-delta, 0px)");
3672
3890
  }
3673
3891
 
3674
3892
  /**
@@ -3760,7 +3978,7 @@ function scaleCodeTokens(baseTokens, scale, lineOffset) {
3760
3978
  * correct everywhere and costs an attribute scan per element per rule.
3761
3979
  * @returns {string} declarations for one `<style>` element ("" when dormant).
3762
3980
  */
3763
- function buildFontCss(config, baseTokens, hints) {
3981
+ function buildFontCss(config, baseTokens, hints, variable) {
3764
3982
  var tokens = baseTokens === undefined || baseTokens === null ? FALLBACK_TOKENS : baseTokens;
3765
3983
  var sets = resolveAxes(config);
3766
3984
  // The rendering preference is NOT an axis: it must paint even when every
@@ -3768,16 +3986,16 @@ function buildFontCss(config, baseTokens, hints) {
3768
3986
  // use case with everything else untouched.
3769
3987
  var smoothing = smoothingRule(config !== null && typeof config === "object" ? config[SMOOTHING_FIELD] : "");
3770
3988
  if (isDormant(sets.light) && isDormant(sets.dark)) return smoothing;
3771
- var light = buildAxisCss(sets.light, tokens, false, hints);
3989
+ var light = buildAxisCss(sets.light, tokens, false, hints, variable);
3772
3990
  if (sameValueSet(sets.light, sets.dark)) {
3773
- return smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing;
3991
+ return hardenDeltaRefs(smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing);
3774
3992
  }
3775
- var dark = buildAxisCss(sets.dark, tokens, true, hints);
3993
+ var dark = buildAxisCss(sets.dark, tokens, true, hints, variable);
3776
3994
  var parts = [];
3777
3995
  if (light !== "") parts.push(light);
3778
3996
  if (dark !== "") parts.push(dark);
3779
3997
  if (smoothing !== "") parts.push(smoothing);
3780
- return parts.join("\n");
3998
+ return hardenDeltaRefs(parts.join("\n"));
3781
3999
  }
3782
4000
 
3783
4001
  /**
@@ -4299,6 +4517,19 @@ var shared = {
4299
4517
  WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
4300
4518
  WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
4301
4519
  WEIGHT_MIN_POSITIONS: WEIGHT_MIN_POSITIONS,
4520
+ VARIABLE_ALIAS_PREFIX: VARIABLE_ALIAS_PREFIX,
4521
+ CONTINUITY_WEIGHTS: CONTINUITY_WEIGHTS,
4522
+ isRootScopeSelector: isRootScopeSelector,
4523
+ hardenDeltaRefs: hardenDeltaRefs,
4524
+ CONTINUOUS_CANDIDATES: CONTINUOUS_CANDIDATES,
4525
+ distinctCount: distinctCount,
4526
+ respondsFinerThan: respondsFinerThan,
4527
+ variableAliasFor: variableAliasFor,
4528
+ variableFaceRule: variableFaceRule,
4529
+ buildVariableFaces: buildVariableFaces,
4530
+ withVariableFaces: withVariableFaces,
4531
+ isContinuousResponse: isContinuousResponse,
4532
+ continuousWeightProfile: continuousWeightProfile,
4302
4533
  WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
4303
4534
  WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
4304
4535
  WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
@@ -4640,13 +4871,13 @@ var DICTS = {
4640
4871
 
4641
4872
  "weight.uiLabel": "Interface font-weight offset",
4642
4873
  "weight.uiHint":
4643
- "Adds to the weight of the whole interface (sidebars, settings, buttons, headings): a positive value is bolder, a negative one lighter. The conversation keeps its own. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch swaps in a different face for body text and headings alike, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4874
+ "Adds to the weight of the whole interface (sidebars, settings, buttons, headings): a positive value is bolder, a negative one lighter. The conversation keeps its own. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch makes body text and headings a little bolder or lighter, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4644
4875
  "weight.dialogLabel": "Conversation font-weight offset",
4645
4876
  "weight.dialogHint":
4646
- "Adds to the weight of each conversation element, so headings stay bolder than body text and bold text stays bold. Unset keeps DSH's own weights. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch swaps in a different face for body text and headings alike, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4877
+ "Adds to the weight of each conversation element, so headings stay bolder than body text and bold text stays bold. Unset keeps DSH's own weights. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch makes body text and headings a little bolder or lighter, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4647
4878
  "weight.codeLabel": "Code font-weight offset",
4648
4879
  "weight.codeHint":
4649
- "Adds to the weight of each code surface (code blocks, inline code, terminal output); 0 or unset keeps DSH's own weight. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch swaps in a different face for body text and headings alike, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4880
+ "Adds to the weight of each code surface (code blocks, inline code, terminal output); 0 or unset keeps DSH's own weight. The slider is divided into the weights the current font can actually render, so the readout counts STEPS (0 = leave DSH alone); every notch makes body text and headings a little bolder or lighter, and another font offers a different number of steps. The step is measured for the current font, and its smallest step is the one that looks weakest.",
4650
4881
 
4651
4882
  "line.dialogLabel": "Conversation line height",
4652
4883
  "line.dialogHint":
@@ -4802,13 +5033,13 @@ var DICTS = {
4802
5033
 
4803
5034
  "weight.uiLabel": "界面字重偏移",
4804
5035
  "weight.uiHint":
4805
- "在整个界面自身的字重上叠加(侧栏、设置、按钮、标题):正数更粗、负数更细;对话 Markdown 不受影响。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
5036
+ "在整个界面自身的字重上叠加(侧栏、设置、按钮、标题):正数更粗、负数更细;对话 Markdown 不受影响。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会变粗或变细;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
4806
5037
  "weight.dialogLabel": "对话字重偏移",
4807
5038
  "weight.dialogHint":
4808
- "在每个元素自身的字重上叠加,所以标题依旧比正文粗、加粗文字依旧加粗;未设置时保持 DSH 原本的粗细。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
5039
+ "在每个元素自身的字重上叠加,所以标题依旧比正文粗、加粗文字依旧加粗;未设置时保持 DSH 原本的粗细。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会变粗或变细;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
4809
5040
  "weight.codeLabel": "代码字重偏移",
4810
5041
  "weight.codeHint":
4811
- "在每个代码面自身的字重上叠加(代码块、行内代码、终端输出);0 或未设置表示保持 DSH 原样。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
5042
+ "在每个代码面自身的字重上叠加(代码块、行内代码、终端输出);0 或未设置表示保持 DSH 原样。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会变粗或变细;换个字体,档数也会跟着变。档距按当前字体实测,其中幅度最小的一档看起来最弱。",
4812
5043
 
4813
5044
  "line.dialogLabel": "对话行高",
4814
5045
  "line.dialogHint": "把对话行高整体缩放为 {ratio};100% 表示保持 DSH 原样。",
@@ -5223,6 +5454,24 @@ function isOwnSheet(sheet) {
5223
5454
  }
5224
5455
  }
5225
5456
 
5457
+ /**
5458
+ * Whether a style rule declares its custom properties for the WHOLE page.
5459
+ *
5460
+ * The design tokens live on `:root`, `html` or `body`. A rule scoped to a class
5461
+ * declares a LOCAL value — another plugin's module stylesheet may reuse one of the
5462
+ * token names for its own compact panel, and reading that as the base hands the
5463
+ * conversation a size that panel chose for itself.
5464
+ * @param {CSSStyleRule} rule - any rule from a document stylesheet.
5465
+ * @returns {boolean} true when every selector in the rule is a root scope.
5466
+ */
5467
+ function isRootScopedRule(rule) {
5468
+ try {
5469
+ return shared.isRootScopeSelector(rule.selectorText);
5470
+ } catch (error) {
5471
+ return false;
5472
+ }
5473
+ }
5474
+
5226
5475
  /**
5227
5476
  * Snapshot the typography tokens the size offsets scale.
5228
5477
  *
@@ -5238,6 +5487,13 @@ function isOwnSheet(sheet) {
5238
5487
  * collected too: they are what the shipped stylesheets actually consume, and
5239
5488
  * the line-height axes rebuild their inline heights.
5240
5489
  *
5490
+ * Declarations come in two ranks, because a token name is not owned by the design
5491
+ * system alone: `dsh-better-sidebar` redefines `--dsw-font-markdown-h1` as a compact
5492
+ * `600 13px/18px` on its own panel class, and reading that as the base is how every
5493
+ * conversation heading shrank once the line-height axis rebuilt those shorthands. A
5494
+ * ROOT-scoped declaration is the page's value; a locally scoped one only fills a gap
5495
+ * (a host line that declares its tokens nowhere else still gets them).
5496
+ *
5241
5497
  * @returns {Record<string, string>} token name to its untouched value.
5242
5498
  */
5243
5499
  function readBaseTokens() {
@@ -5252,6 +5508,11 @@ function readBaseTokens() {
5252
5508
 
5253
5509
  try {
5254
5510
  var sheets = document.styleSheets;
5511
+ // A local (class-scoped) declaration is kept APART and only fills gaps: another
5512
+ // plugin may reuse a token name for its own panel, so it must never override the
5513
+ // page's own value — but a host line that declares its tokens nowhere else still
5514
+ // needs them. Root-scoped declarations always win.
5515
+ var local = {};
5255
5516
  for (var sheetIndex = 0; sheetIndex < sheets.length; sheetIndex += 1) {
5256
5517
  if (isOwnSheet(sheets[sheetIndex])) continue;
5257
5518
  var rules = null;
@@ -5262,16 +5523,24 @@ function readBaseTokens() {
5262
5523
  }
5263
5524
  if (!rules) continue;
5264
5525
  for (var ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
5265
- var style = rules[ruleIndex].style;
5526
+ var rule = rules[ruleIndex];
5527
+ var style = rule.style;
5266
5528
  if (!style) continue;
5529
+ var rootScoped = isRootScopedRule(rule);
5267
5530
  for (var propertyIndex = 0; propertyIndex < style.length; propertyIndex += 1) {
5268
5531
  var property = style[propertyIndex];
5269
5532
  if (property.slice(0, 2) !== "--" || !isScaledToken(property)) continue;
5270
5533
  var declared = style.getPropertyValue(property).trim();
5271
- if (declared !== "") out[property] = declared;
5534
+ if (declared === "") continue;
5535
+ if (rootScoped) out[property] = declared;
5536
+ else local[property] = declared;
5272
5537
  }
5273
5538
  }
5274
5539
  }
5540
+ for (name in local) {
5541
+ if (!Object.prototype.hasOwnProperty.call(local, name)) continue;
5542
+ if (!Object.prototype.hasOwnProperty.call(out, name)) out[name] = local[name];
5543
+ }
5275
5544
  } catch (error) {
5276
5545
  // Keep what the earlier sources produced.
5277
5546
  }
@@ -5345,7 +5614,11 @@ function createStylesheet(tokens) {
5345
5614
  var lastCss = null;
5346
5615
  return function apply(config) {
5347
5616
  if (typeof document === "undefined") return;
5348
- var css = buildFontCss(config, tokens(), harvestScope());
5617
+ // Continuous families (FT-060) bring their own faces: the aliases are declared
5618
+ // here, and `buildFontCss` leads the matching stacks with them.
5619
+ var faces = shared.buildVariableFaces(variableFamilies);
5620
+ var body = buildFontCss(config, tokens(), harvestScope(), variableFamilies);
5621
+ var css = faces === "" ? body : faces + "\n" + body;
5349
5622
  if (css === lastCss && tag !== null && tag.isConnected) return;
5350
5623
  lastCss = css;
5351
5624
  if (tag === null || !tag.isConnected) {
@@ -5748,6 +6021,21 @@ var context = weightProbeCanvas.getContext("2d", { willReadFrequently: true });
5748
6021
  return ink + "/" + advance;
5749
6022
  }
5750
6023
 
6024
+ /**
6025
+ * The ink share of a weight signature, as a number.
6026
+ *
6027
+ * `sampleWeightSignature` answers "<ink>/<advance>": the advance is the laid-out
6028
+ * width, which a monospace family holds constant, so ink is the signal that still
6029
+ * moves for those. Unreadable signatures answer 0, which simply never counts as a
6030
+ * change.
6031
+ * @param {string} signature - a signature from `sampleWeightSignature`.
6032
+ * @returns {number} the summed alpha, or 0.
6033
+ */
6034
+ function inkOfSignature(signature) {
6035
+ var ink = Number(String(signature).split("/")[0]);
6036
+ return isFinite(ink) ? ink : 0;
6037
+ }
6038
+
5751
6039
  /**
5752
6040
  * The slider shape to use for one family: its range and its notch.
5753
6041
  *
@@ -5764,18 +6052,40 @@ var context = weightProbeCanvas.getContext("2d", { willReadFrequently: true });
5764
6052
  */
5765
6053
  function weightProfileFor(stack, top) {
5766
6054
  var base = typeof top === "number" ? top : shared.WEIGHT_LADDER_TOP;
5767
- var key = String(stack) + "\u0000" + base;
6055
+ var resolved = String(stack) === "" ? liveFamily() : String(stack);
6056
+ // `hasOwnProperty` rather than a truthiness test: the alias map is keyed by family
6057
+ // name, and a family called "constructor" must not read as promoted.
6058
+ var promoted = Object.prototype.hasOwnProperty.call(variableFamilies, primaryFamilyOf(resolved));
6059
+ var alias = promoted ? variableFamilies[primaryFamilyOf(resolved)] : "";
6060
+ // The promotion state is part of the key. The warm-up measures a family BEFORE the
6061
+ // probe has finished (the probe awaits a font load), so without it the stepped shape
6062
+ // measured first would be served from the cache forever after the family was
6063
+ // promoted — the card then keeps offering the coarse notches the mode replaced.
6064
+ var key = String(stack) + "\u0000" + base + "\u0000" + (promoted ? "continuous" : "stepped");
5768
6065
  if (Object.prototype.hasOwnProperty.call(weightProfileCache, key)) {
5769
6066
  return weightProfileCache[key];
5770
6067
  }
5771
- var profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
6068
+ var profile = promoted
6069
+ ? shared.continuousWeightProfile()
6070
+ : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
5772
6071
  try {
5773
- var resolved = String(stack) === "" ? liveFamily() : String(stack);
5774
- profile = weightProfileFrom(function (weight) {
5775
- return sampleWeightSignature(resolved, weight);
5776
- }, { top: base });
6072
+ // A continuous family is measured THROUGH ITS ALIAS, on the fine candidate grid:
6073
+ // the axis answers at every notch, so the finest one that still moves body text
6074
+ // wins, and the range stops where the family's own axis does instead of running
6075
+ // past it into weights that render the same.
6076
+ var target = promoted ? '"' + alias + '"' : resolved;
6077
+ var seen = {};
6078
+ var sample = function (weight) {
6079
+ if (!Object.prototype.hasOwnProperty.call(seen, weight)) {
6080
+ seen[weight] = sampleWeightSignature(target, weight);
6081
+ }
6082
+ return seen[weight];
6083
+ };
6084
+ profile = weightProfileFrom(sample, promoted ? { top: base, candidates: shared.CONTINUOUS_CANDIDATES } : { top: base });
5777
6085
  } catch (error) {
5778
- profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
6086
+ profile = promoted
6087
+ ? shared.continuousWeightProfile()
6088
+ : { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
5779
6089
  }
5780
6090
  weightProfileCache[key] = profile;
5781
6091
  return profile;
@@ -5837,14 +6147,17 @@ var scheduleWeightWarm = function (editing) {
5837
6147
  warmSignature = signature;
5838
6148
  var jobs = [
5839
6149
  function () {
6150
+ probeContinuity(stacks.dialog);
5840
6151
  weightProfileFor(stacks.dialog, shared.WEIGHT_LADDER_STRONG);
5841
6152
  probeLog("weight-warm", { axis: "dialog", stack: stacks.dialog });
5842
6153
  },
5843
6154
  function () {
6155
+ probeContinuity(stacks.ui);
5844
6156
  weightProfileFor(stacks.ui, shared.WEIGHT_LADDER_STRONG);
5845
6157
  probeLog("weight-warm", { axis: "ui", stack: stacks.ui });
5846
6158
  },
5847
6159
  function () {
6160
+ probeContinuity(stacks.code);
5848
6161
  weightProfileFor(stacks.code, shared.WEIGHT_BASE);
5849
6162
  probeLog("weight-warm", { axis: "code", stack: stacks.code });
5850
6163
  },
@@ -5858,6 +6171,201 @@ var scheduleWeightWarm = function (editing) {
5858
6171
  deferWeightWarm(runNext);
5859
6172
  };
5860
6173
 
6174
+ /**
6175
+ * Families measured as continuous, family → alias (see FT-060).
6176
+ *
6177
+ * Filled by `probeContinuity` once a family has proven, in THIS page's own
6178
+ * rendering, that it moves in small steps through its variable axis. A family
6179
+ * that is not in here keeps the measured notch shape it always had.
6180
+ */
6181
+ var variableFamilies = {};
6182
+ var continuityProbed = {};
6183
+
6184
+ /**
6185
+ * What to re-render when a family is promoted.
6186
+ *
6187
+ * The promotion lands a second or so after load, while the floating panel may already
6188
+ * be on screen with its weight row built from the stepped shape. Rebuilding the
6189
+ * stylesheet is not enough — the row caches the shape it was rendered with, so the
6190
+ * panel would keep offering the coarse notches until something else happened to
6191
+ * redraw it. Each mounted panel pushes its own render here and takes it back on
6192
+ * destroy.
6193
+ */
6194
+ var promotionListeners = [];
6195
+
6196
+ /** Redraw whatever is showing a weight shape that the promotion just changed. */
6197
+ function notifyPromoted() {
6198
+ var pending = promotionListeners.slice();
6199
+ for (var index = 0; index < pending.length; index += 1) {
6200
+ try {
6201
+ pending[index]();
6202
+ } catch (error) {
6203
+ // A listener must never break the probe.
6204
+ }
6205
+ }
6206
+ }
6207
+
6208
+ /**
6209
+ * A stamp for the build the page is running.
6210
+ *
6211
+ * The client script is cached, and "I refreshed" has been wrong before: this is what
6212
+ * a live page reports about itself (the panel host carries it as `data-dfp-build`).
6213
+ */
6214
+ var CLIENT_BUILD = "2026-10-12-3";
6215
+
6216
+ /** The families this page promoted to continuous weight, in map order. */
6217
+ function continuousNames() {
6218
+ return Object.keys(variableFamilies);
6219
+ }
6220
+
6221
+ /**
6222
+ * Publish the promoted families on the panel host, when one is on screen.
6223
+ *
6224
+ * Promotion happens AFTER the first render (the probe awaits a font load), so the
6225
+ * attribute set during render is re-stamped here: the host element is then the one
6226
+ * place a reader — or a support session — can look without opening the console.
6227
+ */
6228
+ function announceContinuous() {
6229
+ if (typeof document === "undefined") return;
6230
+ try {
6231
+ var nodes = document.querySelectorAll("[data-dfp-build]");
6232
+ var names = continuousNames().join(", ");
6233
+ for (var index = 0; index < nodes.length; index += 1) {
6234
+ if (names === "") nodes[index].removeAttribute("data-dfp-continuous");
6235
+ else nodes[index].setAttribute("data-dfp-continuous", names);
6236
+ }
6237
+ } catch (error) {
6238
+ // Diagnostics only: a host without attributes costs nothing.
6239
+ }
6240
+ }
6241
+
6242
+ /**
6243
+ * The first family a stack names that is a REAL family, or "" when there is none.
6244
+ *
6245
+ * Our own alias families are skipped: once a family is promoted, any stack read back
6246
+ * from the page (`liveFamily()`) leads with the alias, and treating that as the
6247
+ * family name would miss the promotion map and hand back the stepped shape — which is
6248
+ * how the floating panel kept offering the coarse notches after the card moved on.
6249
+ * @param {string} stack - a formatted font-family value.
6250
+ * @returns {string} the family the stack really draws with.
6251
+ */
6252
+ function primaryFamilyOf(stack) {
6253
+ var names = parseStack(stack);
6254
+ for (var index = 0; index < names.length; index += 1) {
6255
+ if (names[index].indexOf(shared.VARIABLE_ALIAS_PREFIX) !== 0) return names[index];
6256
+ }
6257
+ return "";
6258
+ }
6259
+
6260
+ /**
6261
+ * Ask the page whether a family is continuous under weight, once per family.
6262
+ *
6263
+ * The probe does what the page does: it installs the alias face for the family
6264
+ * (`local()` plus a weight range) and waits for the browser to load it. It then
6265
+ * asks TWO ways, both through that alias:
6266
+ *
6267
+ * * the laid-out width, in the real DOM — what a reader's eye follows in prose;
6268
+ * * the canvas ink, which is the only signal a monospace family ever moves on,
6269
+ * since its advance is the same at every weight.
6270
+ *
6271
+ * A family that answers continuously on either signal is promoted to
6272
+ * `variableFamilies`; the stylesheet is then rebuilt so the alias leads its
6273
+ * stacks. Anything that cannot be measured (no font loading API, a rejected load)
6274
+ * leaves the family as it was.
6275
+ * @param {string} stack - the axis's formatted font-family value.
6276
+ */
6277
+ function probeContinuity(stack) {
6278
+ var family = primaryFamilyOf(stack);
6279
+ if (family === "" || continuityProbed[family] === true) return;
6280
+ continuityProbed[family] = true;
6281
+ if (typeof document === "undefined" || !document.fonts || typeof document.fonts.load !== "function") return;
6282
+ var alias = shared.variableAliasFor(family);
6283
+ var style = document.createElement("style");
6284
+ style.setAttribute("data-dfp-continuity", alias);
6285
+ style.textContent = shared.variableFaceRule(family, alias);
6286
+ document.head.appendChild(style);
6287
+ var host = document.createElement("span");
6288
+ host.style.cssText =
6289
+ "position:fixed;left:-10000px;top:0;white-space:nowrap;font-size:64px;font-family:\"" + alias + "\"";
6290
+ host.textContent = WEIGHT_SAMPLE_TEXT;
6291
+ document.body.appendChild(host);
6292
+ var keepFace = false;
6293
+ var finish = function () {
6294
+ host.remove();
6295
+ // A promoted family keeps its face: the stylesheet will declare the same alias,
6296
+ // and the weight profile is measured THROUGH it after promotion, so removing the
6297
+ // rule here would leave that measurement drawing with the fallback.
6298
+ if (!keepFace) style.remove();
6299
+ };
6300
+ // Two signals, because neither covers every family on its own: the DOM width
6301
+ // moves for a proportional font, while a monospace font never changes width and
6302
+ // can only be seen in the canvas ink. The ink is also read through the FAMILY
6303
+ // itself, and the two responses are compared: a family that snaps to named
6304
+ // instances answers identically either way, while a variable family moves through
6305
+ // its alias at weights where its own path already stopped.
6306
+ //
6307
+ // All values equal means the face has not taken effect yet (the page is still
6308
+ // drawing with its fallback), not that the family is stepped: a stepped family
6309
+ // still changes between its faces. Wait a beat and measure again, a few times at
6310
+ // most, so a slow face is not mistaken for a static one.
6311
+ var attempts = 0;
6312
+ var measure = function () {
6313
+ attempts += 1;
6314
+ // `!important` on purpose: the page's own weight rules are `!important`, and a
6315
+ // plain inline weight loses to them, which measured every width as one value.
6316
+ var widths = shared.CONTINUITY_WEIGHTS.map(function (weight) {
6317
+ host.style.setProperty("font-weight", String(weight), "important");
6318
+ return host.getBoundingClientRect().width;
6319
+ });
6320
+ var inks = shared.CONTINUITY_WEIGHTS.map(function (weight) {
6321
+ return inkOfSignature(sampleWeightSignature('"' + alias + '"', weight));
6322
+ });
6323
+ var plainInks = shared.CONTINUITY_WEIGHTS.map(function (weight) {
6324
+ return inkOfSignature(sampleWeightSignature('"' + family + '"', weight));
6325
+ });
6326
+ var unchanged = function (list) {
6327
+ return list.every(function (value) {
6328
+ return value === list[0];
6329
+ });
6330
+ };
6331
+ if (unchanged(widths) && unchanged(inks) && attempts < 8) {
6332
+ globalThis.setTimeout(measure, 80);
6333
+ return;
6334
+ }
6335
+ // The alias must answer more finely than the family itself. A family that snaps
6336
+ // to named instances cannot: both responses hold the same values.
6337
+ var finer = shared.respondsFinerThan(inks, plainInks);
6338
+ var continuous = finer || shared.isContinuousResponse(widths);
6339
+ keepFace = continuous;
6340
+ finish();
6341
+ probeLog("continuity", {
6342
+ family: family,
6343
+ continuous: continuous,
6344
+ attempts: attempts,
6345
+ byWidth: shared.isContinuousResponse(widths),
6346
+ aliasInk: shared.distinctCount(inks),
6347
+ plainInk: shared.distinctCount(plainInks),
6348
+ widths: widths.map(function (width) {
6349
+ return Math.round(width * 100) / 100;
6350
+ }),
6351
+ });
6352
+ if (!continuous) return;
6353
+ variableFamilies[family] = alias;
6354
+ announceContinuous();
6355
+ notifyPromoted();
6356
+ if (typeof repaint === "function") repaint();
6357
+ };
6358
+ document.fonts.load('400 64px "' + alias + '"').then(
6359
+ function () {
6360
+ measure();
6361
+ },
6362
+ function () {
6363
+ finish();
6364
+ probeLog("continuity", { family: family, continuous: false, error: "load" });
6365
+ }
6366
+ );
6367
+ }
6368
+
5861
6369
  /**
5862
6370
  * The family the page actually renders with, as a `font-family` value.
5863
6371
  * @returns {string} the computed family, or "" when unreadable.
@@ -8445,12 +8953,17 @@ function FontCard(props) {
8445
8953
  // nothing else — the weight, size and line height all come from the
8446
8954
  // conversation section.
8447
8955
  var previewUiStyle = {};
8448
- if (uiStack !== "") previewUiStyle.fontFamily = uiStack;
8956
+ if (uiStack !== "") previewUiStyle.fontFamily = shared.withVariableFaces(uiStack, variableFamilies);
8449
8957
  var previewDialogStyle = {};
8450
8958
  // Independent and unset: the conversation keeps DSH's own family, so the
8451
8959
  // preview shows the theme's default rather than the interface stack the page
8452
8960
  // rule would otherwise paint into this box.
8453
- if (dialogStack !== "") previewDialogStyle.fontFamily = dialogStack;
8961
+ //
8962
+ // The alias leads these stacks exactly as it leads the real surfaces: without it
8963
+ // the preview would draw through the system family, where the fine notches snap to
8964
+ // the same named instance — so a notch that visibly moves the conversation would
8965
+ // do nothing here.
8966
+ if (dialogStack !== "") previewDialogStyle.fontFamily = shared.withVariableFaces(dialogStack, variableFamilies);
8454
8967
  previewDialogStyle.fontSize = 13 + editing[SIZE_DIALOG_FIELD] + "px";
8455
8968
  // The axis is a percentage OF DSH's own line height, so the preview multiplies the
8456
8969
  // measured base by the same percentage — and does it at every notch including 100,
@@ -8471,7 +8984,7 @@ function FontCard(props) {
8471
8984
  }
8472
8985
  var previewCodeStyle = {};
8473
8986
  if (editing[MONO_FIELD] !== "") {
8474
- previewCodeStyle.fontFamily = formatStack(parseStack(editing[MONO_FIELD]));
8987
+ previewCodeStyle.fontFamily = shared.withVariableFaces(formatStack(parseStack(editing[MONO_FIELD])), variableFamilies);
8475
8988
  }
8476
8989
  previewCodeStyle.fontSize = 13 + editing[CODE_SIZE_FIELD] + "px";
8477
8990
  if (editing[CODE_LINE_HEIGHT_FIELD] !== 0) {
@@ -10818,8 +11331,13 @@ function mountFloatPanel(scope, t, hostCtx) {
10818
11331
  host.textContent = "";
10819
11332
  // One line in the console answers "which build is live": with a linked plugin
10820
11333
  // and a cached client script, "I refreshed" is not evidence on its own.
10821
- host.setAttribute("data-dfp-build", "2026-10-01-2");
10822
- var dot = el("button", "dfp-dot");
11334
+ host.setAttribute("data-dfp-build", CLIENT_BUILD);
11335
+ // And which families this build promoted to continuous weight, so a report of
11336
+ // "the slider still has four notches" can be answered without a console session:
11337
+ // an empty attribute means the probe has not (yet) promoted the reader's family,
11338
+ // a missing one means the page is running a build from before the mode existed.
11339
+ if (continuousNames().length > 0) host.setAttribute("data-dfp-continuous", continuousNames().join(", "));
11340
+ else host.removeAttribute("data-dfp-continuous"); var dot = el("button", "dfp-dot");
10823
11341
  dot.type = "button";
10824
11342
  dot.setAttribute("aria-label", t("panel.dot"));
10825
11343
  dot.title = t("panel.dot");
@@ -11009,7 +11527,13 @@ function mountFloatPanel(scope, t, hostCtx) {
11009
11527
 
11010
11528
  // The dialog weight in the family's own steps, like the card's control:
11011
11529
  // the document keeps the offset, the readout counts steps.
11012
- var dialogStack = formatStack(parseStack(config[STACK_DIALOG_FIELD]));
11530
+ //
11531
+ // The stack comes from the SAME helper the card and the warm-up use. Resolving it
11532
+ // here instead (the configured field alone) meant an unset stack fell through to
11533
+ // the page's computed family, which carries our alias once a family is promoted —
11534
+ // so the panel missed the promotion and kept showing the coarse notches while the
11535
+ // card showed the fine ones.
11536
+ var dialogStack = weightAxisStacks(config).dialog;
11013
11537
  var profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
11014
11538
  try {
11015
11539
  profile = weightProfileFor(dialogStack, shared.WEIGHT_LADDER_STRONG);
@@ -11021,6 +11545,13 @@ function mountFloatPanel(scope, t, hostCtx) {
11021
11545
  { min: Math.min(profile.min, storedWeight), max: Math.max(profile.max, storedWeight), step: profile.step },
11022
11546
  storedWeight
11023
11547
  );
11548
+ probeLog("panel-weight", {
11549
+ stack: dialogStack,
11550
+ profile: profile,
11551
+ stored: storedWeight,
11552
+ shape: shape,
11553
+ promoted: continuousNames(),
11554
+ });
11024
11555
  (function () {
11025
11556
  var row = el("div", "dfp-floatRow");
11026
11557
  var label = el("div", "dfp-floatLabel");
@@ -11233,8 +11764,14 @@ function mountFloatPanel(scope, t, hostCtx) {
11233
11764
  // A document without events still gets the interval.
11234
11765
  }
11235
11766
 
11767
+ // A promotion changes the weight row's shape; the panel redraws itself when one
11768
+ // lands, and stops listening when it goes away.
11769
+ promotionListeners.push(render);
11770
+
11236
11771
  return {
11237
11772
  destroy: function () {
11773
+ var at = promotionListeners.indexOf(render);
11774
+ if (at >= 0) promotionListeners.splice(at, 1);
11238
11775
  if (statusTimer !== null) globalThis.clearTimeout(statusTimer);
11239
11776
  if (closeTimer !== null) globalThis.clearTimeout(closeTimer);
11240
11777
  if (reflowTimer !== null) globalThis.clearTimeout(reflowTimer);
package/lib/shared.cjs CHANGED
@@ -2329,6 +2329,27 @@ function rebuildShorthand(parts) {
2329
2329
  * token classification
2330
2330
  * ------------------------------------------------------------------ */
2331
2331
 
2332
+ /**
2333
+ * Whether a CSS selector declares something for the WHOLE page.
2334
+ *
2335
+ * The typography tokens live on `:root`, `html` or `body`. A selector scoped to a class
2336
+ * or an attribute declares a LOCAL value: another plugin's module stylesheet may reuse a
2337
+ * token name for its own compact panel (measured: `dsh-better-sidebar` sets
2338
+ * `--dsw-font-markdown-h1` to `600 13px/18px` on `.…_taskMarkdown`), and reading that as
2339
+ * the base hands the conversation a size that panel picked for itself.
2340
+ * @param {string} selector - a rule's selector text.
2341
+ * @returns {boolean} true when every selector in the list is a root scope.
2342
+ */
2343
+ function isRootScopeSelector(selector) {
2344
+ if (typeof selector !== "string" || selector.trim() === "") return false;
2345
+ var parts = selector.split(",");
2346
+ for (var index = 0; index < parts.length; index += 1) {
2347
+ var part = parts[index].trim().toLowerCase();
2348
+ if (part !== ":root" && part !== "html" && part !== "body" && part !== "*") return false;
2349
+ }
2350
+ return true;
2351
+ }
2352
+
2332
2353
  /**
2333
2354
  * Match DSH's typography PART tokens: the design system's sizes plus the
2334
2355
  * content size the theme plugin writes on `body` (whose secondary variant
@@ -3169,6 +3190,180 @@ function weightStepRange(profile, value) {
3169
3190
  return { min: min, max: max, unit: unit, value: count };
3170
3191
  }
3171
3192
 
3193
+ /* ------------------------------------------------------------------ *
3194
+ * continuous weight (FT-060)
3195
+ * ------------------------------------------------------------------ */
3196
+
3197
+ /** Prefix of the alias family a continuous family is drawn through. */
3198
+ var VARIABLE_ALIAS_PREFIX = "dfp-vf-";
3199
+
3200
+ /**
3201
+ * The weights the continuity probe lays out. Dense on purpose: a variable font
3202
+ * that moves in small steps shows a width change at every one of them, while a
3203
+ * family that snaps to named instances repeats its width across each stretch.
3204
+ */
3205
+ var CONTINUITY_WEIGHTS = [400, 420, 440, 460, 480, 500, 550, 600, 650, 700, 750, 800, 900];
3206
+
3207
+ /**
3208
+ * The share of adjacent probe pairs that must differ before a family's laid-out
3209
+ * WIDTH counts as continuous. Measured: a variable family drawn through its alias
3210
+ * changes width at every sampled weight (share 1.0), while a family snapping to
3211
+ * named instances repeats its width across each stretch (share 0.25–0.42).
3212
+ */
3213
+ var CONTINUOUS_MIN_SHARE = 0.9;
3214
+
3215
+ /**
3216
+ * The notches offered for a continuous family, finest first.
3217
+ *
3218
+ * The shape is still measured (the alias is drawn on the same weight grid as any
3219
+ * other family), but the candidates are fine: a continuous axis can answer at every
3220
+ * one of them, which is the whole point of the mode. `weightProfileFrom` walks this
3221
+ * list from the END, so the finest candidate that still yields enough visible
3222
+ * positions wins.
3223
+ */
3224
+ var CONTINUOUS_CANDIDATES = [10, 20, 25];
3225
+
3226
+ /**
3227
+ * The nominal continuous slider, used when the measured shape cannot be taken
3228
+ * (no canvas, a refused probe). One notch is 20 weight units; the range is the
3229
+ * widest offset the ceiling allows either way.
3230
+ */
3231
+ var CONTINUOUS_STEP = 20;
3232
+ var CONTINUOUS_RANGE = 200;
3233
+
3234
+ /**
3235
+ * How many different values a list of measurements holds.
3236
+ * @param {number[]} values - the measurements.
3237
+ * @returns {number} the count of distinct values.
3238
+ */
3239
+ function distinctCount(values) {
3240
+ if (!Array.isArray(values)) return 0;
3241
+ var seen = [];
3242
+ for (var index = 0; index < values.length; index += 1) {
3243
+ if (seen.indexOf(values[index]) < 0) seen.push(values[index]);
3244
+ }
3245
+ return seen.length;
3246
+ }
3247
+
3248
+ /**
3249
+ * Does the same family answer more finely through its alias than through itself?
3250
+ *
3251
+ * This is the discriminator the continuity probe trusts, and it needs no threshold:
3252
+ * a family that snaps to named instances renders IDENTICALLY either way, so both
3253
+ * responses hold the same number of distinct values; a variable family drawn through
3254
+ * the alias moves at weights where the system path already stopped. Comparing the
3255
+ * family against ITSELF also keeps the answer honest for a narrow axis, where a
3256
+ * share-of-neighbours rule would read saturation as "static".
3257
+ * @param {number[]} throughAlias - measurements taken through the alias face.
3258
+ * @param {number[]} plain - the same measurements taken through the family itself.
3259
+ * @returns {boolean} true when the alias resolves finer detail.
3260
+ */
3261
+ function respondsFinerThan(throughAlias, plain) {
3262
+ if (!Array.isArray(throughAlias) || !Array.isArray(plain)) return false;
3263
+ var fine = distinctCount(throughAlias);
3264
+ return fine >= 3 && fine > distinctCount(plain);
3265
+ }
3266
+
3267
+
3268
+ /**
3269
+ * The alias family name for one family. A hash, not the family itself, so the
3270
+ * name is a plain identifier whatever the family is called.
3271
+ * @param {string} family - a family name.
3272
+ * @returns {string} the alias, stable across sessions.
3273
+ */
3274
+ function variableAliasFor(family) {
3275
+ var text = sanitizeFamily(family);
3276
+ var hash = 0;
3277
+ for (var index = 0; index < text.length; index += 1) {
3278
+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
3279
+ }
3280
+ return VARIABLE_ALIAS_PREFIX + hash.toString(36);
3281
+ }
3282
+
3283
+ /**
3284
+ * The `@font-face` rule that draws one family through its own variable axis.
3285
+ *
3286
+ * `local()` points the face at the installed family, and the weight RANGE is the
3287
+ * part that matters: it tells the engine the family is continuous, so `font-weight`
3288
+ * picks a point on the axis instead of the nearest named instance. (Without the
3289
+ * range the same family snaps; `font-variation-settings` does not help a system
3290
+ * family at all — measured, see FT-060.)
3291
+ * @param {string} family - the installed family name.
3292
+ * @param {string} alias - the alias from `variableAliasFor`.
3293
+ * @returns {string} one CSS rule.
3294
+ */
3295
+ function variableFaceRule(family, alias) {
3296
+ var text = sanitizeFamily(family).replace(/"/g, "");
3297
+ return '@font-face{font-family:"' + alias + '";src:local("' + text + '");font-weight:100 900;}';
3298
+ }
3299
+
3300
+ /**
3301
+ * Every `@font-face` rule for a map of family → alias.
3302
+ * @param {Record<string, string>} variable - the families that measured continuous.
3303
+ * @returns {string} the rules, one per line; "" when there are none.
3304
+ */
3305
+ function buildVariableFaces(variable) {
3306
+ var rules = [];
3307
+ if (variable === null || typeof variable !== "object") return "";
3308
+ for (var family in variable) {
3309
+ if (Object.prototype.hasOwnProperty.call(variable, family)) {
3310
+ rules.push(variableFaceRule(family, variable[family]));
3311
+ }
3312
+ }
3313
+ return rules.join("\n");
3314
+ }
3315
+
3316
+ /**
3317
+ * Put the alias IN FRONT of every continuous family a stack names.
3318
+ *
3319
+ * The family stays in the list after its alias, so a glyph the alias lacks still
3320
+ * falls through to the same face the reader chose, and a stack that names no
3321
+ * continuous family is returned unchanged.
3322
+ * @param {string} stack - a formatted font-family value.
3323
+ * @param {Record<string, string>} variable - family → alias map.
3324
+ * @returns {string} the stack, with aliases prepended where they apply.
3325
+ */
3326
+ function withVariableFaces(stack, variable) {
3327
+ if (typeof stack !== "string" || stack === "" || variable === null || typeof variable !== "object") {
3328
+ return stack;
3329
+ }
3330
+ // The stack may already carry its alias — a family read back from the page's own
3331
+ // computed style does, once the mode is on. Prepending it again would stack up a
3332
+ // duplicate on every rebuild.
3333
+ var names = parseStack(stack);
3334
+ var lead = names.length > 0 ? names[0] : "";
3335
+ var prefix = [];
3336
+ for (var family in variable) {
3337
+ if (!Object.prototype.hasOwnProperty.call(variable, family)) continue;
3338
+ if (stack.indexOf(quoteFamily(family)) < 0) continue;
3339
+ if (lead === variable[family]) continue;
3340
+ prefix.push('"' + variable[family] + '"');
3341
+ }
3342
+ return prefix.length === 0 ? stack : prefix.join(", ") + ", " + stack;
3343
+ }
3344
+
3345
+ /**
3346
+ * Did a family respond continuously to weight in the probe?
3347
+ * @param {number[]} widths - laid-out widths, in `CONTINUITY_WEIGHTS` order.
3348
+ * @returns {boolean} true when enough neighbours differ.
3349
+ */
3350
+ function isContinuousResponse(widths) {
3351
+ if (!Array.isArray(widths) || widths.length < 3) return false;
3352
+ var changes = 0;
3353
+ for (var index = 1; index < widths.length; index += 1) {
3354
+ if (widths[index] !== widths[index - 1]) changes += 1;
3355
+ }
3356
+ return changes / (widths.length - 1) >= CONTINUOUS_MIN_SHARE;
3357
+ }
3358
+
3359
+ /**
3360
+ * The slider shape for a continuous family: the finest notch, centred on 0.
3361
+ * @returns {{min: number, max: number, step: number}} the profile, in weight units.
3362
+ */
3363
+ function continuousWeightProfile() {
3364
+ return { min: -CONTINUOUS_RANGE, max: CONTINUOUS_RANGE, step: CONTINUOUS_STEP };
3365
+ }
3366
+
3172
3367
  /**
3173
3368
  * What a slider's local (unconfirmed) value should do when a value arrives from
3174
3369
  * the settings document.
@@ -3256,10 +3451,12 @@ function isDormant(config) {
3256
3451
  * @param {boolean} isDark - prefix every rule with the dark-theme attribute.
3257
3452
  * @returns {string} declarations for one `<style>` element.
3258
3453
  */
3259
- function buildAxisCss(axis, baseTokens, isDark, hints) {
3260
- var sans = formatStack(parseStack(axis[SANS_FIELD]));
3261
- var mono = formatStack(parseStack(axis[MONO_FIELD]));
3262
- var dialog = formatStack(parseStack(axis[STACK_DIALOG_FIELD]));
3454
+ function buildAxisCss(axis, baseTokens, isDark, hints, variable) {
3455
+ // A continuous family leads its stack with its alias (see `withVariableFaces`);
3456
+ // with no map, every stack stays exactly as written.
3457
+ var sans = withVariableFaces(formatStack(parseStack(axis[SANS_FIELD])), variable);
3458
+ var mono = withVariableFaces(formatStack(parseStack(axis[MONO_FIELD])), variable);
3459
+ var dialog = withVariableFaces(formatStack(parseStack(axis[STACK_DIALOG_FIELD])), variable);
3263
3460
  // Normalised once: an expanded axis always carries a number, but a caller
3264
3461
  // handing in a raw object may leave the field out, and "absent" has to mean
3265
3462
  // "unset" rather than "write the string undefined". The conversation's value
@@ -3659,7 +3856,28 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
3659
3856
  if (noBold) declarations.push(bodyRule("font-synthesis-weight:none !important"));
3660
3857
  }
3661
3858
 
3662
- return declarations.join("\n");
3859
+ return hardenDeltaRefs(declarations.join("\n"));
3860
+ }
3861
+
3862
+ /**
3863
+ * Give every reference to the content-size delta a fallback value.
3864
+ *
3865
+ * A heading's `font` shorthand is the markdown token, and DSH's own text for it reads
3866
+ * `calc(21px + var(--dsh-content-font-delta))` — a reference with NO fallback. Nothing
3867
+ * outside this plugin defines that property, so any rebuild that emits the reference
3868
+ * without the definition leaves it resolving to nothing, which makes the WHOLE shorthand
3869
+ * invalid at computed-value time: every font longhand falls back to `inherit`, and the
3870
+ * heading is painted at body size. Measured on a live page: h1 23px → 16px the moment the
3871
+ * property resolved to nothing, back to 23px when it returned.
3872
+ *
3873
+ * The fallback cannot change a page that defines the property (the definition wins), and
3874
+ * where the definition is missing it keeps the design's own size instead of collapsing.
3875
+ * @param {string} css - generated declarations.
3876
+ * @returns {string} the same declarations, with the reference made safe.
3877
+ */
3878
+ function hardenDeltaRefs(css) {
3879
+ if (typeof css !== "string" || css === "") return css;
3880
+ return css.split("var(--dsh-content-font-delta)").join("var(--dsh-content-font-delta, 0px)");
3663
3881
  }
3664
3882
 
3665
3883
  /**
@@ -3751,7 +3969,7 @@ function scaleCodeTokens(baseTokens, scale, lineOffset) {
3751
3969
  * correct everywhere and costs an attribute scan per element per rule.
3752
3970
  * @returns {string} declarations for one `<style>` element ("" when dormant).
3753
3971
  */
3754
- function buildFontCss(config, baseTokens, hints) {
3972
+ function buildFontCss(config, baseTokens, hints, variable) {
3755
3973
  var tokens = baseTokens === undefined || baseTokens === null ? FALLBACK_TOKENS : baseTokens;
3756
3974
  var sets = resolveAxes(config);
3757
3975
  // The rendering preference is NOT an axis: it must paint even when every
@@ -3759,16 +3977,16 @@ function buildFontCss(config, baseTokens, hints) {
3759
3977
  // use case with everything else untouched.
3760
3978
  var smoothing = smoothingRule(config !== null && typeof config === "object" ? config[SMOOTHING_FIELD] : "");
3761
3979
  if (isDormant(sets.light) && isDormant(sets.dark)) return smoothing;
3762
- var light = buildAxisCss(sets.light, tokens, false, hints);
3980
+ var light = buildAxisCss(sets.light, tokens, false, hints, variable);
3763
3981
  if (sameValueSet(sets.light, sets.dark)) {
3764
- return smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing;
3982
+ return hardenDeltaRefs(smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing);
3765
3983
  }
3766
- var dark = buildAxisCss(sets.dark, tokens, true, hints);
3984
+ var dark = buildAxisCss(sets.dark, tokens, true, hints, variable);
3767
3985
  var parts = [];
3768
3986
  if (light !== "") parts.push(light);
3769
3987
  if (dark !== "") parts.push(dark);
3770
3988
  if (smoothing !== "") parts.push(smoothing);
3771
- return parts.join("\n");
3989
+ return hardenDeltaRefs(parts.join("\n"));
3772
3990
  }
3773
3991
 
3774
3992
  /**
@@ -4290,6 +4508,19 @@ var shared = {
4290
4508
  WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
4291
4509
  WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
4292
4510
  WEIGHT_MIN_POSITIONS: WEIGHT_MIN_POSITIONS,
4511
+ VARIABLE_ALIAS_PREFIX: VARIABLE_ALIAS_PREFIX,
4512
+ CONTINUITY_WEIGHTS: CONTINUITY_WEIGHTS,
4513
+ isRootScopeSelector: isRootScopeSelector,
4514
+ hardenDeltaRefs: hardenDeltaRefs,
4515
+ CONTINUOUS_CANDIDATES: CONTINUOUS_CANDIDATES,
4516
+ distinctCount: distinctCount,
4517
+ respondsFinerThan: respondsFinerThan,
4518
+ variableAliasFor: variableAliasFor,
4519
+ variableFaceRule: variableFaceRule,
4520
+ buildVariableFaces: buildVariableFaces,
4521
+ withVariableFaces: withVariableFaces,
4522
+ isContinuousResponse: isContinuousResponse,
4523
+ continuousWeightProfile: continuousWeightProfile,
4293
4524
  WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
4294
4525
  WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
4295
4526
  WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Font plugin for the DeepSeek Harness Web GUI: the conversation gets its own font, size, line height and weight, the interface font and weight follow it, code keeps its own axis with ligature control, and whole setups save as presets.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",