dsh-fonttune 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -143,11 +143,105 @@ var UI_FOLLOWS_FIELD = "uiFollowsDialog";
143
143
  var SIZE_MIN = -3;
144
144
  var SIZE_MAX = 6;
145
145
 
146
- /** Allowed font weight range, and the value meaning "do not touch". */
146
+ /** Allowed CODE weight range (an absolute value), and "do not touch". */
147
147
  var WEIGHT_MIN = 300;
148
148
  var WEIGHT_MAX = 600;
149
149
  var WEIGHT_UNSET = 0;
150
150
 
151
+ /**
152
+ * Allowed range of the two RELATIVE weight axes, in steps of 100-weight.
153
+ *
154
+ * The conversation and interface weight axes are offsets on each element's own
155
+ * weight, not replacements: a flat `font-weight` over the markdown subtree
156
+ * erased the heading hierarchy (measured on 0.1.7-rc.2, where every markdown
157
+ * weight lives inside its `font` shorthand — `._markdown_ h2{font:var(--dsw-font-markdown-h2)}`
158
+ * with `700` in it — and a long-handed `font-weight` with `!important` beat the
159
+ * shorthand). The delta window is the old absolute window shifted by −400, so
160
+ * `300…600` maps onto `−100…+200` one-to-one and `400` (normal) becomes 0, which
161
+ * is the value meaning "do not touch".
162
+ */
163
+ var WEIGHT_DELTA_MIN = -100;
164
+ var WEIGHT_DELTA_MAX = 200;
165
+
166
+ /**
167
+ * The widest range a font-derived slider may use, either way.
168
+ *
169
+ * The nominal window above is what the axes can always express; a family with
170
+ * faces outside it (a black 900 cut, a hairline 100) earns a wider slider — see
171
+ * `weightProfileFrom` — and these are the hard bounds that keeps that in check.
172
+ */
173
+ var WEIGHT_DELTA_FLOOR = -300;
174
+ var WEIGHT_DELTA_CEIL = 600;
175
+
176
+ /** The weight DSH gives an element that declares none of its own. */
177
+ var WEIGHT_BASE = 400;
178
+
179
+ /**
180
+ * The weight range CSS accepts at all.
181
+ *
182
+ * Named because it is easy to confuse with `WEIGHT_MIN…WEIGHT_MAX`, which is the
183
+ * window an ABSOLUTE weight from a pre-relative document occupies (300…600) and
184
+ * has nothing to do with what a font can render.
185
+ */
186
+ var WEIGHT_CSS_MIN = 100;
187
+ var WEIGHT_CSS_MAX = 900;
188
+
189
+ /**
190
+ * The weight ladder of the conversation: each group carries the weight DSH's
191
+ * own stylesheet gives those elements, so an offset lands ON TOP of it.
192
+ *
193
+ * The bases are the measured ones (`--dsw-font-markdown-h*-font-weight` 700 /
194
+ * 600, `-base-strong-font-weight` 600, `-table-head-font-weight` 500, everything
195
+ * else 400).
196
+ */
197
+ var DIALOG_WEIGHT_GROUPS = [
198
+ { base: 700, elements: "h1,h2,h3" },
199
+ { base: 600, elements: "h4,h5,h6" },
200
+ { base: 600, elements: "strong,b" },
201
+ { base: 500, elements: "th" },
202
+ {
203
+ base: 400,
204
+ elements: "p,li,td,dt,dd,blockquote,summary,em,i,a,label,small,div,span",
205
+ },
206
+ ];
207
+
208
+ /**
209
+ * The heaviest base in the conversation ladder — the layer with the least room.
210
+ *
211
+ * Derived from the ladder so the two cannot drift apart.
212
+ */
213
+ var WEIGHT_LADDER_TOP = DIALOG_WEIGHT_GROUPS.reduce(function (top, group) {
214
+ return group.base > top ? group.base : top;
215
+ }, WEIGHT_BASE);
216
+
217
+ /**
218
+ * How far above its base an offset may reach at all.
219
+ *
220
+ * Every axis applies the offset to each element's OWN weight, and the heaviest
221
+ * layer of the ladder sits at `WEIGHT_LADDER_TOP`; past `WEIGHT_CSS_MAX` minus
222
+ * that, the heaviest layer cannot move any further, so a longer range would only
223
+ * add positions that change nothing for the boldest text on screen.
224
+ *
225
+ * Staying inside it has a second, load-bearing effect: offsets then never land in
226
+ * `WEIGHT_MIN…WEIGHT_MAX`, the range an ABSOLUTE weight from a pre-relative
227
+ * document occupies (see `clampWeightDelta`). The two meanings cannot be confused,
228
+ * so a stored offset is never rescaled into a different one.
229
+ */
230
+ var WEIGHT_DELTA_UP_LIMIT = WEIGHT_CSS_MAX - WEIGHT_LADDER_TOP;
231
+
232
+ /**
233
+ * The heaviest layer a notch still has to move: bold text.
234
+ *
235
+ * The heading layer above it is deliberately left out of that requirement. A heavy
236
+ * family spaces its top faces far apart (a 700 cut next to a 900), so headings
237
+ * reach their heaviest rendering part-way up the range and would otherwise hold the
238
+ * whole control down to one wide step. Every notch must move the text you read and
239
+ * the text you emphasise; a heading that is already at its boldest face stays there.
240
+ */
241
+ var WEIGHT_LADDER_STRONG = DIALOG_WEIGHT_GROUPS.reduce(function (strong, group) {
242
+ return group.base < WEIGHT_LADDER_TOP && group.base > strong ? group.base : strong;
243
+ }, WEIGHT_BASE);
244
+
151
245
  /** Allowed line-height ratio range, in percent (100 = untouched). */
152
246
  var LINE_HEIGHT_MIN = 100;
153
247
  var LINE_HEIGHT_MAX = 160;
@@ -363,7 +457,7 @@ function normalizeField(field, value) {
363
457
  case WEIGHT_FIELD:
364
458
  case WEIGHT_DIALOG_FIELD:
365
459
  case CODE_WEIGHT_FIELD:
366
- return clampWeight(value);
460
+ return clampWeightDelta(value);
367
461
  case LINE_HEIGHT_FIELD:
368
462
  case LINE_HEIGHT_DIALOG_FIELD:
369
463
  return clampRatio(value);
@@ -435,6 +529,33 @@ function clampWeight(value) {
435
529
  return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weight));
436
530
  }
437
531
 
532
+ /**
533
+ * Clamp one RELATIVE weight to its allowed range, migrating an absolute value.
534
+ *
535
+ * A document written before the axes became relative stores an absolute weight
536
+ * (`300…600`, `400` = normal). That window sits entirely outside the delta
537
+ * window, so the conversion is unambiguous and idempotent: a value inside
538
+ * `300…600` is old and becomes its distance from normal (`400 → 0`, `560 → +160`,
539
+ * `300 → −100`), while a value already inside `−100…200` is a delta and is left
540
+ * alone. The user's saved setup therefore keeps meaning "as bold as before"
541
+ * instead of jumping to the top of the new scale.
542
+ *
543
+ * @param {unknown} value - candidate offset, or a pre-0.2.8 absolute weight.
544
+ * @returns {number} the offset the stylesheet may use.
545
+ */
546
+ function clampWeightDelta(value) {
547
+ var delta = Number(value);
548
+ if (!isFinite(delta)) return WEIGHT_UNSET;
549
+ delta = Math.round(delta);
550
+ if (delta >= WEIGHT_MIN && delta <= WEIGHT_MAX) delta -= WEIGHT_BASE;
551
+ // The slider's own range comes from the font (see `weightProfileFrom`) and can
552
+ // reach further than the nominal one, so the hard bounds are the widest range
553
+ // any font-derived slider may use.
554
+ if (delta > WEIGHT_DELTA_CEIL) return WEIGHT_DELTA_CEIL;
555
+ if (delta < WEIGHT_DELTA_FLOOR) return WEIGHT_DELTA_FLOOR;
556
+ return delta;
557
+ }
558
+
438
559
  /**
439
560
  * Clamp one line-height ratio to the allowed percent range.
440
561
  * @param {unknown} value - candidate ratio in percent.
@@ -952,6 +1073,28 @@ var DELTA_PATTERN = /^--dsh-content-font-delta(?:-secondary)?$/;
952
1073
  /** The whole official content-size chain (size / line height / delta). */
953
1074
  var CONTENT_PATTERN = /^--dsh-content-font-(?:size|line-height|delta)(?:-secondary)?$/;
954
1075
 
1076
+ /**
1077
+ * The one property the conversation size offset is added to: the official
1078
+ * "对话字号" source, which the app writes INLINE on `body`.
1079
+ *
1080
+ * Every other markdown size is derived from it (through
1081
+ * `--dsh-content-font-delta` and the markdown shorthands), and custom properties
1082
+ * resolve where they are DECLARED — so the offset has to land here, once, or a
1083
+ * deeper override would leave the derived tokens at their body-computed values.
1084
+ */
1085
+ var CONTENT_SIZE_SOURCE = "--dsh-content-font-size";
1086
+
1087
+ /**
1088
+ * Whether one declared value is a plain literal this plugin may wrap in
1089
+ * `calc(… + Npx)`. A `var()` reference is NOT: it is either derived from a
1090
+ * property this plugin already shifted, or it belongs to another axis.
1091
+ * @param {unknown} value - a declared custom-property value.
1092
+ * @returns {boolean}
1093
+ */
1094
+ function isLiteralTokenValue(value) {
1095
+ return typeof value === "string" && value !== "" && value.indexOf("var(") === -1;
1096
+ }
1097
+
955
1098
  /**
956
1099
  * The PART-token suffixes DSH appends for single-axis consumers.
957
1100
  */
@@ -1030,6 +1173,30 @@ function isMarkdownToken(name) {
1030
1173
  return typeof name === "string" && name.indexOf("--dsw-font-markdown-") === 0 && !isCodeToken(name);
1031
1174
  }
1032
1175
 
1176
+ /**
1177
+ * The one markdown family whose sizes are LITERALS instead of following the
1178
+ * official content-size chain: the `small` series (`12px/20px`, measured on
1179
+ * 0.1.7-rc.2).
1180
+ *
1181
+ * Every other markdown size derives from `--dsh-content-font-size` through
1182
+ * `--dsh-content-font-delta`, so shifting them as well applies the offset a
1183
+ * second time. The value cannot tell the families apart: the page also carries
1184
+ * `--dsw-font-markdown-h1` with its var() already substituted
1185
+ * (`700 calc(21px + -2px)/…`), which reads exactly like a literal and made
1186
+ * H1/H2 shrink while H3 stayed right (the asymmetric report that started this).
1187
+ * The NAME is the only reliable discriminator, so this is a whitelist.
1188
+ */
1189
+ var MARKDOWN_LITERAL_PREFIX = "--dsw-font-markdown-small";
1190
+
1191
+ /**
1192
+ * Whether a token belongs to the literal (non-derived) markdown family.
1193
+ * @param {string} name - custom property name including the leading dashes.
1194
+ * @returns {boolean}
1195
+ */
1196
+ function isMarkdownLiteralToken(name) {
1197
+ return typeof name === "string" && name.indexOf(MARKDOWN_LITERAL_PREFIX) === 0;
1198
+ }
1199
+
1033
1200
  /**
1034
1201
  * Whether one NAME looks like a `font` shorthand token (a bare
1035
1202
  * `--dsw-font-…` name that is not a PART token or a family variable).
@@ -1267,8 +1434,7 @@ function codeSurfaceList() {
1267
1434
  * not free: an ancestor-walking selector costs about 0.03 s per 50-step drag on
1268
1435
  * 0.1.5-rc.3 (measured: stripping the ` *` forms from these two rules took the
1269
1436
  * drag from 0.89 s to 0.52 s of style recalculation). The weight family keeps
1270
- * the code subtree right through `--dfp-interface-weight` instead — see
1271
- * `weightRule`.
1437
+ * the code subtree right through `--dfp-wdelta` instead — see `weightRule`.
1272
1438
  *
1273
1439
  * @param {{code?: string[], terminal?: string[]}|null} [hints] - unused; kept so
1274
1440
  * the browser half can keep passing one harvest object to `buildFontCss`.
@@ -1360,16 +1526,19 @@ var WEIGHT_MARKDOWN_GUARD =
1360
1526
  "))";
1361
1527
 
1362
1528
  /**
1363
- * The elements that carry a weight of their own in DSH.
1529
+ * The elements that carry a weight of their own in the INTERFACE (the chrome).
1364
1530
  *
1365
1531
  * The weight is the one property that cannot ride inheritance alone: an element
1366
1532
  * whose own rule declares a weight ignores the value it would inherit, whatever
1367
- * that value's importance. Measured on 0.1.5-rc.3, the elements that do declare
1368
- * one are `button` / `span` / `div` in the chrome and `strong` / `th` in the
1369
- * conversation; the rest of the list is the inline markup that could plausibly
1370
- * join them. This replaces the old `body,body *` reach, which matched 1892
1371
- * elements on every recalculation — the whole point of the table is that it
1372
- * matches a fraction of that while still covering every pinned element.
1533
+ * that value's importance. Measured on 0.1.5-rc.3, the chrome's pinned elements
1534
+ * are `button` / `span` / `div`; the rest of the list is the inline markup that
1535
+ * could plausibly join them. This replaces the old `body,body *` reach, which
1536
+ * matched 1892 elements on every recalculation — the whole point of the table is
1537
+ * that it matches a fraction of that while still covering every pinned element.
1538
+ *
1539
+ * The CONVERSATION is not covered here: it has its own ladder with per-element
1540
+ * bases (`DIALOG_WEIGHT_GROUPS`), because one flat weight cannot preserve a
1541
+ * heading hierarchy.
1373
1542
  */
1374
1543
  var WEIGHT_ELEMENT_TABLE = [
1375
1544
  "button",
@@ -1521,6 +1690,196 @@ function resolveAxes(config) {
1521
1690
  * the stylesheet builder
1522
1691
  * ------------------------------------------------------------------ */
1523
1692
 
1693
+ /**
1694
+ * The steps a weight slider may use, finest first.
1695
+ *
1696
+ * A family ships a fixed set of faces and the browser rounds any requested
1697
+ * weight to the nearest one, so a slider that runs in single units spends long
1698
+ * stretches on one face. The control is therefore built from what the family can
1699
+ * actually render: its range runs from the lightest face it has to the heaviest,
1700
+ * and its notch is the family's own granularity.
1701
+ */
1702
+ var WEIGHT_STEP_CANDIDATES = [1, 5, 10, 25, 50, 100, 200, 300];
1703
+
1704
+ /** The weight grid the profile is measured on. */
1705
+ var WEIGHT_PROBE_LOW = 100;
1706
+ var WEIGHT_PROBE_HIGH = 900;
1707
+ var WEIGHT_PROBE_STEP = 10;
1708
+
1709
+ /** Round down/up to a multiple of `step`. */
1710
+ function floorTo(value, step) {
1711
+ return Math.floor(value / step) * step;
1712
+ }
1713
+ function ceilTo(value, step) {
1714
+ return Math.ceil(value / step) * step;
1715
+ }
1716
+
1717
+ /**
1718
+ * Does every position of `[min..max]` in `step` increments render differently?
1719
+ *
1720
+ * Checked at every base the offset is added to: a step that is fine for body text
1721
+ * can be a no-op one layer up, where the faces are further apart.
1722
+ * @param {(weight: number) => string|number} sample - probe.
1723
+ * @param {number[]} bases - the weights the offsets are added to.
1724
+ * @param {number} min - first offset.
1725
+ * @param {number} max - last offset.
1726
+ * @param {number} step - offset increment.
1727
+ * @returns {boolean} true when no two neighbours render the same at any base.
1728
+ */
1729
+ function everyPositionDiffers(sample, bases, min, max, step) {
1730
+ for (var index = 0; index < bases.length; index += 1) {
1731
+ var base = bases[index];
1732
+ var previous = null;
1733
+ for (var position = min; position <= max; position += step) {
1734
+ var signature = sample(base + position);
1735
+ if (previous !== null && signature === previous) return false;
1736
+ previous = signature;
1737
+ }
1738
+ }
1739
+ return true;
1740
+ }
1741
+
1742
+ /**
1743
+ * The slider a family deserves: how far it can go, and by how much.
1744
+ *
1745
+ * A font with six faces can be made lighter or heavier only as far as those faces
1746
+ * reach, and only in steps between them; a family with a continuous axis gets a
1747
+ * fine step and a wide range. Measured on a weight grid:
1748
+ * - the downward reach is the highest weight that still renders as light as the
1749
+ * family can (asking for less cannot change anything);
1750
+ * - the upward reach is the LOWEST weight at or above the layer a notch has to
1751
+ * move that already renders as heavy as the family can, so the range ends where
1752
+ * bold text stops changing instead of running past it (see `WEIGHT_DELTA_UP_LIMIT`
1753
+ * for why that ceiling is also the widest one allowed);
1754
+ * - the step is the finest candidate whose positions all differ at every base a
1755
+ * notch has to move, so no notch is a no-op for body text or bold text. A family whose faces are unevenly spaced
1756
+ * (a 700 next to a 900) may end up with one repeated position whichever uniform
1757
+ * step is used — the coarsest candidate is then the honest answer, never a crash.
1758
+ * @param {(weight: number) => string|number} sample - renders a probe at one
1759
+ * weight and returns a signature; equal signatures mean identical rendering.
1760
+ * @param {{candidates?: number[], floor?: number, ceil?: number, top?: number}}
1761
+ * [options] overrides for the candidates, the widest allowed range and the
1762
+ * heaviest base the offset is added to (the ladder's, or the axis's own).
1763
+ * @returns {{min: number, max: number, step: number}} the slider's shape; the
1764
+ * range always contains 0, which is the "leave DSH alone" position.
1765
+ */
1766
+ function weightProfileFrom(sample, options) {
1767
+ var settings = options || {};
1768
+ var candidates =
1769
+ Array.isArray(settings.candidates) && settings.candidates.length > 0
1770
+ ? settings.candidates
1771
+ : WEIGHT_STEP_CANDIDATES;
1772
+ var floor = typeof settings.floor === "number" ? settings.floor : WEIGHT_DELTA_FLOOR;
1773
+ var ceil = typeof settings.ceil === "number" ? settings.ceil : WEIGHT_DELTA_CEIL;
1774
+ var top = typeof settings.top === "number" ? settings.top : WEIGHT_LADDER_STRONG;
1775
+ var topSignature = sample(WEIGHT_PROBE_HIGH);
1776
+ var bottomSignature = sample(WEIGHT_PROBE_LOW);
1777
+ var heaviest = WEIGHT_PROBE_HIGH;
1778
+ for (var weight = WEIGHT_PROBE_LOW; weight <= WEIGHT_PROBE_HIGH; weight += WEIGHT_PROBE_STEP) {
1779
+ if (sample(weight) === topSignature) {
1780
+ heaviest = weight;
1781
+ break;
1782
+ }
1783
+ }
1784
+ var lightest = WEIGHT_PROBE_LOW;
1785
+ for (var back = WEIGHT_PROBE_HIGH; back >= WEIGHT_PROBE_LOW; back -= WEIGHT_PROBE_STEP) {
1786
+ if (sample(back) === bottomSignature) {
1787
+ lightest = back;
1788
+ break;
1789
+ }
1790
+ }
1791
+ // How far the offset can go before NO layer that must move can move any more:
1792
+ // the widest horizon among those bases, since a layer that could still change
1793
+ // must not be cut off by one that already sits at its heaviest face.
1794
+ var headroom = Math.max(0, heaviest - WEIGHT_BASE);
1795
+ if (top !== WEIGHT_BASE) {
1796
+ for (var up = top; up <= WEIGHT_PROBE_HIGH; up += WEIGHT_PROBE_STEP) {
1797
+ if (sample(up) === topSignature) {
1798
+ headroom = Math.max(headroom, up - top);
1799
+ break;
1800
+ }
1801
+ }
1802
+ }
1803
+ var reachUp = Math.min(ceil, WEIGHT_DELTA_UP_LIMIT, headroom);
1804
+ var reachDown = Math.max(floor, Math.min(0, lightest - WEIGHT_BASE));
1805
+ var bases = top === WEIGHT_BASE ? [WEIGHT_BASE] : [WEIGHT_BASE, top];
1806
+ for (var index = 0; index < candidates.length; index += 1) {
1807
+ var step = candidates[index];
1808
+ if (!(step > 0)) continue;
1809
+ var min = floorTo(reachDown, step);
1810
+ // Rounding up to the step must not push the range past the limit: the widest
1811
+ // offset has to stay out of the absolute-weight window.
1812
+ var max = Math.min(ceilTo(reachUp, step), WEIGHT_DELTA_UP_LIMIT);
1813
+ // A step wider than the range would leave only one reachable position.
1814
+ if (max > min && step > max - min) continue;
1815
+ if (everyPositionDiffers(sample, bases, min, max, step)) {
1816
+ return { min: min, max: max, step: step };
1817
+ }
1818
+ }
1819
+ var coarsest = candidates[candidates.length - 1];
1820
+ var min = floorTo(reachDown, coarsest);
1821
+ var max = Math.min(ceilTo(reachUp, coarsest), WEIGHT_DELTA_UP_LIMIT);
1822
+ // Nothing kept every position alive: the coarsest step that still fits is the
1823
+ // honest answer, never a step the control cannot reach.
1824
+ var span = max - min;
1825
+ return { min: min, max: max, step: span > 0 ? Math.min(coarsest, span) : 1 };
1826
+ }
1827
+
1828
+ /**
1829
+ * Turn a measured profile into the slider the card shows.
1830
+ *
1831
+ * The document keeps storing a weight offset (so nothing written before has to be
1832
+ * migrated), but the CONTROL counts the family's own steps: a six-face family
1833
+ * offers "-2 … +4" instead of "-200 … +400", which is what a reader can actually
1834
+ * see. `unit` is how many weight units one step is worth.
1835
+ * @param {{min: number, max: number, step: number}} profile - measured shape.
1836
+ * @param {number} value - the stored offset, in weight units.
1837
+ * @returns {{min: number, max: number, unit: number, value: number}} the slider
1838
+ * in step counts; a stored value outside the measured range widens it, so it is
1839
+ * never unreachable.
1840
+ */
1841
+ function weightStepRange(profile, value) {
1842
+ var unit = profile && profile.step > 0 ? profile.step : 1;
1843
+ var count = Math.round(Number(value) / unit);
1844
+ if (!isFinite(count)) count = 0;
1845
+ var min = Math.floor(profile.min / unit);
1846
+ var max = Math.ceil(profile.max / unit);
1847
+ if (count < min) min = count;
1848
+ if (count > max) max = count;
1849
+ return { min: min, max: max, unit: unit, value: count };
1850
+ }
1851
+
1852
+ /**
1853
+ * What a slider's local (unconfirmed) value should do when a value arrives from
1854
+ * the settings document.
1855
+ *
1856
+ * A slider keeps the value the user released until the document echoes it back;
1857
+ * dropping it immediately would show the old number for a frame (the "bounce
1858
+ * back, then settle" flicker). Two cases must NOT drop it:
1859
+ * - a confirmation for the PREVIOUS commit arriving after the user already
1860
+ * started a new drag — the document write is debounced and can land seconds
1861
+ * later (measured on 0.1.7-rc.2: 0.7–5 s), and clearing there snapped the
1862
+ * thumb back to the old value mid-drag;
1863
+ * - any unrelated outside value (another page, a reset) arriving mid-drag.
1864
+ *
1865
+ * @param {{pending: number|null, awaiting: number|null, confirmed: number}} state
1866
+ * the local value, the value a commit is waiting for (null when idle), and the
1867
+ * value the document currently reports.
1868
+ * @returns {{pending: number|null, awaiting: number|null}} the state to keep.
1869
+ */
1870
+ function reconcileSliderValue(state) {
1871
+ var pending = state.pending;
1872
+ var awaiting = state.awaiting;
1873
+ var confirmed = state.confirmed;
1874
+ if (awaiting !== null && confirmed === awaiting) {
1875
+ awaiting = null;
1876
+ if (pending === confirmed) pending = null;
1877
+ return { pending: pending, awaiting: awaiting };
1878
+ }
1879
+ if (awaiting === null && pending !== null && pending === confirmed) pending = null;
1880
+ return { pending: pending, awaiting: awaiting };
1881
+ }
1882
+
1524
1883
  /**
1525
1884
  * Whether a normalized configuration asks for any change at all.
1526
1885
  * @param {unknown} config - candidate configuration.
@@ -1546,9 +1905,9 @@ function isDormant(config) {
1546
1905
  sanitize(axis[MONO_FIELD]) === "" &&
1547
1906
  clampOffset(axis[SIZE_DIALOG_FIELD]) === 0 &&
1548
1907
  clampOffset(axis[CODE_SIZE_FIELD]) === 0 &&
1549
- clampWeight(axis[WEIGHT_FIELD]) === WEIGHT_UNSET &&
1550
- clampWeight(axis[WEIGHT_DIALOG_FIELD]) === WEIGHT_UNSET &&
1551
- clampWeight(axis[CODE_WEIGHT_FIELD]) === WEIGHT_UNSET &&
1908
+ clampWeightDelta(axis[WEIGHT_FIELD]) === WEIGHT_UNSET &&
1909
+ clampWeightDelta(axis[WEIGHT_DIALOG_FIELD]) === WEIGHT_UNSET &&
1910
+ clampWeightDelta(axis[CODE_WEIGHT_FIELD]) === WEIGHT_UNSET &&
1552
1911
  clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
1553
1912
  clampLineOffset(axis[CODE_LINE_HEIGHT_FIELD]) === 0 &&
1554
1913
  clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
@@ -1581,8 +1940,9 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1581
1940
  var dialog = formatStack(parseStack(axis[STACK_DIALOG_FIELD]));
1582
1941
  // Normalised once: an expanded axis always carries a number, but a caller
1583
1942
  // handing in a raw object may leave the field out, and "absent" has to mean
1584
- // "unset" rather than "write the string undefined".
1585
- var dialogWeight = typeof axis[WEIGHT_DIALOG_FIELD] === "number" ? axis[WEIGHT_DIALOG_FIELD] : WEIGHT_UNSET;
1943
+ // "unset" rather than "write the string undefined". The conversation's value
1944
+ // is an OFFSET (see `clampWeightDelta`), not a weight.
1945
+ var dialogDelta = typeof axis[WEIGHT_DIALOG_FIELD] === "number" ? axis[WEIGHT_DIALOG_FIELD] : WEIGHT_UNSET;
1586
1946
  var codeSurfaces = codeSelector(hints);
1587
1947
  var dialogSurfaces = dialogSelector(hints);
1588
1948
  var declarations = [];
@@ -1619,24 +1979,86 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1619
1979
  * plus an element table for the tags DSH pins a weight on of their own (see
1620
1980
  * `WEIGHT_ELEMENT_TABLE`).
1621
1981
  *
1622
- * The value rides a custom property (`--dfp-interface-weight`, declared on
1982
+ * The offset rides a custom property (`--dfp-wdelta`, declared on
1623
1983
  * `:root,body` by the caller) rather than a literal. That is what keeps the
1624
1984
  * code subtree right without a descendant selector: a highlight `<span>`
1625
1985
  * inside a `<pre>` IS matched by this rule's table, but it resolves the
1626
- * variable its code ancestor set (see the code weight rule), so it takes the
1627
- * code weight instead of the interface one. Naming `pre *` and friends here
1986
+ * variable its code ancestor set (see the code weight rules), so it takes the
1987
+ * code offset instead of the interface one. Naming `pre *` and friends here
1628
1988
  * or in the code rules instead cost 0.03 s per selector per drag.
1629
1989
  *
1630
- * The markdown guard only rides along when the conversation has NO weight of
1631
- * its own — with one set, the class-scoped dialog rule out-ranks this rule
1632
- * inside markdown anyway.
1990
+ * The markdown guard only rides along when the conversation has NO offset of
1991
+ * its own — with one set, the class-scoped conversation ladder out-ranks this
1992
+ * rule inside markdown anyway.
1633
1993
  */
1634
1994
  function weightRule(declarations) {
1635
1995
  var base = isDark ? DARK_ATTR : "body";
1636
1996
  var selector = base + "," + base + " :where(" + WEIGHT_ELEMENT_TABLE + ")";
1637
- if (dialogWeight === WEIGHT_UNSET) selector += WEIGHT_MARKDOWN_GUARD;
1997
+ if (dialogDelta === WEIGHT_UNSET) selector += WEIGHT_MARKDOWN_GUARD;
1638
1998
  return selector + "{" + declarations + "}";
1639
1999
  }
2000
+ /**
2001
+ * The weight one element group resolves to: its own base plus the offset that
2002
+ * is in force where the element sits, clamped to the legal CSS range so an
2003
+ * extreme offset cannot leave it (a weight below 100 is invalid, above 900 is
2004
+ * ignored by the font).
2005
+ * @param {number} base - the weight DSH gives this group.
2006
+ * @returns {string} the `font-weight` value.
2007
+ */
2008
+ function ladderWeight(base) {
2009
+ return (
2010
+ "clamp(" + WEIGHT_CSS_MIN + ",calc(" + base + " + var(--dfp-wdelta,0))," + WEIGHT_CSS_MAX + ")"
2011
+ );
2012
+ }
2013
+ /**
2014
+ * The scope the conversation WEIGHT rules hang on: the cheap markdown wrapper
2015
+ * (the harvested hint when the page has one, the substring form otherwise).
2016
+ *
2017
+ * Not the family rule's `dialogSurfaces` list: that one has to reach every
2018
+ * descendant because it overrides an inherited family, while the ladder names
2019
+ * its own elements — and reusing that list would multiply its ancestor-walking
2020
+ * `… *` selectors by five groups, which is exactly the cost the element table
2021
+ * was introduced to remove. Code surfaces need no exclusion here either: the
2022
+ * code rules pin `--dfp-wdelta` on themselves, so an element inside one adds
2023
+ * the offset in force THERE (the code's), which is the value it should get.
2024
+ * @returns {string} one selector.
2025
+ */
2026
+ function weightScopeSelector() {
2027
+ return hints !== null && typeof hints === "object" && typeof hints.markdown === "string"
2028
+ ? hints.markdown
2029
+ : '[class*="_markdown_" i]';
2030
+ }
2031
+ /**
2032
+ * A rule for the conversation scope itself, so text sitting directly in the
2033
+ * container moves with the axis (and so `--dfp-wdelta` is pinned for the
2034
+ * ladder below before any child resolves it).
2035
+ * @param {string} declarations - the declarations to write.
2036
+ * @returns {string} the rule.
2037
+ */
2038
+ function weightScopeRule(declarations) {
2039
+ var scope = weightScopeSelector();
2040
+ return (isDark ? DARK_ATTR + " " : "") + scope + "{" + declarations + "}";
2041
+ }
2042
+ /**
2043
+ * The conversation ladder: one rule per group in `DIALOG_WEIGHT_GROUPS`, each
2044
+ * scoped under the conversation.
2045
+ * @param {string} surfaceList - a comma-separated selector list.
2046
+ * @returns {string[]} one rule per group.
2047
+ */
2048
+ function weightLadderRules(surfaceList) {
2049
+ var rules = [];
2050
+ var surfaces = splitSelectorList(surfaceList);
2051
+ for (var groupIndex = 0; groupIndex < DIALOG_WEIGHT_GROUPS.length; groupIndex += 1) {
2052
+ var group = DIALOG_WEIGHT_GROUPS[groupIndex];
2053
+ var scoped = [];
2054
+ for (var surfaceIndex = 0; surfaceIndex < surfaces.length; surfaceIndex += 1) {
2055
+ var one = surfaces[surfaceIndex] + " :is(" + group.elements + ")";
2056
+ scoped.push(isDark ? DARK_ATTR + " " + one : one);
2057
+ }
2058
+ rules.push(scoped.join(",") + "{font-weight:" + ladderWeight(group.base) + " !important}");
2059
+ }
2060
+ return rules;
2061
+ }
1640
2062
  /**
1641
2063
  * Re-assert the code family inside the dialog: scoped under the markdown
1642
2064
  * container, after the dialog rule and above it in specificity, so a
@@ -1694,39 +2116,35 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1694
2116
  // page-wide `font-family` on body plus the theme variable) and the weight
1695
2117
  // (below).
1696
2118
 
1697
- // ---- interface weight ----
2119
+ // ---- interface weight (a relative offset) ----
1698
2120
  // A `body{font-weight}` rule only reaches the elements that INHERIT their
1699
2121
  // weight; the chrome's `button` / `span` / `div` class rules pin their own
1700
2122
  // (measured on 0.1.5-rc.3), which is why the second reach exists. The
1701
2123
  // conversation is held apart by `weightRule`'s guard (or by the class-scoped
1702
- // dialog rule when the conversation has a weight of its own).
1703
- var interfaceWeight = typeof axis[WEIGHT_FIELD] === "number" ? axis[WEIGHT_FIELD] : WEIGHT_UNSET;
2124
+ // conversation ladder when the conversation has an offset of its own).
2125
+ //
2126
+ // The value is an OFFSET on the element's own weight, never a replacement, so
2127
+ // raising the interface weight cannot flatten anything that shipped heavier
2128
+ // than the base. The offset rides `--dfp-wdelta` so the code rules can pin it
2129
+ // back to zero (or to the code offset) on their surfaces without any
2130
+ // descendant selector.
2131
+ var interfaceDelta = typeof axis[WEIGHT_FIELD] === "number" ? axis[WEIGHT_FIELD] : WEIGHT_UNSET;
1704
2132
  var codeWeight = typeof axis[CODE_WEIGHT_FIELD] === "number" ? axis[CODE_WEIGHT_FIELD] : WEIGHT_UNSET;
1705
- if (interfaceWeight !== WEIGHT_UNSET) {
1706
- // The variable the weight rule resolves; the code rules below override it
1707
- // on their surfaces so everything inside them inherits the code weight.
1708
- declarations.push(
1709
- (isDark ? DARK_ATTR : ":root,body") +
1710
- "{--dfp-interface-weight:" +
1711
- interfaceWeight +
1712
- "}"
1713
- );
2133
+ if (interfaceDelta !== WEIGHT_UNSET) {
1714
2134
  declarations.push(
1715
- weightRule(
1716
- "font-weight:var(--dfp-interface-weight," + interfaceWeight + ") !important"
1717
- )
2135
+ (isDark ? DARK_ATTR : ":root,body") + "{--dfp-wdelta:" + interfaceDelta + "}"
1718
2136
  );
2137
+ declarations.push(weightRule("font-weight:" + ladderWeight(WEIGHT_BASE) + " !important"));
1719
2138
  if (codeWeight === WEIGHT_UNSET) {
1720
2139
  // The code rules come after this one and win on their own (class hooks by
1721
2140
  // specificity, element selectors by order), so code surfaces — which
1722
2141
  // inherit nothing but start from the body weight — are reset here.
1723
2142
  // `normal` is what DSH computes for them (the code `font` shorthands
1724
2143
  // carry no weight), so an unset code weight keeps code exactly as DSH
1725
- // shipped it. The variable is reset alongside so an element INSIDE a code
1726
- // surface (which the interface table matches) lands on `normal` too.
1727
- declarations.push(
1728
- codeRule("font-weight:normal !important;--dfp-interface-weight:normal")
1729
- );
2144
+ // shipped it. The offset is pinned to zero alongside so an element INSIDE
2145
+ // a code surface (which the interface table matches) lands on `normal`
2146
+ // too.
2147
+ declarations.push(codeRule("font-weight:normal !important;--dfp-wdelta:0"));
1730
2148
  }
1731
2149
  }
1732
2150
 
@@ -1739,17 +2157,55 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1739
2157
  }
1740
2158
  }
1741
2159
 
1742
- // ---- dialog weight (uniform inside the conversation) ----
1743
- if (dialogWeight !== WEIGHT_UNSET) {
1744
- declarations.push(dialogRule("font-weight:" + dialogWeight + " !important"));
2160
+ // ---- dialog weight (a relative offset, applied element by element) ----
2161
+ // The conversation cannot take one flat weight: DSH gives h1..h3 700,
2162
+ // h4..h6/strong 600, th 500 and the rest 400, all inside `font` shorthands, so
2163
+ // a single `font-weight: <value> !important` on the markdown scope replaced
2164
+ // every one of them with that value — setting the axis at all flattened the
2165
+ // headings (measured on 0.1.7-rc.2: `##` came out at the body weight). The
2166
+ // ladder below re-states each group's own base PLUS the offset, so the
2167
+ // hierarchy survives any offset, in both directions, and `d = 0` injects
2168
+ // nothing at all.
2169
+ if (dialogDelta === WEIGHT_UNSET && interfaceDelta !== WEIGHT_UNSET) {
2170
+ // The conversation has NO offset of its own — which is exactly why it has to
2171
+ // say so. The interface axis declares `--dfp-wdelta` on `:root,body`, so an
2172
+ // unset conversation INHERITED it: with the interface at +80 the card showed 0
2173
+ // for the conversation while the page rendered +80, and the first notch above
2174
+ // it only added 20 units — a step nobody can see (measured on 0.1.7-rc.2:
2175
+ // `delta` read 80 at the "0" position and 100 at "+1"). Pinning zero keeps
2176
+ // "0 = leave DSH alone" true no matter what the other axes are set to.
2177
+ declarations.push(weightScopeRule("--dfp-wdelta:0"));
2178
+ }
2179
+ if (dialogDelta !== WEIGHT_UNSET) {
2180
+ // Every ladder rule reads `--dfp-wdelta`, so the conversation pins it to its
2181
+ // own offset: the interface's value must not leak in, and an element inside
2182
+ // a code surface inside the conversation still resolves the code offset that
2183
+ // the code rules pin on their own surfaces. The scope itself takes the base
2184
+ // group's weight, so text sitting directly in the container moves with the
2185
+ // axis exactly as it did when the axis was absolute.
2186
+ declarations.push(
2187
+ weightScopeRule(
2188
+ "--dfp-wdelta:" + dialogDelta + ";font-weight:" + ladderWeight(WEIGHT_BASE) + " !important"
2189
+ )
2190
+ );
2191
+ var dialogLadder = weightLadderRules(weightScopeSelector());
2192
+ for (var ladderIndex = 0; ladderIndex < dialogLadder.length; ladderIndex += 1) {
2193
+ declarations.push(dialogLadder[ladderIndex]);
2194
+ }
1745
2195
  // The card's conversation preview simulates this surface, so it takes the
1746
- // same weight (and is excluded from the interface rule above).
2196
+ // same ladder (and is excluded from the interface rule above).
1747
2197
  declarations.push(
1748
2198
  (isDark ? DARK_ATTR + " " : "") +
1749
- ".dfp-previewDialog{font-weight:" +
1750
- dialogWeight +
2199
+ ".dfp-previewDialog{--dfp-wdelta:" +
2200
+ dialogDelta +
2201
+ ";font-weight:" +
2202
+ ladderWeight(WEIGHT_BASE) +
1751
2203
  " !important}"
1752
2204
  );
2205
+ var previewLadder = weightLadderRules(".dfp-previewDialog");
2206
+ for (var previewIndex = 0; previewIndex < previewLadder.length; previewIndex += 1) {
2207
+ declarations.push(previewLadder[previewIndex]);
2208
+ }
1753
2209
  }
1754
2210
 
1755
2211
  // ---- dialog size offset (additive, on the official content-size source) ----
@@ -1766,32 +2222,33 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1766
2222
  var dialogOffsetValue = axis[SIZE_DIALOG_FIELD];
1767
2223
  if (dialogOffsetValue !== 0) {
1768
2224
  var contentSources = tokenNames(baseTokens).filter(function (name) {
1769
- if (!isContentToken(name)) return false;
1770
- var base = baseTokens[name];
1771
- // The source token only: the derived ones carry var()/calc() and would
1772
- // add the offset a second time if they were shifted as well.
1773
- return (
1774
- typeof base === "string" &&
1775
- base !== "" &&
1776
- base.indexOf("var(") === -1 &&
1777
- base.indexOf("calc(") === -1
1778
- );
2225
+ // The source property, by NAME: `--dsh-content-font-size`. The derived
2226
+ // members of the chain (`-delta`, `-size-secondary`, the markdown
2227
+ // shorthands) all read it, so shifting them as well would count the offset
2228
+ // twice.
2229
+ if (name !== CONTENT_SIZE_SOURCE) return false;
2230
+ return isLiteralTokenValue(baseTokens[name]);
1779
2231
  });
1780
2232
  var contentShift = shiftTokens(contentSources, baseTokens, dialogOffsetValue);
1781
2233
  if (contentShift.length > 0) declarations.push(rootRule(contentShift.join(";")));
1782
- // The `small` series is the only markdown family with literal sizes: it
1783
- // does not derive from the chain, so its parts and shorthands shift here.
2234
+ // The `small` series is the only markdown family with literal sizes: it does
2235
+ // not derive from the chain, so its parts and shorthands shift here — and
2236
+ // ONLY it, by name. A value test cannot stand in for the name: the page also
2237
+ // carries `--dsw-font-markdown-h1` with its var() already substituted, which
2238
+ // looks like a literal and used to take the offset a second time (H1/H2
2239
+ // shrank, H3 did not).
1784
2240
  var literals = markdownTokenNames(baseTokens).filter(function (name) {
1785
- var base = baseTokens[name];
1786
- return name.indexOf("-font-size") >= 0 && typeof base === "string" && base.indexOf("var(") === -1;
2241
+ return (
2242
+ isMarkdownLiteralToken(name) &&
2243
+ name.indexOf("-font-size") >= 0 &&
2244
+ isLiteralTokenValue(baseTokens[name])
2245
+ );
1787
2246
  });
1788
2247
  var literalShift = shiftTokens(literals, baseTokens, dialogOffsetValue);
1789
2248
  if (literalShift.length > 0) declarations.push(rootRule(literalShift.join(";")));
1790
- // The shorthands whose size is a literal (the small series) must ride the
1791
- // shift too; the delta-derived shorthands flow through the chain above.
1792
2249
  var sizeShifted = [];
1793
2250
  var markdownEntries = shorthandTokens(baseTokens).filter(function (entry) {
1794
- return isMarkdownToken(entry.name) && entry.parts.size.indexOf("var(") === -1;
2251
+ return isMarkdownLiteralToken(entry.name) && isLiteralTokenValue(entry.parts.size);
1795
2252
  });
1796
2253
  for (var mIndex = 0; mIndex < markdownEntries.length; mIndex += 1) {
1797
2254
  var mEntry = markdownEntries[mIndex];
@@ -1846,12 +2303,16 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1846
2303
  if (codeScaled.length > 0) declarations.push(rootRule(codeScaled.join(";")));
1847
2304
  }
1848
2305
  if (codeWeight !== WEIGHT_UNSET) {
1849
- // The variable rides along so the elements INSIDE a code surface — which the
1850
- // interface weight rule's element table matches — resolve the code weight
1851
- // instead of the interface one, without any `… *` selector.
2306
+ // The code axis is a relative offset too (all three weight axes share one
2307
+ // scale, so the card shows one kind of control). Pinning the offset here
2308
+ // instead of writing a flat `font-weight` keeps whatever weight a code
2309
+ // surface ships with AND is what makes the elements INSIDE a code surface —
2310
+ // which the interface table and the conversation ladder both match — resolve
2311
+ // the code offset rather than the surrounding one, without any `… *`
2312
+ // selector. `WEIGHT_BASE` is what DSH's code `font` shorthands carry.
1852
2313
  declarations.push(
1853
2314
  codeRule(
1854
- "font-weight:" + codeWeight + " !important;--dfp-interface-weight:" + codeWeight
2315
+ "font-weight:" + ladderWeight(WEIGHT_BASE) + " !important;--dfp-wdelta:" + codeWeight
1855
2316
  )
1856
2317
  );
1857
2318
  }
@@ -2406,7 +2867,27 @@ var shared = {
2406
2867
  isFontToken: isFontToken,
2407
2868
  isCodeToken: isCodeToken,
2408
2869
  isMarkdownToken: isMarkdownToken,
2870
+ isMarkdownLiteralToken: isMarkdownLiteralToken,
2871
+ MARKDOWN_LITERAL_PREFIX: MARKDOWN_LITERAL_PREFIX,
2872
+ CONTENT_SIZE_SOURCE: CONTENT_SIZE_SOURCE,
2873
+ isLiteralTokenValue: isLiteralTokenValue,
2874
+ clampWeightDelta: clampWeightDelta,
2875
+ WEIGHT_DELTA_MIN: WEIGHT_DELTA_MIN,
2876
+ WEIGHT_DELTA_MAX: WEIGHT_DELTA_MAX,
2877
+ WEIGHT_BASE: WEIGHT_BASE,
2878
+ DIALOG_WEIGHT_GROUPS: DIALOG_WEIGHT_GROUPS,
2409
2879
  isDeltaToken: isDeltaToken,
2880
+ reconcileSliderValue: reconcileSliderValue,
2881
+ WEIGHT_STEP_CANDIDATES: WEIGHT_STEP_CANDIDATES,
2882
+ WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
2883
+ WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
2884
+ WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
2885
+ WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
2886
+ WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
2887
+ WEIGHT_CSS_MIN: WEIGHT_CSS_MIN,
2888
+ WEIGHT_CSS_MAX: WEIGHT_CSS_MAX,
2889
+ weightProfileFrom: weightProfileFrom,
2890
+ weightStepRange: weightStepRange,
2410
2891
  isShorthandName: isShorthandName,
2411
2892
  isScaledToken: isScaledToken,
2412
2893
  tokenNames: tokenNames,
@@ -2492,6 +2973,8 @@ var SIZE_MAX = shared.SIZE_MAX;
2492
2973
  var WEIGHT_MIN = shared.WEIGHT_MIN;
2493
2974
  var WEIGHT_MAX = shared.WEIGHT_MAX;
2494
2975
  var WEIGHT_UNSET = shared.WEIGHT_UNSET;
2976
+ var WEIGHT_DELTA_MIN = shared.WEIGHT_DELTA_MIN;
2977
+ var WEIGHT_DELTA_MAX = shared.WEIGHT_DELTA_MAX;
2495
2978
  var LINE_HEIGHT_MIN = shared.LINE_HEIGHT_MIN;
2496
2979
  var LINE_HEIGHT_MAX = shared.LINE_HEIGHT_MAX;
2497
2980
  var CODE_LINE_HEIGHT_MIN = shared.CODE_LINE_HEIGHT_MIN;
@@ -2514,6 +2997,9 @@ var normalizePresets = shared.normalizePresets;
2514
2997
  var normalizeValueSet = shared.normalizeValueSet;
2515
2998
  var parseStack = shared.parseStack;
2516
2999
  var quoteFamily = shared.quoteFamily;
3000
+ var reconcileSliderValue = shared.reconcileSliderValue;
3001
+ var weightProfileFrom = shared.weightProfileFrom;
3002
+ var weightStepRange = shared.weightStepRange;
2517
3003
  var resolveAxes = shared.resolveAxes;
2518
3004
  var sanitizeFamily = shared.sanitizeFamily;
2519
3005
  var isGenericFamilyName = shared.isGenericFamilyName;
@@ -2541,8 +3027,22 @@ var inject = ["slots", "locale"];
2541
3027
  var cardScope = null;
2542
3028
  var cardT = null;
2543
3029
 
2544
- /** The weight DSH uses for body text; choosing it means "leave it alone". */
2545
- var NEUTRAL_WEIGHT = 400;
3030
+ /**
3031
+ * The neutral value of the two RELATIVE weight axes: an offset of zero, which
3032
+ * means "leave every element at its own weight" and is what the slider's middle
3033
+ * position and the "unset" state share.
3034
+ */
3035
+ var NEUTRAL_WEIGHT = 0;
3036
+
3037
+ /**
3038
+ * Print a weight offset the way the slider reads it: a signed number, so `+60`
3039
+ * and `-40` are distinguishable at a glance (plain `String(0)` for neutral).
3040
+ * @param {number} value - the offset.
3041
+ * @returns {string}
3042
+ */
3043
+ function weightOffsetText(value) {
3044
+ return value > 0 ? "+" + value : String(value);
3045
+ }
2546
3046
 
2547
3047
  /** Panel width, kept in one place because positioning reads it too. */
2548
3048
  var PANEL_WIDTH = 320;
@@ -2711,15 +3211,15 @@ var DICTS = {
2711
3211
  "Adds {offset} to code blocks and inline code only; the interface offset does not reach them. 0 keeps DSH's sizes.",
2712
3212
  "size.unit": "px",
2713
3213
 
2714
- "weight.uiLabel": "Interface font weight",
3214
+ "weight.uiLabel": "Interface font-weight offset",
2715
3215
  "weight.uiHint":
2716
- "Sets the weight of the whole interface (sidebars, settings, buttons, headings). The conversation keeps its own.",
2717
- "weight.dialogLabel": "Conversation font weight",
3216
+ "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.",
3217
+ "weight.dialogLabel": "Conversation font-weight offset",
2718
3218
  "weight.dialogHint":
2719
- "Sets the weight of the conversation markdown; unset keeps DSH's own weights.",
2720
- "weight.codeLabel": "Code font weight",
3219
+ "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.",
3220
+ "weight.codeLabel": "Code font-weight offset",
2721
3221
  "weight.codeHint":
2722
- "Overrides code blocks, inline code and terminal output only. 400 or unset keeps DSH's own weight.",
3222
+ "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.",
2723
3223
 
2724
3224
  "line.dialogLabel": "Conversation line height",
2725
3225
  "line.dialogHint":
@@ -2855,14 +3355,15 @@ var DICTS = {
2855
3355
  "只作用于代码块与行内代码,与界面字号互不影响;0 表示保持原样。",
2856
3356
  "size.unit": "px",
2857
3357
 
2858
- "weight.uiLabel": "界面字重",
3358
+ "weight.uiLabel": "界面字重偏移",
2859
3359
  "weight.uiHint":
2860
- "设置整个界面的字重(侧栏、设置、按钮、标题);对话 Markdown 不受影响。",
2861
- "weight.dialogLabel": "对话字重",
2862
- "weight.dialogHint": "设置对话 Markdown 的粗细;未设置时保持 DSH 原本的粗细。",
2863
- "weight.codeLabel": "代码字重",
3360
+ "在整个界面自身的字重上叠加(侧栏、设置、按钮、标题):正数更粗、负数更细;对话 Markdown 不受影响。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。",
3361
+ "weight.dialogLabel": "对话字重偏移",
3362
+ "weight.dialogHint":
3363
+ "在每个元素自身的字重上叠加,所以标题依旧比正文粗、加粗文字依旧加粗;未设置时保持 DSH 原本的粗细。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。",
3364
+ "weight.codeLabel": "代码字重偏移",
2864
3365
  "weight.codeHint":
2865
- "只覆盖代码块、行内代码与终端输出;400 或未设置表示保持 DSH 原样。",
3366
+ "在每个代码面自身的字重上叠加(代码块、行内代码、终端输出);0 或未设置表示保持 DSH 原样。滑块按当前字体实际能渲染的粗细分档,读数是档数(0 = 保持 DSH 原样):每挪一格,正文与标题都会换一张字形;换个字体,档数也会跟着变。",
2866
3367
 
2867
3368
  "line.dialogLabel": "对话行高",
2868
3369
  "line.dialogHint": "把对话行高整体缩放为 {ratio};100% 表示保持 DSH 原样。",
@@ -3309,6 +3810,180 @@ function readDefaultFamily(name) {
3309
3810
  }
3310
3811
  }
3311
3812
 
3813
+ /* ------------------------------------------------------------------ *
3814
+ * weight granularity
3815
+ * ------------------------------------------------------------------ */
3816
+
3817
+ /** Sample drawn to compare two weights; mixed scripts, like the UI. */
3818
+ var WEIGHT_SAMPLE_TEXT = "对话 Aa 字重 0189";
3819
+
3820
+ /** Canvas used for the granularity probe, created on first use. */
3821
+ var weightProbeCanvas = null;
3822
+
3823
+ /** Cache of measured slider shapes, keyed by the resolved family stack. */
3824
+ var weightProfileCache = {};
3825
+
3826
+ /**
3827
+ * Render the sample at one weight and describe the result.
3828
+ *
3829
+ * The signature is the total ink (summed alpha) plus the advance width at high
3830
+ * precision. Counting only fully-covered pixels would miss what a variable axis
3831
+ * does between two named instances — a couple of units of extra alpha per glyph
3832
+ * — and report a coarse family where the weights are in fact continuous.
3833
+ * @param {string} stack - resolved `font-family` value.
3834
+ * @param {number} weight - CSS weight to draw with.
3835
+ * @returns {string} the signature; equal signatures mean identical rendering.
3836
+ */
3837
+ function sampleWeightSignature(stack, weight) {
3838
+ if (weightProbeCanvas === null) {
3839
+ weightProbeCanvas = document.createElement("canvas");
3840
+ weightProbeCanvas.width = 260;
3841
+ weightProbeCanvas.height = 56;
3842
+ }
3843
+ // `willReadFrequently` matters here: the profile probe reads the canvas back once
3844
+ // per sampled weight (~80 times on the first measurement), and without it the
3845
+ // browser keeps a GPU-backed surface and warns about the readbacks — which is part
3846
+ // of why dragging the weight felt heavy.
3847
+ var context = weightProbeCanvas.getContext("2d", { willReadFrequently: true });
3848
+ context.clearRect(0, 0, weightProbeCanvas.width, weightProbeCanvas.height);
3849
+ context.font = weight + " 32px " + stack;
3850
+ context.textBaseline = "top";
3851
+ context.fillStyle = "#000";
3852
+ context.fillText(WEIGHT_SAMPLE_TEXT, 2, 6);
3853
+ var advance = Math.round(context.measureText(WEIGHT_SAMPLE_TEXT).width * 1000);
3854
+ var pixels = context.getImageData(0, 0, weightProbeCanvas.width, weightProbeCanvas.height).data;
3855
+ var ink = 0;
3856
+ for (var index = 3; index < pixels.length; index += 4) ink += pixels[index];
3857
+ return ink + "/" + advance;
3858
+ }
3859
+
3860
+ /**
3861
+ * The slider shape to use for one family: its range and its notch.
3862
+ *
3863
+ * A family can only be made as light or as heavy as the faces it ships, so the
3864
+ * range is measured rather than fixed (`weightProfileFrom`), which also makes
3865
+ * every notch a change — for every layer of the ladder, so the measurement is
3866
+ * told which base is the heaviest one the offset lands on. Measured once per
3867
+ * family and base and cached; any failure (no canvas, no 2D context) falls back
3868
+ * to the nominal window with single units.
3869
+ * @param {string} stack - resolved `font-family` value, "" for the page default.
3870
+ * @param {number} [top] - the heaviest base the offset is added to; the ladder's
3871
+ * own top for the conversation and interface axes, the axis's base for code.
3872
+ * @returns {{min: number, max: number, step: number}} the slider shape.
3873
+ */
3874
+ function weightProfileFor(stack, top) {
3875
+ var base = typeof top === "number" ? top : shared.WEIGHT_LADDER_TOP;
3876
+ var key = String(stack) + "\u0000" + base;
3877
+ if (Object.prototype.hasOwnProperty.call(weightProfileCache, key)) {
3878
+ return weightProfileCache[key];
3879
+ }
3880
+ var profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
3881
+ try {
3882
+ var resolved = String(stack) === "" ? liveFamily() : String(stack);
3883
+ profile = weightProfileFrom(function (weight) {
3884
+ return sampleWeightSignature(resolved, weight);
3885
+ }, { top: base });
3886
+ } catch (error) {
3887
+ profile = { min: WEIGHT_DELTA_MIN, max: WEIGHT_DELTA_MAX, step: 1 };
3888
+ }
3889
+ weightProfileCache[key] = profile;
3890
+ return profile;
3891
+ }
3892
+
3893
+ /**
3894
+ * The family the page actually renders with, as a `font-family` value.
3895
+ * @returns {string} the computed family, or "" when unreadable.
3896
+ */
3897
+ function liveFamily() {
3898
+ if (typeof document === "undefined") return "";
3899
+ try {
3900
+ return document.defaultView.getComputedStyle(document.body).fontFamily || "";
3901
+ } catch (error) {
3902
+ return "";
3903
+ }
3904
+ }
3905
+
3906
+ /**
3907
+ * The conversation's own line-height ratio, measured once.
3908
+ *
3909
+ * The dialog line-height axis is a percentage OF DSH's own height (`28px × 1.05`),
3910
+ * so a preview that used the percentage as an ABSOLUTE ratio was wrong twice over:
3911
+ * at 100% it fell back to DSH's real (taller) default, while 105% asked for 1.05 —
3912
+ * shorter than that default and out of order with the notches above it.
3913
+ *
3914
+ * The base is read from a hidden node carrying the markdown class whose rule
3915
+ * consumes DSH's markdown shorthand, so it is the same ratio the page multiplies.
3916
+ * @returns {number} line height ÷ font size; a plain default when unmeasurable.
3917
+ */
3918
+ var DIALOG_LINE_RATIO_FALLBACK = 1.5;
3919
+ var dialogLineRatioCache = null;
3920
+ function dialogLineRatio() {
3921
+ if (typeof dialogLineRatioCache === "number") return dialogLineRatioCache;
3922
+ if (typeof document === "undefined" || typeof document.defaultView === "undefined") {
3923
+ return DIALOG_LINE_RATIO_FALLBACK;
3924
+ }
3925
+ try {
3926
+ var className = "";
3927
+ var sheets = document.styleSheets || [];
3928
+ for (var index = 0; index < sheets.length && className === ""; index += 1) {
3929
+ var rules = null;
3930
+ try {
3931
+ rules = sheets[index].cssRules;
3932
+ } catch (error) {
3933
+ rules = null;
3934
+ }
3935
+ if (!rules) continue;
3936
+ for (var ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
3937
+ var selector = rules[ruleIndex].selectorText || "";
3938
+ var body = rules[ruleIndex].style ? rules[ruleIndex].style.cssText : "";
3939
+ if (selector.indexOf("_markdown_") < 0) continue;
3940
+ if (body.indexOf("--dsw-font-markdown-base") < 0) continue;
3941
+ var found = selector.match(/\.(_markdown_[A-Za-z0-9_-]+)/);
3942
+ if (found) className = found[1];
3943
+ }
3944
+ }
3945
+ if (className !== "") {
3946
+ var host = document.createElement("div");
3947
+ host.className = className;
3948
+ host.setAttribute("data-dss-prose", "");
3949
+ host.style.position = "absolute";
3950
+ host.style.visibility = "hidden";
3951
+ host.style.left = "-9999px";
3952
+ var probe = document.createElement("p");
3953
+ probe.textContent = "line";
3954
+ probe.style.margin = "0";
3955
+ host.appendChild(probe);
3956
+ document.body.appendChild(host);
3957
+ var style = document.defaultView.getComputedStyle(probe);
3958
+ var size = parseFloat(style.fontSize);
3959
+ var height = parseFloat(style.lineHeight);
3960
+ if (isFinite(size) && size > 0 && isFinite(height) && height > 0) {
3961
+ dialogLineRatioCache = Math.round((height / size) * 10000) / 10000;
3962
+ }
3963
+ if (host.parentNode) host.parentNode.removeChild(host);
3964
+ }
3965
+ } catch (error) {
3966
+ /* the fallback stands */
3967
+ }
3968
+ if (typeof dialogLineRatioCache !== "number") dialogLineRatioCache = DIALOG_LINE_RATIO_FALLBACK;
3969
+ return dialogLineRatioCache;
3970
+ }
3971
+
3972
+ /**
3973
+ * The family code surfaces render with when the code axis sets none.
3974
+ * @returns {string} the computed family, or "" when unreadable.
3975
+ */
3976
+ function liveCodeFamily() {
3977
+ if (typeof document === "undefined") return "";
3978
+ try {
3979
+ var sample = document.querySelector("pre, code, kbd, samp");
3980
+ if (sample === null) return liveFamily();
3981
+ return document.defaultView.getComputedStyle(sample).fontFamily || liveFamily();
3982
+ } catch (error) {
3983
+ return "";
3984
+ }
3985
+ }
3986
+
3312
3987
  /* ------------------------------------------------------------------ *
3313
3988
  * hooks and controls
3314
3989
  * ------------------------------------------------------------------ */
@@ -3441,17 +4116,25 @@ function NumberSlider(props) {
3441
4116
  // (the "bounce back, then settle" the user saw), so the local value stays
3442
4117
  // on screen until the confirmed value arrives.
3443
4118
  var awaitingRef = useRef(null);
4119
+ // The same value as `pending`, readable without a render: the reconciliation
4120
+ // below runs from an effect that only depends on the incoming value.
4121
+ var pendingRef = useRef(null);
4122
+ var setLocal = function (value) {
4123
+ pendingRef.current = value;
4124
+ setPending(value);
4125
+ };
3444
4126
  useEffect(
3445
4127
  function () {
3446
- if (awaitingRef.current === null) {
3447
- // idle: any value that arrives from outside (reset, another page) wins
3448
- setPending(null);
3449
- return undefined;
3450
- }
3451
- if (props.value === awaitingRef.current) {
3452
- awaitingRef.current = null;
3453
- setPending(null);
3454
- }
4128
+ // `reconcileSliderValue` owns the decision (and is unit-tested): keeping
4129
+ // the local value while the user is mid-drag is what stops a late
4130
+ // confirmation from snapping the thumb back to the previous value.
4131
+ var next = reconcileSliderValue({
4132
+ pending: pendingRef.current,
4133
+ awaiting: awaitingRef.current,
4134
+ confirmed: props.value,
4135
+ });
4136
+ awaitingRef.current = next.awaiting;
4137
+ setLocal(next.pending);
3455
4138
  return undefined;
3456
4139
  },
3457
4140
  [props.value]
@@ -3460,12 +4143,39 @@ function NumberSlider(props) {
3460
4143
  awaitingRef.current = value;
3461
4144
  props.onChange(value);
3462
4145
  };
4146
+ /**
4147
+ * Stop holding a value the host never confirmed.
4148
+ *
4149
+ * The local value is kept until the document echoes it back, which is what
4150
+ * stops the "bounce back, then settle" flicker — but a write the host REFUSES
4151
+ * (an out-of-range value against an older schema, a read-only deployment) never
4152
+ * echoes, and the slider would then sit on a value the document does not have,
4153
+ * i.e. it would look like it jumped somewhere else and stayed there. The
4154
+ * document is the truth, so after a few seconds the local value yields to it.
4155
+ */
4156
+ var COMMIT_PATIENCE_MS = 6000;
4157
+ useEffect(
4158
+ function () {
4159
+ if (awaitingRef.current === null) return undefined;
4160
+ var timer = setTimeout(function () {
4161
+ awaitingRef.current = null;
4162
+ setLocal(null);
4163
+ }, COMMIT_PATIENCE_MS);
4164
+ return function () {
4165
+ clearTimeout(timer);
4166
+ };
4167
+ },
4168
+ [pending]
4169
+ );
3463
4170
  useEffect(
3464
4171
  function () {
3465
4172
  if (pending === null) return undefined;
3466
4173
  var release = function () {
3467
- // one release per drag; a late pointerup must not rewrite the same value
3468
- if (awaitingRef.current !== null) return;
4174
+ // One release per drag: a late pointerup must not rewrite the SAME value.
4175
+ // The guard compares values instead of "is anything outstanding" — a
4176
+ // commit still waiting for the document must not swallow the next drag's
4177
+ // release, which silently dropped that adjustment on a slow host.
4178
+ if (awaitingRef.current === pending) return;
3469
4179
  commit(pending);
3470
4180
  };
3471
4181
  window.addEventListener("pointerup", release, true);
@@ -4445,11 +5155,19 @@ function SimpleFamilyField(props) {
4445
5155
  * @param {boolean} uiFollows - whether the interface follows the conversation.
4446
5156
  * @param {(key: string, params?: object) => string} t - translate seat.
4447
5157
  * @returns {{ui: string, dialog: string, code: string}}
5158
+ * @param {(field: string, value: number) => number} [weightCount] - turns a
5159
+ * stored weight offset into the number of font steps the sliders show, so a
5160
+ * collapsed section reads "+2" exactly like the open slider does.
4448
5161
  */
4449
- function sectionSummaries(axis, uiFollows, t) {
5162
+ function sectionSummaries(axis, uiFollows, t, weightCount) {
4450
5163
  var sign = function (value) {
4451
5164
  return value > 0 ? "+" + value : String(value);
4452
5165
  };
5166
+ var weight = function (field, value) {
5167
+ return typeof weightCount === "function"
5168
+ ? weightOffsetText(weightCount(field, value))
5169
+ : weightOffsetText(value);
5170
+ };
4453
5171
  // The conversation owns every axis, so its summary is always its values.
4454
5172
  var dialogParts = [];
4455
5173
  if (axis[STACK_DIALOG_FIELD] !== "") dialogParts.push(firstFamily(axis[STACK_DIALOG_FIELD]));
@@ -4458,7 +5176,7 @@ function sectionSummaries(axis, uiFollows, t) {
4458
5176
  dialogParts.push(axis[LINE_HEIGHT_DIALOG_FIELD] + "%");
4459
5177
  }
4460
5178
  if (axis[WEIGHT_DIALOG_FIELD] !== WEIGHT_UNSET) {
4461
- dialogParts.push(String(axis[WEIGHT_DIALOG_FIELD]));
5179
+ dialogParts.push(weight(WEIGHT_DIALOG_FIELD, axis[WEIGHT_DIALOG_FIELD]));
4462
5180
  }
4463
5181
  // The interface either follows (nothing of its own to report) or shows the
4464
5182
  // two axes it owns.
@@ -4466,13 +5184,15 @@ function sectionSummaries(axis, uiFollows, t) {
4466
5184
  if (!uiFollows) {
4467
5185
  var family = axis[SANS_FIELD] === "" ? null : firstFamily(axis[SANS_FIELD]);
4468
5186
  if (family !== null) uiParts.push(family);
4469
- if (axis[WEIGHT_FIELD] !== WEIGHT_UNSET) uiParts.push(String(axis[WEIGHT_FIELD]));
5187
+ if (axis[WEIGHT_FIELD] !== WEIGHT_UNSET) uiParts.push(weight(WEIGHT_FIELD, axis[WEIGHT_FIELD]));
4470
5188
  }
4471
5189
  var codeParts = [];
4472
5190
  if (axis[MONO_FIELD] !== "") codeParts.push(firstFamily(axis[MONO_FIELD]));
4473
5191
  if (axis[CODE_SIZE_FIELD] !== 0) codeParts.push(sign(axis[CODE_SIZE_FIELD]) + "px");
4474
5192
  if (axis[CODE_LINE_HEIGHT_FIELD] !== 0) codeParts.push(sign(axis[CODE_LINE_HEIGHT_FIELD]) + "px");
4475
- if (axis[CODE_WEIGHT_FIELD] !== WEIGHT_UNSET) codeParts.push(String(axis[CODE_WEIGHT_FIELD]));
5193
+ if (axis[CODE_WEIGHT_FIELD] !== WEIGHT_UNSET) {
5194
+ codeParts.push(weight(CODE_WEIGHT_FIELD, axis[CODE_WEIGHT_FIELD]));
5195
+ }
4476
5196
  if (axis[LIGATURES_FIELD] !== LIGATURES_DEFAULT) {
4477
5197
  codeParts.push(t(axis[LIGATURES_FIELD] === LIGATURES_OFF ? "lig.off" : "lig.on"));
4478
5198
  }
@@ -4760,7 +5480,28 @@ function FontCard(props) {
4760
5480
  };
4761
5481
 
4762
5482
  // ---- weight slider ----
5483
+ // All three weight axes share one shape: an offset added to each element's own
5484
+ // weight, centred on zero (code included — its surfaces ship their own weight
5485
+ // too, and an offset keeps any bold element inside them bold).
5486
+ //
5487
+ // The CONTROL counts the family's own steps ("-2 -1 0 +1 +2 +3 +4"), because a
5488
+ // family can only be made as light or as heavy as the faces it ships and only
5489
+ // in steps between them: how many positions there are is a property of the font,
5490
+ // measured on the page. The VALUE written to the document stays a weight offset,
5491
+ // so nothing stored before has to be migrated and the stylesheet is unchanged.
4763
5492
  var weightField = function (props) {
5493
+ var profile = weightProfileFor(
5494
+ props.stack === undefined ? "" : props.stack,
5495
+ props.top === undefined ? shared.WEIGHT_LADDER_STRONG : props.top
5496
+ );
5497
+ var stored = props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value;
5498
+ // A stored offset outside the measured range (the font changed since it was
5499
+ // set) widens the control, so it is never unreachable.
5500
+ var shape = weightStepRange(
5501
+ { min: Math.min(profile.min, stored), max: Math.max(profile.max, stored), step: profile.step },
5502
+ stored
5503
+ );
5504
+ var text = weightOffsetText;
4764
5505
  return h(
4765
5506
  FieldShell,
4766
5507
  {
@@ -4774,22 +5515,27 @@ function FontCard(props) {
4774
5515
  },
4775
5516
  },
4776
5517
  h(NumberSlider, {
4777
- min: WEIGHT_MIN,
4778
- max: WEIGHT_MAX,
4779
- value: props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value,
5518
+ min: shape.min,
5519
+ max: shape.max,
5520
+ value: shape.value,
4780
5521
  disabled: !writable,
4781
5522
  label: t(props.labelKey),
4782
- // "Unset" IS 400 here: that is the weight DSH uses for body text, the
4783
- // slider already sits at 400, and the hint says 400 keeps DSH's own.
4784
- // Printing "unset" made the readout disagree with the control.
4785
- readout: String(props.value === WEIGHT_UNSET ? NEUTRAL_WEIGHT : props.value),
4786
- minLabel: String(WEIGHT_MIN),
4787
- maxLabel: String(WEIGHT_MAX),
4788
- onChange: function (value) {
4789
- // 400 is DSH's own body weight, so choosing it means "leave the axis
4790
- // alone" rather than "write 400 on every element".
4791
- if (value === NEUTRAL_WEIGHT) resetField(props.field);
4792
- else setField(props.field, value);
5523
+ // One notch is one family step, so every notch changes what is on screen.
5524
+ step: 1,
5525
+ // The readout is the STEP COUNT, not the weight: "+2" is two faces
5526
+ // bolder, and the numbers stay small whatever the family is.
5527
+ readout: text(shape.value),
5528
+ minLabel: text(shape.min),
5529
+ maxLabel: text(shape.max),
5530
+ // The pending readout goes through the same formatter: without it the
5531
+ // sign only appeared on release ("80" while dragging, then "+80" once
5532
+ // the document confirmed it).
5533
+ pendingText: text,
5534
+ onChange: function (count) {
5535
+ // The neutral position means "leave the axis alone" rather than "write
5536
+ // it on every element".
5537
+ if (count === 0) resetField(props.field);
5538
+ else setField(props.field, count * shape.unit);
4793
5539
  },
4794
5540
  })
4795
5541
  );
@@ -5146,33 +5892,59 @@ function FontCard(props) {
5146
5892
  // fallback. The switch lives in the interface section.
5147
5893
  var uiFollows = config[UI_FOLLOWS_FIELD] !== false;
5148
5894
 
5149
- var summaries = sectionSummaries(editing, uiFollows, t);
5895
+ // The family each weight axis is measured against: the configured stack when
5896
+ // there is one, the page's own family otherwise. Resolved once, so the
5897
+ // collapsed summaries, the sliders and the previews all agree on the count.
5898
+ var uiStack =
5899
+ editing[SANS_FIELD] !== "" ? formatStack(parseStack(editing[SANS_FIELD])) : "";
5900
+ var dialogFallbackFamily =
5901
+ typeof FALLBACK_TOKENS["--dsw-font-family"] === "string" ? FALLBACK_TOKENS["--dsw-font-family"] : "";
5902
+ var dialogStack =
5903
+ editing[STACK_DIALOG_FIELD] !== ""
5904
+ ? formatStack(parseStack(editing[STACK_DIALOG_FIELD]))
5905
+ : dialogFallbackFamily;
5906
+ var codeStack =
5907
+ editing[MONO_FIELD] !== "" ? formatStack(parseStack(editing[MONO_FIELD])) : liveCodeFamily();
5908
+ /**
5909
+ * One weight offset, as the number of family steps the reader sees.
5910
+ * @param {string} field - which weight axis.
5911
+ * @param {number} value - the stored offset.
5912
+ * @returns {number} the step count.
5913
+ */
5914
+ var weightCount = function (field, value) {
5915
+ var isCode = field === CODE_WEIGHT_FIELD;
5916
+ var stack =
5917
+ field === WEIGHT_DIALOG_FIELD ? dialogStack : isCode ? codeStack : uiStack;
5918
+ var profile = weightProfileFor(stack, isCode ? shared.WEIGHT_BASE : shared.WEIGHT_LADDER_STRONG);
5919
+ return weightStepRange(profile, value === WEIGHT_UNSET ? 0 : value).value;
5920
+ };
5921
+
5922
+ var summaries = sectionSummaries(editing, uiFollows, t, weightCount);
5150
5923
 
5151
5924
  // The interface owns one axis now (its family); its preview shows that and
5152
5925
  // nothing else — the weight, size and line height all come from the
5153
5926
  // conversation section.
5154
5927
  var previewUiStyle = {};
5155
- if (editing[SANS_FIELD] !== "") {
5156
- previewUiStyle.fontFamily = formatStack(parseStack(editing[SANS_FIELD]));
5157
- }
5928
+ if (uiStack !== "") previewUiStyle.fontFamily = uiStack;
5158
5929
  var previewDialogStyle = {};
5159
- if (editing[STACK_DIALOG_FIELD] !== "") {
5160
- previewDialogStyle.fontFamily = formatStack(parseStack(editing[STACK_DIALOG_FIELD]));
5161
- } else {
5162
- // Independent and unset: the conversation keeps DSH's own family, so the
5163
- // preview shows the theme's default rather than the interface stack the
5164
- // page rule would otherwise paint into this box.
5165
- var dialogFallback = FALLBACK_TOKENS["--dsw-font-family"];
5166
- if (typeof dialogFallback === "string" && dialogFallback !== "") {
5167
- previewDialogStyle.fontFamily = dialogFallback;
5168
- }
5169
- }
5930
+ // Independent and unset: the conversation keeps DSH's own family, so the
5931
+ // preview shows the theme's default rather than the interface stack the page
5932
+ // rule would otherwise paint into this box.
5933
+ if (dialogStack !== "") previewDialogStyle.fontFamily = dialogStack;
5170
5934
  previewDialogStyle.fontSize = 13 + editing[SIZE_DIALOG_FIELD] + "px";
5171
- if (editing[LINE_HEIGHT_DIALOG_FIELD] !== LINE_HEIGHT_MIN) {
5172
- previewDialogStyle.lineHeight = String(editing[LINE_HEIGHT_DIALOG_FIELD] / 100);
5173
- }
5935
+ // The axis is a percentage OF DSH's own line height, so the preview multiplies the
5936
+ // measured base by the same percentage — and does it at every notch including 100,
5937
+ // or the preview would show a shorter line at 105% than at 100%.
5938
+ previewDialogStyle.lineHeight = String(
5939
+ Math.round(dialogLineRatio() * (editing[LINE_HEIGHT_DIALOG_FIELD] / LINE_HEIGHT_MIN) * 10000) /
5940
+ 10000
5941
+ );
5174
5942
  if (editing[WEIGHT_DIALOG_FIELD] !== WEIGHT_UNSET) {
5175
- previewDialogStyle.fontWeight = editing[WEIGHT_DIALOG_FIELD];
5943
+ // The offset, not a weight: the injected stylesheet's conversation ladder
5944
+ // reads `--dfp-wdelta` on this very class and re-states each element's own
5945
+ // base plus the offset, so the preview shows the same hierarchy the
5946
+ // conversation does instead of one flat weight.
5947
+ previewDialogStyle["--dfp-wdelta"] = editing[WEIGHT_DIALOG_FIELD];
5176
5948
  }
5177
5949
  var previewCodeStyle = {};
5178
5950
  if (editing[MONO_FIELD] !== "") {
@@ -5423,6 +6195,7 @@ function FontCard(props) {
5423
6195
  labelKey: "weight.dialogLabel",
5424
6196
  hintKey: "weight.dialogHint",
5425
6197
  value: editing[WEIGHT_DIALOG_FIELD],
6198
+ stack: dialogStack,
5426
6199
  }),
5427
6200
  h(
5428
6201
  "div",
@@ -5509,6 +6282,7 @@ function FontCard(props) {
5509
6282
  labelKey: "weight.uiLabel",
5510
6283
  hintKey: "weight.uiHint",
5511
6284
  value: editing[WEIGHT_FIELD],
6285
+ stack: uiStack,
5512
6286
  }),
5513
6287
  h(
5514
6288
  "div",
@@ -5551,6 +6325,11 @@ function FontCard(props) {
5551
6325
  labelKey: "weight.codeLabel",
5552
6326
  hintKey: "weight.codeHint",
5553
6327
  value: editing[CODE_WEIGHT_FIELD],
6328
+ // The code axis puts one weight on the whole surface, so its own base
6329
+ // is the heaviest layer there is: any lower base would cap the range
6330
+ // short of what code text can actually reach.
6331
+ top: shared.WEIGHT_BASE,
6332
+ stack: codeStack,
5554
6333
  }),
5555
6334
  ligatureField(),
5556
6335
  view === "advanced" ? featuresField() : null,